diff --git a/Gemfile b/Gemfile index 825ea19..d16a2bc 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,8 @@ gem "gpx" gem "gnuplot" gem "matrix" +gem "rack-session" + gem "ruby-readability", require: "readability" gem "down" gem "httparty" diff --git a/Gemfile.lock b/Gemfile.lock index 84eebb9..bf5fb01 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -330,6 +330,8 @@ GEM que (2.3.0) racc (1.7.3) rack (2.2.8) + rack-session (1.0.1) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) rainbow (3.1.1) @@ -515,6 +517,7 @@ DEPENDENCIES pinboard! puma que + rack-session rack-test rake redcarpet diff --git a/Rakefile b/Rakefile index 53de569..06234b5 100644 --- a/Rakefile +++ b/Rakefile @@ -51,6 +51,13 @@ namespace :blog do ) end end + + task :create_user, [:email] => ["blog:load_environment"] do |t, args| + require "hanami/prepare" + + user_repo = Admin::Container["repos.user_repo"] + user_repo.create(id: SecureRandom.uuid, email: args[:email]) + end end namespace :tailwind do diff --git a/app/relations/login_tokens.rb b/app/relations/login_tokens.rb new file mode 100644 index 0000000..3b14146 --- /dev/null +++ b/app/relations/login_tokens.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Adamantium + module Relations + class LoginTokens < ROM::Relation[:sql] + schema :login_tokens, infer: true + + auto_struct(true) + end + end +end diff --git a/app/relations/users.rb b/app/relations/users.rb new file mode 100644 index 0000000..34d9257 --- /dev/null +++ b/app/relations/users.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Adamantium + module Relations + class Users < ROM::Relation[:sql] + schema :users, infer: true + + auto_struct(true) + end + end +end diff --git a/config.ru b/config.ru index 4e620c3..b84bd83 100644 --- a/config.ru +++ b/config.ru @@ -4,4 +4,12 @@ require "hanami/boot" use Rack::Static, urls: ["/assets", "/media"], root: "public" +raise StandardError.new("No secret key") unless ENV["SESSION_SECRET"] + +use Rack::Session::Cookie, + :domain => URI.parse(ENV["MICROPUB_SITE_URL"]).host, + :path => '/', + :expire_after => 3600*24, + :secret => ENV["SESSION_SECRET"] + run Hanami.app diff --git a/config/routes.rb b/config/routes.rb index dd57573..e367b11 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true require "hanami/middleware/body_parser" -# require_relative "../slices/admin/config/routes" -# require_relative "authenticated_admin_action" module Adamantium class Routes < Hanami::Routes use Hanami::Middleware::BodyParser, [:form, :json] - # use Adamantium::Middleware::ProcessParams slice :micropub, at: "/micropub" diff --git a/config/settings.rb b/config/settings.rb index 2c36083..263cd50 100644 --- a/config/settings.rb +++ b/config/settings.rb @@ -44,6 +44,7 @@ module Adamantium setting :from_email, default: nil setting :dayone_email, default: nil + setting :smtp_server, default: nil setting :smtp_password, default: nil setting :smtp_username, default: nil # Micropub endpoints diff --git a/db/migrate/20231118054424_create_users.rb b/db/migrate/20231118054424_create_users.rb new file mode 100644 index 0000000..0284097 --- /dev/null +++ b/db/migrate/20231118054424_create_users.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +ROM::SQL.migration do + change do + create_table :users do + uuid :id, primary_key: true + column :email, :text, null: false, unique: true + end + end +end diff --git a/db/migrate/20231118054707_create_login_token.rb b/db/migrate/20231118054707_create_login_token.rb new file mode 100644 index 0000000..d2ba519 --- /dev/null +++ b/db/migrate/20231118054707_create_login_token.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +ROM::SQL.migration do + change do + create_table :login_tokens do + primary_key :id + column :user_id, :uuid, null: false + column :token, :uuid, null: false + column :created_at, :timestamptz, default: Sequel.lit("now()") + end + end +end diff --git a/lib/adamantium/middleware/authenticate.rb b/lib/adamantium/middleware/authenticate.rb new file mode 100644 index 0000000..b8cae7b --- /dev/null +++ b/lib/adamantium/middleware/authenticate.rb @@ -0,0 +1,17 @@ +module Adamantium + module Middleware + class Authenticate + def initialize(app, auth_proc) + @app = app + @auth_proc = auth_proc + end + + def call(env) + session = env["rack.session"] + return [403, {'Content-Type' => 'text/html'}, ["Unauthorized | Login"]] unless @auth_proc.call(session[:user_id]) + + @app.call(env) + end + end + end +end diff --git a/lib/adamantium/middleware/process_params.rb b/lib/adamantium/middleware/process_params.rb deleted file mode 100644 index 89884c1..0000000 --- a/lib/adamantium/middleware/process_params.rb +++ /dev/null @@ -1,14 +0,0 @@ -module Adamantium - module Middleware - class ProcessParams - def initialize(app) - @app = app - end - - def call(env) - # NOOP for now. - @app.call(env) - end - end - end -end diff --git a/slices/admin/actions/sessions/create.rb b/slices/admin/actions/sessions/create.rb new file mode 100644 index 0000000..af72d91 --- /dev/null +++ b/slices/admin/actions/sessions/create.rb @@ -0,0 +1,15 @@ +module Admin + module Actions + module Sessions + class Create < Action + include Deps["commands.sessions.create"] + + def handle(req, res) + create.(email: req.params[:email]) + + res.redirect_to "/admin" + end + end + end + end +end diff --git a/slices/admin/actions/sessions/new.rb b/slices/admin/actions/sessions/new.rb new file mode 100644 index 0000000..2a0d2ed --- /dev/null +++ b/slices/admin/actions/sessions/new.rb @@ -0,0 +1,13 @@ +module Admin + module Actions + module Sessions + class New < Action + include Deps["views.sessions.new"] + + def handle(req, res) + res.render new + end + end + end + end +end diff --git a/slices/admin/actions/sessions/validate.rb b/slices/admin/actions/sessions/validate.rb new file mode 100644 index 0000000..dfb361f --- /dev/null +++ b/slices/admin/actions/sessions/validate.rb @@ -0,0 +1,18 @@ +module Admin + module Actions + module Sessions + class Validate < Action + include Deps["commands.sessions.validate"] + + def handle(req, res) + user_id = validate.(token: req.params[:token]) + session = req.env["rack.session"] + + session[:user_id] = user_id + + res.redirect_to "/admin" + end + end + end + end +end diff --git a/slices/admin/commands/sessions/create.rb b/slices/admin/commands/sessions/create.rb new file mode 100644 index 0000000..0d0e4d9 --- /dev/null +++ b/slices/admin/commands/sessions/create.rb @@ -0,0 +1,48 @@ +require "mail" + +module Admin + module Commands + module Sessions + class Create + include Deps[ + "repos.login_tokens_repo", + "repos.user_repo" + ] + + def call(email:) + app_settings = Admin::Container["settings"] + user = user_repo.by_email(email: email) + + return unless user + + login_tokens_repo.delete_all + + token = login_tokens_repo.create(user_id: user.id, token: SecureRandom.uuid) + + Mail.defaults do + delivery_method :smtp, { + address: app_settings.smtp_server, + port: 587, + authentication: "plain", + openssl_verify_mode: "peer", + enable_starttls_auto: true + } + end + + Mail.delivery_method.settings[:user_name] = app_settings.smtp_username + Mail.delivery_method.settings[:password] = app_settings.smtp_password + + mail = Mail.new do + subject "Login to #{app_settings.site_name}" + body "#{app_settings.micropub_site_url}/admin/login/#{token.token}" + end + + mail[:to] = email + mail[:from] = app_settings.from_email + + mail.deliver + end + end + end + end +end diff --git a/slices/admin/commands/sessions/validate.rb b/slices/admin/commands/sessions/validate.rb new file mode 100644 index 0000000..a33a6bd --- /dev/null +++ b/slices/admin/commands/sessions/validate.rb @@ -0,0 +1,17 @@ +module Admin + module Commands + module Sessions + class Validate + include Deps["repos.login_tokens_repo"] + + def call(token:) + user_id = login_tokens_repo.by_token(token: token).user_id + if user_id + login_tokens_repo.delete_all + user_id + end + end + end + end + end +end \ No newline at end of file diff --git a/slices/admin/config/authenticated_admin_action.rb b/slices/admin/config/authenticated_admin_action.rb index 097c607..56b891b 100644 --- a/slices/admin/config/authenticated_admin_action.rb +++ b/slices/admin/config/authenticated_admin_action.rb @@ -1,16 +1,12 @@ +require "adamantium/middleware/authenticate" + module Adamantium class AuthenticatedAdminAction def self.call(action:) + auth_proc = -> (id) { Admin::Container["repos.user_repo"].exists(id) } action_proc = ->(env) { Admin::Container["actions.#{action}"].call(env) } - if Hanami.app.settings.basic_auth_username && Hanami.app.settings.basic_auth_password - Rack::Auth::Basic.new(action_proc) do |username, password| - username == Hanami.app.settings.basic_auth_username && - password == Hanami.app.settings.basic_auth_password - end - else - Rack::Auth::Basic.new(action_proc) { |_username, _password| true } - end + Adamantium::Middleware::Authenticate.new(action_proc, auth_proc) end end end diff --git a/slices/admin/config/routes.rb b/slices/admin/config/routes.rb index 7a253d6..c4fe9e4 100644 --- a/slices/admin/config/routes.rb +++ b/slices/admin/config/routes.rb @@ -11,6 +11,10 @@ module Admin get "/", to: Auth.call(action: "index") + get "/login", to: "sessions.new" + get "/login/:token", to: "sessions.validate" + post "/sessions/create", to: "sessions.create" + get "/pages", to: Auth.call(action: "pages.index") get "/pages/new", to: Auth.call(action: "pages.new") get "/pages/:slug/edit", to: Auth.call(action: "pages.edit") diff --git a/slices/admin/repos/login_tokens_repo.rb b/slices/admin/repos/login_tokens_repo.rb new file mode 100644 index 0000000..3f19701 --- /dev/null +++ b/slices/admin/repos/login_tokens_repo.rb @@ -0,0 +1,15 @@ +module Admin + module Repos + class LoginTokensRepo < Adamantium::Repo[:login_tokens] + commands :create + + def by_token(token:) + login_tokens.where(token: token).one + end + + def delete_all + login_tokens.delete + end + end + end +end diff --git a/slices/admin/repos/user_repo.rb b/slices/admin/repos/user_repo.rb new file mode 100644 index 0000000..e6ef38b --- /dev/null +++ b/slices/admin/repos/user_repo.rb @@ -0,0 +1,17 @@ +module Admin + module Repos + class UserRepo < Adamantium::Repo[:users] + commands :create + + def exists(id) + !!users + .where(id: id) + .one + end + + def by_email(email:) + users.where(email: email).one + end + end + end +end diff --git a/slices/admin/templates/layouts/minimal.html.slim b/slices/admin/templates/layouts/minimal.html.slim new file mode 100644 index 0000000..dd65972 --- /dev/null +++ b/slices/admin/templates/layouts/minimal.html.slim @@ -0,0 +1,24 @@ +html + head + meta charest="utf-8" + + meta name="viewport" content="width=device-width, initial-scale=1.0" + + meta name="theme-color" content="rgb(37, 99, 235)" + + title Admin // Daniel Nitsikopoulos + + = stylesheet_tag "app" + link rel="icon" type="image/x-icon" href="/assets/favicon.ico" + + body class="bg-white dark:bg-black selection:bg-blue-100 selection:text-blue-900 dark:selection:bg-blue-600 dark:selection:text-blue-100" + main class="pb-8 px-4 pt-4 md:pt-8" + header class="mb-12 max-w-screen-md mx-auto" + div class="flex items-center mb-8 md:mb-12 text-lg md:text-xl text-gray-400 dark:text-gray-600" + div class="flex-none mx-auto md:flex-auto md:mx-0" + div class="h-card flex items-center" + img class="u-photo w-6 h6 md:w-10 md:h-10 rounded-full mr-1.5" src="/assets/memoji.png" + a href="/" rel="me" class="u-url u-uid" + h1 class="p-name uppercase text-sm md:text-sm text-gray-400 dark:text-gray-400" = Hanami.app.settings.site_name + + == yield \ No newline at end of file diff --git a/slices/admin/templates/sessions/new.html.slim b/slices/admin/templates/sessions/new.html.slim new file mode 100644 index 0000000..2c24a1a --- /dev/null +++ b/slices/admin/templates/sessions/new.html.slim @@ -0,0 +1,7 @@ +div class="max-w-prose mx-auto prose dark:prose-invert" + form action="/admin/sessions/create" method="POST" + div class="mb-2" + label for="email" class="mr-2" Email + input type="email" id="email" name="email" class="border" + div class="mb-2" + button Login diff --git a/slices/admin/views/sessions/new.rb b/slices/admin/views/sessions/new.rb new file mode 100644 index 0000000..015341c --- /dev/null +++ b/slices/admin/views/sessions/new.rb @@ -0,0 +1,9 @@ +module Admin + module Views + module Sessions + class New < Admin::View + config.layout = "minimal" + end + end + end +end