From 8e3b2314655172dd2b8f4f988008c887a482d090 Mon Sep 17 00:00:00 2001 From: sloane <git@sloanelybutsurely.com> Date: Fri, 2 May 2025 07:32:02 -0400 Subject: [PATCH] add support for bluesky syndication --- .formatter.exs | 2 +- lib/core/posts.ex | 6 +- lib/core/posts/post.ex | 7 +- lib/core/syndication.ex | 96 ++++++++++++++++++- lib/core/syndication/bluesky_account.ex | 47 +++++++++ lib/core/syndication/bluesky_client.ex | 50 ++++++++++ lib/core/syndication/bluesky_post.ex | 9 ++ lib/schema.ex | 4 +- lib/schema/bluesky_account.ex | 20 ++++ lib/schema/bluesky_post.ex | 13 +++ lib/schema/post.ex | 2 + lib/schema/user.ex | 1 + .../controllers/status_html/show.html.heex | 21 +++- lib/web/live/admin_post_live.html.heex | 6 ++ lib/web/live/admin_syndication_live.ex | 15 +++ lib/web/live/admin_syndication_live.html.heex | 13 ++- mix.exs | 1 + mix.lock | 2 + .../20250502113428_add_bluesky_tables.exs | 44 +++++++++ 19 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 lib/core/syndication/bluesky_account.ex create mode 100644 lib/core/syndication/bluesky_client.ex create mode 100644 lib/core/syndication/bluesky_post.ex create mode 100644 lib/schema/bluesky_account.ex create mode 100644 lib/schema/bluesky_post.ex create mode 100644 priv/repo/migrations/20250502113428_add_bluesky_tables.exs diff --git a/.formatter.exs b/.formatter.exs index 2f55c0e..98279b9 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ - import_deps: [:oban, :ecto, :ecto_sql, :phoenix], + import_deps: [:oban, :ecto, :ecto_sql, :phoenix, :tesla], subdirectories: ["priv/*/migrations"], plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] diff --git a/lib/core/posts.ex b/lib/core/posts.ex index ba4754c..92b8f88 100644 --- a/lib/core/posts.ex +++ b/lib/core/posts.ex @@ -88,9 +88,9 @@ defmodule Core.Posts do with {:ok, post} <- post |> Post.publish_changeset(published_at) - |> Core.Repo.update() do - Core.Syndication.syndicate_to_mastodon(post) - + |> Core.Repo.update(), + {:ok, post} <- Core.Syndication.syndicate_to_mastodon(post), + {:ok, post} <- Core.Syndication.syndicate_to_bluesky(post) do {:ok, post} end end diff --git a/lib/core/posts/post.ex b/lib/core/posts/post.ex index 5aa6a28..da9203a 100644 --- a/lib/core/posts/post.ex +++ b/lib/core/posts/post.ex @@ -14,7 +14,8 @@ defmodule Core.Posts.Post do :body, :deleted_at, :published_at, - :syndicate_to_mastodon + :syndicate_to_mastodon, + :syndicate_to_bluesky ]) |> validate_required([:kind], message: "must have a kind") |> validate_required([:body], message: "must have a body") @@ -90,7 +91,9 @@ defmodule Core.Posts.Post do as: :posts, left_join: mp in assoc(p, :mastodon_post), as: :mastodon_posts, - preload: [mastodon_post: mp] + left_join: bp in assoc(p, :bluesky_post), + as: :bluesky_posts, + preload: [mastodon_post: mp, bluesky_post: bp] end def current(query \\ base()) do diff --git a/lib/core/syndication.ex b/lib/core/syndication.ex index 02bc49d..981b95b 100644 --- a/lib/core/syndication.ex +++ b/lib/core/syndication.ex @@ -1,6 +1,8 @@ defmodule Core.Syndication do alias __MODULE__ + ## mastodon + @mastodon_instance Application.compile_env!(:sloanely_but_surely, [ Core.Syndication, :mastodon_instance @@ -33,13 +35,13 @@ defmodule Core.Syndication do }) |> Core.Repo.insert() - post + {:ok, post} else - post + {:ok, post} end end - defp get_mastodon_access_token do + defp get_mastodon_access_token! do mastodon_account = Core.Repo.one!(Schema.MastodonAccount) mastodon_account.access_token end @@ -47,7 +49,93 @@ defmodule Core.Syndication do defp build_mastodon_client_conn do %MastodonClient.Conn{ instance: @mastodon_instance, - access_token: get_mastodon_access_token() + access_token: get_mastodon_access_token!() } end + + ## bluesky + + def get_bluesky_account(%Schema.User{} = user) do + Core.Repo.get_by(Schema.BlueskyAccount, user_id: user.id) + end + + def save_bluesky_account(user, identifier, password) do + with {:ok, session_resp} <- + Syndication.BlueskyClient.create_session(identifier, password), + {:ok, attrs} <- parse_bluesky_token_resp(session_resp) do + user + |> Ecto.build_assoc(:bluesky_account) + |> Syndication.BlueskyAccount.create_changeset(attrs) + |> Core.Repo.insert( + conflict_target: [:user_id], + on_conflict: {:replace_all_except, [:id, :inserted_at]} + ) + end + end + + def refresh_bluesky_account(%Schema.BlueskyAccount{} = bluesky_account) do + with {:ok, refresh_resp} <- + Syndication.BlueskyClient.refresh_session(bluesky_account), + {:ok, attrs} <- parse_bluesky_token_resp(refresh_resp) do + bluesky_account + |> Syndication.BlueskyAccount.refresh_changeset(attrs) + |> Core.Repo.update() + end + end + + def syndicate_to_bluesky(%Schema.Post{} = post) do + post = Core.Repo.preload(post, [:bluesky_post]) + + if post.syndicate_to_bluesky && is_nil(post.bluesky_post) do + bluesky_account = get_bluesky_account!() + + with {:ok, resp} <- Syndication.BlueskyClient.post_status(bluesky_account, post.body) do + post + |> Ecto.build_assoc(:bluesky_post) + |> Syndication.BlueskyPost.changeset(resp.body) + |> Core.Repo.insert() + + {:ok, post} + end + else + {:ok, post} + end + end + + def bsky_app_url(%Schema.BlueskyPost{uri: uri}) do + [did, id] = Regex.run(~r|at://(did:.*)/app.bsky.feed.post/(.*)|, uri, capture: :all_but_first) + + "https://bsky.app/profile/#{did}/post/#{id}" + end + + defp parse_bluesky_token_resp(resp) do + with {:ok, %{iat: access_jwt_iat, exp: access_jwt_exp}} <- + parse_bluesky_jwt(resp.body["accessJwt"]), + {:ok, %{iat: refresh_jwt_iat, exp: refresh_jwt_exp}} <- + parse_bluesky_jwt(resp.body["refreshJwt"]) do + {:ok, + %{ + handle: resp.body["handle"], + did: resp.body["did"], + access_jwt: resp.body["accessJwt"], + access_jwt_iat: access_jwt_iat, + access_jwt_exp: access_jwt_exp, + refresh_jwt: resp.body["refreshJwt"], + refresh_jwt_iat: refresh_jwt_iat, + refresh_jwt_exp: refresh_jwt_exp + }} + end + end + + defp parse_bluesky_jwt(jwt) do + with {:ok, claims} <- Joken.peek_claims(jwt), + {:ok, iat} <- DateTime.from_unix(claims["iat"]), + {:ok, exp} <- DateTime.from_unix(claims["exp"]) do + {:ok, %{iat: iat, exp: exp}} + end + end + + defp get_bluesky_account! do + Core.Repo.one!(Schema.BlueskyAccount) + end end diff --git a/lib/core/syndication/bluesky_account.ex b/lib/core/syndication/bluesky_account.ex new file mode 100644 index 0000000..815a2c5 --- /dev/null +++ b/lib/core/syndication/bluesky_account.ex @@ -0,0 +1,47 @@ +defmodule Core.Syndication.BlueskyAccount do + import Ecto.Changeset + + def create_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do + bluesky_account + |> cast(attrs, [ + :handle, + :did, + :access_jwt, + :access_jwt_iat, + :access_jwt_exp, + :refresh_jwt, + :refresh_jwt_iat, + :refresh_jwt_exp + ]) + |> validate_required([ + :handle, + :did, + :access_jwt, + :access_jwt_iat, + :access_jwt_exp, + :refresh_jwt, + :refresh_jwt_iat, + :refresh_jwt_exp + ]) + end + + def refresh_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do + bluesky_account + |> cast(attrs, [ + :access_jwt, + :access_jwt_iat, + :access_jwt_exp, + :refresh_jwt, + :refresh_jwt_iat, + :refresh_jwt_exp + ]) + |> validate_required([ + :access_jwt, + :access_jwt_iat, + :access_jwt_exp, + :refresh_jwt, + :refresh_jwt_iat, + :refresh_jwt_exp + ]) + end +end diff --git a/lib/core/syndication/bluesky_client.ex b/lib/core/syndication/bluesky_client.ex new file mode 100644 index 0000000..0d54ae0 --- /dev/null +++ b/lib/core/syndication/bluesky_client.ex @@ -0,0 +1,50 @@ +defmodule Core.Syndication.BlueskyClient do + @middleware [ + Tesla.Middleware.Logger, + {Tesla.Middleware.BaseUrl, "https://bsky.social/xrpc"}, + Tesla.Middleware.JSON + ] + + def new(opts \\ []) do + middleware = @middleware + + middleware = + case Keyword.fetch(opts, :token) do + {:ok, token} -> + middleware ++ [{Tesla.Middleware.BearerAuth, token: token}] + + :error -> + middleware + end + + Tesla.client(middleware) + end + + def create_session(identifier, password) do + client = new() + + Tesla.post(client, "/com.atproto.server.createSession", %{ + identifier: identifier, + password: password + }) + end + + def refresh_session(%Schema.BlueskyAccount{refresh_jwt: token}) do + client = new(token: token) + + Tesla.post(client, "/com.atproto.server.refreshSession", nil) + end + + def post_status(%Schema.BlueskyAccount{} = bluesky_account, text) do + client = new(token: bluesky_account.access_jwt) + + Tesla.post(client, "/com.atproto.repo.createRecord", %{ + repo: bluesky_account.did, + collection: "app.bsky.feed.post", + record: %{ + text: text, + createdAt: DateTime.utc_now() |> DateTime.to_iso8601() + } + }) + end +end diff --git a/lib/core/syndication/bluesky_post.ex b/lib/core/syndication/bluesky_post.ex new file mode 100644 index 0000000..6f8bdae --- /dev/null +++ b/lib/core/syndication/bluesky_post.ex @@ -0,0 +1,9 @@ +defmodule Core.Syndication.BlueskyPost do + import Ecto.Changeset + + def changeset(%Schema.BlueskyPost{} = bluesky_post, attrs) do + bluesky_post + |> cast(attrs, [:cid, :uri, :commit]) + |> validate_required([:cid, :uri, :commit]) + end +end diff --git a/lib/schema.ex b/lib/schema.ex index bd4adfc..5f27a60 100644 --- a/lib/schema.ex +++ b/lib/schema.ex @@ -1,6 +1,8 @@ defmodule Schema do @moduledoc false - use Boundary, deps: [], exports: [Post, User, UserToken, MastodonAccount] + use Boundary, + deps: [], + exports: [Post, User, UserToken, MastodonAccount, BlueskyAccount, BlueskyPost] defmacro __using__(_) do quote do diff --git a/lib/schema/bluesky_account.ex b/lib/schema/bluesky_account.ex new file mode 100644 index 0000000..74ac573 --- /dev/null +++ b/lib/schema/bluesky_account.ex @@ -0,0 +1,20 @@ +defmodule Schema.BlueskyAccount do + use Schema + + schema "bluesky_accounts" do + field :handle, :string + field :did, :string + + field :access_jwt, :string, redact: true + field :access_jwt_iat, :utc_datetime + field :access_jwt_exp, :utc_datetime + + field :refresh_jwt, :string, redact: true + field :refresh_jwt_iat, :utc_datetime + field :refresh_jwt_exp, :utc_datetime + + belongs_to :user, Schema.User + + timestamps() + end +end diff --git a/lib/schema/bluesky_post.ex b/lib/schema/bluesky_post.ex new file mode 100644 index 0000000..9750117 --- /dev/null +++ b/lib/schema/bluesky_post.ex @@ -0,0 +1,13 @@ +defmodule Schema.BlueskyPost do + use Schema + + schema "bluesky_posts" do + field :cid, :string + field :uri, :string + field :commit, :map + + belongs_to :post, Schema.Post + + timestamps() + end +end diff --git a/lib/schema/post.ex b/lib/schema/post.ex index e913fb4..84b29ed 100644 --- a/lib/schema/post.ex +++ b/lib/schema/post.ex @@ -29,8 +29,10 @@ defmodule Schema.Post do field :deleted_at, :utc_datetime_usec field :syndicate_to_mastodon, :boolean + field :syndicate_to_bluesky, :boolean has_one :mastodon_post, Schema.MastodonPost + has_one :bluesky_post, Schema.BlueskyPost timestamps() end diff --git a/lib/schema/user.ex b/lib/schema/user.ex index 21c4e49..29e294e 100644 --- a/lib/schema/user.ex +++ b/lib/schema/user.ex @@ -9,6 +9,7 @@ defmodule Schema.User do field :current_password, :string, virtual: true, redact: true has_one :mastodon_account, Schema.MastodonAccount + has_one :bluesky_account, Schema.BlueskyAccount timestamps(type: :utc_datetime_usec) end diff --git a/lib/web/controllers/status_html/show.html.heex b/lib/web/controllers/status_html/show.html.heex index bb4743d..a0c6fda 100644 --- a/lib/web/controllers/status_html/show.html.heex +++ b/lib/web/controllers/status_html/show.html.heex @@ -55,11 +55,22 @@ <% end %> </div> <div class="flex flex-row gap-x-2"> - <%= if @status.mastodon_post do %> - <.link href={@status.mastodon_post.url} target="_blank" class="u-syndication"> - tech.lgbt - </.link> - <% end %> + <.link + :if={@status.mastodon_post} + href={@status.mastodon_post.url} + target="_blank" + class="u-syndication" + > + tech.lgbt + </.link> + <.link + :if={@status.bluesky_post} + href={Core.Syndication.bsky_app_url(@status.bluesky_post)} + target="_blank" + class="u-syndication" + > + bsky.app + </.link> </div> </footer> </article> diff --git a/lib/web/live/admin_post_live.html.heex b/lib/web/live/admin_post_live.html.heex index 92408bf..2bfaf52 100644 --- a/lib/web/live/admin_post_live.html.heex +++ b/lib/web/live/admin_post_live.html.heex @@ -31,6 +31,12 @@ class="self-end" field={@form[:syndicate_to_mastodon]} /> + <.input + type="checkbox" + label="syndicate to bluesky" + class="self-end" + field={@form[:syndicate_to_bluesky]} + /> <%= if @post.deleted_at do %> <.button phx-click="undelete" class="self-end">undelete</.button> <% else %> diff --git a/lib/web/live/admin_syndication_live.ex b/lib/web/live/admin_syndication_live.ex index d2eb034..23f8c27 100644 --- a/lib/web/live/admin_syndication_live.ex +++ b/lib/web/live/admin_syndication_live.ex @@ -3,11 +3,26 @@ defmodule Web.AdminSyndicationLive do def mount(_params, _session, socket) do mastodon_account = Core.Syndication.get_mastodon_account(socket.assigns.current_user) + bluesky_account = Core.Syndication.get_bluesky_account(socket.assigns.current_user) socket = socket |> assign(:mastodon_account, mastodon_account) + |> assign(:bluesky_account, bluesky_account) {:ok, socket} end + + def handle_event( + "save_bluesky_account", + %{"bluesky_account" => %{"identifier" => identifier, "password" => password}}, + socket + ) do + {:ok, bluesky_account} = + Core.Syndication.save_bluesky_account(socket.assigns.current_user, identifier, password) + + socket = assign(socket, bluesky_account: bluesky_account) + + {:noreply, socket} + end end diff --git a/lib/web/live/admin_syndication_live.html.heex b/lib/web/live/admin_syndication_live.html.heex index 9522e66..4d9af31 100644 --- a/lib/web/live/admin_syndication_live.html.heex +++ b/lib/web/live/admin_syndication_live.html.heex @@ -31,7 +31,18 @@ <% end %> </div> <div> - <strong>Bluesky: </strong>Coming soon! + <strong>Bluesky: </strong> + <%= if @bluesky_account do %> + <.link href={"https://bsky.app/profile/#{@bluesky_account.handle}"} target="_blank"> + @{@bluesky_account.handle} + </.link> + <% else %> + <.form :let={f} for={%{}} as={:bluesky_account} phx-submit="save_bluesky_account"> + <.input field={f[:identifier]} label="email" /> + <.input field={f[:password]} type="password" /> + <.button type="submit">Connect account</.button> + </.form> + <% end %> </div> </main> </div> diff --git a/mix.exs b/mix.exs index 2c11260..9492cf2 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ defmodule SlaonelyButSurely.MixProject do {:ueberauth_mastodon, "~> 0.3.0"}, {:tesla, "~> 1.14"}, {:mint, "~> 1.7"}, + {:joken, "~> 2.6"}, # Added dev and/or test dependencies {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 3b392d2..b34d993 100644 --- a/mix.lock +++ b/mix.lock @@ -31,6 +31,8 @@ "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"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "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"}, diff --git a/priv/repo/migrations/20250502113428_add_bluesky_tables.exs b/priv/repo/migrations/20250502113428_add_bluesky_tables.exs new file mode 100644 index 0000000..8c1f432 --- /dev/null +++ b/priv/repo/migrations/20250502113428_add_bluesky_tables.exs @@ -0,0 +1,44 @@ +defmodule Core.Repo.Migrations.AddBlueskyTables do + use Ecto.Migration + + def change do + create table(:bluesky_accounts, primary_key: false) do + add :id, :uuid, primary_key: true + + add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false + + add :handle, :text, null: false + add :did, :text, null: false + + add :access_jwt, :text, null: false + add :access_jwt_iat, :utc_datetime, null: false + add :access_jwt_exp, :utc_datetime, null: false + + add :refresh_jwt, :text, null: false + add :refresh_jwt_iat, :utc_datetime, null: false + add :refresh_jwt_exp, :utc_datetime, null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:bluesky_accounts, [:user_id]) + + create table(:bluesky_posts, primary_key: false) do + add :id, :uuid, primary_key: true + + add :post_id, references(:posts, type: :uuid, on_delete: :delete_all), null: false + + add :cid, :text, null: false + add :uri, :text, null: false + add :commit, :jsonb, null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:bluesky_posts, [:post_id]) + + alter table(:posts) do + add :syndicate_to_bluesky, :boolean, default: false, null: false + end + end +end