2 |
3 | Reset password
4 |
--------------------------------------------------------------------------------
/crystal/src/serializers/user_serializer.cr:
--------------------------------------------------------------------------------
1 | class UserSerializer < BaseSerializer
2 | def initialize(@user : User)
3 | end
4 |
5 | def render
6 | {email: @user.email}
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/crystal/src/operations/import_post.cr:
--------------------------------------------------------------------------------
1 | class ImportPost < Post::SaveOperation
2 | permit_columns guid, title, url, twitter_url, mastodon_url, author, summary, tweeted_at, tooted_at, created_at
3 | end
4 |
--------------------------------------------------------------------------------
/crystal/src/operations/save_creator.cr:
--------------------------------------------------------------------------------
1 | class SaveCreator < Creator::SaveOperation
2 | permit_columns name, avatar, support_link_name, support_link_url, code_link_name, code_link_url, description
3 | end
4 |
--------------------------------------------------------------------------------
/crystal/src/actions/sign_ins/new.cr:
--------------------------------------------------------------------------------
1 | class SignIns::New < BrowserAction
2 | include Auth::RedirectSignedInUsers
3 |
4 | get "/sign_in" do
5 | html NewPage, operation: SignInUser.new
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/crystal/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*.cr]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_style = space
8 | indent_size = 2
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/crystal/spec/setup/start_app_server.cr:
--------------------------------------------------------------------------------
1 | app_server = AppServer.new
2 |
3 | spawn do
4 | app_server.listen
5 | end
6 |
7 | Spec.after_suite do
8 | LuckyFlow.shutdown
9 | app_server.close
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/pages/mixins/page/render_markdown.cr:
--------------------------------------------------------------------------------
1 | module Page::RenderMarkdown
2 | def render_markdown(source)
3 | options = Markd::Options.new(smart: true)
4 | Markd.to_html(source, options)
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/crystal/src/operations/mixins/user_from_email.cr:
--------------------------------------------------------------------------------
1 | module UserFromEmail
2 | private def user_from_email : User?
3 | email.value.try do |value|
4 | UserQuery.new.email(value).first?
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/crystal/config/env.cr:
--------------------------------------------------------------------------------
1 | # Environments are managed using `LuckyEnv`. By default, development, production
2 | # and test are supported.
3 |
4 | # If you need additional environment support, add it here
5 | # LuckyEnv.add_env :staging
--------------------------------------------------------------------------------
/crystal/src/actions/submit/show.cr:
--------------------------------------------------------------------------------
1 | class Submit::Show < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_in_varnish(2.minutes)
5 |
6 | get "/submit" do
7 | html ShowPage
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/rust/migrations/2019-12-26-021519_create_tags/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS tags (
2 | id bigserial PRIMARY KEY,
3 | name text NOT NULL
4 | );
5 |
6 | CREATE UNIQUE INDEX IF NOT EXISTS tags_name_index ON tags (name);
7 |
--------------------------------------------------------------------------------
/crystal/src/models/creator_tag.cr:
--------------------------------------------------------------------------------
1 | class CreatorTag < BaseModel
2 | skip_default_columns
3 |
4 | table do
5 | primary_key id : Int64
6 | belongs_to creator : Creator
7 | belongs_to tag : Tag
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/crystal/src/actions/sign_ins/delete.cr:
--------------------------------------------------------------------------------
1 | class SignIns::Delete < BrowserAction
2 | delete "/sign_out" do
3 | cache_friendly_sign_out
4 | flash.info = "You have been signed out"
5 | redirect to: SignIns::New
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/crystal/spec/support/factories/user_box.cr:
--------------------------------------------------------------------------------
1 | class UserFactory < Avram::Factory
2 | def initialize
3 | email "#{sequence("test-email")}@example.com"
4 | encrypted_password Authentic.generate_encrypted_password("password")
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/crystal/src/actions/tags/index.cr:
--------------------------------------------------------------------------------
1 | class Tags::Index < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_in_varnish(2.minutes)
5 |
6 | get "/tags" do
7 | html IndexPage, tags: TagQuery.with_posts
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/crystal/config/colors.cr:
--------------------------------------------------------------------------------
1 | # This enables the color output when in development or test
2 | # Check out the Colorize docs for more information
3 | # https://crystal-lang.org/api/Colorize.html
4 | Colorize.enabled = LuckyEnv.development? || LuckyEnv.test?
5 |
--------------------------------------------------------------------------------
/crystal/src/actions/about/show.cr:
--------------------------------------------------------------------------------
1 | class About::Show < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_in_varnish(2.minutes)
5 |
6 | get "/about" do
7 | html ShowPage, categories: CategoryQuery.new
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/crystal/src/models/post_tag.cr:
--------------------------------------------------------------------------------
1 | class PostTag < BaseModel
2 | skip_default_columns
3 | delegate name, to: tag
4 |
5 | table do
6 | primary_key id : Int64
7 | belongs_to post : Post
8 | belongs_to tag : Tag
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/operations/save_post_category.cr:
--------------------------------------------------------------------------------
1 | class SavePostCategory < PostCategory::SaveOperation
2 | permit_columns :post_id, :category_id
3 |
4 | before_save do
5 | validate_inclusion_of category_id, in: Category.valid_ids
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/password_resets/token_from_session.cr:
--------------------------------------------------------------------------------
1 | module Auth::PasswordResets::TokenFromSession
2 | private def token : String
3 | session.get?(:password_reset_token) || raise "Password reset token not found in session"
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/crystal/src/actions/favicon/show.cr:
--------------------------------------------------------------------------------
1 | class Favicon::Show < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_publicly(1.day)
5 |
6 | get "/favicon.ico" do
7 | file "public/favicon.ico", disposition: "inline"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/password_resets/base.cr:
--------------------------------------------------------------------------------
1 | module Auth::PasswordResets::Base
2 | macro included
3 | include Auth::RedirectSignedInUsers
4 | include Auth::PasswordResets::FindUser
5 | include Auth::PasswordResets::RequireToken
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/crystal/src/actions/password_reset_requests/new.cr:
--------------------------------------------------------------------------------
1 | class PasswordResetRequests::New < BrowserAction
2 | include Auth::RedirectSignedInUsers
3 |
4 | get "/password_reset_requests/new" do
5 | html NewPage, operation: RequestPasswordReset.new
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/crystal/src/models/tag_name.cr:
--------------------------------------------------------------------------------
1 | class TagName
2 | def initialize(@tag_name : String)
3 | end
4 |
5 | def format : String
6 | File.extname(@tag_name)
7 | end
8 |
9 | def name : String
10 | File.basename(@tag_name, format)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/crystal/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "checkJs": true,
5 | "noEmit": true,
6 | "strictNullChecks": true,
7 | "noImplicitAny": true
8 | },
9 | "files": [
10 | "src/js/admin.js"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/crystal/src/actions/api/sign_ups/create.cr:
--------------------------------------------------------------------------------
1 | class Api::SignUps::Create < ApiAction
2 | include Api::Auth::SkipRequireAuthToken
3 |
4 | post "/api/sign_ups" do
5 | user = SignUpUser.create!(params)
6 | json({token: UserToken.generate(user)})
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/crystal/.gitignore:
--------------------------------------------------------------------------------
1 | /docs/
2 | /lib/
3 | /bin/
4 | /.shards/
5 | *.dwarf
6 | start_server
7 | *.dwarf
8 | *.local.cr
9 | /tmp
10 | /public/js
11 | /public/css
12 | /public/fonts
13 | /public/images
14 | /public/mix-manifest.json
15 | /node_modules
16 | yarn-error.log
17 |
--------------------------------------------------------------------------------
/crystal/src/serializers/base_serializer.cr:
--------------------------------------------------------------------------------
1 | abstract class BaseSerializer < Lucky::Serializer
2 | def self.for_collection(collection : Enumerable, *args, **named_args)
3 | collection.map do |object|
4 | new(object, *args, **named_args)
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/auth/allow_guests.cr:
--------------------------------------------------------------------------------
1 | module Auth::AllowGuests
2 | macro included
3 | skip require_sign_in
4 | end
5 |
6 | # Since sign in is not required, current_user might be nil
7 | def current_user : User?
8 | current_user?
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/shards.cr:
--------------------------------------------------------------------------------
1 | # Load .env file before any other config or app code
2 | require "lucky_env"
3 | LuckyEnv.load?(".env")
4 |
5 | # Require your shards here
6 | require "lucky"
7 | require "avram/lucky"
8 | require "carbon"
9 | require "authentic"
10 | require "jwt"
11 |
--------------------------------------------------------------------------------
/crystal/src/actions/robots/show.cr:
--------------------------------------------------------------------------------
1 | class Robots::Show < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_publicly(1.hour)
5 |
6 | get "/robots.txt" do
7 | plain_text "User-Agent: *
8 | Disallow:
9 | Sitemap: #{Sitemap::Show.url}
10 | "
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/categories/find_category.cr:
--------------------------------------------------------------------------------
1 | module Categories::FindCategory
2 | private def category
3 | if category = CategoryQuery.new.slug(slug).first?
4 | category
5 | else
6 | raise Lucky::RouteNotFoundError.new(context)
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/crystal/spec/support/app_client.cr:
--------------------------------------------------------------------------------
1 | class ApiClient < Lucky::BaseHTTPClient
2 | def initialize
3 | super
4 | headers("Content-Type": "application/json")
5 | end
6 |
7 | def self.auth(user : User)
8 | new.headers("Authorization": UserToken.generate(user))
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/pages/posts/show_page.cr:
--------------------------------------------------------------------------------
1 | class Posts::ShowPage < MainLayout
2 | needs post : Post
3 | quick_def page_title, @post.title
4 | quick_def page_description, @post.summary
5 |
6 | def content
7 | mount Posts::Summary, @post, @current_user, show_categories: true
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/crystal/spec/support/factories/post_box.cr:
--------------------------------------------------------------------------------
1 | class PostFactory < Avram::Factory
2 | def initialize
3 | guid UUID.random(Random.new, UUID::Variant::RFC4122, UUID::Version::V4)
4 | title "Test"
5 | url "https://example.com/"
6 | author "Test Suite"
7 | summary "Summary"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/api/auth/skip_require_auth_token.cr:
--------------------------------------------------------------------------------
1 | module Api::Auth::SkipRequireAuthToken
2 | macro included
3 | skip require_auth_token
4 | end
5 |
6 | # Since sign in is not required, current_user might be nil
7 | def current_user : User?
8 | current_user?
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/actions/password_resets/edit.cr:
--------------------------------------------------------------------------------
1 | class PasswordResets::Edit < BrowserAction
2 | include Auth::PasswordResets::Base
3 | include Auth::PasswordResets::TokenFromSession
4 |
5 | get "/password_resets/:user_id/edit" do
6 | html NewPage, operation: ResetPassword.new, user_id: user_id.to_i
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/crystal/public/assets/images/facebook.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/crystal/src/actions/posts/show.cr:
--------------------------------------------------------------------------------
1 | class Posts::Show < BrowserAction
2 | before cache_in_varnish(2.minutes)
3 |
4 | get "/posts/:post_id" do
5 | post = PostQuery.new.preload_post_categories.preload_tags.find(post_id)
6 | weak_etag(post.updated_at.to_unix)
7 |
8 | html ShowPage, post: post
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/actions/sign_ups/new.cr:
--------------------------------------------------------------------------------
1 | class SignUps::New < BrowserAction
2 | include Auth::RedirectSignedInUsers
3 |
4 | get "/sign_up" do
5 | if ReadRust::Config.allow_sign_up?
6 | html NewPage, operation: SignUpUser.new
7 | else
8 | raise Lucky::RouteNotFoundError.new(context)
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/crystal/src/models/user.cr:
--------------------------------------------------------------------------------
1 | class User < BaseModel
2 | include Carbon::Emailable
3 | include Authentic::PasswordAuthenticatable
4 |
5 | table do
6 | column email : String
7 | column encrypted_password : String
8 | end
9 |
10 | def emailable : Carbon::Address
11 | Carbon::Address.new(email)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/crystal/src/actions/rust_blogs/index.cr:
--------------------------------------------------------------------------------
1 | class RustBlogs::Index < BrowserAction
2 | include Auth::AllowGuests
3 | include Lucky::SkipRouteStyleCheck
4 |
5 | before cache_publicly(1.hour)
6 |
7 | get "/rust-blogs.opml" do
8 | file "public/rust-blogs.opml", content_type: "application/xml", disposition: "inline"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/emails/base_email.cr:
--------------------------------------------------------------------------------
1 | abstract class BaseEmail < Carbon::Email
2 | # You can add defaults using the 'inherited' hook
3 | #
4 | # Example:
5 | #
6 | # macro inherited
7 | # from default_from
8 | # end
9 | #
10 | # def default_from
11 | # Carbon::Address.new("support@app.com")
12 | # end
13 | end
14 |
--------------------------------------------------------------------------------
/crystal/public/assets/images/rss.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/crystal/src/operations/save_tag.cr:
--------------------------------------------------------------------------------
1 | class SaveTag < Tag::SaveOperation
2 | before_save validate_size_of name, max: 25
3 | before_save validate_name
4 |
5 | private def validate_name
6 | if (name.value || "").strip.downcase !~ /\A[a-z][a-z0-9-]*\z/
7 | name.add_error "must only contain a-z, 0-9, or hyphen"
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/pages/posts/new_page.cr:
--------------------------------------------------------------------------------
1 | class Posts::NewPage < AdminLayout
2 | needs form : SavePost
3 | quick_def page_title, "New Post"
4 | quick_def page_description, ""
5 |
6 | def content
7 | form_for Posts::Create, id: "new-post-form", class: "form-stacked" do
8 | mount Posts::Form, @form, post: nil
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/auth/test_backdoor.cr:
--------------------------------------------------------------------------------
1 | module Auth::TestBackdoor
2 | macro included
3 | before test_backdoor
4 | end
5 |
6 | private def test_backdoor
7 | if LuckyEnv.test? && (user_id = params.get?(:backdoor_user_id))
8 | user = UserQuery.find(user_id)
9 | sign_in user
10 | end
11 | continue
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/crystal/config/authentic.cr:
--------------------------------------------------------------------------------
1 | require "./server"
2 |
3 | Authentic.configure do |settings|
4 | settings.secret_key = Lucky::Server.settings.secret_key_base
5 |
6 | unless LuckyEnv.production?
7 | # This value can be between 4 and 31
8 | fastest_encryption_possible = 4
9 | settings.encryption_cost = fastest_encryption_possible
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/crystal/src/models/search_results.cr:
--------------------------------------------------------------------------------
1 | class SearchResults
2 | getter results
3 | getter total
4 | getter page
5 |
6 | def self.none
7 | new([] of SearchResult, Page.new(1), 0)
8 | end
9 |
10 | def initialize(@results : Array(SearchResult), @page : Page, @total : UInt32)
11 | end
12 |
13 | def empty?
14 | @total == 0
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/crystal/src/start_server.cr:
--------------------------------------------------------------------------------
1 | require "./app"
2 |
3 | if LuckyEnv.development?
4 | Avram::Migrator::Runner.new.ensure_migrated!
5 | Avram::SchemaEnforcer.ensure_correct_column_mappings!
6 | end
7 | Habitat.raise_if_missing_settings!
8 |
9 | app_server = AppServer.new
10 |
11 | Signal::INT.trap do
12 | app_server.close
13 | end
14 |
15 | app_server.listen
16 |
--------------------------------------------------------------------------------
/crystal/src/actions/posts/edit.cr:
--------------------------------------------------------------------------------
1 | class Posts::Edit < BrowserAction
2 | get "/posts/:post_id/edit" do
3 | post = PostQuery.new.preload_post_categories.preload_tags.find(post_id)
4 | form = SavePost.new(post)
5 | form.tags.value = post.tags.map(&.name).join(" ")
6 |
7 | html EditPage,
8 | form: form,
9 | post: post
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/crystal/src/serializers/errors/show_serializer.cr:
--------------------------------------------------------------------------------
1 | class ErrorSerializer < BaseSerializer
2 | def initialize(
3 | @message : String,
4 | @details : String? = nil,
5 | @param : String? = nil # If there was a problem with a specific param
6 | )
7 | end
8 |
9 | def render
10 | {message: @message, param: @param, details: @details}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/crystal/src/models/avatar.cr:
--------------------------------------------------------------------------------
1 | class Avatar
2 | getter image
3 |
4 | def initialize(image : String)
5 | @image = Path[image]
6 | end
7 |
8 | def thumbnail_path : Path
9 | if image.extension == ".svg"
10 | Path["images/u"].join(image)
11 | else
12 | Path["images/u/thumb"].join(image.basename(image.extension) + ".jpg")
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/crystal/src/models/post_category.cr:
--------------------------------------------------------------------------------
1 | class PostCategory < BaseModel
2 | skip_default_columns
3 | delegate slug, name, to: category
4 |
5 | table do
6 | primary_key id : Int64
7 | belongs_to post : Post
8 | column category_id : Int16
9 | end
10 |
11 | def category : Category
12 | Category::ALL.find { |cat| cat.id == category_id }.not_nil!
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/crystal/src/models/tag.cr:
--------------------------------------------------------------------------------
1 | class Tag < BaseModel
2 | skip_default_columns
3 |
4 | table do
5 | primary_key id : Int64
6 | column name : String
7 | has_many creator_tags : CreatorTag
8 | has_many creators : Creator, through: [:creator_tags, :creator]
9 | has_many post_tags : PostTag
10 | has_many posts : Post, through: [:post_tags, :post]
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/rust/migrations/2019-12-26-021440_create_users/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS users (
2 | id bigserial PRIMARY KEY,
3 | created_at timestamp with time zone NOT NULL,
4 | updated_at timestamp with time zone NOT NULL,
5 | email text NOT NULL,
6 | encrypted_password text NOT NULL
7 | );
8 |
9 | CREATE UNIQUE INDEX IF NOT EXISTS users_email_index ON users (email);
10 |
--------------------------------------------------------------------------------
/crystal/src/pages/posts/edit_page.cr:
--------------------------------------------------------------------------------
1 | class Posts::EditPage < MainLayout
2 | needs form : SavePost
3 | needs post : Post
4 | quick_def page_title, "Edit Post"
5 | quick_def page_description, ""
6 |
7 | def content
8 | form_for Posts::Update.with(@post.id), id: "edit-post-form", class: "form-stacked" do
9 | mount Posts::Form, @form, @post
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/support_rust.md:
--------------------------------------------------------------------------------
1 | If you are suggesting a new entry to the Support Rust page, please include the
2 | following details:
3 |
4 | **Name:** _Preferred name to show on the page._
5 | **Source Code URL:** _Primary place where published code lives._
6 | **Support URL:** _Patreon, GitHub, Liberapay, etc._
7 | **Description:** _Short description of what this person or project is working on._
8 |
--------------------------------------------------------------------------------
/crystal/spec/support/factories/creator_box.cr:
--------------------------------------------------------------------------------
1 | class CreatorFactory < Avram::Factory
2 | def initialize
3 | name "Test Creator"
4 | avatar "test.jpg"
5 | support_link_name "Support on Patreon"
6 | support_link_url "http://example.com/support"
7 | code_link_name "test"
8 | code_link_url "http://example.com/code/test"
9 | description "Description"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/rust/migrations/00000000000000_diesel_initial_setup/down.sql:
--------------------------------------------------------------------------------
1 | -- This file was automatically created by Diesel to setup helper functions
2 | -- and other internal bookkeeping. This file is safe to edit, any future
3 | -- changes will be added to existing projects as new migrations.
4 |
5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
6 | DROP FUNCTION IF EXISTS diesel_set_updated_at();
7 |
--------------------------------------------------------------------------------
/crystal/src/operations/reset_password.cr:
--------------------------------------------------------------------------------
1 | class ResetPassword < User::SaveOperation
2 | # Change password validations in src/operations/mixins/password_validations.cr
3 | include PasswordValidations
4 |
5 | attribute password : String
6 | attribute password_confirmation : String
7 |
8 | before_save do
9 | Authentic.copy_and_encrypt password, to: encrypted_password
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/crystal/src/actions/api/sign_ins/create.cr:
--------------------------------------------------------------------------------
1 | class Api::SignIns::Create < ApiAction
2 | include Api::Auth::SkipRequireAuthToken
3 |
4 | post "/api/sign_ins" do
5 | SignInUser.run(params) do |operation, user|
6 | if user
7 | json({token: UserToken.generate(user)})
8 | else
9 | raise Avram::InvalidOperationError.new(operation)
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/crystal/src/components/shared/flash_messages.cr:
--------------------------------------------------------------------------------
1 | class Shared::FlashMessages < BaseComponent
2 | needs flash : Lucky::FlashStore
3 |
4 | def render
5 | @flash.each do |flash_type, flash_message|
6 | div class: "flash-container" do
7 | div class: "flash flash-#{flash_type}", flow_id: "flash" do
8 | text flash_message
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/crystal/src/queries/category_query.cr:
--------------------------------------------------------------------------------
1 | class CategoryQuery
2 | include Enumerable(Category)
3 |
4 | def each
5 | Category::ALL.each do |category|
6 | yield category
7 | end
8 | end
9 |
10 | def slug(slug)
11 | Category::ALL.select { |category| category.slug == slug }
12 | end
13 |
14 | def without_all
15 | Category::ALL.reject { |category| category.all? }
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/crystal/config/route_helper.cr:
--------------------------------------------------------------------------------
1 | # This is used when generating URLs for your application
2 | Lucky::RouteHelper.configure do |settings|
3 | if LuckyEnv.production?
4 | # Example: https://my_app.com
5 | settings.base_uri = ENV.fetch("APP_DOMAIN")
6 | else
7 | # Set domain to the default host/port in development/test
8 | settings.base_uri = "http://localhost:#{Lucky::ServerSettings.port}"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/crystal/src/models/page.cr:
--------------------------------------------------------------------------------
1 | # A type representing a page number
2 | #
3 | # Will always be in the range 1..=UInt16::MAX
4 | struct Page
5 | delegate to_i, to_u16, to_u32, to_s, succ, pred, to: @page
6 |
7 | def initialize(page)
8 | if page < 1 || page > UInt16::MAX
9 | @page = 1_u16
10 | else
11 | @page = page.to_u16
12 | end
13 | end
14 |
15 | def first?
16 | @page == 1_u16
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/crystal/src/operations/mixins/password_validations.cr:
--------------------------------------------------------------------------------
1 | module PasswordValidations
2 | macro included
3 | before_save run_password_validations
4 | end
5 |
6 | private def run_password_validations
7 | validate_required password, password_confirmation
8 | validate_confirmation_of password, with: password_confirmation
9 | # 72 is the limit of BCrypt
10 | validate_size_of password, min: 6, max: 72
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/crystal/src/pages/tags/index_page.cr:
--------------------------------------------------------------------------------
1 | class Tags::IndexPage < MainLayout
2 | needs tags : Array(String)
3 | quick_def page_title, "Tags"
4 | quick_def page_description, "This page lists all the tags that posts are categorised by."
5 |
6 | def content
7 | div class: "justify-text" do
8 | @tags.each do |tag|
9 | text " "
10 | link tag, to: Tags::Show.with(tag), class: "tag"
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/crystal/src/components/shared/field_errors.cr:
--------------------------------------------------------------------------------
1 | class Shared::FieldErrors(T) < BaseComponent
2 | needs field : Avram::PermittedAttribute(T)
3 |
4 | # Customize the markup and styles to match your application
5 | def render
6 | unless @field.valid?
7 | div class: "error" do
8 | label_text = Wordsmith::Inflector.humanize(@field.name.to_s)
9 | text "#{label_text} #{@field.errors.first}"
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/crystal/public/assets/images/twitter.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | DATABASE_URL=postgres://wmoore@localhost/read_rust_development
2 | TEST_DATABASE_URL=postgres://wmoore@localhost/read_rust_test
3 | MASTODON_BASE=https://botsin.space
4 | MASTODON_CLIENT_ID=
5 | MASTODON_CLIENT_SECRET=
6 | MASTODON_REDIRECT=urn:ietf:wg:oauth:2.0:oob
7 | MASTODON_TOKEN=
8 | TWITTER_CONSUMER_KEY=
9 | TWITTER_CONSUMER_SECRET=
10 | TWITTER_ACCESS_KEY=
11 | TWITTER_ACCESS_SECRET=
12 | FEEDBIN_USERNAME=
13 | FEEDBIN_PASSWORD=
14 | READRUST_ALLOW_SIGNUP=0
15 |
--------------------------------------------------------------------------------
/crystal/src/serializers/error_serializer.cr:
--------------------------------------------------------------------------------
1 | # This is the default error serializer generated by Lucky.
2 | # Feel free to customize it in any way you like.
3 | class ErrorSerializer < BaseSerializer
4 | def initialize(
5 | @message : String,
6 | @details : String? = nil,
7 | @param : String? = nil # If there was a problem with a specific param
8 | )
9 | end
10 |
11 | def render
12 | {message: @message, param: @param, details: @details}
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/crystal/spec/requests/api/me/show_spec.cr:
--------------------------------------------------------------------------------
1 | require "../../../spec_helper"
2 |
3 | describe Api::Me::Show do
4 | it "returns the signed in user" do
5 | user = UserFactory.create
6 |
7 | response = ApiClient.auth(user).exec(Api::Me::Show)
8 |
9 | response.should send_json(200, email: user.email)
10 | end
11 |
12 | it "fails if not authenticated" do
13 | response = ApiClient.exec(Api::Me::Show)
14 |
15 | response.status_code.should eq(401)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/rust/migrations/2019-12-26-021511_create_creators/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS creators (
2 | id bigserial PRIMARY KEY,
3 | name text NOT NULL,
4 | avatar text NOT NULL,
5 | support_link_name text NOT NULL,
6 | support_link_url text NOT NULL,
7 | code_link_name text NOT NULL,
8 | code_link_url text NOT NULL,
9 | description text NOT NULL,
10 | created_at timestamp with time zone NOT NULL,
11 | updated_at timestamp with time zone NOT NULL
12 | );
13 |
--------------------------------------------------------------------------------
/crystal/src/emails/password_reset_request_email.cr:
--------------------------------------------------------------------------------
1 | class PasswordResetRequestEmail < BaseEmail
2 | Habitat.create { setting stubbed_token : String? }
3 | delegate stubbed_token, to: :settings
4 |
5 | def initialize(@user : User)
6 | @token = stubbed_token || Authentic.generate_password_reset_token(@user)
7 | end
8 |
9 | to @user
10 | from "myapp@support.com" # or set a default in src/emails/base_email.cr
11 | subject "Reset your password"
12 | templates html, text
13 | end
14 |
--------------------------------------------------------------------------------
/crystal/src/actions/creators/index.cr:
--------------------------------------------------------------------------------
1 | class Creators::Index < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_in_varnish(5.minutes)
5 |
6 | get "/support" do
7 | weak_etag(last_modified.to_unix)
8 |
9 | html IndexPage, creators: CreatorQuery.new.preload_tags
10 | end
11 |
12 | private def last_modified
13 | time = CreatorQuery.new.updated_at.select_max
14 | if time.nil?
15 | Time.utc
16 | else
17 | time
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/crystal/src/actions/posts/index.cr:
--------------------------------------------------------------------------------
1 | class Posts::Index < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_in_varnish(1.minute)
5 |
6 | get "/all" do
7 | weak_etag(last_modified.to_unix)
8 |
9 | html Posts::IndexPage, posts: PostQuery.new.created_at.desc_order
10 | end
11 |
12 | private def last_modified : Time
13 | time = PostQuery.new.updated_at.select_max
14 | if time.nil?
15 | Time.utc
16 | else
17 | time
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/crystal/src/components/search/form.cr:
--------------------------------------------------------------------------------
1 | class Search::Form < BaseComponent
2 | needs query : String = ""
3 |
4 | def render
5 | form action: "/search", class: "search-form", method: "get" do
6 | input aria_label: "Search Read Rust", autocapitalize: "off", autocomplete: "off", id: "q", maxlength: "255", name: "q", placeholder: "Search", title: "Search Read Rust", type: "search", value: @query
7 | text " "
8 | input type: "submit", value: "Search"
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/crystal/src/operations/sign_up_user.cr:
--------------------------------------------------------------------------------
1 | class SignUpUser < User::SaveOperation
2 | param_key :user
3 | # Change password validations in src/operations/mixins/password_validations.cr
4 | include PasswordValidations
5 |
6 | permit_columns email
7 | attribute password : String
8 | attribute password_confirmation : String
9 |
10 | before_save do
11 | validate_uniqueness_of email
12 | Authentic.copy_and_encrypt(password, to: encrypted_password) if password.valid?
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/crystal/src/models/post.cr:
--------------------------------------------------------------------------------
1 | class Post < BaseModel
2 | table do
3 | column guid : UUID
4 | column title : String
5 | column url : String
6 | column twitter_url : String?
7 | column mastodon_url : String?
8 | column author : String
9 | column summary : String
10 | column tweeted_at : Time?
11 | column tooted_at : Time?
12 | has_many post_categories : PostCategory
13 | has_many post_tags : PostTag
14 | has_many tags : Tag, through: [:post_tags, :tag]
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/crystal/README.md:
--------------------------------------------------------------------------------
1 | # read_rust
2 |
3 | This is a project written using [Lucky](https://luckyframework.org). Enjoy!
4 |
5 | ### Setting up the project
6 |
7 | 1. [Install required dependencies](http://luckyframework.org/guides/installing.html#install-required-dependencies)
8 | 1. Run `script/setup`
9 | 1. Run `lucky dev` to start the app
10 |
11 | ### Learning Lucky
12 |
13 | Lucky uses the [Crystal](https://crystal-lang.org) programming language. You can learn about Lucky from the [Lucky Guides](http://luckyframework.org/guides).
14 |
--------------------------------------------------------------------------------
/crystal/src/serializers/json_feed/post_serializer.cr:
--------------------------------------------------------------------------------
1 | class JsonFeed::PostSerializer < BaseSerializer
2 | def initialize(@post : Post)
3 | end
4 |
5 | def render
6 | {
7 | id: @post.guid.to_s,
8 | title: @post.title,
9 | content_text: @post.summary,
10 | url: @post.url,
11 | date_published: @post.created_at.to_rfc3339,
12 | author: { name: @post.author },
13 | tags: @post.post_categories.map(&.name),
14 | }
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/auth/redirect_signed_in_users.cr:
--------------------------------------------------------------------------------
1 | module Auth::RedirectSignedInUsers
2 | macro included
3 | include Auth::AllowGuests
4 | before redirect_signed_in_users
5 | end
6 |
7 | private def redirect_signed_in_users
8 | if current_user?
9 | flash.success = "You are already signed in"
10 | redirect to: Home::Index
11 | else
12 | continue
13 | end
14 | end
15 |
16 | # current_user returns nil because signed in users are redirected.
17 | def current_user
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/crystal/src/queries/tag_query.cr:
--------------------------------------------------------------------------------
1 | class TagQuery < Tag::BaseQuery
2 | # Return the names of the tags that have at least one associated post
3 | def self.with_posts : Array(String)
4 | names = [] of String
5 |
6 | AppDatabase.run do |db|
7 | db.query_each "SELECT tags.name FROM tags, post_tags WHERE post_tags.tag_id = tags.id GROUP BY tags.name HAVING count(tags.name) > 0 ORDER BY tags.name;" do |result_set|
8 | names << result_set.read(String)
9 | end
10 | end
11 |
12 | names
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/crystal/spec/avatar_spec.cr:
--------------------------------------------------------------------------------
1 | require "./spec_helper"
2 |
3 | describe Avatar do
4 | describe "avatar_thumbnail" do
5 | context "svg" do
6 | it { Avatar.new("test.svg").thumbnail_path.should eq Path["images/u/test.svg"] }
7 | end
8 |
9 | context "jpg" do
10 | it { Avatar.new("test.jpg").thumbnail_path.should eq Path["images/u/thumb/test.jpg"] }
11 | end
12 |
13 | context "png" do
14 | it { Avatar.new("test.png").thumbnail_path.should eq Path["images/u/thumb/test.jpg"] }
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/rust/migrations/2019-12-26-021504_create_post_categories/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS post_categories (
2 | id bigserial PRIMARY KEY,
3 | post_id bigint NOT NULL,
4 | category_id smallint NOT NULL,
5 | CONSTRAINT post_categories_post_id_fkey FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
6 | );
7 |
8 | CREATE UNIQUE INDEX IF NOT EXISTS post_categories_post_id_category_id_index ON post_categories (post_id, category_id);
9 | CREATE INDEX IF NOT EXISTS post_categories_post_id_index ON post_categories (post_id);
10 |
--------------------------------------------------------------------------------
/crystal/src/actions/json_feed/show.cr:
--------------------------------------------------------------------------------
1 | class JsonFeed::Show < BrowserAction
2 | include Auth::AllowGuests
3 | include Categories::FindCategory
4 |
5 | before cache_in_varnish(2.minutes)
6 |
7 | get "/:slug/feed.json" do
8 | unconditional_weak_etag(last_modified.to_unix)
9 |
10 | json ShowSerializer.new(category)
11 | end
12 |
13 | private def last_modified
14 | time = PostQuery.new.last_modified_in_category(category)
15 | if time.nil?
16 | Time.utc
17 | else
18 | time
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/crystal/src/models/creator.cr:
--------------------------------------------------------------------------------
1 | class Creator < BaseModel
2 | table do
3 | column name : String
4 | column avatar : String
5 | column support_link_name : String
6 | column support_link_url : String
7 | column code_link_name : String
8 | column code_link_url : String
9 | column description : String
10 | has_many creator_tags : CreatorTag
11 | has_many tags : Tag, through: [:creator_tags, :tag]
12 | end
13 |
14 | def avatar_thumbnail : String
15 | Avatar.new(avatar).thumbnail_path.to_s
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/crystal/config/feedbin.cr:
--------------------------------------------------------------------------------
1 | Feedbin::Client.configure do |settings|
2 | settings.username = feedbin_user_from_env
3 | settings.password = feedbin_pass_from_env
4 | end
5 |
6 | private def feedbin_user_from_env
7 | ENV["FEEDBIN_USERNAME"]? || warn_missing_credentials
8 | end
9 |
10 | private def feedbin_pass_from_env
11 | ENV["FEEDBIN_PASSWORD"]? || warn_missing_credentials
12 | end
13 |
14 | private def warn_missing_credentials
15 | puts "FEEDBIN_USERNAME and/or FEEDBIN_PASSWORD are not set, Feedbin integration won't work".colorize.red
16 | ""
17 | end
18 |
--------------------------------------------------------------------------------
/crystal/src/actions/password_reset_requests/create.cr:
--------------------------------------------------------------------------------
1 | class PasswordResetRequests::Create < BrowserAction
2 | include Auth::RedirectSignedInUsers
3 |
4 | post "/password_reset_requests" do
5 | RequestPasswordReset.run(params) do |operation, user|
6 | if user
7 | PasswordResetRequestEmail.new(user).deliver
8 | flash.success = "You should receive an email on how to reset your password shortly"
9 | redirect SignIns::New
10 | else
11 | html NewPage, operation: operation
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/crystal/src/actions/search/show.cr:
--------------------------------------------------------------------------------
1 | class Search::Show < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | param q : String = ""
5 | param page : Int32 = 1 # Trying to make this UInt16 or UInt32 gives Error: undefined constant UInt32::Lucky
6 |
7 | get "/search" do
8 | if q.blank?
9 | flash.failure = "You need to specify what to search for"
10 | html Search::ShowPage, query: q, results: SearchResults.none
11 | else
12 | html Search::ShowPage, query: q, results: PostQuery.search(q, Page.new(page))
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/.github/contributing.md:
--------------------------------------------------------------------------------
1 | ## Missing Posts
2 |
3 | Adding new posts to Read Rust is currently a manual process and I generally
4 | update the feed a few times a week. I read Mastodon, Twitter and
5 | [Reddit][rust-reddit] as well as subscribe to a lot of RSS feeds to discover
6 | new posts. Please only submit posts written in, or after 2018.
7 |
8 | For all other posts [create an issue on GitHub][add-post].
9 |
10 | [rust-reddit]: https://www.reddit.com/r/rust/
11 | [add-post]: https://github.com/wezm/read-rust/issues/new?labels=missing-post&title=Add+post&template=missing_post.md
12 |
--------------------------------------------------------------------------------
/crystal/public/assets/images/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/crystal/spec/flows/reset_password_spec_disabled.cr:
--------------------------------------------------------------------------------
1 | require "../spec_helper"
2 |
3 | describe "Reset password flow", tags: "flow" do
4 | it "works" do
5 | user = UserFactory.create
6 | flow = ResetPasswordFlow.new(user)
7 |
8 | flow.request_password_reset
9 | flow.should_have_sent_reset_email
10 | flow.reset_password "new-password"
11 | flow.should_be_signed_in
12 | flow.sign_out
13 | flow.sign_in "wrong-password"
14 | flow.should_have_password_error
15 | flow.sign_in "new-password"
16 | flow.should_be_signed_in
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/password_resets/require_token.cr:
--------------------------------------------------------------------------------
1 | module Auth::PasswordResets::RequireToken
2 | macro included
3 | before require_valid_password_reset_token
4 | end
5 |
6 | abstract def token : String
7 | abstract def user : User
8 |
9 | private def require_valid_password_reset_token
10 | if Authentic.valid_password_reset_token?(user, token)
11 | continue
12 | else
13 | flash.failure = "The password reset link is incorrect or expired. Please try again."
14 | redirect to: PasswordResetRequests::New
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/crystal/src/pages/password_reset_requests/new_page.cr:
--------------------------------------------------------------------------------
1 | class PasswordResetRequests::NewPage < AuthLayout
2 | needs operation : RequestPasswordReset
3 | quick_def page_title, "Password Reset"
4 | quick_def page_description, ""
5 |
6 | def content
7 | h1 "Reset your password"
8 | render_form(@operation)
9 | end
10 |
11 | private def render_form(op)
12 | form_for PasswordResetRequests::Create do
13 | mount Shared::Field, op.email, &.email_input
14 | submit "Reset Password", flow_id: "request-password-reset-button"
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/crystal/src/utils/sanitise.cr:
--------------------------------------------------------------------------------
1 | @[Link("striptags")]
2 | lib LibStriptags
3 | fun strip_tags(input : UInt8*, input_len : Int32, output : UInt8**, output_len : Int32*)
4 | fun strip_tags_free(string : UInt8*)
5 | end
6 |
7 | module Sanitise
8 | def self.strip_tags(input : String) : String?
9 | LibStriptags.strip_tags(input.to_unsafe, input.bytesize, out output, out length)
10 |
11 | if output.null?
12 | return nil
13 | end
14 |
15 | result = String.new(output, length)
16 | LibStriptags.strip_tags_free(output)
17 | result.strip
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/crystal/public/assets/images/mastodon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/crystal/src/actions/sign_ins/create.cr:
--------------------------------------------------------------------------------
1 | class SignIns::Create < BrowserAction
2 | include Auth::RedirectSignedInUsers
3 |
4 | post "/sign_ins" do
5 | SignInUser.run(params) do |operation, authenticated_user|
6 | if authenticated_user
7 | cache_friendly_sign_in(authenticated_user)
8 | flash.success = "You're now signed in"
9 | Authentic.redirect_to_originally_requested_path(self, fallback: Home::Index)
10 | else
11 | flash.failure = "Sign in failed"
12 | html NewPage, operation: operation
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/rust/migrations/2019-12-26-021456_create_posts/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS posts (
2 | id bigserial PRIMARY KEY,
3 | guid uuid NOT NULL,
4 | title text NOT NULL,
5 | url text NOT NULL,
6 | twitter_url text,
7 | mastodon_url text,
8 | author text NOT NULL,
9 | summary text NOT NULL,
10 | tweeted_at timestamp with time zone,
11 | tooted_at timestamp with time zone,
12 | created_at timestamp with time zone NOT NULL,
13 | updated_at timestamp with time zone NOT NULL
14 | );
15 |
16 | CREATE UNIQUE INDEX IF NOT EXISTS posts_url_index ON posts (url);
17 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/auth/require_sign_in.cr:
--------------------------------------------------------------------------------
1 | module Auth::RequireSignIn
2 | macro included
3 | before require_sign_in
4 | end
5 |
6 | private def require_sign_in
7 | if current_user?
8 | continue
9 | else
10 | Authentic.remember_requested_path(self)
11 | flash.info = "Please sign in first"
12 | redirect to: SignIns::New
13 | end
14 | end
15 |
16 | # Tells the compiler that the current_user is not nil since we have checked
17 | # that the user is signed in
18 | private def current_user : User
19 | current_user?.not_nil!
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/crystal/src/actions/password_resets/create.cr:
--------------------------------------------------------------------------------
1 | class PasswordResets::Create < BrowserAction
2 | include Auth::PasswordResets::Base
3 | include Auth::PasswordResets::TokenFromSession
4 |
5 | post "/password_resets/:user_id" do
6 | ResetPassword.update(user, params) do |operation, user|
7 | if operation.saved?
8 | session.delete(:password_reset_token)
9 | sign_in user
10 | flash.success = "Your password has been reset"
11 | redirect to: Home::Index
12 | else
13 | html NewPage, operation: operation, user_id: user_id.to_i
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/crystal/src/models/category.cr:
--------------------------------------------------------------------------------
1 | require "json_mapping"
2 |
3 | class Category
4 | JSON.mapping(
5 | id: Int16,
6 | name: String,
7 | hashtag: String,
8 | slug: String,
9 | year: UInt16?,
10 | description: String,
11 | )
12 |
13 | ALL = Array(Category).from_json({{ read_file("../content/_data/categories.json") }})
14 | VALID_IDS = ALL.compact_map { |category| category.all? ? nil : category.id }
15 |
16 | # Return the list of category ids that are valid for a PostCategory record
17 | def self.valid_ids
18 | VALID_IDS
19 | end
20 |
21 | def all?
22 | id.zero?
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/crystal/bs-config.js:
--------------------------------------------------------------------------------
1 | /*
2 | | Browser-sync config file
3 | |
4 | | For up-to-date information about the options:
5 | | http://www.browsersync.io/docs/options/
6 | |
7 | */
8 |
9 | module.exports = {
10 | snippetOptions: {
11 | rule: {
12 | match: /<\/head>/i,
13 | fn: function (snippet, match) {
14 | return snippet + match;
15 | }
16 | }
17 | },
18 | files: ["public/css/**/*.css", "public/js/**/*.js"],
19 | watchEvents: ["change"],
20 | open: false,
21 | browser: "default",
22 | ghostMode: false,
23 | ui: false,
24 | online: false,
25 | logConnections: false
26 | };
27 |
--------------------------------------------------------------------------------
/crystal/src/operations/request_password_reset.cr:
--------------------------------------------------------------------------------
1 | class RequestPasswordReset < Avram::Operation
2 | # You can modify this in src/operations/mixins/user_from_email.cr
3 | include UserFromEmail
4 |
5 | attribute email : String
6 |
7 | # Run validations and yield the form and the user if valid
8 | def run
9 | user = user_from_email
10 | validate(user)
11 |
12 | if valid?
13 | user
14 | else
15 | nil
16 | end
17 | end
18 |
19 | def validate(user : User?)
20 | validate_required email
21 | if user.nil?
22 | email.add_error "is not in our system"
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/crystal/src/actions/sign_ups/create.cr:
--------------------------------------------------------------------------------
1 | class SignUps::Create < BrowserAction
2 | include Auth::RedirectSignedInUsers
3 |
4 | post "/sign_ups" do
5 | if ReadRust::Config.allow_sign_up?
6 | SignUpUser.create(params) do |operation, user|
7 | if user
8 | flash.info = "Thanks for signing up"
9 | cache_friendly_sign_in(user)
10 | redirect to: Home::Index
11 | else
12 | flash.info = "Couldn't sign you up"
13 | html NewPage, operation: operation
14 | end
15 | end
16 | else
17 | raise Lucky::RouteNotFoundError.new(context)
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/crystal/src/actions/api_action.cr:
--------------------------------------------------------------------------------
1 | # Include modules and add methods that are for all API requests
2 | abstract class ApiAction < Lucky::Action
3 | accepted_formats [:json]
4 |
5 | include Api::Auth::Helpers
6 |
7 | # By default all actions require sign in.
8 | # Add 'include Api::Auth::SkipRequireAuthToken' to your actions to allow all requests.
9 | include Api::Auth::RequireAuthToken
10 |
11 | # By default all actions are required to use underscores to separate words.
12 | # Add 'include Lucky::SkipRouteStyleCheck' to your actions if you wish to ignore this check for specific routes.
13 | include Lucky::EnforceUnderscoredRoute
14 | end
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/missing_post.md:
--------------------------------------------------------------------------------
1 | Please note that I'm winding down the Read Rust posting frequency and currently
2 | mainly publishing [Rust 2021] posts. For more information see my post:
3 |
4 | https://www.wezm.net/v2/posts/2020/slowing-read-rust-posting/
5 |
6 | [Rust 2021]: https://blog.rust-lang.org/2020/09/03/Planning-2021-Roadmap.html
7 |
8 | If you'd still like to submit your post please fill in the following:
9 |
10 | Please add this post:
11 |
12 | **Post URL:**
13 | **Author Name:**
14 | **Categories:** pick one: Crates, Embedded, Games and Graphics, Getting Started, Language, Performance, Tools and Applications, Web and Network Services
15 |
--------------------------------------------------------------------------------
/rust/migrations/2019-12-26-021534_create_post_tags/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS post_tags (
2 | id bigserial PRIMARY KEY,
3 | post_id bigint NOT NULL,
4 | tag_id bigint NOT NULL,
5 | CONSTRAINT post_tags_post_id_fkey FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
6 | CONSTRAINT post_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
7 | );
8 |
9 | CREATE INDEX IF NOT EXISTS post_tags_post_id_index ON post_tags (post_id);
10 | CREATE INDEX IF NOT EXISTS post_tags_tag_id_index ON post_tags (tag_id);
11 | CREATE UNIQUE INDEX IF NOT EXISTS post_tags_post_id_tag_id_index ON post_tags (post_id, tag_id);
12 |
--------------------------------------------------------------------------------
/crystal/src/actions/home/index.cr:
--------------------------------------------------------------------------------
1 | class Home::Index < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_in_varnish(1.minute)
5 |
6 | get "/" do
7 | category = CategoryQuery.new.slug("all").first
8 | recent_posts = PostQuery.new.preload_post_categories.recent_in_category(category).limit(10)
9 | weak_etag(last_modified.to_unix)
10 |
11 | html Categories::IndexPage, categories: CategoryQuery.new.without_all, recent_posts: recent_posts
12 | end
13 |
14 | private def last_modified
15 | time = PostQuery.new.updated_at.select_max
16 | if time.nil?
17 | Time.utc
18 | else
19 | time
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/crystal/src/pages/search/show_page.cr:
--------------------------------------------------------------------------------
1 | class Search::ShowPage < MainLayout
2 | needs results : SearchResults
3 |
4 | quick_def page_title, "Search Results"
5 | quick_def page_description, ""
6 |
7 | def content
8 | mount Search::Form, @query
9 |
10 | if @results.empty?
11 | para "No results were found."
12 | else
13 | @results.results.each do |result|
14 | mount Posts::Summary, result.post, @current_user, show_categories: true, highlight: result.summary
15 | end
16 |
17 | mount Posts::Pagination, query: @query, page: @results.page, per_page: PostQuery::PER_PAGE.to_u16, total: @results.total
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/crystal/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "UNLICENSED",
3 | "private": true,
4 | "dependencies": {
5 | "rails-ujs": "^5.1.4"
6 | },
7 | "scripts": {
8 | "heroku-postbuild": "yarn prod",
9 | "dev": "yarn run mix",
10 | "watch": "yarn run mix watch",
11 | "prod": "yarn run mix --production"
12 | },
13 | "devDependencies": {
14 | "@babel/compat-data": "^7.9.0",
15 | "browser-sync": "^2.18.13",
16 | "compression-webpack-plugin": "^7.0.0",
17 | "laravel-mix": "^6.0.0",
18 | "postcss": "^8.1.0",
19 | "resolve-url-loader": "^3.1.1",
20 | "sass": "^1.26.10",
21 | "sass-loader": "^10.0.2",
22 | "typescript": "^3.6.4"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/crystal/src/pages/auth_layout.cr:
--------------------------------------------------------------------------------
1 | abstract class AuthLayout
2 | include Lucky::HTMLPage
3 |
4 | abstract def content
5 | abstract def page_title
6 |
7 | def extra_css
8 | "css/admin.css"
9 | end
10 |
11 | def render
12 | html_doctype
13 |
14 | html lang: "en" do
15 | mount Shared::LayoutHead, page_title: page_title, page_description: "", categories: CategoryQuery.new, app_js: false, admin: false, extra_css: extra_css
16 |
17 | body do
18 | mount Shared::Header, nil
19 |
20 | main class: "main" do
21 | mount Shared::FlashMessages, @context.flash
22 | content
23 | end
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/crystal/src/components/posts/action_bar.cr:
--------------------------------------------------------------------------------
1 | class Posts::ActionBar < BaseComponent
2 | needs post : Post
3 | needs current_user : User?
4 |
5 | def render
6 | @current_user.try do
7 | div class: "post-action-bar" do
8 | link "Show", Posts::Show.with(@post.id)
9 | link "Edit", Posts::Edit.with(@post.id)
10 | twitter_url = @post.twitter_url
11 | mastodon_url = @post.mastodon_url
12 | a(href: twitter_url) { text "Tweet URL" } if twitter_url
13 | a(href: mastodon_url) { text "Fediverse URL" } if mastodon_url
14 | # link "Delete", Posts::Delete.with(@post.id), data_confirm: "Are you sure?"
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/crystal/src/actions/categories/show.cr:
--------------------------------------------------------------------------------
1 | class Categories::Show < BrowserAction
2 | include Auth::AllowGuests
3 |
4 | before cache_in_varnish(1.minute)
5 |
6 | get "/:slug" do
7 | if category = CategoryQuery.new.slug(slug).first?
8 | weak_etag(last_modified(category).to_unix)
9 |
10 | html ShowPage, category: category, posts: PostQuery.new.preload_tags.recent_in_category(category)
11 | else
12 | raise Lucky::RouteNotFoundError.new(context)
13 | end
14 | end
15 |
16 | private def last_modified(category)
17 | time = PostQuery.new.last_modified_in_category(category)
18 | if time.nil?
19 | Time.utc
20 | else
21 | time
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/rust/migrations/2019-12-26-021527_create_creator_tags/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS creator_tags (
2 | id bigserial PRIMARY KEY,
3 | creator_id bigint NOT NULL,
4 | tag_id bigint NOT NULL,
5 | CONSTRAINT creator_tags_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES creators(id) ON DELETE CASCADE,
6 | CONSTRAINT creator_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
7 | );
8 |
9 | CREATE INDEX IF NOT EXISTS creator_tags_creator_id_index ON creator_tags (creator_id);
10 | CREATE INDEX IF NOT EXISTS creator_tags_tag_id_index ON creator_tags (tag_id);
11 | CREATE UNIQUE INDEX IF NOT EXISTS creator_tags_creator_id_tag_id_index ON creator_tags (creator_id, tag_id);
12 |
--------------------------------------------------------------------------------
/rust/migrations/2020-01-06-035808_add_full_text_index/up.sql:
--------------------------------------------------------------------------------
1 | CREATE MATERIALIZED VIEW search_view AS
2 | SELECT posts.id,
3 | posts.summary,
4 | setweight(to_tsvector('english', posts.title), 'A') ||
5 | setweight(to_tsvector('english', coalesce(string_agg(tags.name, ' '), '')), 'B') ||
6 | setweight(to_tsvector('english', posts.summary), 'C') ||
7 | setweight(to_tsvector('english', posts.author), 'D') AS vector
8 | FROM posts
9 | LEFT JOIN post_tags ON (posts.id = post_tags.post_id)
10 | LEFT JOIN tags ON (post_tags.tag_id = tags.id)
11 | GROUP BY posts.id;
12 |
13 | CREATE INDEX search_index ON search_view USING GIN (vector);
14 |
--------------------------------------------------------------------------------
/crystal/src/pages/creators/index_page.cr:
--------------------------------------------------------------------------------
1 | class Creators::IndexPage < MainLayout
2 | quick_def page_title, "Support Rust"
3 | quick_def page_description, "This page used to list people and projects contributing to the Rust ecosystem that are accepting financial contributions."
4 |
5 | def app_js?
6 | true
7 | end
8 |
9 | def content
10 | para do
11 | text "This page used to list people and projects in the Rust ecosystem that were accepting financial contributions."
12 | text " It became stale so has been removed. To support folks consider using "
13 | a "cargo fund", href: "https://github.com/acfoltzer/cargo-fund"
14 | text " on your projects."
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/crystal/tasks.cr:
--------------------------------------------------------------------------------
1 | # This file loads your app and all your tasks when running 'lucky'
2 | #
3 | # Run 'lucky --help' to see all available tasks.
4 | #
5 | # Learn to create your own tasks:
6 | # https://luckyframework.org/guides/command-line-tasks/custom-tasks
7 |
8 | # See `LuckyEnv#task?`
9 | ENV["LUCKY_TASK"] = "true"
10 |
11 | # Load Lucky and the app (actions, models, etc.)
12 | require "./src/app"
13 | require "lucky_task"
14 |
15 | # You can add your own tasks here in the ./tasks folder
16 | require "./tasks/**"
17 |
18 | # Load migrations
19 | require "./db/migrations/**"
20 |
21 | # Load Lucky tasks (dev, routes, etc.)
22 | require "lucky/tasks/**"
23 | require "avram/lucky/tasks"
24 |
25 | LuckyTask::Runner.run
--------------------------------------------------------------------------------
/crystal/src/pages/password_resets/new_page.cr:
--------------------------------------------------------------------------------
1 | class PasswordResets::NewPage < AuthLayout
2 | needs operation : ResetPassword
3 | needs user_id : Int32
4 | quick_def page_title, "Password Reset"
5 | quick_def page_description, ""
6 |
7 | def content
8 | h1 "Reset your password"
9 | render_password_reset_form(@operation)
10 | end
11 |
12 | private def render_password_reset_form(op)
13 | form_for PasswordResets::Create.with(@user_id) do
14 | mount Shared::Field, op.password, &.password_input(autofocus: "true")
15 | mount Shared::Field, op.password_confirmation, &.password_input
16 |
17 | submit "Update Password", flow_id: "update-password-button"
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/crystal/public/assets/images/heart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/rust/src/models.rs:
--------------------------------------------------------------------------------
1 | use chrono::{DateTime, Utc};
2 | use uuid::Uuid;
3 |
4 | use crate::schema::posts;
5 |
6 | #[derive(Identifiable, Queryable)]
7 | pub struct Post {
8 | pub id: i64,
9 | pub guid: Uuid,
10 | pub title: String,
11 | pub url: String,
12 | pub twitter_url: Option,
13 | pub mastodon_url: Option,
14 | pub author: String,
15 | pub summary: String,
16 | pub tweeted_at: Option>,
17 | pub tooted_at: Option>,
18 | pub created_at: DateTime,
19 | pub updated_at: DateTime,
20 | }
21 |
22 | #[derive(Queryable)]
23 | pub struct PostCategory {
24 | pub id: i64,
25 | pub post_id: i64,
26 | pub category_id: i16,
27 | }
28 |
--------------------------------------------------------------------------------
/crystal/src/actions/password_resets/new.cr:
--------------------------------------------------------------------------------
1 | class PasswordResets::New < BrowserAction
2 | include Auth::PasswordResets::Base
3 |
4 | param token : String
5 |
6 | get "/password_resets/:user_id" do
7 | redirect_to_edit_form_without_token_param
8 | end
9 |
10 | # This is to prevent password reset tokens from being scraped in the HTTP Referer header
11 | # See more info here: https://github.com/thoughtbot/clearance/pull/707
12 | private def redirect_to_edit_form_without_token_param
13 | make_token_available_to_future_actions
14 | redirect to: PasswordResets::Edit.with(user_id)
15 | end
16 |
17 | private def make_token_available_to_future_actions
18 | session.set(:password_reset_token, token)
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/crystal/src/actions/mixins/api/auth/helpers.cr:
--------------------------------------------------------------------------------
1 | module Api::Auth::Helpers
2 | def current_user? : User?
3 | auth_token.try do |value|
4 | user_from_auth_token(value)
5 | end
6 | end
7 |
8 | private def auth_token : String?
9 | bearer_token || token_param
10 | end
11 |
12 | private def bearer_token : String?
13 | context.request.headers["Authorization"]?
14 | .try(&.gsub("Bearer", ""))
15 | .try(&.strip)
16 | end
17 |
18 | private def token_param : String?
19 | params.get?(:auth_token)
20 | end
21 |
22 | private def user_from_auth_token(token : String) : User?
23 | UserToken.decode_user_id(token).try do |user_id|
24 | UserQuery.new.id(user_id).first?
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/crystal/src/pages/me/show_page.cr:
--------------------------------------------------------------------------------
1 | class Me::ShowPage < MainLayout
2 | quick_def page_title, "Your Profile"
3 | quick_def page_description, ""
4 |
5 | def content
6 | h1 "This is your profile"
7 | @current_user.try do |user|
8 | # FIXME: How to deal with this nicely
9 | h3 "Email: #{user.email}"
10 | end
11 | helpful_tips
12 | end
13 |
14 | private def helpful_tips
15 | h3 "Next, you may want to:"
16 | ul do
17 | li "Modify this page: src/pages/me/show_page.cr"
18 | li "Change where you go after sign in: src/actions/home/index.cr"
19 | li "To add pages that do not require sign in, include the" +
20 | "Auth::AllowGuests module in your actions"
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/crystal/src/operations/save_post.cr:
--------------------------------------------------------------------------------
1 | class SavePost < Post::SaveOperation
2 | permit_columns title, url, twitter_url, mastodon_url, author, summary
3 |
4 | attribute tags : String
5 |
6 | before_save assign_guid
7 | before_save validate_tags
8 | before_save validate_url
9 |
10 | private def assign_guid
11 | guid.value ||= UUID.random
12 | end
13 |
14 | private def validate_tags
15 | if (tags.value || "").strip.downcase !~ /\A[ a-z0-9-]*\z/
16 | tags.add_error "must be space separated and only contain a-z, 0-9, or hyphen"
17 | end
18 | end
19 |
20 | private def validate_url
21 | if (url.value || "").includes?("utm_source")
22 | url.add_error "must not contain tracking parameters"
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/crystal/src/serializers/json_feed/show_serializer.cr:
--------------------------------------------------------------------------------
1 | class JsonFeed::ShowSerializer < BaseSerializer
2 | def initialize(@category : Category)
3 | end
4 |
5 | def render
6 | posts = PostQuery.new.preload_post_categories.recent_in_category(@category).limit(100)
7 | {
8 | version: "https://jsonfeed.org/version/1",
9 | title: "Read Rust - #{@category.name}",
10 | home_page_url: "https://readrust.net/",
11 |
12 | feed_url: JsonFeed::Show.with(@category.slug).url,
13 | description: @category.description,
14 | author: {
15 | name: "Wesley Moore",
16 | url: "https://www.wezm.net/",
17 | },
18 | items: posts.map { |post| PostSerializer.new(post) },
19 | }
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/crystal/config/application.cr:
--------------------------------------------------------------------------------
1 | # This file may be used for custom Application configurations.
2 | # It will be loaded before other config files.
3 | #
4 | # Read more on configuration:
5 | # https://luckyframework.org/guides/getting-started/configuration#configuring-your-own-code
6 |
7 | # Use this code as an example:
8 | #
9 | # ```
10 | # module Application
11 | # Habitat.create do
12 | # setting support_email : String
13 | # setting lock_with_basic_auth : Bool
14 | # end
15 | # end
16 | #
17 | # Application.configure do |settings|
18 | # settings.support_email = "support@myapp.io"
19 | # settings.lock_with_basic_auth = LuckEnv.staging?
20 | # end
21 | #
22 | # # In your application, call
23 | # # `Application.settings.support_email` anywhere you need it.
24 | # ```
--------------------------------------------------------------------------------
/crystal/public/assets/images/u/Amethyst.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/rust/src/social_network.rs:
--------------------------------------------------------------------------------
1 | use std::error::Error;
2 | use std::rc::Rc;
3 |
4 | use diesel::pg::PgConnection;
5 | use diesel::prelude::*;
6 |
7 | use crate::categories::Category;
8 | use crate::models::Post;
9 |
10 | #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 | pub enum AccessMode {
12 | ReadOnly,
13 | ReadWrite,
14 | }
15 |
16 | pub trait SocialNetwork: Sized {
17 | fn from_env(access_mode: AccessMode) -> Result>;
18 |
19 | fn register() -> Result<(), Box>;
20 |
21 | fn unpublished_posts(connection: &PgConnection) -> QueryResult>;
22 |
23 | fn publish_post(&self, post: &Post, categories: &[Rc]) -> Result<(), Box>;
24 |
25 | fn mark_post_published(&self, connection: &PgConnection, post: Post) -> QueryResult<()>;
26 | }
27 |
--------------------------------------------------------------------------------
/crystal/src/app_server.cr:
--------------------------------------------------------------------------------
1 | class AppServer < Lucky::BaseAppServer
2 | def middleware : Array(HTTP::Handler)
3 | [
4 | Lucky::RequestIdHandler.new,
5 | Lucky::ForceSSLHandler.new,
6 | Lucky::HttpMethodOverrideHandler.new,
7 | Lucky::LogHandler.new,
8 | Lucky::ErrorHandler.new(action: Errors::Show),
9 | Lucky::RemoteIpHandler.new,
10 | Lucky::RouteHandler.new,
11 | Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"),
12 | Lucky::StaticFileHandler.new("./public", fallthrough: false, directory_listing: false),
13 | Lucky::RouteNotFoundHandler.new,
14 | ] of HTTP::Handler
15 | end
16 |
17 | def protocol
18 | "http"
19 | end
20 |
21 | def listen
22 | server.listen(host, port, reuse_port: false)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/rust/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "read-rust"
3 | version = "2.0.0"
4 | authors = ["Wesley Moore "]
5 | edition = "2018"
6 |
7 | [dependencies]
8 | chrono = { version = "0", features = ['serde'] }
9 | diesel = { version = "=1.4.3", features = ["postgres", "chrono", "uuidv07"] }
10 | dotenv = "0"
11 | egg-mode = { version = "0", default-features = false, features = ["hyper-rustls"], optional = true }
12 | env_logger = "0"
13 | getopts = "0"
14 | log = "0"
15 | elefren = { version = "0.21.0", default-features = false, features = ["rustls-tls"] }
16 | serde = { version = "1.0", features = ["derive"] }
17 | serde_json = "1.0"
18 | signal-hook = "0"
19 | tokio = "0.1"
20 | url = "2.0"
21 | uuid = { version = "0.7.0", features = ['v4', 'serde'] } # version needs to match diesel
22 |
23 | [features]
24 | twitter = ["egg-mode"]
--------------------------------------------------------------------------------
/crystal/spec/sanitise_spec.cr:
--------------------------------------------------------------------------------
1 | require "./spec_helper"
2 |
3 | describe Sanitise do
4 | context "strip_tags" do
5 | it "strips tags" do
6 | stripped = Sanitise.strip_tags("
This is a simple test.
")
7 | stripped.should eq "This is a simple test."
8 | end
9 |
10 | it "puts whitespace around inline tags" do
11 | stripped = Sanitise.strip_tags("
Paragraph
Next to paragraph.")
12 | stripped.should eq "Paragraph Next to paragraph."
13 | end
14 |
15 | it "strips content from script, style, etc. tags" do
16 | stripped = Sanitise.strip_tags("Do not want.