diff options
author | floppydiskette <floppydisk@hyprcat.net> | 2024-09-13 12:58:12 +0100 |
---|---|---|
committer | floppydiskette <floppydisk@hyprcat.net> | 2024-09-13 12:59:16 +0100 |
commit | 2c3400fb4f5a22951d42f286975201bf817d7883 (patch) | |
tree | a08b06f5f6d5df4f6774da7645d85418609a4cf2 /src | |
parent | d8915dcca4d9752f6f254e86afa39ef7f83617d1 (diff) |
wronglucky
Diffstat (limited to 'src')
76 files changed, 1212 insertions, 0 deletions
diff --git a/src/actions/api/me/show.cr b/src/actions/api/me/show.cr new file mode 100644 index 0000000..0060271 --- /dev/null +++ b/src/actions/api/me/show.cr @@ -0,0 +1,5 @@ +class Api::Me::Show < ApiAction + get "/api/me" do + json UserSerializer.new(current_user) + end +end diff --git a/src/actions/api/sign_ins/create.cr b/src/actions/api/sign_ins/create.cr new file mode 100644 index 0000000..3670356 --- /dev/null +++ b/src/actions/api/sign_ins/create.cr @@ -0,0 +1,13 @@ +class Api::SignIns::Create < ApiAction + include Api::Auth::SkipRequireAuthToken + + post "/api/sign_ins" do + SignInUser.run(params) do |operation, user| + if user + json({token: UserToken.generate(user)}) + else + raise Avram::InvalidOperationError.new(operation) + end + end + end +end diff --git a/src/actions/api/sign_ups/create.cr b/src/actions/api/sign_ups/create.cr new file mode 100644 index 0000000..15bbd04 --- /dev/null +++ b/src/actions/api/sign_ups/create.cr @@ -0,0 +1,8 @@ +class Api::SignUps::Create < ApiAction + include Api::Auth::SkipRequireAuthToken + + post "/api/sign_ups" do + user = SignUpUser.create!(params) + json({token: UserToken.generate(user)}) + end +end diff --git a/src/actions/api_action.cr b/src/actions/api_action.cr new file mode 100644 index 0000000..a16fd09 --- /dev/null +++ b/src/actions/api_action.cr @@ -0,0 +1,17 @@ +# Include modules and add methods that are for all API requests +abstract class ApiAction < Lucky::Action + # APIs typically do not need to send cookie/session data. + # Remove this line if you want to send cookies in the response header. + disable_cookies + accepted_formats [:json] + + include Api::Auth::Helpers + + # By default all actions require sign in. + # Add 'include Api::Auth::SkipRequireAuthToken' to your actions to allow all requests. + include Api::Auth::RequireAuthToken + + # By default all actions are required to use underscores to separate words. + # Add 'include Lucky::SkipRouteStyleCheck' to your actions if you wish to ignore this check for specific routes. + include Lucky::EnforceUnderscoredRoute +end diff --git a/src/actions/browser_action.cr b/src/actions/browser_action.cr new file mode 100644 index 0000000..674f208 --- /dev/null +++ b/src/actions/browser_action.cr @@ -0,0 +1,45 @@ +abstract class BrowserAction < Lucky::Action + include Lucky::ProtectFromForgery + + # By default all actions are required to use underscores. + # Add `include Lucky::SkipRouteStyleCheck` to your actions if you wish to ignore this check for specific routes. + include Lucky::EnforceUnderscoredRoute + + # This module disables Google FLoC by setting the + # [Permissions-Policy](https://github.com/WICG/floc) HTTP header to `interest-cohort=()`. + # + # This header is a part of Google's Federated Learning of Cohorts (FLoC) which is used + # to track browsing history instead of using 3rd-party cookies. + # + # Remove this include if you want to use the FLoC tracking. + include Lucky::SecureHeaders::DisableFLoC + + accepted_formats [:html, :json], default: :html + + # This module provides current_user, sign_in, and sign_out methods + include Authentic::ActionHelpers(User) + + # When testing you can skip normal sign in by using `visit` with the `as` param + # + # flow.visit Me::Show, as: UserFactory.create + include Auth::TestBackdoor + + # By default all actions that inherit 'BrowserAction' require sign in. + # + # You can remove the 'include Auth::RequireSignIn' below to allow anyone to + # access actions that inherit from 'BrowserAction' or you can + # 'include Auth::AllowGuests' in individual actions to skip sign in. + include Auth::RequireSignIn + + # `expose` means that `current_user` will be passed to pages automatically. + # + # In default Lucky apps, the `MainLayout` declares it `needs current_user : User` + # so that any page that inherits from MainLayout can use the `current_user` + expose current_user + + # This method tells Authentic how to find the current user + # The 'memoize' macro makes sure only one query is issued to find the user + private memoize def find_current_user(id : String | User::PrimaryKeyType) : User? + UserQuery.new.id(id).first? + end +end diff --git a/src/actions/errors/show.cr b/src/actions/errors/show.cr new file mode 100644 index 0000000..d01ed54 --- /dev/null +++ b/src/actions/errors/show.cr @@ -0,0 +1,63 @@ +# This class handles error responses and reporting. +# +# https://luckyframework.org/guides/http-and-routing/error-handling +class Errors::Show < Lucky::ErrorAction + DEFAULT_MESSAGE = "Something went wrong." + default_format :html + dont_report [Lucky::RouteNotFoundError, Avram::RecordNotFoundError] + + def render(error : Lucky::RouteNotFoundError | Avram::RecordNotFoundError) + if html? + error_html "Sorry, we couldn't find that page.", status: 404 + else + error_json "Not found", status: 404 + end + end + + # When the request is JSON and an InvalidOperationError is raised, show a + # helpful error with the param that is invalid, and what was wrong with it. + def render(error : Avram::InvalidOperationError) + if html? + error_html DEFAULT_MESSAGE, status: 500 + else + error_json \ + message: error.renderable_message, + details: error.renderable_details, + param: error.invalid_attribute_name, + status: 400 + end + end + + # Always keep this below other 'render' methods or it may override your + # custom 'render' methods. + def render(error : Lucky::RenderableError) + if html? + error_html DEFAULT_MESSAGE, status: error.renderable_status + else + error_json error.renderable_message, status: error.renderable_status + end + end + + # If none of the 'render' methods return a response for the raised Exception, + # Lucky will use this method. + def default_render(error : Exception) : Lucky::Response + if html? + error_html DEFAULT_MESSAGE, status: 500 + else + error_json DEFAULT_MESSAGE, status: 500 + end + end + + private def error_html(message : String, status : Int) + context.response.status_code = status + html_with_status Errors::ShowPage, status, message: message, status_code: status + end + + private def error_json(message : String, status : Int, details = nil, param = nil) + json ErrorSerializer.new(message: message, details: details, param: param), status: status + end + + private def report(error : Exception) : Nil + # Send to Rollbar, send an email, etc. + end +end diff --git a/src/actions/home/index.cr b/src/actions/home/index.cr new file mode 100644 index 0000000..f780130 --- /dev/null +++ b/src/actions/home/index.cr @@ -0,0 +1,18 @@ +class Home::Index < BrowserAction + include Auth::AllowGuests + + get "/" do + if current_user? + redirect Me::Show + else + # When you're ready change this line to: + # + # redirect SignIns::New + # + # Or maybe show signed out users a marketing page: + # + # html Marketing::IndexPage + html Lucky::WelcomePage + end + end +end diff --git a/src/actions/me/show.cr b/src/actions/me/show.cr new file mode 100644 index 0000000..5e35848 --- /dev/null +++ b/src/actions/me/show.cr @@ -0,0 +1,5 @@ +class Me::Show < BrowserAction + get "/me" do + html ShowPage + end +end diff --git a/src/actions/mixins/.keep b/src/actions/mixins/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/actions/mixins/.keep diff --git a/src/actions/mixins/api/auth/helpers.cr b/src/actions/mixins/api/auth/helpers.cr new file mode 100644 index 0000000..6b51cb5 --- /dev/null +++ b/src/actions/mixins/api/auth/helpers.cr @@ -0,0 +1,28 @@ +module Api::Auth::Helpers + # The 'memoize' macro makes sure only one query is issued to find the user + memoize def current_user? : User? + auth_token.try do |value| + user_from_auth_token(value) + end + end + + private def auth_token : String? + bearer_token || token_param + end + + private def bearer_token : String? + context.request.headers["Authorization"]? + .try(&.gsub("Bearer", "")) + .try(&.strip) + end + + private def token_param : String? + params.get?(:auth_token) + end + + private def user_from_auth_token(token : String) : User? + UserToken.decode_user_id(token).try do |user_id| + UserQuery.new.id(user_id).first? + end + end +end diff --git a/src/actions/mixins/api/auth/require_auth_token.cr b/src/actions/mixins/api/auth/require_auth_token.cr new file mode 100644 index 0000000..e018638 --- /dev/null +++ b/src/actions/mixins/api/auth/require_auth_token.cr @@ -0,0 +1,34 @@ +module Api::Auth::RequireAuthToken + macro included + before require_auth_token + end + + private def require_auth_token + if current_user? + continue + else + json auth_error_json, 401 + end + end + + private def auth_error_json + ErrorSerializer.new( + message: "Not authenticated.", + details: auth_error_details + ) + end + + private def auth_error_details : String + if auth_token + "The provided authentication token was incorrect." + else + "An authentication token is required. Please include a token in an 'auth_token' param or 'Authorization' header." + end + end + + # Tells the compiler that the current_user is not nil since we have checked + # that the user is signed in + private def current_user : User + current_user?.as(User) + end +end diff --git a/src/actions/mixins/api/auth/skip_require_auth_token.cr b/src/actions/mixins/api/auth/skip_require_auth_token.cr new file mode 100644 index 0000000..68098cf --- /dev/null +++ b/src/actions/mixins/api/auth/skip_require_auth_token.cr @@ -0,0 +1,10 @@ +module Api::Auth::SkipRequireAuthToken + macro included + skip require_auth_token + end + + # Since sign in is not required, current_user might be nil + def current_user : User? + current_user? + end +end diff --git a/src/actions/mixins/auth/allow_guests.cr b/src/actions/mixins/auth/allow_guests.cr new file mode 100644 index 0000000..3961399 --- /dev/null +++ b/src/actions/mixins/auth/allow_guests.cr @@ -0,0 +1,10 @@ +module Auth::AllowGuests + macro included + skip require_sign_in + end + + # Since sign in is not required, current_user might be nil + def current_user : User? + current_user? + end +end diff --git a/src/actions/mixins/auth/password_resets/base.cr b/src/actions/mixins/auth/password_resets/base.cr new file mode 100644 index 0000000..77166a9 --- /dev/null +++ b/src/actions/mixins/auth/password_resets/base.cr @@ -0,0 +1,7 @@ +module Auth::PasswordResets::Base + macro included + include Auth::RedirectSignedInUsers + include Auth::PasswordResets::FindUser + include Auth::PasswordResets::RequireToken + end +end diff --git a/src/actions/mixins/auth/password_resets/find_user.cr b/src/actions/mixins/auth/password_resets/find_user.cr new file mode 100644 index 0000000..cab02d5 --- /dev/null +++ b/src/actions/mixins/auth/password_resets/find_user.cr @@ -0,0 +1,5 @@ +module Auth::PasswordResets::FindUser + private def user : User + UserQuery.find(user_id) + end +end diff --git a/src/actions/mixins/auth/password_resets/require_token.cr b/src/actions/mixins/auth/password_resets/require_token.cr new file mode 100644 index 0000000..15da423 --- /dev/null +++ b/src/actions/mixins/auth/password_resets/require_token.cr @@ -0,0 +1,17 @@ +module Auth::PasswordResets::RequireToken + macro included + before require_valid_password_reset_token + end + + abstract def token : String + abstract def user : User + + private def require_valid_password_reset_token + if Authentic.valid_password_reset_token?(user, token) + continue + else + flash.failure = "The password reset link is incorrect or expired. Please try again." + redirect to: PasswordResetRequests::New + end + end +end diff --git a/src/actions/mixins/auth/password_resets/token_from_session.cr b/src/actions/mixins/auth/password_resets/token_from_session.cr new file mode 100644 index 0000000..820b91b --- /dev/null +++ b/src/actions/mixins/auth/password_resets/token_from_session.cr @@ -0,0 +1,5 @@ +module Auth::PasswordResets::TokenFromSession + private def token : String + session.get?(:password_reset_token) || raise "Password reset token not found in session" + end +end diff --git a/src/actions/mixins/auth/redirect_signed_in_users.cr b/src/actions/mixins/auth/redirect_signed_in_users.cr new file mode 100644 index 0000000..546bf7b --- /dev/null +++ b/src/actions/mixins/auth/redirect_signed_in_users.cr @@ -0,0 +1,19 @@ +module Auth::RedirectSignedInUsers + macro included + include Auth::AllowGuests + before redirect_signed_in_users + end + + private def redirect_signed_in_users + if current_user? + flash.success = "You are already signed in" + redirect to: Home::Index + else + continue + end + end + + # current_user returns nil because signed in users are redirected. + def current_user + end +end diff --git a/src/actions/mixins/auth/require_sign_in.cr b/src/actions/mixins/auth/require_sign_in.cr new file mode 100644 index 0000000..27a6f5e --- /dev/null +++ b/src/actions/mixins/auth/require_sign_in.cr @@ -0,0 +1,21 @@ +module Auth::RequireSignIn + macro included + before require_sign_in + end + + private def require_sign_in + if current_user? + continue + else + Authentic.remember_requested_path(self) + flash.info = "Please sign in first" + redirect to: SignIns::New + end + end + + # Tells the compiler that the current_user is not nil since we have checked + # that the user is signed in + private def current_user : User + current_user?.as(User) + end +end diff --git a/src/actions/mixins/auth/test_backdoor.cr b/src/actions/mixins/auth/test_backdoor.cr new file mode 100644 index 0000000..68c9d91 --- /dev/null +++ b/src/actions/mixins/auth/test_backdoor.cr @@ -0,0 +1,13 @@ +module Auth::TestBackdoor + macro included + before test_backdoor + end + + private def test_backdoor + if LuckyEnv.test? && (user_id = params.get?(:backdoor_user_id)) + user = UserQuery.find(user_id) + sign_in user + end + continue + end +end diff --git a/src/actions/password_reset_requests/create.cr b/src/actions/password_reset_requests/create.cr new file mode 100644 index 0000000..8f3c513 --- /dev/null +++ b/src/actions/password_reset_requests/create.cr @@ -0,0 +1,15 @@ +class PasswordResetRequests::Create < BrowserAction + include Auth::RedirectSignedInUsers + + post "/password_reset_requests" do + RequestPasswordReset.run(params) do |operation, user| + if user + PasswordResetRequestEmail.new(user).deliver + flash.success = "You should receive an email on how to reset your password shortly" + redirect SignIns::New + else + html NewPage, operation: operation + end + end + end +end diff --git a/src/actions/password_reset_requests/new.cr b/src/actions/password_reset_requests/new.cr new file mode 100644 index 0000000..7d16a7d --- /dev/null +++ b/src/actions/password_reset_requests/new.cr @@ -0,0 +1,7 @@ +class PasswordResetRequests::New < BrowserAction + include Auth::RedirectSignedInUsers + + get "/password_reset_requests/new" do + html NewPage, operation: RequestPasswordReset.new + end +end diff --git a/src/actions/password_resets/create.cr b/src/actions/password_resets/create.cr new file mode 100644 index 0000000..da1e711 --- /dev/null +++ b/src/actions/password_resets/create.cr @@ -0,0 +1,17 @@ +class PasswordResets::Create < BrowserAction + include Auth::PasswordResets::Base + include Auth::PasswordResets::TokenFromSession + + post "/password_resets/:user_id" do + ResetPassword.update(user, params) do |operation, user| + if operation.saved? + session.delete(:password_reset_token) + sign_in user + flash.success = "Your password has been reset" + redirect to: Home::Index + else + html NewPage, operation: operation, user_id: user_id.to_i64 + end + end + end +end diff --git a/src/actions/password_resets/edit.cr b/src/actions/password_resets/edit.cr new file mode 100644 index 0000000..9408109 --- /dev/null +++ b/src/actions/password_resets/edit.cr @@ -0,0 +1,8 @@ +class PasswordResets::Edit < BrowserAction + include Auth::PasswordResets::Base + include Auth::PasswordResets::TokenFromSession + + get "/password_resets/:user_id/edit" do + html NewPage, operation: ResetPassword.new, user_id: user_id.to_i64 + end +end diff --git a/src/actions/password_resets/new.cr b/src/actions/password_resets/new.cr new file mode 100644 index 0000000..5503468 --- /dev/null +++ b/src/actions/password_resets/new.cr @@ -0,0 +1,20 @@ +class PasswordResets::New < BrowserAction + include Auth::PasswordResets::Base + + param token : String + + get "/password_resets/:user_id" do + redirect_to_edit_form_without_token_param + end + + # This is to prevent password reset tokens from being scraped in the HTTP Referer header + # See more info here: https://github.com/thoughtbot/clearance/pull/707 + private def redirect_to_edit_form_without_token_param + make_token_available_to_future_actions + redirect to: PasswordResets::Edit.with(user_id) + end + + private def make_token_available_to_future_actions + session.set(:password_reset_token, token) + end +end diff --git a/src/actions/sign_ins/create.cr b/src/actions/sign_ins/create.cr new file mode 100644 index 0000000..af22588 --- /dev/null +++ b/src/actions/sign_ins/create.cr @@ -0,0 +1,16 @@ +class SignIns::Create < BrowserAction + include Auth::RedirectSignedInUsers + + post "/sign_in" do + SignInUser.run(params) do |operation, authenticated_user| + if authenticated_user + sign_in(authenticated_user) + flash.success = "You're now signed in" + Authentic.redirect_to_originally_requested_path(self, fallback: Home::Index) + else + flash.failure = "Sign in failed" + html NewPage, operation: operation + end + end + end +end diff --git a/src/actions/sign_ins/delete.cr b/src/actions/sign_ins/delete.cr new file mode 100644 index 0000000..8d34612 --- /dev/null +++ b/src/actions/sign_ins/delete.cr @@ -0,0 +1,7 @@ +class SignIns::Delete < BrowserAction + delete "/sign_out" do + sign_out + flash.info = "You have been signed out" + redirect to: SignIns::New + end +end diff --git a/src/actions/sign_ins/new.cr b/src/actions/sign_ins/new.cr new file mode 100644 index 0000000..3275b40 --- /dev/null +++ b/src/actions/sign_ins/new.cr @@ -0,0 +1,7 @@ +class SignIns::New < BrowserAction + include Auth::RedirectSignedInUsers + + get "/sign_in" do + html NewPage, operation: SignInUser.new + end +end diff --git a/src/actions/sign_ups/create.cr b/src/actions/sign_ups/create.cr new file mode 100644 index 0000000..a291ca6 --- /dev/null +++ b/src/actions/sign_ups/create.cr @@ -0,0 +1,16 @@ +class SignUps::Create < BrowserAction + include Auth::RedirectSignedInUsers + + post "/sign_up" do + SignUpUser.create(params) do |operation, user| + if user + flash.info = "Thanks for signing up" + sign_in(user) + redirect to: Home::Index + else + flash.info = "Couldn't sign you up" + html NewPage, operation: operation + end + end + end +end diff --git a/src/actions/sign_ups/new.cr b/src/actions/sign_ups/new.cr new file mode 100644 index 0000000..2299df6 --- /dev/null +++ b/src/actions/sign_ups/new.cr @@ -0,0 +1,7 @@ +class SignUps::New < BrowserAction + include Auth::RedirectSignedInUsers + + get "/sign_up" do + html NewPage, operation: SignUpUser.new + end +end diff --git a/src/app.cr b/src/app.cr new file mode 100644 index 0000000..9d3ee3c --- /dev/null +++ b/src/app.cr @@ -0,0 +1,26 @@ +require "./shards" + +# Load the asset manifest +Lucky::AssetHelpers.load_manifest "public/mix-manifest.json" + +require "../config/server" +require "./app_database" +require "../config/**" +require "./models/base_model" +require "./models/mixins/**" +require "./models/**" +require "./queries/mixins/**" +require "./queries/**" +require "./operations/mixins/**" +require "./operations/**" +require "./serializers/base_serializer" +require "./serializers/**" +require "./emails/base_email" +require "./emails/**" +require "./actions/mixins/**" +require "./actions/**" +require "./components/base_component" +require "./components/**" +require "./pages/**" +require "../db/migrations/**" +require "./app_server" diff --git a/src/app_database.cr b/src/app_database.cr new file mode 100644 index 0000000..0efd4f5 --- /dev/null +++ b/src/app_database.cr @@ -0,0 +1,2 @@ +class AppDatabase < Avram::Database +end diff --git a/src/app_server.cr b/src/app_server.cr new file mode 100644 index 0000000..8ec16c3 --- /dev/null +++ b/src/app_server.cr @@ -0,0 +1,26 @@ +class AppServer < Lucky::BaseAppServer + # Learn about middleware with HTTP::Handlers: + # https://luckyframework.org/guides/http-and-routing/http-handlers + def middleware : Array(HTTP::Handler) + [ + Lucky::RequestIdHandler.new, + Lucky::ForceSSLHandler.new, + Lucky::HttpMethodOverrideHandler.new, + Lucky::LogHandler.new, + Lucky::ErrorHandler.new(action: Errors::Show), + Lucky::RemoteIpHandler.new, + Lucky::RouteHandler.new, + Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"), + Lucky::StaticFileHandler.new("./public", fallthrough: false, directory_listing: false), + Lucky::RouteNotFoundHandler.new, + ] of HTTP::Handler + end + + def protocol + "http" + end + + def listen + server.listen(host, port, reuse_port: false) + end +end diff --git a/src/components/.keep b/src/components/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/components/.keep diff --git a/src/components/base_component.cr b/src/components/base_component.cr new file mode 100644 index 0000000..c9829b4 --- /dev/null +++ b/src/components/base_component.cr @@ -0,0 +1,2 @@ +abstract class BaseComponent < Lucky::BaseComponent +end diff --git a/src/components/shared/field.cr b/src/components/shared/field.cr new file mode 100644 index 0000000..5c32e8a --- /dev/null +++ b/src/components/shared/field.cr @@ -0,0 +1,57 @@ +# This component is used to make it easier to render the same fields styles +# throughout your app. +# +# Extensive documentation at: https://luckyframework.org/guides/frontend/html-forms#shared-components +# +# ## Basic usage: +# +# # Renders a text input by default and will guess the label name "Name" +# mount Shared::Field, op.name +# # Call any of the input methods on the block +# mount Shared::Field, op.email, &.email_input +# # Add other HTML attributes +# mount Shared::Field, op.email, &.email_input(autofocus: "true") +# # Pass an explicit label name +# mount Shared::Field, attribute: op.username, label_text: "Your username" +# +# ## Customization +# +# You can customize this component so that fields render like you expect. +# For example, you might wrap it in a div with a "field-wrapper" class. +# +# div class: "field-wrapper" +# label_for field +# yield field +# mount Shared::FieldErrors, field +# end +# +# You may also want to have more components if your fields look +# different in different parts of your app, e.g. `CompactField` or +# `InlineTextField` +class Shared::Field(T) < BaseComponent + # Raises a helpful error if component receives an unpermitted attribute + include Lucky::CatchUnpermittedAttribute + + needs attribute : Avram::PermittedAttribute(T) + needs label_text : String? + + def render(&) + label_for attribute, label_text + + # You can add more default options here. For example: + # + # tag_defaults field: attribute, class: "input" + # + # Will add the class "input" to the generated HTML. + tag_defaults field: attribute do |tag_builder| + yield tag_builder + end + + mount Shared::FieldErrors, attribute + end + + # Use a text_input by default + def render + render &.text_input + end +end diff --git a/src/components/shared/field_errors.cr b/src/components/shared/field_errors.cr new file mode 100644 index 0000000..3f2937a --- /dev/null +++ b/src/components/shared/field_errors.cr @@ -0,0 +1,16 @@ +class Shared::FieldErrors(T) < BaseComponent + needs attribute : Avram::PermittedAttribute(T) + + # Customize the markup and styles to match your application + def render + unless attribute.valid? + div class: "error" do + text "#{label_text} #{attribute.errors.first}" + end + end + end + + def label_text : String + Wordsmith::Inflector.humanize(attribute.name.to_s) + end +end diff --git a/src/components/shared/flash_messages.cr b/src/components/shared/flash_messages.cr new file mode 100644 index 0000000..bc44440 --- /dev/null +++ b/src/components/shared/flash_messages.cr @@ -0,0 +1,11 @@ +class Shared::FlashMessages < BaseComponent + needs flash : Lucky::FlashStore + + def render + flash.each do |flash_type, flash_message| + div class: "flash-#{flash_type}", flow_id: "flash" do + text flash_message + end + end + end +end diff --git a/src/components/shared/layout_head.cr b/src/components/shared/layout_head.cr new file mode 100644 index 0000000..5a05331 --- /dev/null +++ b/src/components/shared/layout_head.cr @@ -0,0 +1,18 @@ +class Shared::LayoutHead < BaseComponent + needs page_title : String + + def render + head do + utf8_charset + title "My App - #{@page_title}" + css_link asset("css/app.css") + js_link asset("js/app.js"), defer: "true" + csrf_meta_tags + responsive_meta_tag + + # Development helper used with the `lucky watch` command. + # Reloads the browser when files are updated. + live_reload_connect_tag if LuckyEnv.development? + end + end +end diff --git a/src/css/app.scss b/src/css/app.scss new file mode 100644 index 0000000..68b60cc --- /dev/null +++ b/src/css/app.scss @@ -0,0 +1,66 @@ +// Lucky generates 3 folders to help you organize your CSS: +// +// - src/css/variables # Files for colors, spacing, etc. +// - src/css/mixins # Put your mixin functions in files here +// - src/css/components # CSS for your components +// +// Remember to import your new CSS files or they won't be loaded: +// +// @import "./variables/colors" # Imports the file in src/css/variables/_colors.scss +// +// Note: importing with `~` tells webpack to look in the installed npm packages +// https://stackoverflow.com/questions/39535760/what-does-a-tilde-in-a-css-url-do + +@import 'modern-normalize/modern-normalize.css'; +// Add your own components and import them like this: +// +// @import "components/my_new_component"; + +// Default Lucky styles. +// Delete these when you're ready to bring in your own CSS. +body { + font-family: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, + Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, + sans-serif; + margin: 0 auto; + max-width: 800px; + padding: 20px 40px; +} + +label, input { + display: flex; +} + +label { + font-weight: 500; +} + +[type='color'], +[type='date'], +[type='datetime'], +[type='datetime-local'], +[type='email'], +[type='month'], +[type='number'], +[type='password'], +[type='search'], +[type='tel'], +[type='text'], +[type='time'], +[type='url'], +[type='week'], +input:not([type]), +textarea { + border-radius: 3px; + border: 1px solid #bbb; + margin: 7px 0 14px 0; + max-width: 400px; + padding: 8px 6px; + width: 100%; +} + +[type='submit'] { + font-weight: 900; + margin: 9px 0; + padding: 6px 9px; +} diff --git a/src/diskfloppydotme.cr b/src/diskfloppydotme.cr new file mode 100644 index 0000000..68e1a8d --- /dev/null +++ b/src/diskfloppydotme.cr @@ -0,0 +1,6 @@ +# Typically you will not use or modify this file. 'shards build' and some +# other crystal tools will sometimes use this. +# +# When this file is compiled/run it will require and run 'start_server', +# which as its name implies will start the server for you app. +require "./start_server" diff --git a/src/emails/.keep b/src/emails/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/emails/.keep diff --git a/src/emails/base_email.cr b/src/emails/base_email.cr new file mode 100644 index 0000000..656f4f1 --- /dev/null +++ b/src/emails/base_email.cr @@ -0,0 +1,15 @@ +# Learn about sending emails +# https://luckyframework.org/guides/emails/sending-emails-with-carbon +abstract class BaseEmail < Carbon::Email + # You can add defaults using the 'inherited' hook + # + # Example: + # + # macro inherited + # from default_from + # end + # + # def default_from + # Carbon::Address.new("support@app.com") + # end +end diff --git a/src/emails/password_reset_request_email.cr b/src/emails/password_reset_request_email.cr new file mode 100644 index 0000000..a41c8ba --- /dev/null +++ b/src/emails/password_reset_request_email.cr @@ -0,0 +1,13 @@ +class PasswordResetRequestEmail < BaseEmail + Habitat.create { setting stubbed_token : String? } + delegate stubbed_token, to: :settings + + def initialize(@user : User) + @token = stubbed_token || Authentic.generate_password_reset_token(@user) + end + + to @user + from "myapp@support.com" # or set a default in src/emails/base_email.cr + subject "Reset your password" + templates html, text +end diff --git a/src/emails/templates/password_reset_request_email/html.ecr b/src/emails/templates/password_reset_request_email/html.ecr new file mode 100644 index 0000000..00c24fc --- /dev/null +++ b/src/emails/templates/password_reset_request_email/html.ecr @@ -0,0 +1,3 @@ +<h1>Please reset your password</h1> + +<a href="<%= PasswordResets::New.url(@user.id, @token) %>">Reset password</a> diff --git a/src/emails/templates/password_reset_request_email/text.ecr b/src/emails/templates/password_reset_request_email/text.ecr new file mode 100644 index 0000000..7a7a0ab --- /dev/null +++ b/src/emails/templates/password_reset_request_email/text.ecr @@ -0,0 +1,3 @@ +Please reset your password: + +<%= PasswordResets::New.url(@user.id, @token) %> diff --git a/src/js/app.js b/src/js/app.js new file mode 100644 index 0000000..cca98fa --- /dev/null +++ b/src/js/app.js @@ -0,0 +1,7 @@ +/* eslint no-console:0 */ + +// Rails Unobtrusive JavaScript (UJS) is *required* for links in Lucky that use DELETE, POST and PUT. +// Though it says "Rails" it actually works with any framework. +import Rails from "@rails/ujs"; +Rails.start(); + diff --git a/src/models/base_model.cr b/src/models/base_model.cr new file mode 100644 index 0000000..6bafeb8 --- /dev/null +++ b/src/models/base_model.cr @@ -0,0 +1,5 @@ +abstract class BaseModel < Avram::Model + def self.database : Avram::Database.class + AppDatabase + end +end diff --git a/src/models/mixins/.keep b/src/models/mixins/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/models/mixins/.keep diff --git a/src/models/user.cr b/src/models/user.cr new file mode 100644 index 0000000..39729bb --- /dev/null +++ b/src/models/user.cr @@ -0,0 +1,13 @@ +class User < BaseModel + include Carbon::Emailable + include Authentic::PasswordAuthenticatable + + table do + column email : String + column encrypted_password : String + end + + def emailable : Carbon::Address + Carbon::Address.new(email) + end +end diff --git a/src/models/user_token.cr b/src/models/user_token.cr new file mode 100644 index 0000000..6586303 --- /dev/null +++ b/src/models/user_token.cr @@ -0,0 +1,30 @@ +# Generates and decodes JSON Web Tokens for Authenticating users. +class UserToken + Habitat.create { setting stubbed_token : String? } + ALGORITHM = JWT::Algorithm::HS256 + + def self.generate(user : User) : String + payload = {"user_id" => user.id} + + settings.stubbed_token || create_token(payload) + end + + def self.create_token(payload) + JWT.encode(payload, Lucky::Server.settings.secret_key_base, ALGORITHM) + end + + def self.decode_user_id(token : String) : Int64? + payload, _header = JWT.decode(token, Lucky::Server.settings.secret_key_base, ALGORITHM) + payload["user_id"].to_s.to_i64 + rescue e : JWT::Error + Lucky::Log.dexter.error { {jwt_decode_error: e.message} } + nil + end + + # Used in tests to return a fake token to test against. + def self.stub_token(token : String, &) + temp_config(stubbed_token: token) do + yield + end + end +end diff --git a/src/operations/.keep b/src/operations/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/operations/.keep diff --git a/src/operations/mixins/.keep b/src/operations/mixins/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/operations/mixins/.keep diff --git a/src/operations/mixins/password_validations.cr b/src/operations/mixins/password_validations.cr new file mode 100644 index 0000000..c56b975 --- /dev/null +++ b/src/operations/mixins/password_validations.cr @@ -0,0 +1,12 @@ +module PasswordValidations + macro included + before_save run_password_validations + end + + private def run_password_validations + validate_required password, password_confirmation + validate_confirmation_of password, with: password_confirmation + # 72 is a limitation of BCrypt + validate_size_of password, min: 6, max: 72 + end +end diff --git a/src/operations/mixins/user_from_email.cr b/src/operations/mixins/user_from_email.cr new file mode 100644 index 0000000..862fa9a --- /dev/null +++ b/src/operations/mixins/user_from_email.cr @@ -0,0 +1,7 @@ +module UserFromEmail + private def user_from_email : User? + email.value.try do |value| + UserQuery.new.email(value).first? + end + end +end diff --git a/src/operations/request_password_reset.cr b/src/operations/request_password_reset.cr new file mode 100644 index 0000000..4941aa7 --- /dev/null +++ b/src/operations/request_password_reset.cr @@ -0,0 +1,25 @@ +class RequestPasswordReset < Avram::Operation + # You can modify this in src/operations/mixins/user_from_email.cr + include UserFromEmail + + attribute email : String + + # Run validations and yield the operation and the user if valid + def run + user = user_from_email + validate(user) + + if valid? + user + else + nil + end + end + + def validate(user : User?) + validate_required email + if user.nil? + email.add_error "is not in our system" + end + end +end diff --git a/src/operations/reset_password.cr b/src/operations/reset_password.cr new file mode 100644 index 0000000..3bdd3c8 --- /dev/null +++ b/src/operations/reset_password.cr @@ -0,0 +1,11 @@ +class ResetPassword < User::SaveOperation + # Change password validations in src/operations/mixins/password_validations.cr + include PasswordValidations + + attribute password : String + attribute password_confirmation : String + + before_save do + Authentic.copy_and_encrypt password, to: encrypted_password + end +end diff --git a/src/operations/sign_in_user.cr b/src/operations/sign_in_user.cr new file mode 100644 index 0000000..de80342 --- /dev/null +++ b/src/operations/sign_in_user.cr @@ -0,0 +1,40 @@ +class SignInUser < Avram::Operation + param_key :user + # You can modify this in src/operations/mixins/user_from_email.cr + include UserFromEmail + + attribute email : String + attribute password : String + + # Run validations and yields the operation and the user if valid + def run + user = user_from_email + validate_credentials(user) + + if valid? + user + else + nil + end + end + + # `validate_credentials` determines if a user can sign in. + # + # If desired, you can add additional checks in this method, e.g. + # + # if user.locked? + # email.add_error "is locked out" + # end + private def validate_credentials(user) + if user + unless Authentic.correct_password?(user, password.value.to_s) + password.add_error "is wrong" + end + else + # Usually ok to say that an email is not in the system: + # https://kev.inburke.com/kevin/invalid-username-or-password-useless/ + # https://github.com/luckyframework/lucky_cli/issues/192 + email.add_error "is not in our system" + end + end +end diff --git a/src/operations/sign_up_user.cr b/src/operations/sign_up_user.cr new file mode 100644 index 0000000..8c46fad --- /dev/null +++ b/src/operations/sign_up_user.cr @@ -0,0 +1,14 @@ +class SignUpUser < User::SaveOperation + param_key :user + # Change password validations in src/operations/mixins/password_validations.cr + include PasswordValidations + + permit_columns email + attribute password : String + attribute password_confirmation : String + + before_save do + validate_uniqueness_of email + Authentic.copy_and_encrypt(password, to: encrypted_password) if password.valid? + end +end diff --git a/src/pages/auth_layout.cr b/src/pages/auth_layout.cr new file mode 100644 index 0000000..c2ac1b0 --- /dev/null +++ b/src/pages/auth_layout.cr @@ -0,0 +1,27 @@ +abstract class AuthLayout + include Lucky::HTMLPage + + abstract def content + abstract def page_title + + # The default page title. It is passed to `Shared::LayoutHead`. + # + # Add a `page_title` method to pages to override it. You can also remove + # This method so every page is required to have its own page title. + def page_title + "Welcome" + end + + def render + html_doctype + + html lang: "en" do + mount Shared::LayoutHead, page_title: page_title + + body do + mount Shared::FlashMessages, context.flash + content + end + end + end +end diff --git a/src/pages/errors/show_page.cr b/src/pages/errors/show_page.cr new file mode 100644 index 0000000..e7636de --- /dev/null +++ b/src/pages/errors/show_page.cr @@ -0,0 +1,93 @@ +class Errors::ShowPage + include Lucky::HTMLPage + + needs message : String + needs status_code : Int32 + + def render + html_doctype + html lang: "en" do + head do + utf8_charset + title "Something went wrong" + load_lato_font + normalize_styles + error_page_styles + end + + body do + div class: "container" do + h2 status_code, class: "status-code" + h1 message, class: "message" + + ul class: "helpful-links" do + li do + a "Try heading back to home", href: "/", class: "helpful-link" + end + end + end + end + end + end + + def load_lato_font + css_link "https://fonts.googleapis.com/css?family=Lato" + end + + def normalize_styles + style <<-CSS + /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none} + CSS + end + + def error_page_styles + style <<-CSS + body { + background-color: #f5f5f5; + color: #000; + font-family: 'Lato', sans-serif; + padding-top: 100px; + } + + .helpful-links { + list-style-type: none; + margin: 0; + padding: 0; + } + + .helpful-link { + color: #15A38B; + } + + .status-code { + opacity: 0.4; + font-size: 26px; + font-weight: normal; + } + + .message { + font-size: 34px; + line-height: 56px; + font-weight: normal; + } + + .container { + margin: 0 auto; + max-width: 450px; + padding: 55px; + } + + @media only screen and (max-width: 500px) { + .status-code { + font-size: 18px; + } + + .message { + font-size: 26px; + line-height: 40px; + margin: 20px 0 35px 0; + } + } + CSS + end +end diff --git a/src/pages/main_layout.cr b/src/pages/main_layout.cr new file mode 100644 index 0000000..06a1ed7 --- /dev/null +++ b/src/pages/main_layout.cr @@ -0,0 +1,45 @@ +abstract class MainLayout + include Lucky::HTMLPage + + # 'needs current_user : User' makes it so that the current_user + # is always required for pages using MainLayout + needs current_user : User + + abstract def content + abstract def page_title + + # MainLayout defines a default 'page_title'. + # + # Add a 'page_title' method to your indivual pages to customize each page's + # title. + # + # Or, if you want to require every page to set a title, change the + # 'page_title' method in this layout to: + # + # abstract def page_title : String + # + # This will force pages to define their own 'page_title' method. + def page_title + "Welcome" + end + + def render + html_doctype + + html lang: "en" do + mount Shared::LayoutHead, page_title: page_title + + body do + mount Shared::FlashMessages, context.flash + render_signed_in_user + content + end + end + end + + private def render_signed_in_user + text current_user.email + text " - " + link "Sign out", to: SignIns::Delete, flow_id: "sign-out-button" + end +end diff --git a/src/pages/me/show_page.cr b/src/pages/me/show_page.cr new file mode 100644 index 0000000..6a6bd87 --- /dev/null +++ b/src/pages/me/show_page.cr @@ -0,0 +1,21 @@ +class Me::ShowPage < MainLayout + def content + h1 "This is your profile" + h3 "Email: #{@current_user.email}" + helpful_tips + end + + private def helpful_tips + h3 "Next, you may want to:" + ul do + li { link_to_authentication_guides } + li "Modify this page: src/pages/me/show_page.cr" + li "Change where you go after sign in: src/actions/home/index.cr" + end + end + + private def link_to_authentication_guides + a "Check out the authentication guides", + href: "https://luckyframework.org/guides/authentication" + end +end diff --git a/src/pages/password_reset_requests/new_page.cr b/src/pages/password_reset_requests/new_page.cr new file mode 100644 index 0000000..368784c --- /dev/null +++ b/src/pages/password_reset_requests/new_page.cr @@ -0,0 +1,15 @@ +class PasswordResetRequests::NewPage < AuthLayout + needs operation : RequestPasswordReset + + def content + h1 "Reset your password" + render_form(@operation) + end + + private def render_form(op) + form_for PasswordResetRequests::Create do + mount Shared::Field, attribute: op.email, label_text: "Email", &.email_input + submit "Reset Password", flow_id: "request-password-reset-button" + end + end +end diff --git a/src/pages/password_resets/new_page.cr b/src/pages/password_resets/new_page.cr new file mode 100644 index 0000000..16a6635 --- /dev/null +++ b/src/pages/password_resets/new_page.cr @@ -0,0 +1,18 @@ +class PasswordResets::NewPage < AuthLayout + needs operation : ResetPassword + needs user_id : Int64 + + def content + h1 "Reset your password" + render_password_reset_form(@operation) + end + + private def render_password_reset_form(op) + form_for PasswordResets::Create.with(@user_id) do + mount Shared::Field, attribute: op.password, label_text: "Password", &.password_input(autofocus: "true") + mount Shared::Field, attribute: op.password_confirmation, label_text: "Confirm Password", &.password_input + + submit "Update Password", flow_id: "update-password-button" + end + end +end diff --git a/src/pages/sign_ins/new_page.cr b/src/pages/sign_ins/new_page.cr new file mode 100644 index 0000000..1018813 --- /dev/null +++ b/src/pages/sign_ins/new_page.cr @@ -0,0 +1,23 @@ +class SignIns::NewPage < AuthLayout + needs operation : SignInUser + + def content + h1 "Sign In" + render_sign_in_form(@operation) + end + + private def render_sign_in_form(op) + form_for SignIns::Create do + sign_in_fields(op) + submit "Sign In", flow_id: "sign-in-button" + end + link "Reset password", to: PasswordResetRequests::New + text " | " + link "Sign up", to: SignUps::New + end + + private def sign_in_fields(op) + mount Shared::Field, attribute: op.email, label_text: "Email", &.email_input(autofocus: "true") + mount Shared::Field, attribute: op.password, label_text: "Password", &.password_input + end +end diff --git a/src/pages/sign_ups/new_page.cr b/src/pages/sign_ups/new_page.cr new file mode 100644 index 0000000..24f6cb2 --- /dev/null +++ b/src/pages/sign_ups/new_page.cr @@ -0,0 +1,22 @@ +class SignUps::NewPage < AuthLayout + needs operation : SignUpUser + + def content + h1 "Sign Up" + render_sign_up_form(@operation) + end + + private def render_sign_up_form(op) + form_for SignUps::Create do + sign_up_fields(op) + submit "Sign Up", flow_id: "sign-up-button" + end + link "Sign in instead", to: SignIns::New + end + + private def sign_up_fields(op) + mount Shared::Field, attribute: op.email, label_text: "Email", &.email_input(autofocus: "true") + mount Shared::Field, attribute: op.password, label_text: "Password", &.password_input + mount Shared::Field, attribute: op.password_confirmation, label_text: "Confirm Password", &.password_input + end +end diff --git a/src/queries/.keep b/src/queries/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/queries/.keep diff --git a/src/queries/mixins/.keep b/src/queries/mixins/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/queries/mixins/.keep diff --git a/src/queries/user_query.cr b/src/queries/user_query.cr new file mode 100644 index 0000000..8a7e9a7 --- /dev/null +++ b/src/queries/user_query.cr @@ -0,0 +1,2 @@ +class UserQuery < User::BaseQuery +end diff --git a/src/serializers/.keep b/src/serializers/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/serializers/.keep diff --git a/src/serializers/base_serializer.cr b/src/serializers/base_serializer.cr new file mode 100644 index 0000000..3ad0a66 --- /dev/null +++ b/src/serializers/base_serializer.cr @@ -0,0 +1,7 @@ +abstract class BaseSerializer < Lucky::Serializer + def self.for_collection(collection : Enumerable, *args, **named_args) + collection.map do |object| + new(object, *args, **named_args) + end + end +end diff --git a/src/serializers/error_serializer.cr b/src/serializers/error_serializer.cr new file mode 100644 index 0000000..21a53aa --- /dev/null +++ b/src/serializers/error_serializer.cr @@ -0,0 +1,14 @@ +# This is the default error serializer generated by Lucky. +# Feel free to customize it in any way you like. +class ErrorSerializer < BaseSerializer + def initialize( + @message : String, + @details : String? = nil, + @param : String? = nil # so you can track which param (if any) caused the problem + ) + end + + def render + {message: @message, param: @param, details: @details} + end +end diff --git a/src/serializers/user_serializer.cr b/src/serializers/user_serializer.cr new file mode 100644 index 0000000..1a86f14 --- /dev/null +++ b/src/serializers/user_serializer.cr @@ -0,0 +1,8 @@ +class UserSerializer < BaseSerializer + def initialize(@user : User) + end + + def render + {email: @user.email} + end +end diff --git a/src/shards.cr b/src/shards.cr new file mode 100644 index 0000000..7cadec1 --- /dev/null +++ b/src/shards.cr @@ -0,0 +1,10 @@ +# Load .env file before any other config or app code +require "lucky_env" +LuckyEnv.load?(".env") + +# Require your shards here +require "lucky" +require "avram/lucky" +require "carbon" +require "authentic" +require "jwt" diff --git a/src/start_server.cr b/src/start_server.cr new file mode 100644 index 0000000..9df5d1f --- /dev/null +++ b/src/start_server.cr @@ -0,0 +1,16 @@ +require "./app" + +Habitat.raise_if_missing_settings! + +if LuckyEnv.development? + Avram::Migrator::Runner.new.ensure_migrated! + Avram::SchemaEnforcer.ensure_correct_column_mappings! +end + +app_server = AppServer.new + +Signal::INT.trap do + app_server.close +end + +app_server.listen |