diff --git a/lib/annot_at/atproto/identity.ex b/lib/annot_at/atproto/identity.ex index 911d193..62e607c 100644 --- a/lib/annot_at/atproto/identity.ex +++ b/lib/annot_at/atproto/identity.ex @@ -54,13 +54,19 @@ defmodule AnnotAt.Atproto.Identity do with :ok <- validate_handle(handle), {:ok, did} <- handle_to_did(handle), :ok <- validate_did(did), - {:ok, document} <- did_to_document(did), - {:ok, parsed} <- DIDDocument.parse(document, did), + {:ok, parsed} <- resolve_did(did), :ok <- confirm_bidirectional(parsed, handle) do {:ok, %__MODULE__{did: did, handle: handle, pds_endpoint: parsed.pds_endpoint}} end end + @spec resolve_did(String.t()) :: {:ok, DIDDocument.t()} | {:error, term()} + def resolve_did(did) when is_binary(did) do + with {:ok, doc} <- did_to_document(did) do + DIDDocument.parse(doc, did) + end + end + defp validate_handle(handle) do if Handle.valid?(handle) do :ok diff --git a/lib/annot_at/atproto/standard_site.ex b/lib/annot_at/atproto/standard_site.ex index ae93045..2e14356 100644 --- a/lib/annot_at/atproto/standard_site.ex +++ b/lib/annot_at/atproto/standard_site.ex @@ -6,6 +6,7 @@ defmodule AnnotAt.Atproto.StandardSite do alias AnnotAt.Accounts alias AnnotAt.Atproto.HTTP + alias AnnotAt.Atproto.Identity alias AnnotAt.Atproto.OAuth.Client alias AnnotAt.Atproto.StandardSite.Document alias AnnotAt.Atproto.StandardSite.Publication @@ -145,6 +146,24 @@ defmodule AnnotAt.Atproto.StandardSite do "at://#{did}/site.standard.document/#{rkey}" end + def get_public_document(did, rkey), do: get_public_record(did, @document, rkey) + + def get_public_publication("at://" <> _ = site_uri) do + with {:ok, did, rkey} <- parse_aturi(site_uri, @publication) do + get_public_record(did, @publication, rkey) + end + end + + def get_public_publication(_site), do: {:error, :no_publication} + + def get_public_record(did, collection, rkey) do + with {:ok, did_doc} <- Identity.resolve_did(did), + url = record_url(did_doc.pds_endpoint, did, collection, rkey), + {:ok, %{"value" => value}} <- HTTP.get_json(url) do + {:ok, value, did_doc} + end + end + defp fetch_user(user_id) do case Accounts.get_user(user_id) do nil -> {:error, :no_session} @@ -229,4 +248,18 @@ defmodule AnnotAt.Atproto.StandardSite do String.slice(text, 0, max) end end + + defp record_url(pds, did, collection, rkey) do + query = URI.encode_query(repo: did, collection: collection, rkey: rkey) + "#{pds}/xrpc/com.atproto.repo.getRecord?#{query}" + end + + defp parse_aturi("at://" <> rest, collection) do + case String.split(rest, "/") do + [did, ^collection, rkey] -> {:ok, did, rkey} + _ -> {:error, :invalid} + end + end + + defp parse_aturi(_uri, _collection), do: {:error, :invalid} end diff --git a/lib/annot_at_web/components/document_components.ex b/lib/annot_at_web/components/document_components.ex index f4863ff..2add987 100644 --- a/lib/annot_at_web/components/document_components.ex +++ b/lib/annot_at_web/components/document_components.ex @@ -17,7 +17,7 @@ defmodule AnnotAtWeb.DocumentComponents do :if={@cover_url} src={@cover_url} alt={@doc["title"]} - class="aspect-[1.91/1] w-full rounded-2dxl border-2 border-ink object-cover" + class="aspect-[1.91/1] w-full rounded-2xl border-2 border-ink object-cover" />

{@doc["title"]}

@@ -30,7 +30,7 @@ defmodule AnnotAtWeb.DocumentComponents do /> {@author.display_name || @author.handle} - +
diff --git a/lib/annot_at_web/components/layouts.ex b/lib/annot_at_web/components/layouts.ex index b553e3b..61978ec 100644 --- a/lib/annot_at_web/components/layouts.ex +++ b/lib/annot_at_web/components/layouts.ex @@ -250,4 +250,17 @@ defmodule AnnotAtWeb.Layouts do
""" end + + slot :inner_block, required: true + + def public(assigns) do + ~H""" +
+
+ <.link navigate={~p"/"} class="font-display text-xl font-bold tracking-tight">annot.at +
+
{render_slot(@inner_block)}
+
+ """ + end end diff --git a/lib/annot_at_web/controllers/live/posts_live.ex b/lib/annot_at_web/controllers/live/posts_live.ex index 87bc10f..a668aaa 100644 --- a/lib/annot_at_web/controllers/live/posts_live.ex +++ b/lib/annot_at_web/controllers/live/posts_live.ex @@ -106,7 +106,7 @@ defmodule AnnotAtWeb.PostsLive do {if publishing?(@publishing, entry), do: "Publishing…", else: "Publish"} No - date + date diff --git a/lib/annot_at_web/controllers/public_post_controller.ex b/lib/annot_at_web/controllers/public_post_controller.ex new file mode 100644 index 0000000..35e81f8 --- /dev/null +++ b/lib/annot_at_web/controllers/public_post_controller.ex @@ -0,0 +1,45 @@ +defmodule AnnotAtWeb.PublicPostController do + use AnnotAtWeb, :controller + + alias AnnotAt.Atproto + alias AnnotAt.Atproto.StandardSite + + require Logger + + def show(conn, %{"did" => did, "rkey" => rkey}) do + case StandardSite.get_public_document(did, rkey) do + {:ok, doc, did_doc} -> + render(conn, :show, + doc: doc, + author: author(doc, did_doc), + cover_url: blob(did_doc.pds_endpoint, did, doc["coverImage"]), + page_title: doc["title"] + ) + + {:error, error} -> + Logger.warning("PublicPost: failed to get public doc", reason: error) + + conn + |> put_status(:not_found) + |> put_view(html: AnnotAtWeb.ErrorHTML) + |> render(:"404") + end + end + + defp author(doc, did_doc) do + case StandardSite.get_public_publication(doc["site"]) do + {:ok, pub, pub_doc} -> + %{ + avatar_url: blob(pub_doc.pds_endpoint, pub_doc.did, pub["icon"]), + display_name: pub["name"], + handle: did_doc.handle + } + + {:error, _} -> + %{display_name: nil, handle: did_doc.handle, avatar_url: nil} + end + end + + defp blob(_pds, _did, nil), do: nil + defp blob(pds, did, blob), do: Atproto.blob_url(pds, did, blob) +end diff --git a/lib/annot_at_web/controllers/public_post_html.ex b/lib/annot_at_web/controllers/public_post_html.ex new file mode 100644 index 0000000..92b9be2 --- /dev/null +++ b/lib/annot_at_web/controllers/public_post_html.ex @@ -0,0 +1,6 @@ +defmodule AnnotAtWeb.PublicPostHTML do + use AnnotAtWeb, :html + import AnnotAtWeb.DocumentComponents + + embed_templates "public_post_html/*" +end diff --git a/lib/annot_at_web/controllers/public_post_html/show.html.heex b/lib/annot_at_web/controllers/public_post_html/show.html.heex new file mode 100644 index 0000000..29bf97b --- /dev/null +++ b/lib/annot_at_web/controllers/public_post_html/show.html.heex @@ -0,0 +1,3 @@ + + <.document doc={@doc} author={@author} cover_url={@cover_url} /> + diff --git a/lib/annot_at_web/router.ex b/lib/annot_at_web/router.ex index 6e34fc4..1ab95cf 100644 --- a/lib/annot_at_web/router.ex +++ b/lib/annot_at_web/router.ex @@ -17,6 +17,12 @@ defmodule AnnotAtWeb.Router do plug :accepts, ["json"] end + scope "/", AnnotAtWeb do + pipe_through :browser + + get "/p/:did/:rkey", PublicPostController, :show + end + scope "/", AnnotAtWeb do pipe_through [:browser, :redirect_if_user_is_authenticated] diff --git a/test/annot_at_web/controllers/public_post_controller_test.exs b/test/annot_at_web/controllers/public_post_controller_test.exs new file mode 100644 index 0000000..4fed0bd --- /dev/null +++ b/test/annot_at_web/controllers/public_post_controller_test.exs @@ -0,0 +1,49 @@ +defmodule AnnotAtWeb.PublicPostControllerTest do + use AnnotAtWeb.ConnCase, async: true + use Mimic + + alias AnnotAt.Atproto.DIDDocument + alias AnnotAt.Atproto.StandardSite + + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" + @rkey "3mlhhbujc22gw" + + test "GET /p/:did/rkey renders the document", %{conn: conn} do + did_doc = %DIDDocument{did: @did, handle: "jola.dev", pds_endpoint: "https://pds.example"} + + doc = %{ + "title" => "Running local models on M4", + "publishedAt" => "2026-03-23T12:00:00Z", + "site" => "at://#{@did}/site.standard.publication/pub1", + "tags" => ["ai"], + "content" => %{ + "$type" => "org.wordpress.html", + "html" => "

Helloworld.

" + }, + "textContent" => "Hello world." + } + + pub = %{"name" => "jola.dev blog", "icon" => nil} + + expect(StandardSite, :get_public_document, fn @did, @rkey -> {:ok, doc, did_doc} end) + expect(StandardSite, :get_public_publication, fn "at://" <> _ -> {:ok, pub, did_doc} end) + + html = + conn + |> get(~p"/p/#{@did}/#{@rkey}") + |> html_response(200) + + assert html =~ "Running local models on M4" + assert html =~ "world" + assert html =~ "jola.dev blog" + end + + test "GET /p/:did/:rkey 404s when the record can't be loaded", %{conn: conn} do + expect(StandardSite, :get_public_document, fn + @did, @rkey -> {:error, {:http_status, 404}} + end) + + conn = get(conn, ~p"/p/#{@did}/#{@rkey}") + assert response(conn, 404) + end +end