diff --git a/.kamal/secrets b/.kamal/secrets index 5663118..a5dbb56 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -2,10 +2,13 @@ # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. -SECRETS=$(kamal secrets fetch --adapter 1password --account Perrault --from Private/sloanelybutsurely.com KAMAL_REGISTRY_PASSWORD POSTGRES_PASSWORD SECRET_KEY_BASE) +SECRETS=$(kamal secrets fetch --adapter 1password --account Perrault --from Private/sloanelybutsurely.com KAMAL_REGISTRY_PASSWORD POSTGRES_PASSWORD SECRET_KEY_BASE MASTODON_CLIENT_ID MASTODON_CLIENT_SECRET) KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) POSTGRES_PASSWORD=$(kamal secrets extract POSTGRES_PASSWORD $SECRETS) SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) DATABASE_URL="postgresql://sloanely_but_surely_prod:$POSTGRES_PASSWORD@sloanelybutsurely-db:5432/sloanely_but_surely_prod" + +MASTODON_CLIENT_ID=$(kamal secrets extract MASTODON_CLIENT_ID $SECRETS) +MASTODON_CLIENT_SECRET=$(kamal secrets extract MASTODON_CLIENT_SECRET $SECRETS) diff --git a/config/config.exs b/config/config.exs index e3b35dc..d8d5fa0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -59,4 +59,18 @@ config :tailwind, config :flop, repo: Core.Repo +config :tesla, adapter: Tesla.Adapter.Mint + +config :ueberauth, Ueberauth, + providers: [ + mastodon: + {Ueberauth.Strategy.Mastodon, + [ + instance: "https://tech.lgbt", + client_id: {System, :get_env, ["MASTODON_CLIENT_ID"]}, + client_secret: {System, :get_env, ["MASTODON_CLIENT_SECRET"]}, + scope: "read write push" + ]} + ] + import_config "#{config_env()}.exs" diff --git a/config/deploy.yml b/config/deploy.yml index ffbb3a3..1f7f472 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -12,6 +12,8 @@ env: secret: - DATABASE_URL - SECRET_KEY_BASE + - MASTODON_CLIENT_ID + - MASTODON_CLIENT_SECRET # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. diff --git a/lib/core.ex b/lib/core.ex index 73576ec..80fb3a5 100644 --- a/lib/core.ex +++ b/lib/core.ex @@ -1,4 +1,4 @@ defmodule Core do @moduledoc false - use Boundary, deps: [Schema], exports: [Accounts, Posts, Author, DateTime, Release] + use Boundary, deps: [Schema], exports: [Accounts, Posts, Author, DateTime, Release, Syndication] end diff --git a/lib/core/syndication.ex b/lib/core/syndication.ex new file mode 100644 index 0000000..fcf06d4 --- /dev/null +++ b/lib/core/syndication.ex @@ -0,0 +1,14 @@ +defmodule Core.Syndication do + alias __MODULE__ + + def get_mastodon_account(user) do + Core.Repo.get_by(Schema.MastodonAccount, user_id: user.id) + end + + def save_mastodon_account(user, attrs) do + user + |> Ecto.build_assoc(:mastodon_account) + |> Syndication.MastodonAccount.changeset(attrs) + |> Core.Repo.insert() + end +end diff --git a/lib/core/syndication/mastodon_account.ex b/lib/core/syndication/mastodon_account.ex new file mode 100644 index 0000000..c5dd548 --- /dev/null +++ b/lib/core/syndication/mastodon_account.ex @@ -0,0 +1,11 @@ +defmodule Core.Syndication.MastodonAccount do + import Ecto.Changeset + + def changeset(%Schema.MastodonAccount{} = mastodon_account, attrs) do + mastodon_account + |> cast(attrs, [:uid, :access_token]) + |> validate_required([:uid, :access_token]) + |> unique_constraint(:user_id) + |> unique_constraint(:uid) + end +end diff --git a/lib/schema.ex b/lib/schema.ex index a886b6d..bd4adfc 100644 --- a/lib/schema.ex +++ b/lib/schema.ex @@ -1,6 +1,6 @@ defmodule Schema do @moduledoc false - use Boundary, deps: [], exports: [Post, User, UserToken] + use Boundary, deps: [], exports: [Post, User, UserToken, MastodonAccount] defmacro __using__(_) do quote do diff --git a/lib/schema/mastodon_account.ex b/lib/schema/mastodon_account.ex new file mode 100644 index 0000000..2f16c73 --- /dev/null +++ b/lib/schema/mastodon_account.ex @@ -0,0 +1,12 @@ +defmodule Schema.MastodonAccount do + use Schema + + schema "mastodon_accounts" do + field :uid, :string + field :access_token, :string, redact: true + + belongs_to :user, Schema.User + + timestamps() + end +end diff --git a/lib/schema/user.ex b/lib/schema/user.ex index 900262a..21c4e49 100644 --- a/lib/schema/user.ex +++ b/lib/schema/user.ex @@ -8,6 +8,8 @@ defmodule Schema.User do field :hashed_password, :string, redact: true field :current_password, :string, virtual: true, redact: true + has_one :mastodon_account, Schema.MastodonAccount + timestamps(type: :utc_datetime_usec) end end diff --git a/lib/web/controllers/auth_controller.ex b/lib/web/controllers/auth_controller.ex new file mode 100644 index 0000000..f664c11 --- /dev/null +++ b/lib/web/controllers/auth_controller.ex @@ -0,0 +1,26 @@ +defmodule Web.AuthController do + use Web, :controller + + plug Ueberauth + + def callback( + %{assigns: %{ueberauth_auth: %{provider: :mastodon} = auth, current_user: user}} = conn, + _params + ) do + {:ok, _mastodon_account} = + Core.Syndication.save_mastodon_account(user, %{ + uid: auth.uid, + access_token: auth.credentials.token + }) + + conn + |> put_flash(:info, "Mastodon account registered") + |> redirect(to: ~p"/admin/syndication") + end + + def callback(conn, _params) do + conn + |> put_flash(:error, "Mastodon auth failure") + |> redirect(to: ~p"/admin/syndication") + end +end diff --git a/lib/web/live/admin_dashboard_live.html.heex b/lib/web/live/admin_dashboard_live.html.heex index e07c2df..62e64a3 100644 --- a/lib/web/live/admin_dashboard_live.html.heex +++ b/lib/web/live/admin_dashboard_live.html.heex @@ -12,6 +12,11 @@ microblog </.link> </li> + <li> + <.link navigate={~p"/admin/syndication"}> + syndication + </.link> + </li> </ul> </nav> </header> diff --git a/lib/web/live/admin_syndication_live.ex b/lib/web/live/admin_syndication_live.ex new file mode 100644 index 0000000..d2eb034 --- /dev/null +++ b/lib/web/live/admin_syndication_live.ex @@ -0,0 +1,13 @@ +defmodule Web.AdminSyndicationLive do + use Web, :live_view + + def mount(_params, _session, socket) do + mastodon_account = Core.Syndication.get_mastodon_account(socket.assigns.current_user) + + socket = + socket + |> assign(:mastodon_account, mastodon_account) + + {:ok, socket} + end +end diff --git a/lib/web/live/admin_syndication_live.html.heex b/lib/web/live/admin_syndication_live.html.heex new file mode 100644 index 0000000..9522e66 --- /dev/null +++ b/lib/web/live/admin_syndication_live.html.heex @@ -0,0 +1,37 @@ +<div class="flex flex-col py-4 px-6"> + <header class="mb-4"> + <nav> + <ul class="flex flex-row gap-x-4"> + <li> + <.link navigate={~p"/admin/writing"}> + writing + </.link> + </li> + <li> + <.link navigate={~p"/admin/microblog"}> + microblog + </.link> + </li> + <li> + <.link class="underline" patch={~p"/admin/syndication"}> + syndication + </.link> + </li> + </ul> + </nav> + </header> + + <main class="flex flex-col"> + <div> + <strong>Mastodon: </strong> + <%= if @mastodon_account do %> + <.link href={@mastodon_account.uid} target="_blank">{@mastodon_account.uid}</.link> + <% else %> + <.link href={~p"/auth/mastodon"}>Connect account</.link> + <% end %> + </div> + <div> + <strong>Bluesky: </strong>Coming soon! + </div> + </main> +</div> diff --git a/lib/web/router.ex b/lib/web/router.ex index d418ae0..6c540e4 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -25,6 +25,13 @@ defmodule Web.Router do post "/admin/users/log_in", UserSessionController, :create end + scope "/auth", Web do + pipe_through [:browser, :require_authenticated_user] + + get "/mastodon", AuthController, :request + get "/mastodon/callback", AuthController, :callback + end + scope "/admin", Web do pipe_through [:browser, :require_authenticated_user] @@ -36,6 +43,8 @@ defmodule Web.Router do live "/posts/new", AdminPostLive, :new live "/posts/:post_id", AdminPostLive, :edit + + live "/syndication", AdminSyndicationLive, :index end end diff --git a/mix.exs b/mix.exs index e2add4f..2c11260 100644 --- a/mix.exs +++ b/mix.exs @@ -66,6 +66,10 @@ defmodule SlaonelyButSurely.MixProject do {:flop_phoenix, "~> 0.24.1"}, {:oban, "~> 2.19"}, {:igniter, "~> 0.5", only: [:dev]}, + {:ueberauth, "~> 0.10"}, + {:ueberauth_mastodon, "~> 0.3.0"}, + {:tesla, "~> 1.14"}, + {:mint, "~> 1.7"}, # Added dev and/or test dependencies {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 6595e57..3b392d2 100644 --- a/mix.lock +++ b/mix.lock @@ -31,6 +31,7 @@ "igniter": {:hex, :igniter, "0.5.47", "7a1041d5e38303e526fa6b6de37c9e78013f5cb573833ed51183d18e3a152f10", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "53a900909e20f217a25d15a34fef629c562b4822c1fb39cfa5d6999bc72992ed"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mastodon_client": {:hex, :mastodon_client, "0.1.0", "7f1a9e54367d0e126c76d0bb1097de346fb9c6da6d682a6b57bc936c92d643eb", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "3daa37fc7a95430eb0b77cdb168af58b8db84efea6e0f3244d15d45f39a87160"}, "mdex": {:hex, :mdex, "0.5.0", "252c83cebc6a089801dfc1e142b4d98c9c358378ec7096a94796bce8bd13b0fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "73e3ddee03130267e3be6aaf47a7f423c6f86add4bb5c62b352465cd9fb87d95"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, @@ -65,12 +66,15 @@ "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "tesla": {:hex, :tesla, "1.14.1", "71c5b031b4e089c0fbfb2b362e24b4478465773ae4ef569760a8c2899ad1e73c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c1dde8140a49a3bef5bb622356e77ac5a24ad0c8091f12c3b7fc1077ce797155"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.3.10", "a9971ebab1dfb36e2710a86b37c3f54973fbc9470d892035334415521fb53328", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17ab1f1b13aadb1f4b4c8e5b59c06874d701119fed082884c9c6d38addad254f"}, "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"}, + "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, + "ueberauth_mastodon": {:hex, :ueberauth_mastodon, "0.3.0", "e0b80adc29d8734f74cac908d25e2113739112ea84b95f3c9f27ef3161128426", [:mix], [{:hackney, "~> 1.20", [hex: :hackney, repo: "hexpm", optional: true]}, {:mastodon_client, "~> 0.1", [hex: :mastodon_client, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "28a049497b3b708a232c8d6da1c7fb80c92ae4a6961f2c9274dfe9333a1aa7d0"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, diff --git a/priv/repo/migrations/20250429161028_create_mastodon_auth_tables.exs b/priv/repo/migrations/20250429161028_create_mastodon_auth_tables.exs new file mode 100644 index 0000000..8057d33 --- /dev/null +++ b/priv/repo/migrations/20250429161028_create_mastodon_auth_tables.exs @@ -0,0 +1,17 @@ +defmodule Core.Repo.Migrations.CreateMastodonAuthTables do + use Ecto.Migration + + def change do + create table(:mastodon_accounts, primary_key: false) do + add :id, :uuid, primary_key: true + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :uid, :text, null: false + add :access_token, :text, null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:mastodon_accounts, [:user_id]) + create unique_index(:mastodon_accounts, [:uid]) + end +end diff --git a/scripts/register_mastodon_application.exs b/scripts/register_mastodon_application.exs new file mode 100755 index 0000000..673333d --- /dev/null +++ b/scripts/register_mastodon_application.exs @@ -0,0 +1,39 @@ +#!/usr/bin/env elixir + +Mix.install([ + {:req, "~> 0.5.10"} +]) + +{opts, []} = + OptionParser.parse!(System.argv(), + strict: [ + instance: :string, + client_name: :string, + redirect_uri: :string, + scopes: :string, + website: :string + ] + ) + +instance = Keyword.fetch!(opts, :instance) +client_name = Keyword.get(opts, :client_name, "sloanelybutsurely.com") + +redirect_uri = + Keyword.get(opts, :redirect_uri, "https://sloanelybutsurely.com/auth/mastodon/callback") + +scopes = Keyword.get(opts, :scopes, "read write push") +website = Keyword.get(opts, :website, "https://sloanelybutsurely.com") + +%{status: 200, body: resp} = + Req.post!( + base_url: instance, + url: "/api/v1/apps", + json: %{ + client_name: client_name, + redirect_uris: [redirect_uri], + scopes: scopes, + website: website + } + ) + +IO.inspect(resp)