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