diff --git a/lib/annot_at/atproto/standard_site.ex b/lib/annot_at/atproto/standard_site.ex index 9e84624..85f241a 100644 --- a/lib/annot_at/atproto/standard_site.ex +++ b/lib/annot_at/atproto/standard_site.ex @@ -5,15 +5,17 @@ defmodule AnnotAt.Atproto.StandardSite do """ alias AnnotAt.Accounts + alias AnnotAt.Atproto.HTTP alias AnnotAt.Atproto.OAuth.Client alias AnnotAt.Atproto.StandardSite.Document alias AnnotAt.Atproto.StandardSite.Publication @publication "site.standard.publication" @document "site.standard.document" + @wellknown_path "/.well-known/site.standard.publication" @doc """ - Creates or updates the user's publication record (one per repo, rkey `self`). + Creates or updates the user's publication record. """ @spec put_publication(integer(), Publication.t()) :: {:ok, map()} | {:error, term()} def put_publication(user_id, %Publication{} = publication) do @@ -37,8 +39,80 @@ defmodule AnnotAt.Atproto.StandardSite do @doc """ The AT-URI of the user's publication record, used as a document's `site`. """ - @spec publication_uri(String.t()) :: String.t() - def publication_uri(did), do: "at://#{did}/#{@publication}/self" + @spec publication_uri(String.t(), String.t()) :: String.t() + def publication_uri(did, rkey), do: "at://#{did}/#{@publication}/#{rkey}" + + @doc """ + Verifies the website hosts the publication's AT-URI at its well-known path, + proving control of the domain. + """ + @spec verify_ownership(String.t(), String.t()) :: + :ok + | {:error, + :mismatch | :not_found | {:http_status, pos_integer()} | {:transport, term()}} + def verify_ownership(url, at_uri) do + url = wellknown_url(url) + + case HTTP.get_text(url) do + {:ok, body} -> + if String.trim(body) == at_uri do + :ok + else + {:error, :mismatch} + end + + {:error, {:http_status, 404}} -> + {:error, :not_found} + + {:error, _reason} = error -> + error + end + end + + @spec list_publications(integer()) :: + {:ok, [%{rkey: String.t(), url: String.t() | nil, name: String.t() | nil}]} + | {:error, :no_session} + def list_publications(user_id) do + with {:ok, user} <- fetch_user(user_id), + {:ok, %{"records" => records}} <- + Client.query(user.id, "com.atproto.repo.listRecords", + repo: user.did, + collection: @publication + ) do + {:ok, Enum.map(records, &to_existing/1)} + end + end + + @doc """ + Reads the user's publication document. + """ + @spec get_publication(integer(), String.t()) :: {:ok, map()} | {:error, term()} + def get_publication(user_id, rkey) do + with {:ok, user} <- fetch_user(user_id), + {:ok, %{"value" => value}} <- + Client.query(user.id, "com.atproto.repo.getRecord", + repo: user.did, + collection: @publication, + rkey: rkey + ) do + {:ok, value} + end + end + + @doc """ + Builds the publication document we create, from page metadata. + """ + @spec draft_publication(String.t(), %{title: String.t() | nil, description: String.t() | nil}) :: + map() + def draft_publication(url, %{title: title, description: description}) do + %{ + "$type" => @publication, + "name" => title, + "url" => url, + "description" => description, + "preferences" => %{"showInDiscover" => true} + } + end defp fetch_user(user_id) do case Accounts.get_user(user_id) do @@ -86,4 +160,24 @@ defmodule AnnotAt.Atproto.StandardSite do defp put_optional(map, _key, nil), do: map defp put_optional(map, key, value), do: Map.put(map, key, value) + + defp wellknown_url(url) do + uri = URI.parse(url) + + URI.to_string(%{ + uri + | path: @wellknown_path, + query: nil, + fragment: nil + }) + end + + defp to_existing(%{"uri" => uri, "value" => value}) do + rkey = + uri + |> String.split("/") + |> List.last() + + %{rkey: rkey, url: value["url"], name: value["name"]} + end end diff --git a/lib/annot_at/atproto/tid.ex b/lib/annot_at/atproto/tid.ex new file mode 100644 index 0000000..ba6f106 --- /dev/null +++ b/lib/annot_at/atproto/tid.ex @@ -0,0 +1,28 @@ +defmodule AnnotAt.Atproto.TID do + @moduledoc """ + atproto TID (timestamp identifier), frequenty used as record keys. + + https://atproto.com/specs/tid) + """ + + @alphabet "234567abcdefghijklmnopqrstuvwxyz" + + @spec now() :: String.t() + def now(time \\ System) do + # Shifts the time to make space for a random "clock ID" which basically + # just means we wouldn't get collisions if two TIDs were generated the + # same microsecond. Probably overkill. + clock_id = :rand.uniform(1024) - 1 + shifted = time.os_time(:microsecond) * 1024 + timestamp = shifted + clock_id + new(timestamp) + end + + @spec new(non_neg_integer()) :: String.t() + def new(int) do + int + |> Integer.digits(32) + |> Enum.map_join(&<<:binary.at(@alphabet, &1)>>) + |> String.pad_leading(13, "2") + end +end diff --git a/lib/annot_at/feeds.ex b/lib/annot_at/feeds.ex index eac0e41..69866d2 100644 --- a/lib/annot_at/feeds.ex +++ b/lib/annot_at/feeds.ex @@ -49,6 +49,26 @@ defmodule AnnotAt.Feeds do |> Enum.uniq_by(& &1.url) end + @doc """ + Extracts a page's title and description from its HTML. + """ + @spec metadata(binary()) :: %{title: String.t() | nil, description: String.t() | nil} + def metadata(html) when is_binary(html) do + doc = LazyHTML.from_document(html) + + %{ + title: + text_of(doc, "title") || + meta_content( + doc, + ~s(meta[property="og:title"]) + ), + description: + meta_content(doc, ~s(meta[name="description"])) || + meta_content(doc, ~s(meta[property="og:description"])) + } + end + defp source_from_attributes(attributes, base_url) do attributes = Map.new(attributes) @@ -84,4 +104,28 @@ defmodule AnnotAt.Feeds do true -> :unknown end end + + defp text_of(doc, selector) do + doc + |> LazyHTML.query(selector) + |> LazyHTML.text() + |> presence() + end + + defp meta_content(doc, selector) do + doc + |> LazyHTML.query(selector) + |> LazyHTML.attribute("content") + |> List.first() + |> presence() + end + + defp presence(nil), do: nil + + defp presence(str) do + case String.trim(str) do + "" -> nil + trimmed -> trimmed + end + end end diff --git a/lib/annot_at/feeds/client.ex b/lib/annot_at/feeds/client.ex new file mode 100644 index 0000000..1503165 --- /dev/null +++ b/lib/annot_at/feeds/client.ex @@ -0,0 +1,64 @@ +defmodule AnnotAt.Feeds.Client do + @moduledoc """ + Network stuff for feeds, like fetching pages or feeds and handing + them to the dicovery/parsing. + """ + + alias AnnotAt.Feeds + alias AnnotAt.Feeds.Feed + alias AnnotAt.Feeds.Source + + @receive_timeout 10_000 + + @doc """ + Fetches a page and returns the feeds discovered. + """ + @spec discover(String.t()) :: + {:ok, [Source.t()]} | {:error, {:http_status, pos_integer()} | {:transport, term()}} + def discover(url) do + with {:ok, html} <- get(url) do + {:ok, Feeds.discover(html, url)} + end + end + + @doc """ + Fetches and parses a single feed. + """ + @spec load(String.t()) :: + {:ok, Feed.t()} + | {:error, + :invalid_feed + | :unsupported_feed + | :unrecognized_feed + | {:http_status, pos_integer()} + | {:transport, term()}} + def load(feed_url) do + with {:ok, body} <- get(feed_url) do + Feeds.parse(body) + end + end + + @doc """ + Fetches a page and extracts its title/description. + """ + @spec metadata(String.t()) :: + {:ok, map()} | {:error, {:http_status, pos_integer()} | {:transport, term()}} + def metadata(url) do + with {:ok, html} <- get(url) do + {:ok, Feeds.metadata(html)} + end + end + + defp get(url) do + case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> + {:ok, body} + + {:ok, %Req.Response{status: status}} -> + {:error, {:http_status, status}} + + {:error, reason} -> + {:error, {:transport, reason}} + end + end +end diff --git a/lib/annot_at/publishing.ex b/lib/annot_at/publishing.ex index 7c1ca04..8b35f61 100644 --- a/lib/annot_at/publishing.ex +++ b/lib/annot_at/publishing.ex @@ -9,6 +9,7 @@ defmodule AnnotAt.Publishing do alias AnnotAt.Accounts.Scope alias AnnotAt.Accounts.User + alias AnnotAt.Atproto.TID alias AnnotAt.Publishing.Site alias AnnotAt.Repo @@ -21,14 +22,53 @@ defmodule AnnotAt.Publishing do Repo.get_by!(Site, id: id, user_id: user_id) end - def create_site(%Scope{user: %User{id: user_id}}, rkey, attrs) do - %Site{ - user_id: user_id, - rkey: rkey, + def create_site(%Scope{user: %User{id: user_id}}, url) do + case Repo.get_by(Site, user_id: user_id, url: url) do + nil -> + %Site{user_id: user_id} + |> Site.changeset(%{url: url}) + |> Repo.insert() + + %Site{} = site -> + {:ok, site} + end + end + + def use_new_publication(%Scope{user: %User{id: user_id}}, %Site{} = site) do + verify_user_ownership!(site, user_id) + rkey = TID.now() + + site + |> Ecto.Changeset.change(%{rkey: rkey}) + |> Repo.update() + end + + def use_existing_publication(%Scope{user: %User{id: user_id}}, %Site{} = site, rkey) do + verify_user_ownership!(site, user_id) + + site + |> Ecto.Changeset.change(%{rkey: rkey, published_at: DateTime.utc_now(:second)}) + |> Repo.update() + end + + def mark_verified(%Scope{user: %User{id: user_id}}, %Site{} = site) do + verify_user_ownership!(site, user_id) + + site + |> Ecto.Changeset.change(%{ verified_at: DateTime.utc_now(:second) - } - |> Site.changeset(attrs) - |> Repo.insert() + }) + |> Repo.update() + end + + def mark_published(%Scope{user: %User{id: user_id}}, %Site{} = site) do + verify_user_ownership!(site, user_id) + + site + |> Ecto.Changeset.change(%{ + published_at: DateTime.utc_now(:second) + }) + |> Repo.update() end def update_site(%Scope{user: %User{id: user_id}}, %Site{} = site, attrs) do diff --git a/lib/annot_at/publishing/site.ex b/lib/annot_at/publishing/site.ex index 45bb497..e5d5468 100644 --- a/lib/annot_at/publishing/site.ex +++ b/lib/annot_at/publishing/site.ex @@ -5,12 +5,13 @@ defmodule AnnotAt.Publishing.Site do @type t :: %__MODULE__{ user_id: integer(), - name: String.t(), + name: String.t() | nil, url: String.t(), description: String.t() | nil, - feed_url: String.t(), - rkey: String.t(), - verified_at: DateTime.t() + feed_url: String.t() | nil, + rkey: String.t() | nil, + verified_at: DateTime.t() | nil, + published_at: DateTime.t() | nil } schema "sites" do @@ -23,10 +24,11 @@ defmodule AnnotAt.Publishing.Site do # The url of the feed field :feed_url, :string # rkey of the site.standard.publication record - # deterministically generated from did and url field :rkey, :string # when the site was verified field :verified_at, :utc_datetime + # when the user clicked publish + field :published_at, :utc_datetime belongs_to :user, AnnotAt.Accounts.User @@ -35,14 +37,17 @@ defmodule AnnotAt.Publishing.Site do def changeset(site, attrs) do site - |> cast(attrs, [:name, :url, :description, :feed_url]) - |> validate_required([:name, :url, :feed_url, :rkey]) + |> cast(attrs, [:url, :name, :description, :feed_url]) + |> validate_required([:url]) + |> validate_length(:url, max: 2048) |> validate_length(:name, max: 255) |> validate_length(:description, max: 1000) - |> validate_length(:url, max: 2048) |> validate_length(:feed_url, max: 2048) - |> validate_length(:rkey, max: 512) + |> unique_constraint(:url, name: :sites_user_id_url_index) |> foreign_key_constraint(:user_id) - |> unique_constraint(:rkey, name: :sites_user_id_rkey_index) end + + def status(%__MODULE__{published_at: %DateTime{}}), do: :published + def status(%__MODULE__{verified_at: %DateTime{}}), do: :verified + def status(%__MODULE__{}), do: :draft end diff --git a/lib/annot_at/url.ex b/lib/annot_at/url.ex new file mode 100644 index 0000000..baf41bd --- /dev/null +++ b/lib/annot_at/url.ex @@ -0,0 +1,52 @@ +defmodule AnnotAt.URL do + @moduledoc """ + URL helpers. + """ + + @doc """ + Clean up URLs so we got consistent site "identifiers". + """ + def canonical(url) do + uri = + url + |> with_scheme() + |> URI.parse() + + URI.to_string(%{ + uri + | host: downcase(uri.host), + path: trim_slash(uri.path) + }) + end + + def valid?(url) do + uri = + url + |> String.trim() + |> canonical() + |> URI.parse() + + case uri do + %URI{host: host} when is_binary(host) -> + String.contains?(host, ".") + + _ -> + false + end + end + + defp with_scheme(url) do + if url =~ ~r{^https?://}i do + url + else + "https://" <> url + end + end + + defp downcase(nil), do: nil + defp downcase(host), do: String.downcase(host) + + defp trim_slash(nil), do: nil + defp trim_slash("/"), do: nil + defp trim_slash(path), do: String.trim_trailing(path, "/") +end diff --git a/lib/annot_at_web/components/core_components.ex b/lib/annot_at_web/components/core_components.ex index 8cc2a21..e62093c 100644 --- a/lib/annot_at_web/components/core_components.ex +++ b/lib/annot_at_web/components/core_components.ex @@ -453,6 +453,22 @@ defmodule AnnotAtWeb.CoreComponents do """ end + attr :label, :string, required: true + attr :value, :string, required: true + attr :sub, :string, required: true + attr :tint, :string, required: true + attr :shadow, :string, required: true + + def stat_card(assigns) do + ~H""" +
+
{@label}
+
{@value}
+
{@sub}
+
+ """ + end + ## JS Commands def show(js \\ %JS{}, selector) do diff --git a/lib/annot_at_web/controllers/live/dashboard_live.ex b/lib/annot_at_web/controllers/live/dashboard_live.ex index 2ecffe8..1a65f4f 100644 --- a/lib/annot_at_web/controllers/live/dashboard_live.ex +++ b/lib/annot_at_web/controllers/live/dashboard_live.ex @@ -1,27 +1,17 @@ defmodule AnnotAtWeb.DashboardLive do use AnnotAtWeb, :live_view - @impl Phoenix.LiveView - def mount(_params, _session, socket) do - {:ok, assign(socket, page_title: "Dashboard", sites: [])} - end - - @impl Phoenix.LiveView - def handle_event("add_site", _params, socket) do - {:noreply, put_flash(socket, :info, "Adding sites is coming next.")} - end - @impl Phoenix.LiveView def render(assigns) do ~H"""
- + <.icon name="hero-plus" class="size-4" /> Add your first site +

@@ -64,7 +54,7 @@ defmodule AnnotAtWeb.DashboardLive do
<.icon name="hero-globe-alt" class="size-7" /> @@ -73,30 +63,19 @@ defmodule AnnotAtWeb.DashboardLive do

Connect a blog's RSS feed and we'll publish every new post to the ATmosphere.

- +
""" end - attr :label, :string, required: true - attr :value, :string, required: true - attr :sub, :string, required: true - attr :tint, :string, required: true - attr :shadow, :string, required: true - - defp stat_card(assigns) do - ~H""" -
-
{@label}
-
{@value}
-
{@sub}
-
- """ + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, assign(socket, page_title: "Dashboard", sites: [])} end end diff --git a/lib/annot_at_web/controllers/live/site_live.ex b/lib/annot_at_web/controllers/live/site_live.ex new file mode 100644 index 0000000..de5d33f --- /dev/null +++ b/lib/annot_at_web/controllers/live/site_live.ex @@ -0,0 +1,567 @@ +defmodule AnnotAtWeb.SiteLive do + use AnnotAtWeb, :live_view + + alias AnnotAt.Atproto.StandardSite + alias AnnotAt.Feeds.Client + alias AnnotAt.Publishing + alias AnnotAt.Publishing.Site + alias AnnotAt.URL + alias Phoenix.LiveView.AsyncResult + + @impl Phoenix.LiveView + def mount(%{"id" => id}, _session, socket) do + site = Publishing.get_site!(socket.assigns.current_scope, id) + + socket = + socket + |> assign(page_title: site.url, site: site) + |> advance(phase(site)) + + {:ok, socket} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" + + <.link navigate={~p"/dashboard"} class="text-sm text-ink/60 + hover:text-ink">← Back + +
+

{@site.url}

+ +
+ + <.icon name="hero-check-badge" class="size-5 text-green-600" /> Verified + + +
+
+ +
+ <%= case phase(@site) do %> + <% :done -> %> + <.site_cards site={@site} record={@record} feed={@feed} /> + <% :feed -> %> + <.feed_step feeds={@feeds} /> + <% :publication -> %> + <.publication_step publications={@publications} /> + <% :well_known -> %> + <.well_known_step verification={@verification} at_uri={@at_uri} /> + <% end %> +
+
+ """ + end + + @impl Phoenix.LiveView + def handle_event("pick_feed", %{"url" => feed_url}, socket) do + {:ok, site} = + Publishing.update_site(socket.assigns.current_scope, socket.assigns.site, %{ + feed_url: feed_url + }) + + socket = + socket + |> assign(site: site) + |> advance(phase(site)) + + {:noreply, socket} + end + + def handle_event("use_existing", %{"rkey" => rkey}, socket) do + {:ok, site} = + Publishing.use_existing_publication(socket.assigns.current_scope, socket.assigns.site, rkey) + + socket = + socket + |> assign(site: site) + |> advance(phase(site)) + + {:noreply, socket} + end + + def handle_event("use_new", _params, socket) do + {:ok, site} = + Publishing.use_new_publication(socket.assigns.current_scope, socket.assigns.site) + + socket = + socket + |> assign(site: site) + |> advance(phase(site)) + + {:noreply, socket} + end + + def handle_event("confirm_verified", _params, socket) do + {:ok, site} = Publishing.mark_verified(socket.assigns.current_scope, socket.assigns.site) + + socket = + socket + |> assign(site: site) + |> advance(phase(site)) + + {:noreply, socket} + end + + def handle_event("revalidate", _params, socket) do + {:noreply, advance(socket, :well_known)} + end + + def handle_event("publish", _params, socket) do + {:noreply, put_flash(socket, :info, "Publishing to the Atmosphere is coming soon.")} + end + + defp phase(%Site{verified_at: %DateTime{}}), do: :done + defp phase(%Site{feed_url: nil}), do: :feed + defp phase(%Site{rkey: nil}), do: :publication + defp phase(%Site{}), do: :well_known + + defp advance(socket, :feed) do + url = socket.assigns.site.url + + if connected?(socket) do + assign_async(socket, :feeds, fn -> + with {:ok, feeds} <- Client.discover(url) do + {:ok, %{feeds: feeds}} + end + end) + else + assign(socket, feeds: AsyncResult.loading()) + end + end + + defp advance(socket, :publication) do + %{site: site, current_scope: scope} = socket.assigns + + if connected?(socket) do + assign_async(socket, :publications, fn -> + with {:ok, pubs} <- StandardSite.list_publications(scope.user.id) do + {:ok, %{publications: Enum.filter(pubs, &matches_url?(&1, site.url))}} + end + end) + else + assign(socket, publications: AsyncResult.loading()) + end + end + + defp advance(socket, :well_known) do + %{site: site, current_scope: scope} = socket.assigns + at_uri = StandardSite.publication_uri(scope.user.did, site.rkey) + socket = assign(socket, at_uri: at_uri) + + if connected?(socket) do + assign_async(socket, :verification, fn -> + case StandardSite.verify_ownership(site.url, at_uri) do + :ok -> {:ok, %{verification: :ok}} + {:error, _reason} = error -> error + end + end) + else + assign(socket, verification: AsyncResult.loading()) + end + end + + defp advance(socket, :done) do + %{site: site, current_scope: scope} = socket.assigns + + if connected?(socket) do + socket + |> assign_async(:record, fn -> fetch_record(site, scope) end) + |> assign_async(:feed, fn -> + with {:ok, feed} <- Client.load(site.feed_url) do + {:ok, %{feed: feed}} + end + end) + else + assign(socket, record: AsyncResult.loading(), feed: AsyncResult.loading()) + end + end + + defp matches_url?(%{url: url}, site_url) when is_binary(url) do + URL.canonical(url) == site_url + end + + defp matches_url?(_pub, _site_url), do: false + + defp fetch_record(%Site{published_at: %DateTime{}} = site, scope) do + with {:ok, doc} <- StandardSite.get_publication(scope.user.id, site.rkey) do + {:ok, %{record: doc}} + end + end + + defp fetch_record(%Site{} = site, _scope) do + with {:ok, metadata} <- Client.metadata(site.url) do + {:ok, %{record: StandardSite.draft_publication(site.url, metadata)}} + end + end + + attr :feeds, :any, required: true + + defp feed_step(assigns) do + ~H""" +
+
+
+ <.icon name="hero-rss" class="size-6" /> +
+
+

Discovering + your feed

+

We look for an RSS or Atom feed on your + site.

+
+
+ + <.async_result :let={feeds} assign={@feeds}> + <:loading> +
+ <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Looking… +
+ + <:failed :let={_}> +

Couldn't reach the + site.

+ + +

No feed found on + this site.

+ +
+ +
+ +
+ """ + end + + attr :publications, :any, required: true + + defp publication_step(assigns) do + ~H""" +
+
+
+ <.icon name="hero-newspaper" class="size-6" /> +
+
+

Your + publication

+

Reuse a publication you already have, + or create a new one.

+
+
+ + <.async_result :let={publications} assign={@publications}> + <:loading> +
+ <.icon name="hero-arrow-path" class="size-5 animate-spin" /> repo. +
+ + +
+ +
+ + + +
+ """ + end + + attr :verification, :any, required: true + attr :at_uri, :string, required: true + + defp well_known_step(assigns) do + ~H""" +
+
+
+ <.icon name="hero-shield-check" class="size-6" /> +
+
+

Verify your + domain

+

Prove you control this site by hosting + a small file.

+
+
+ + <.async_result :let={_v} assign={@verification}> + <:loading> +
+ <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Checking your .well-known… +
+ + <:failed :let={_reason}> +
+

Host this file on your site, then + check again:

+
+
Path
+ + /.well-known/site.standard.publication + +
Contents
+ {@at_uri} +
+ +
+ + +
+
+ <.icon name="hero-check-circle" class="size-6" /> Verified, you + control this domain. +
+ +
+ +
+ """ + end + + attr :icon, :string, required: true + attr :title, :string, required: true + attr :tint, :string, default: "sky" + slot :badge + slot :inner_block, required: true + slot :actions + + defp info_card(assigns) do + ~H""" +
+
+
+
+ <.icon name={@icon} class="size-5" /> +
+

{@title}

+
+
{render_slot(@badge)}
+
+ +
{render_slot(@inner_block)}
+ +
+ {render_slot(@actions)} +
+
+ """ + end + + attr :label, :string, required: true + attr :value, :string, default: nil + + defp record_field(assigns) do + ~H""" +
+
{@label}
+
{@value}
+
Not set + yet
+
+ """ + end + + attr :site, :map, required: true + attr :record, :any, required: true + attr :feed, :any, required: true + + defp site_cards(assigns) do + ~H""" +
+ <.info_card icon="hero-rss" title="Feed" tint="sky"> + <:badge> + + <.icon name="hero-clock" class="size-3.5" /> Checked just now + + + + <.async_result :let={feed} assign={@feed}> + <:loading> +
+ <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Reading the feed… +
+ + <:failed :let={_}> +

Couldn't read the + feed.

+ + +
+ <%= case List.first(feed.entries) do %> + <% nil -> %> +

No posts in this feed yet.

+ <% latest -> %> +
+
Latest post
+
{latest.title}
+
+ {Calendar.strftime(latest.published_at, "%b %d, %Y")} +
+
+ <% end %> + +

{length(feed.entries)} posts in the feed

+
+ + + + {@site.feed_url} + + + + <.info_card icon="hero-newspaper" title="Publication" tint="peach"> + <:badge> + + {if @site.published_at, do: "Published", else: "Not published yet"} + + + + <.async_result :let={record} assign={@record}> + <:loading> +
+ <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Reading your site… +
+ + <:failed :let={_}> +

Couldn't read your + site.

+ + +
+ <.record_field label="Name" value={record["name"]} /> + <.record_field label="URL" value={record["url"]} /> + <.record_field label="Description" value={record["description"]} /> + <.record_field label="Type" value={record["$type"]} /> +
+
Show in discover
+
+ {if get_in(record, ["preferences", "showInDiscover"]), + do: "Yes", + else: "No"} +
+
+
+ + +
+ """ + end +end diff --git a/lib/annot_at_web/controllers/live/site_new_live.ex b/lib/annot_at_web/controllers/live/site_new_live.ex new file mode 100644 index 0000000..9772ea5 --- /dev/null +++ b/lib/annot_at_web/controllers/live/site_new_live.ex @@ -0,0 +1,100 @@ +defmodule AnnotAtWeb.SiteNewLive do + use AnnotAtWeb, :live_view + + alias AnnotAt.Publishing + alias AnnotAt.URL + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" + + <.link navigate={~p"/dashboard"} class="text-sm font-bold text-ink/50 + hover:text-ink"> + ← Back + + +
+
+
+ <.icon name="hero-rss" class="size-8" /> +
+
+

Add a + site

+

Paste your blog's URL, we'll sniff out + its feed.

+
+
+ + <.form for={@form} id="site-form" phx-change="validate" phx-submit="submit" class="mt-8"> +
+
+ <.input + field={@form[:url]} + type="url" + label="Website URL" + placeholder="https://yourblog.com" + class="w-full rounded-xl border-2 border-ink bg-paper px-5 py-4 + text-lg font-medium placeholder:text-ink/30 focus:outline-none focus:ring-2 + focus:ring-sky-bold" + /> +
+ +
+ +
+
+ """ + end + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, + assign(socket, + page_title: "Add a site", + form: to_form(%{"url" => ""}, as: :site), + valid?: false + )} + end + + @impl Phoenix.LiveView + def handle_event("validate", %{"site" => %{"url" => url} = params}, socket) do + {:noreply, + assign(socket, + form: to_form(params, as: :site), + valid?: URL.valid?(url) + )} + end + + def handle_event("submit", %{"site" => %{"url" => url}}, socket) do + if URL.valid?(url) do + case Publishing.create_site(socket.assigns.current_scope, URL.canonical(url)) do + {:ok, site} -> + {:noreply, push_navigate(socket, to: ~p"/sites/#{site.id}")} + + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset, as: :site))} + end + else + {:noreply, socket} + end + end +end diff --git a/lib/annot_at_web/router.ex b/lib/annot_at_web/router.ex index 52b2484..fca42f0 100644 --- a/lib/annot_at_web/router.ex +++ b/lib/annot_at_web/router.ex @@ -35,6 +35,8 @@ defmodule AnnotAtWeb.Router do live_session :authenticated, on_mount: [{AnnotAtWeb.UserAuth, :require_authenticated}] do live "/dashboard", DashboardLive + live "/sites/new", SiteNewLive + live "/sites/:id", SiteLive end end diff --git a/priv/repo/migrations/20260621104044_alter_sites_for_wizard.exs b/priv/repo/migrations/20260621104044_alter_sites_for_wizard.exs new file mode 100644 index 0000000..afe7ca4 --- /dev/null +++ b/priv/repo/migrations/20260621104044_alter_sites_for_wizard.exs @@ -0,0 +1,16 @@ +defmodule AnnotAt.Repo.Migrations.AlterSitesForWizard do + use Ecto.Migration + + def change do + alter table(:sites) do + modify :name, :text, null: true, from: {:text, null: false} + modify :feed_url, :text, null: true, from: {:text, null: false} + modify :verified_at, :utc_datetime, null: true, from: {:utc_datetime, null: false} + modify :rkey, :text, null: true, from: {:text, null: false} + add :published_at, :utc_datetime + end + + drop unique_index(:sites, [:user_id, :rkey]) + create unique_index(:sites, [:user_id, :url]) + end +end diff --git a/test/annot_at/atproto/standard_site_test.exs b/test/annot_at/atproto/standard_site_test.exs index fa9148e..a757a73 100644 --- a/test/annot_at/atproto/standard_site_test.exs +++ b/test/annot_at/atproto/standard_site_test.exs @@ -3,133 +3,239 @@ defmodule AnnotAt.Atproto.StandardSiteTest do use Mimic alias AnnotAt.Accounts + alias AnnotAt.Atproto.HTTP alias AnnotAt.Atproto.OAuth.Client alias AnnotAt.Atproto.StandardSite alias AnnotAt.Atproto.StandardSite.Document alias AnnotAt.Atproto.StandardSite.Publication @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" - - defp create_user do - {:ok, user} = - Accounts.upsert_user(%{did: @did, handle: "jola.dev", pds_host: "https://pds.example.com"}) - - user + @rkey "3mope7jyypk22" + + describe "put_publication/2" do + test "writes a publication record at rkey self" do + user = create_user() + + expect(Client, :procedure, fn user_id, "com.atproto.repo.putRecord", body -> + assert user.id == user_id + assert @did == body.repo + assert "site.standard.publication" == body.collection + assert "self" == body.rkey + assert "site.standard.publication" == body.record["$type"] + assert "jola.dev" == body.record["name"] + {:ok, %{"uri" => "at://x"}} + end) + + pub = %Publication{name: "jola.dev", url: "https://jola.dev", description: "blog"} + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) + end + + test "returns :no_session when the user does not exist" do + reject(&Client.procedure/3) + + assert {:error, :no_session} = + StandardSite.put_publication(-1, %Publication{name: "x", url: "y"}) + end + + test "uploads the icon and embeds the returned blob" do + user = create_user() + + blob = %{ + "$type" => "blob", + "ref" => %{"$link" => "bafyicon"}, + "mimeType" => "image/png", + "size" => 3 + } + + expect(Client, :upload_blob, fn user_id, <<1, 2, 3>>, "image/png" -> + assert user.id == user_id + {:ok, %{"blob" => blob}} + end) + + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> + assert blob == body.record["icon"] + {:ok, %{"uri" => "at://x"}} + end) + + pub = %Publication{ + name: "jola.dev", + url: "https://jola.dev", + icon: {<<1, 2, 3>>, "image/png"} + } + + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) + end + + test "omits optional fields that are nil" do + user = create_user() + + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> + refute Map.has_key?(body.record, "description") + refute Map.has_key?(body.record, "icon") + {:ok, %{}} + end) + + assert {:ok, %{}} = + StandardSite.put_publication(user.id, %Publication{ + name: "n", + url: "https://n.example" + }) + end end - test "put_publication/2 writes a publication record at rkey self" do - user = create_user() - - expect(Client, :procedure, fn user_id, "com.atproto.repo.putRecord", body -> - assert user.id == user_id - assert @did == body.repo - assert "site.standard.publication" == body.collection - assert "self" == body.rkey - assert "site.standard.publication" == body.record["$type"] - assert "jola.dev" == body.record["name"] - {:ok, %{"uri" => "at://x"}} - end) - - pub = %Publication{name: "jola.dev", url: "https://jola.dev", description: "blog"} - assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) + describe "put_document/2" do + test "writes a document record with an rkey and rfc3339 timestamps" do + user = create_user() + + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> + assert "site.standard.document" == body.collection + assert "post-1" == body.rkey + assert "Hello" == body.record["title"] + assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] + assert ["a", "b"] == body.record["tags"] + {:ok, %{"uri" => "at://y"}} + end) + + doc = %Document{ + rkey: "post-1", + site: StandardSite.publication_uri(@did, @rkey), + title: "Hello", + path: "/posts/1", + published_at: ~U[2026-01-01 00:00:00Z], + updated_at: ~U[2026-01-01 00:00:00Z], + description: "desc", + text_content: "body", + tags: ["a", "b"] + } + + assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) + end + + test "omits updatedAt and path when not set" do + user = create_user() + + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> + refute Map.has_key?(body.record, "updatedAt") + refute Map.has_key?(body.record, "path") + assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] + {:ok, %{"uri" => "at://y"}} + end) + + doc = %Document{ + rkey: "post-2", + site: StandardSite.publication_uri(@did, @rkey), + title: "Hello", + published_at: ~U[2026-01-01 00:00:00Z] + } + + assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) + end end - test "put_document/2 writes a document record with an rkey and rfc3339 timestamps" do - user = create_user() - - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> - assert "site.standard.document" == body.collection - assert "post-1" == body.rkey - assert "Hello" == body.record["title"] - assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] - assert ["a", "b"] == body.record["tags"] - {:ok, %{"uri" => "at://y"}} - end) - - doc = %Document{ - rkey: "post-1", - site: StandardSite.publication_uri(@did), - title: "Hello", - path: "/posts/1", - published_at: ~U[2026-01-01 00:00:00Z], - updated_at: ~U[2026-01-01 00:00:00Z], - description: "desc", - text_content: "body", - tags: ["a", "b"] - } - - assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) + describe "verify_ownership/2" do + @url "https://example.com" + @at_uri "at://#{@did}/site.standard.publication/#{@rkey}" + + test "ok when the well-known matches the at-uri, ignoring surrounding +whitespace" do + expect(HTTP, :get_text, fn + "https://example.com/.well-known/site.standard.publication" -> + {:ok, " #{@at_uri}\n"} + end) + + assert :ok = StandardSite.verify_ownership(@url, @at_uri) + end + + test "fetches the well-known at the domain root even when the url has a +path" do + expect(HTTP, :get_text, fn + "https://example.com/.well-known/site.standard.publication" -> + {:ok, @at_uri} + end) + + assert :ok = + StandardSite.verify_ownership( + "https://example.com/blog", + @at_uri + ) + end + + test "mismatch when the well-known holds a different uri" do + expect(HTTP, :get_text, fn _ -> + {:ok, "at://did:plc:someoneelse/site.standard.publication/x"} + end) + + assert {:error, :mismatch} = StandardSite.verify_ownership(@url, @at_uri) + end + + test "not_found when the file is missing" do + expect(HTTP, :get_text, fn _ -> {:error, {:http_status, 404}} end) + assert {:error, :not_found} = StandardSite.verify_ownership(@url, @at_uri) + end end - test "returns :no_session when the user does not exist" do - reject(&Client.procedure/3) - - assert {:error, :no_session} = - StandardSite.put_publication(-1, %Publication{name: "x", url: "y"}) + describe "list_publications/1" do + test "returns existing publications with their rkeys" do + user = create_user() + + expect(Client, :query, fn _id, "com.atproto.repo.listRecords", params -> + assert "site.standard.publication" == params[:collection] + + {:ok, + %{ + "records" => [ + %{ + "uri" => "at://#{@did}/site.standard.publication/#{@rkey}", + "value" => %{"name" => "jola.dev", "url" => "https://jola.dev"} + } + ] + }} + end) + + assert {:ok, [pub]} = StandardSite.list_publications(user.id) + assert "3mope7jyypk22" == pub.rkey + assert "https://jola.dev" == pub.url + assert "jola.dev" == pub.name + end end - test "put_publication/2 uploads the icon and embeds the returned blob" do - user = create_user() - - blob = %{ - "$type" => "blob", - "ref" => %{"$link" => "bafyicon"}, - "mimeType" => "image/png", - "size" => 3 - } - - expect(Client, :upload_blob, fn user_id, <<1, 2, 3>>, "image/png" -> - assert user.id == user_id - {:ok, %{"blob" => blob}} - end) - - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> - assert blob == body.record["icon"] - {:ok, %{"uri" => "at://x"}} - end) - - pub = %Publication{ - name: "jola.dev", - url: "https://jola.dev", - icon: {<<1, 2, 3>>, "image/png"} - } - - assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) - end + describe "get_publication/2" do + test "reads the record value" do + user = create_user() - test "put_publication/2 omits optional fields that are nil" do - user = create_user() + expect(Client, :query, fn _id, "com.atproto.repo.getRecord", params -> + assert "abc123" == params[:rkey] + {:ok, %{"value" => %{"name" => "jola.dev", "url" => "https://jola.dev"}}} + end) - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> - refute Map.has_key?(body.record, "description") - refute Map.has_key?(body.record, "icon") - {:ok, %{}} - end) - - assert {:ok, %{}} = - StandardSite.put_publication(user.id, %Publication{ - name: "n", - url: "https://n.example" - }) + assert {:ok, %{"name" => "jola.dev"}} = StandardSite.get_publication(user.id, "abc123") + end end - test "put_document/2 omits updatedAt and path when not set" do - user = create_user() - - expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> - refute Map.has_key?(body.record, "updatedAt") - refute Map.has_key?(body.record, "path") - assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] - {:ok, %{"uri" => "at://y"}} - end) + describe "draft_publication/2" do + test "builds a publication document with discover default" do + doc = + StandardSite.draft_publication("https://jola.dev", %{ + title: "jola.dev", + description: "blog" + }) + + assert "site.standard.publication" == doc["$type"] + assert "jola.dev" == doc["name"] + assert "https://jola.dev" == doc["url"] + assert "blog" == doc["description"] + assert %{"showInDiscover" => true} == doc["preferences"] + end + end - doc = %Document{ - rkey: "post-2", - site: StandardSite.publication_uri(@did), - title: "Hello", - published_at: ~U[2026-01-01 00:00:00Z] - } + defp create_user do + {:ok, user} = + Accounts.upsert_user(%{ + did: @did, + handle: "jola.dev", + pds_host: "https://pds.example.com" + }) - assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) + user end end diff --git a/test/annot_at/atproto/tid_test.exs b/test/annot_at/atproto/tid_test.exs new file mode 100644 index 0000000..677a165 --- /dev/null +++ b/test/annot_at/atproto/tid_test.exs @@ -0,0 +1,17 @@ +defmodule AnnotAt.Atproto.TIDTest do + use ExUnit.Case, async: true + + alias AnnotAt.Atproto.TID + + @tid_regex ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/ + + test "encode/1 of zero is the spec's zero-value TID" do + assert "2222222222222" == TID.new(0) + end + + test "now/0 produces a syntactically valid TID" do + tid = TID.now() + assert String.length(tid) == 13 + assert tid =~ @tid_regex + end +end diff --git a/test/annot_at/feeds_test.exs b/test/annot_at/feeds_test.exs index 2b1a592..bcba4e0 100644 --- a/test/annot_at/feeds_test.exs +++ b/test/annot_at/feeds_test.exs @@ -67,4 +67,20 @@ type="application/rss+xml">) assert [] == Feeds.discover(html, "https://example.com") end end + + describe "Feeds.metadata1/" do + test "extracts title and description" do + html = ~s(My Blog) + + assert %{title: "My Blog", description: "Thoughts."} = + Feeds.metadata(html) + end + + test "falls back to og tags, nils when absent" do + html = ~s() + assert %{title: "OG\n Title", description: nil} = Feeds.metadata(html) + end + end end diff --git a/test/annot_at/publishing_test.exs b/test/annot_at/publishing_test.exs index 4a4aa26..0f315d1 100644 --- a/test/annot_at/publishing_test.exs +++ b/test/annot_at/publishing_test.exs @@ -10,47 +10,80 @@ defmodule AnnotAt.PublishingTest do {:ok, user} = Accounts.upsert_user(%{ did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", - handle: "alice.test", + handle: "jola.dev", pds_host: "https://pds.example.com" }) %{scope: Scope.for_user(user)} end - test "create_site/3 persists a verified site owned by the scope", %{scope: scope} do - assert {:ok, %Site{} = site} = Publishing.create_site(scope, "3mope7jyypk22", site_attrs()) + test "create_site/2 creates a site with no rkey yet", %{scope: scope} do + assert {:ok, %Site{} = site} = + Publishing.create_site( + scope, + "https://example.com" + ) + assert site.user_id == scope.user.id - assert "3mope7jyypk22" == site.rkey - assert %DateTime{} = site.verified_at + refute site.rkey + refute site.verified_at + end + + test "use_new_publication/2 mints and stores an rkey", %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + assert {:ok, updated} = Publishing.use_new_publication(scope, site) + assert updated.rkey =~ ~r/^[234567abcdefghij]/ + end - assert [listed] = Publishing.list_sites(scope) - assert listed.id == site.id + test "use_existing_publication/3 stores the given rkey", %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + assert {:ok, updated} = Publishing.use_existing_publication(scope, site, "3mope7jyypk22") + assert "3mope7jyypk22" == updated.rkey end - test "a user can hold many sites for different websites", %{scope: scope} do - {:ok, _} = Publishing.create_site(scope, "aaa", site_attrs(%{url: "https://one.com"})) - {:ok, _} = Publishing.create_site(scope, "bbb", site_attrs(%{url: "https://two.com"})) + test "create_site/2 resumes the same site for a url", %{scope: scope} do + {:ok, first} = Publishing.create_site(scope, "https://example.com/") + {:ok, again} = Publishing.create_site(scope, "https://example.com/") + assert first.id == again.id + assert first.rkey == again.rkey + end + test "create_site/2 keeps distinct sites per url", %{scope: scope} do + {:ok, _} = Publishing.create_site(scope, "https://one.com") + {:ok, _} = Publishing.create_site(scope, "https://two.com") assert 2 == length(Publishing.list_sites(scope)) end - test "create_site/3 requires name, url and feed_url", %{scope: scope} do - assert {:error, changeset} = Publishing.create_site(scope, "rkey", %{}) - assert %{name: _, url: _, feed_url: _} = errors_on(changeset) + test "get_site!/2 returns the scope's site", %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + assert Publishing.get_site!(scope, site.id).id == site.id + end + + test "update_site/3 fills editable fields", %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + + assert {:ok, updated} = + Publishing.update_site(scope, site, %{ + name: "Blog", + feed_url: "https://example.com/feed.xml" + }) + + assert "Blog" == updated.name + assert "https://example.com/feed.xml" == updated.feed_url end - test "rkey is unique per user", %{scope: scope} do - {:ok, _} = Publishing.create_site(scope, "dup", site_attrs()) - assert {:error, changeset} = Publishing.create_site(scope, "dup", site_attrs()) - assert %{rkey: _} = errors_on(changeset) + test "mark_verified/2 then mark_published/2 stamp the timestamps", %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + {:ok, verified} = Publishing.mark_verified(scope, site) + assert %DateTime{} = verified.verified_at + {:ok, published} = Publishing.mark_published(scope, verified) + assert %DateTime{} = published.published_at end - test "get_site!/2 raises for a site the scope doesn't own", %{scope: scope} do + test "scoped functions refuse a site the scope doesn't own", %{scope: scope} do {:ok, other} = Accounts.upsert_user(%{did: "did:plc:otheruser000000000000000"}) other_scope = Scope.for_user(other) - {:ok, site} = Publishing.create_site(scope, "scoped", site_attrs()) - - assert Publishing.get_site!(scope, site.id).id == site.id + {:ok, site} = Publishing.create_site(scope, "https://example.com") assert_raise Ecto.NoResultsError, fn -> Publishing.get_site!( @@ -58,22 +91,32 @@ defmodule AnnotAt.PublishingTest do site.id ) end - end - - test "update_site/3 refuses a site the scope doesn't own", %{scope: scope} do - {:ok, other} = Accounts.upsert_user(%{did: "did:plc:otheruser000000000000000"}) - other_scope = Scope.for_user(other) - {:ok, site} = Publishing.create_site(scope, "owned", site_attrs()) assert_raise Ecto.NoResultsError, fn -> Publishing.update_site(other_scope, site, %{name: "x"}) end + + assert_raise Ecto.NoResultsError, fn -> + Publishing.mark_verified(other_scope, site) + end + + assert_raise Ecto.NoResultsError, fn -> + Publishing.mark_published(other_scope, site) + end + end + + test "use_new_publication/2 mints an rkey and leaves it unpublished", %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + assert {:ok, updated} = Publishing.use_new_publication(scope, site) + assert updated.rkey =~ ~r/^[234567abcdefghij]/ + refute updated.published_at end - defp site_attrs(overrides \\ %{}) do - Map.merge( - %{name: "My Blog", url: "https://example.com", feed_url: "https://example.com/feed.xml"}, - overrides - ) + test "use_existing_publication/3 stores the rkey and marks it published", + %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + assert {:ok, updated} = Publishing.use_existing_publication(scope, site, "3mope7jyypk22") + assert "3mope7jyypk22" == updated.rkey + assert %DateTime{} = updated.published_at end end diff --git a/test/annot_at/url_test.exs b/test/annot_at/url_test.exs new file mode 100644 index 0000000..cfccccb --- /dev/null +++ b/test/annot_at/url_test.exs @@ -0,0 +1,14 @@ +defmodule AnnotAt.URLTest do + use ExUnit.Case, async: true + + alias AnnotAt.URL + + describe "canonical/1" do + test "defaults scheme, downcases host, strips trailing slash" do + assert "https://example.com" == URL.canonical("example.com") + assert "https://example.com" == URL.canonical("https://EXAMPLE.com/") + + assert "https://example.com/blog" == URL.canonical("https://example.com/blog") + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index dcb04fe..f3e30c2 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,5 @@ Mimic.copy(AnnotAt.Atproto.DNS) +Mimic.copy(AnnotAt.Feeds.Client) Mimic.copy(AnnotAt.Atproto.HTTP) Mimic.copy(AnnotAt.Atproto.Identity) Mimic.copy(AnnotAt.Atproto.Profile)