···6677Requires your site to have a `link rel=alternate` for a feed set, and that you're able to verify your ownership by serving a `/.well-known` file.
8899+Document rkeys are derived from the post `<link rel="site.standard.document" href="<aturi>"`, so your blog must set that up. Document rkeys have to be TIDs to be valid, and should be stable, so either generate them and store them in your blog post fronmatter/metadata, or use a deterministic implementation that can run multiple times for the same post and give the same result.
1010+911## TODO
10121113- [x] Sign up/sign in with Bluesky
1214- [x] Maybe some design?
1315- [x] Add site including verification
1616+- [ ] Write a guide for using this
1717+- [ ] Document cover images
1818+- [ ] Document content with html content type
1419- [ ] RSS poller
1520- [ ] standard.site document reader
1621- [ ] Post to Bluesky
2222+- [ ] Let atproto drive things more
2323+ - [ ] List publications, including not explicitly added ones
2424+ - [ ] List documents, including not explicitly added ones
2525+ - [ ] List dangling documents/documents that no longer validate against post etc
17261827## Authenticating
1928
+15
lib/annot_at/feeds.ex
···7878 |> List.first()
7979 end
80808181+ @spec image(binary(), String.t()) :: String.t() | nil
8282+ def image(html, base_url) when is_binary(html) and is_binary(base_url) do
8383+ doc = LazyHTML.from_document(html)
8484+8585+ content =
8686+ meta_content(doc, ~s(meta[property="og:image"])) ||
8787+ meta_content(doc, ~s(meta[name="twitter:image"]))
8888+8989+ if content do
9090+ base_url
9191+ |> URI.merge(content)
9292+ |> URI.to_string()
9393+ end
9494+ end
9595+8196 defp source_from_attributes(attributes, base_url) do
8297 attributes = Map.new(attributes)
8398
+103-23
lib/annot_at/feeds/client.ex
···1111 alias AnnotAt.Feeds.Source
12121313 @receive_timeout 10_000
1414+ @cover_max_bytes 1_000_000
14151516 @doc """
1617 Fetches a page and returns the feeds discovered.
···5152 end
5253 end
53545454- @doc """
5555- Fetches a page to extract the <link rel="site.standard.document"> href if it exists.
5656- """
5757- @spec document_rkey(String.t(), String.t()) ::
5858- {:ok, String.t()}
5959- | {:error,
6060- :invalid
6161- | :not_declared
6262- | :did_mismatch
6363- | {:http_status, pos_integer()}
6464- | {:transport, term()}}
6565- def document_rkey(url, expected_did) do
6666- with {:ok, html} <- get(url),
6767- {:ok, uri} <- document_uri(html) do
6868- extract_rkey(uri, expected_did)
6969- end
7070- end
7171-7255 def resolve_documents(%Feed{entries: entries} = feed, did) do
7356 entries =
7457 entries
7575- |> Task.async_stream(&put_rkey(&1, did), timeout: :infinity, max_concurrency: 4)
5858+ |> Task.async_stream(&resolve_entry(&1, did), timeout: :infinity, max_concurrency: 4)
7659 |> Enum.map(fn {:ok, entry} -> entry end)
77607861 %{feed | entries: entries}
7962 end
80636464+ @spec fetch_image(String.t()) ::
6565+ {:ok, {binary(), String.t()}}
6666+ | {:error,
6767+ :not_an_image | :too_large | {:http_status, pos_integer()} | {:transport, term()}}
6868+ def fetch_image(url) do
6969+ case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do
7070+ {:ok, %Req.Response{status: status, body: body, headers: headers}}
7171+ when status in 200..299 ->
7272+ type = content_type(headers)
7373+7474+ cond do
7575+ is_nil(type) or not image_type?(type) -> {:error, :not_an_image}
7676+ byte_size(body) > @cover_max_bytes -> {:error, :too_large}
7777+ true -> {:ok, {body, type}}
7878+ end
7979+8080+ {:ok, %Req.Response{status: status}} ->
8181+ {:error, {:http_status, status}}
8282+8383+ {:error, reason} ->
8484+ {:error, {:transport, reason}}
8585+ end
8686+ end
8787+8188 defp get(url) do
8289 case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do
8390 {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
···112119 end
113120 end
114121115115- defp put_rkey(%Entry{url: url} = entry, did) do
116116- case document_rkey(url, did) do
117117- {:ok, rkey} ->
118118- %{entry | rkey: rkey}
122122+ defp resolve_entry(%Entry{url: url} = entry, did) when is_binary(url) do
123123+ case get(url) do
124124+ {:ok, html} ->
125125+ entry
126126+ |> put_cover(html, url)
127127+ |> put_rkey(html, did)
119128120129 {:error, _} ->
121130 entry
122131 end
123132 end
133133+134134+ defp resolve_entry(entry, _did), do: entry
135135+136136+ defp put_cover(entry, html, url) do
137137+ image = Feeds.image(html, url)
138138+ %{entry | image: image, cover_status: cover_status(image)}
139139+ end
140140+141141+ defp put_rkey(entry, html, did) do
142142+ case resolve_rkey(html, did) do
143143+ {:ok, rkey} -> %{entry | rkey: rkey}
144144+ {:error, _} -> entry
145145+ end
146146+ end
147147+148148+ defp resolve_rkey(html, did) do
149149+ with {:ok, uri} <- document_uri(html) do
150150+ extract_rkey(uri, did)
151151+ end
152152+ end
153153+154154+ defp cover_status(nil), do: :none
155155+156156+ defp cover_status(url) do
157157+ case Req.request(url: url, method: :head, receive_timeout: @receive_timeout) do
158158+ {:ok, %Req.Response{status: status, headers: headers}} when status in 200..299 ->
159159+ classify(content_type(headers), content_length(headers))
160160+161161+ _ ->
162162+ :unknown
163163+ end
164164+ end
165165+166166+ defp classify(nil, _length), do: :unknown
167167+168168+ defp classify(type, length) do
169169+ cond do
170170+ not image_type?(type) -> :not_image
171171+ is_nil(length) -> :unknown
172172+ length > @cover_max_bytes -> :too_large
173173+ true -> :ok
174174+ end
175175+ end
176176+177177+ defp content_type(headers) do
178178+ case headers["content-type"] do
179179+ [value | _] ->
180180+ value
181181+ |> String.split(";")
182182+ |> List.first()
183183+ |> String.trim()
184184+185185+ _ ->
186186+ nil
187187+ end
188188+ end
189189+190190+ defp content_length(headers) do
191191+ case headers["content-length"] do
192192+ [value | _] ->
193193+ case Integer.parse(value) do
194194+ {bytes, _} -> bytes
195195+ :error -> nil
196196+ end
197197+198198+ _ ->
199199+ nil
200200+ end
201201+ end
202202+203203+ defp image_type?(type), do: String.starts_with?(type, "image/")
124204end