Serve server-generated Open Graph cards for public pages

271189b1ed87 · AtlantisPleb · · parent 57cd8e71d9c0

Serve server-generated Open Graph cards for public pages

Links to the forge used to unfurl as bare URLs. Now every public page
emits og:* and twitter:* tags, and resource pages point at cards the
application draws itself:

- OpenAgentsWeb.OG builds a Card from data the anonymous page already
  shows, versions it by content digest, and signs its path; the image
  endpoint verifies that signature first and refuses everything else
  with one indistinguishable 404 -- private repositories included.
  Versions are advisory, so stale shared links heal to current facts.
- OG.Templates renders 1200x630 SVG from the dark palette with our own
  brand mark; OG.Rasterizer rasterizes through rsvg-convert under a
  concurrency limiter with a hard timeout, degrading to a committed
  fallback card whenever rendering is unavailable or fails.
- Repository cards show description, visibility, issue counts, branch,
  last-commit date, and import provenance. Blob cards carry language,
  size, line count, and ref; commit cards carry subject, author, and
  changed-file count -- information GitHub's cards leave out.
- "og" joins the reserved namespace slugs, and the route ledger
  classifies the endpoint as public read.

Fleet images need librsvg2-bin (plus Geist TTFs for exact type) before
dynamic cards render on production nodes; until then they serve the
branded fallback by design.

Changelog: Links to openagents.com now unfurl with generated images describing the repository, issue, file, or commit they point at.

Changelog-Category: feature

Changelog-Visibility: public
Changelog
Links to openagents.com now unfurl with generated images describing the repository, issue, file, or commit they point at.
Changelog-Category
feature
Changelog-Visibility
public

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/2026-08-21-open-graph-cards.md
  • modified lib/openagents/repositories/namespace.ex
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/root.html.heex
  • added lib/openagents_web/controllers/og_image_controller.ex
  • modified lib/openagents_web/live/code_blob_live.ex
  • modified lib/openagents_web/live/code_commit_live.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • added lib/openagents_web/og.ex
  • added lib/openagents_web/og/brand_mark.ex
  • added lib/openagents_web/og/limiter.ex
  • added lib/openagents_web/og/rasterizer.ex
  • added lib/openagents_web/og/templates.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added priv/static/images/og-card-default.png
  • added test/openagents_web/controllers/og_image_controller_test.exs
  • modified test/openagents_web/icon_affordances_test.exs
  • added test/openagents_web/og_test.exs

Diff

22 files changed, +2141 -17

docs/2026-08-21-open-graph-cards.md modified +29 -14

@@ -2,9 +2,12 @@

2 2
3 3
Date: 2026-08-21
4 4
5
Status: Proposed. This document records what GitHub's card system actually
6
does, measured from production responses, and specifies how OpenAgents builds
7
its own version with strictly more information per card.
5
Status: Implemented. Meta tags ship on every public page; the signed,
6
content-versioned card endpoint serves repository, issue, blob, and commit
7
cards; rasterization runs through librsvg with a committed fallback card when
8
the binary is absent. One operations requirement remains: fleet images need
9
`librsvg2-bin` (and, for exact brand type, the Geist TTFs) installed — until
10
then nodes serve the fallback card by design.
8 11
9 12
## What links to our pages look like today
10 13

@@ -187,14 +190,26 @@ the anonymous page already displayed.

187 190
188 191
## Delivery sequence
189 192
190
1. **Meta tags plus the static brand card.** No new dependencies, immediate
191
   improvement everywhere links are shared. Ships alone.
192
2. **Repository and issue cards.** Lands the SVG pipeline, the signed
193
   endpoint, the rasterizer requirement in the release image, and the two
194
   highest-value templates.
195
3. **Blob and commit cards.** The beyond-GitHub information layer.
196
197
Phase 2 is gated on the release-image change shipping to staging first,
198
since a node without `rsvg-convert` silently serves the fallback card — a
199
degradation worth catching in a staging check rather than in production
200
analytics.
193
All three phases shipped together on 2026-08-21: the meta-tag layer, the
194
signed endpoint with the SVG-to-PNG pipeline, and the deep-surface cards.
195
The implementation notes below record what landed and where.
196
197
1. **Meta tags plus the static brand card.** `Layouts.og_tags/1` renders the
198
   block in the root layout; views without a card get honest site-level
199
   tags pointing at the committed brand PNG.
200
2. **Repository and issue cards.** `OpenAgentsWeb.OG` builds cards from page
201
   data, signs content-versioned URLs, and
202
   `OpenAgentsWeb.OgImageController` renders them through
203
   `OG.Templates` + `OG.Rasterizer` (librsvg port, concurrency-limited,
204
   hard timeout).
205
3. **Blob and commit cards.** The beyond-GitHub information layer: language,
206
   size, line count, and ref for files; subject, author, and changed-file
207
   count for commits.
208
209
Remaining operations work, tracked here so it cannot be forgotten:
210
211
* Add `librsvg2-bin` (and optionally the Geist TTF faces) to the release
212
  image. Until that lands, production nodes serve the fallback card for all
213
  dynamic URLs — correct, branded, but generic.
214
* The staging check should assert a dynamic card URL returns bytes other
215
  than the committed fallback, proving rasterization end to end.
lib/openagents/repositories/namespace.ex modified +1 -1

@@ -8,7 +8,7 @@ defmodule OpenAgents.Repositories.Namespace do

8 8
  @foreign_key_type :binary_id
9 9
  @timestamps_opts [type: :utc_datetime_usec]
10 10
  @reserved_slugs ~w(
11
    admin api assets auth changelog chat components computers controller data dev device docs git
11
    admin api assets auth changelog chat components computers controller data dev device docs git og
12 12
    health healthz leaderboard machines memory repositories settings status voice
13 13
  )
14 14
lib/openagents_web/component_catalog.ex modified +3 -1

@@ -636,7 +636,9 @@ defmodule OpenAgentsWeb.ComponentCatalog do

636 636
  def documented_modules do
637 637
    %{
638 638
      OpenAgentsWeb.UI => [],
639
      OpenAgentsWeb.Layouts => [:app, :flash_group],
639
      # og_tags renders head metadata for crawlers, not a visible surface, so
640
      # it has no demoable page.
641
      OpenAgentsWeb.Layouts => [:app, :flash_group, :og_tags],
640 642
      # graph_defs/1 emits marker definitions into a parent graph surface; it
641 643
      # renders nothing on its own, so it has no demoable page. graph_surface/1
642 644
      # is the host element and is demoed through the components that use it.
lib/openagents_web/components/layouts.ex modified +41

@@ -684,6 +684,47 @@ defmodule OpenAgentsWeb.Layouts do

684 684
    end
685 685
  end
686 686
687
  @doc """
688
  The `og:*` / `twitter:*` block for the root layout.
689
690
  Views that build an `OpenAgentsWeb.OG` card assign `:og` (via `OG.meta/2`)
691
  and this renders it; every other page gets honest site-level tags rather
692
  than nothing. Crawlers read the initial server-rendered HTML, so these tags
693
  ride the first paint only — exactly where they are consumed.
694
  """
695
  attr :og, :map, default: nil, doc: "an `OpenAgentsWeb.OG.meta/2` map"
696
697
  def og_tags(assigns) do
698
    og =
699
      assigns[:og] ||
700
        %{
701
          title: "OpenAgents",
702
          description: "Code hosting, issues, and projects on the agent-native forge.",
703
          type: "website",
704
          url: OpenAgentsWeb.OG.site_url(),
705
          image_url: OpenAgentsWeb.OG.static_card_url(),
706
          alt: "OpenAgents — code hosting, issues, and projects."
707
        }
708
709
    assigns = assign(assigns, :og, og)
710
711
    ~H"""
712
    <meta property="og:site_name" content="OpenAgents" />
713
    <meta property="og:type" content={@og.type} />
714
    <meta property="og:title" content={@og.title} />
715
    <meta property="og:description" content={@og.description} />
716
    <meta property="og:url" content={@og.url} />
717
    <meta property="og:image" content={@og.image_url} />
718
    <meta property="og:image:width" content="1200" />
719
    <meta property="og:image:height" content="630" />
720
    <meta property="og:image:alt" content={@og.alt} />
721
    <meta name="twitter:card" content="summary_large_image" />
722
    <meta name="twitter:title" content={@og.title} />
723
    <meta name="twitter:description" content={@og.description} />
724
    <meta name="twitter:image" content={@og.image_url} />
725
    """
726
  end
727
687 728
  @doc """
688 729
  One browser analytics identity field from the session-written map, or nil.
689 730
lib/openagents_web/components/layouts/root.html.heex modified +1

@@ -12,6 +12,7 @@

12 12
    which applies it to `default` as well: a page with no title of its own
13 13
    rendered the brand twice. --%>
14 14
    <.live_title default="OpenAgents" phx-no-format>{page_title(assigns)}</.live_title>
15
    <Layouts.og_tags og={assigns[:og]} />
15 16
    <%!-- The faces the first paint actually uses, fetched in parallel with the
16 17
    stylesheet instead of after it. Every face is `font-display: swap`, so one
17 18
    arriving late re-lays-out text that is already on screen -- and the hero
lib/openagents_web/controllers/og_image_controller.ex added +242

@@ -0,0 +1,242 @@

1
defmodule OpenAgentsWeb.OgImageController do
2
  @moduledoc """
3
  Serves server-generated Open Graph card PNGs.
4
5
  Contract, from `docs/2026-08-21-open-graph-cards.md`:
6
7
    * Every request path is HMAC-signed (`?sig=`); an invalid or missing
8
      signature is the same 404 as everything else this endpoint refuses.
9
    * Repositories resolve through the public visibility predicate only. A
10
      private repository, a missing repository, and a bad signature are
11
      indistinguishable.
12
    * The version segment is advisory: it exists so a page's emitted URL is
13
      content-addressed and caches immutably. The controller always renders
14
      current data for any well-formed request, so stale shared links heal
15
      instead of pinning old facts.
16
    * When rasterization is unavailable, busy, or fails, the committed
17
      fallback card ships under identical headers — previews degrade,
18
      nothing errors.
19
  """
20
21
  use OpenAgentsWeb, :controller
22
23
  require Logger
24
25
  alias OpenAgents.Forge
26
  alias OpenAgents.Forge.Browse
27
  alias OpenAgents.Issues
28
  alias OpenAgents.Repositories
29
  alias OpenAgentsWeb.{OG, RepositoryAccess}
30
31
  @cache_control "public, max-age=21600, immutable"
32
  @not_found_cache_control "public, max-age=60"
33
34
  # Last-resort bytes if even the committed fallback asset cannot be read: a
35
  # valid transparent 1x1 PNG keeps responses well-formed.
36
  @transparent_png Base.decode64!(
37
                     "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
38
                   )
39
40
  ## Actions -------------------------------------------------------------------
41
42
  def static(conn, _params) do
43
    send_png(conn)
44
  end
45
46
  def repo(conn, params) do
47
    authorize_and_run(conn, params, fn owner, name ->
48
      with {:ok, repository} <- public_repository(owner, name) do
49
        OG.repo_card_for(repository)
50
      else
51
        _error -> :error
52
      end
53
    end)
54
  end
55
56
  def issue(conn, params) do
57
    authorize_and_run(conn, params, fn owner, name ->
58
      with {number, ""} <- Integer.parse(strip_png(params["number"])),
59
           %Issues.Issue{} = issue <-
60
             safe(fn -> Issues.get_issue_by_path!(owner, name, number) end) do
61
        OG.issue(owner, name, issue)
62
      else
63
        _error -> :error
64
      end
65
    end)
66
  end
67
68
  def commit(conn, params) do
69
    authorize_and_run(conn, params, fn owner, name ->
70
      sha = strip_png(params["sha"])
71
72
      with {:ok, repository} <- public_repository(owner, name),
73
           true <- Forge.enabled?(),
74
           true <- Browse.valid_ref?(sha),
75
           {:ok, commit} <- safe(fn -> Browse.commit(repository, sha) end) do
76
        files =
77
          case safe(fn -> Browse.changed_files(repository, commit.sha) end) do
78
            {:ok, list} when is_list(list) -> list
79
            _other -> nil
80
          end
81
82
        OG.commit(owner, name, commit, files && length(files))
83
      else
84
        _error -> :error
85
      end
86
    end)
87
  end
88
89
  # The blob card must never show what the anonymous file page would not:
90
  # the same disclosure gate as `OpenAgentsWeb.CodeBlobLive` runs here, with
91
  # no user (crawlers carry no session).
92
  def blob(conn, params) do
93
    authorize_and_run(conn, params, fn owner, name ->
94
      ref = strip_png(params["ref"])
95
      path = joined_path(params["path"])
96
97
      with {:ok, repository} <- public_repository(owner, name),
98
           true <- Forge.enabled?() and repository.lifecycle_state == "ready",
99
           true <- Browse.valid_ref?(ref) and Browse.valid_path?(path),
100
           {:ok, sha} <- safe(fn -> Browse.resolve_commit(repository, ref) end),
101
           head <- resolved_head(repository),
102
           true <- RepositoryAccess.allows_file?(repository, nil, path, sha, head),
103
           {:ok, blob_info} <- safe(fn -> Browse.blob(repository, sha, path) end) do
104
        OG.blob(owner, name, path, %{
105
          ref: ref,
106
          size: blob_info.size,
107
          lines: blob_lines(blob_info),
108
          truncated: blob_info.truncated
109
        })
110
      else
111
        _refused -> :error
112
      end
113
    end)
114
  end
115
116
  ## Pipeline ------------------------------------------------------------------
117
118
  # One response for every refusal — bad signature, unknown repository,
119
  # private repository, missing resource — so none of them can be told apart.
120
  defp authorize_and_run(conn, params, build) do
121
    if OG.valid_signature?(conn.request_path, conn.query_params["sig"]) do
122
      case build.(strip_png(params["owner"]), strip_png(params["repo"])) do
123
        %OG{} = card -> respond_with_card(conn, card)
124
        _refused -> not_found(conn)
125
      end
126
    else
127
      not_found(conn)
128
    end
129
  end
130
131
  defp respond_with_card(conn, card) do
132
    svg = OG.Templates.render(card)
133
134
    case OG.Rasterizer.rasterize(svg) do
135
      {:ok, png} ->
136
        send_png(conn, png)
137
138
      {:error, reason} ->
139
        # Exit tuples can carry payloads; log the safe classification only.
140
        safe_reason =
141
          case reason do
142
            {:exit, _payload} -> :exit
143
            other -> other
144
          end
145
146
        Logger.warning("og_card_fallback kind=#{card.kind} reason=#{safe_reason}")
147
148
        send_png(conn, fallback_png())
149
    end
150
  end
151
152
  defp send_png(conn, bytes \\ nil) do
153
    conn
154
    |> put_resp_header("content-type", "image/png")
155
    |> put_resp_header("cache-control", @cache_control)
156
    |> put_resp_header("x-content-type-options", "nosniff")
157
    |> send_resp(200, bytes || default_card_bytes())
158
  end
159
160
  defp not_found(conn) do
161
    conn
162
    |> put_resp_header("cache-control", @not_found_cache_control)
163
    |> send_resp(404, "")
164
  end
165
166
  ## Helpers -------------------------------------------------------------------
167
168
  defp public_repository(owner, name) do
169
    case safe(fn -> Repositories.get_public_by_path!(owner, name) end) do
170
      %Repositories.Repository{} = repository -> {:ok, repository}
171
      _other -> :error
172
    end
173
  end
174
175
  # `Browse.head/1` reports emptiness as an error tuple; the disclosure gate
176
  # only wants a sha or nil, exactly as the file page derives it.
177
  defp resolved_head(repository) do
178
    case safe(fn -> Browse.head(repository) end) do
179
      {:ok, sha} -> sha
180
      _other -> nil
181
    end
182
  end
183
184
  # ".png" rides at the end of the last path segment; a resource genuinely
185
  # named "*.png" arrives doubled and survives one strip intact.
186
  defp strip_png(nil), do: nil
187
188
  defp strip_png(value) when is_binary(value),
189
    do: String.replace_suffix(value, ".png", "")
190
191
  defp joined_path(segments) when is_list(segments) do
192
    segments
193
    |> Enum.join("/")
194
    |> String.replace_suffix(".png", "")
195
  end
196
197
  defp joined_path(_other), do: ""
198
199
  defp blob_lines(%{binary: true}), do: nil
200
201
  defp blob_lines(%{content: content}) when is_binary(content),
202
    do: content |> String.split("\n") |> length()
203
204
  defp blob_lines(_blob_info), do: nil
205
206
  defp safe(fun) do
207
    fun.()
208
  rescue
209
    _error -> nil
210
  catch
211
    :exit, _reason -> nil
212
  end
213
214
  # The committed brand card doubles as the rasterization fallback, cached in
215
  # process-global storage after its first read.
216
  defp fallback_png do
217
    key = {__MODULE__, :fallback_png}
218
219
    case :persistent_term.get(key, nil) do
220
      nil ->
221
        path = Application.app_dir(:openagents, "priv/static/images/og-card-default.png")
222
223
        case File.read(path) do
224
          {:ok, bytes} ->
225
            :persistent_term.put(key, bytes)
226
            bytes
227
228
          _unreadable ->
229
            @transparent_png
230
        end
231
232
      bytes ->
233
        bytes
234
    end
235
  end
236
237
  defp default_card_bytes do
238
    # The static route serves exactly the committed asset; the fallback bytes
239
    # are the same file, so both paths share one read.
240
    fallback_png()
241
  end
242
end
lib/openagents_web/live/code_blob_live.ex modified +12

@@ -16,6 +16,7 @@ defmodule OpenAgentsWeb.CodeBlobLive do

16 16
  use OpenAgentsWeb, :live_view
17 17
18 18
  alias OpenAgents.Forge.Browse
19
  alias OpenAgentsWeb.OG
19 20
  alias OpenAgentsWeb.RepositoryAccess
20 21
21 22
  @impl true

@@ -68,6 +69,17 @@ defmodule OpenAgentsWeb.CodeBlobLive do

68 69
     |> assign(:sha, sha)
69 70
     |> assign(:path, path)
70 71
     |> assign(:blob, blob)
72
     |> assign(
73
       :og,
74
       OG.meta(
75
         OG.blob(repository.namespace.slug, repository.name, path, %{
76
           ref: ref,
77
           size: blob.size,
78
           lines: if(blob.binary, do: nil, else: blob.content |> String.split("\n") |> length()),
79
           truncated: blob.truncated
80
         })
81
       )
82
     )
71 83
     |> assign(:browsable, RepositoryAccess.full_source?(repository, socket.assigns.current_user))
72 84
     |> assign(:markdown?, markdown?(path) and not plain and not blob.binary)}
73 85
  rescue
lib/openagents_web/live/code_commit_live.ex modified +7

@@ -16,6 +16,7 @@ defmodule OpenAgentsWeb.CodeCommitLive do

16 16
17 17
  alias OpenAgents.Forge
18 18
  alias OpenAgents.Forge.Browse
19
  alias OpenAgentsWeb.OG
19 20
  alias OpenAgentsWeb.RepositoryAccess
20 21
21 22
  @impl true

@@ -68,6 +69,12 @@ defmodule OpenAgentsWeb.CodeCommitLive do

68 69
     |> assign(:base, base)
69 70
     |> assign(:commit, commit)
70 71
     |> assign(:files, files)
72
     |> assign(
73
       :og,
74
       OG.meta(
75
         OG.commit(repository.namespace.slug, repository.name, commit, files && length(files))
76
       )
77
     )
71 78
     |> assign(:diff, diff)
72 79
     |> assign(:diff_truncated, diff_truncated)
73 80
     |> assign(:diff_files, diff_files)
lib/openagents_web/live/code_repo_live.ex modified +2

@@ -12,6 +12,7 @@ defmodule OpenAgentsWeb.CodeRepoLive do

12 12
  alias OpenAgents.Forge
13 13
  alias OpenAgents.Forge.Browse
14 14
  alias OpenAgents.Repositories
15
  alias OpenAgentsWeb.OG
15 16
  alias OpenAgentsWeb.RepositoryAccess
16 17
17 18
  @impl true

@@ -59,6 +60,7 @@ defmodule OpenAgentsWeb.CodeRepoLive do

59 60
     |> assign(:branch_count, Enum.count(refs, &(&1.kind == :branch)))
60 61
     |> assign(:tag_count, Enum.count(refs, &(&1.kind == :tag)))
61 62
     |> assign(:open_issue_count, open_issue_count(repository))
63
     |> assign(:og, OG.meta(OG.repo_card_for(repository)))
62 64
     |> assign(:clone_url, RepositoryAccess.clone_url(repository))
63 65
     |> assign(:delete_allowed?, delete_allowed?)
64 66
     |> assign(:delete_error, nil)
lib/openagents_web/live/issue_index_live.ex modified +2

@@ -14,6 +14,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

14 14
  alias OpenAgents.Labels
15 15
  alias OpenAgents.Milestones
16 16
  alias OpenAgents.Repositories
17
  alias OpenAgentsWeb.OG
17 18
  alias OpenAgentsWeb.UI.Circle
18 19
19 20
  @filter_keys ~w(label assignee milestone q)

@@ -46,6 +47,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

46 47
        |> assign(:assignable, Repositories.list_assignable_users(repository))
47 48
        |> assign(:milestone_options, Milestones.list_milestones(repository))
48 49
        |> load()
50
        |> assign(:og, OG.meta(OG.repo_card_for(repository)))
49 51
50 52
      {:noreply, socket}
51 53
    else
lib/openagents_web/live/issue_show_live.ex modified +5

@@ -33,6 +33,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

33 33
  alias OpenAgents.Markdown
34 34
  alias OpenAgents.Milestones
35 35
  alias OpenAgents.Repositories
36
  alias OpenAgentsWeb.OG
36 37
  alias OpenAgentsWeb.UI.Circle
37 38
38 39
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do

@@ -75,6 +76,10 @@ defmodule OpenAgentsWeb.IssueShowLive do

75 76
       :assignable,
76 77
       if(can_write, do: Repositories.list_assignable_users(repository), else: [])
77 78
     )
79
     |> assign(
80
       :og,
81
       OG.meta(OG.issue(repository.namespace.slug, repository.name, issue))
82
     )
78 83
     |> load(issue)}
79 84
  end
80 85
lib/openagents_web/og.ex added +613

@@ -0,0 +1,613 @@

1
defmodule OpenAgentsWeb.OG do
2
  @moduledoc """
3
  Server-generated Open Graph cards.
4
5
  A `%Card{}` is the single unit that flows through the whole pipeline: views
6
  build one from data they already rendered, this module turns it into a
7
  content-versioned signed URL for the `<meta>` tags, and
8
  `OpenAgentsWeb.OgImageController` rebuilds it at request time and renders it
9
  to PNG. See `docs/2026-08-21-open-graph-cards.md`.
10
11
  The security posture of cards matches the pages they describe: a card may
12
  contain only data the anonymous page already displays, every dynamic string
13
  is escaped and clamped before it reaches a template, and the image endpoint
14
  resolves repositories through the public visibility predicate.
15
  """
16
17
  @enforce_keys [:kind]
18
  defstruct [
19
    :kind,
20
    :kicker,
21
    :heading,
22
    :description,
23
    :avatar,
24
    :provenance,
25
    chips: [],
26
    stats: [],
27
    title: nil,
28
    # Canonical page URL this card describes (for og:url), e.g.
29
    # "/OpenAgentsInc/openagents.com/issues/12".
30
    page_path: nil,
31
    # Path segments under "/og/v/{version}/repos/", e.g.
32
    # ["Owner", "repo"] or ["Owner", "repo", "blob", "main", "lib/a.ex"].
33
    path_suffix: []
34
  ]
35
36
  @type kind :: :site | :repo | :issue | :blob | :commit
37
38
  @type t :: %__MODULE__{
39
          kind: kind(),
40
          kicker: String.t() | nil,
41
          heading: String.t() | nil,
42
          description: String.t() | nil,
43
          avatar: String.t() | nil,
44
          provenance: String.t() | nil,
45
          chips: [%{required(:label) => String.t(), optional(:tone) => atom}],
46
          stats: [String.t()],
47
          title: String.t() | nil,
48
          page_path: String.t() | nil,
49
          path_suffix: [String.t()]
50
        }
51
52
  @version_bytes 12
53
  @signature_bytes 16
54
55
  # ── builders ────────────────────────────────────────────────────────────────
56
57
  @doc "The site-level card every page falls back to."
58
  def site do
59
    %__MODULE__{
60
      kind: :site,
61
      kicker: "OPENAGENTS",
62
      heading: "The agent-native forge",
63
      description: "Host your code, track your issues, and work with agents."
64
    }
65
  end
66
67
  @doc """
68
  A repository card. Nil inputs simply drop their stat; a repository without
69
  a description says so rather than showing a blank band.
70
  """
71
  def repo(repository, opts \\ []) when is_list(opts) do
72
    owner = namespace_slug(repository)
73
    suffix = [owner, repository.name]
74
75
    %__MODULE__{
76
      kind: :repo,
77
      kicker: "#{owner} /",
78
      heading: repository.name,
79
      description: present(repository.description) || "No description yet.",
80
      chips: [%{label: "Public"}],
81
      stats:
82
        clean_stats([
83
          opt_stat(opts[:default_branch]),
84
          issue_stat(opts[:open_issues], opts[:closed_issues]),
85
          dated_stat("Updated", opts[:updated_at])
86
        ]),
87
      provenance: if(opts[:imported], do: "Imported from GitHub"),
88
      title: meta_title("#{owner}/#{repository.name}"),
89
      page_path: Path.join(["/" | suffix]),
90
      path_suffix: suffix
91
    }
92
  end
93
94
  @doc "An issue card: state, title, author, labels, and conversation size."
95
  def issue(repository_owner, repository_name, issue) do
96
    labels = Enum.take(issue.labels || [], 3)
97
    hidden = max(length(issue.labels || []) - length(labels), 0)
98
    number_string = Integer.to_string(issue.number)
99
100
    chips =
101
      [%{label: state_label(issue), tone: state_tone(issue)}] ++
102
        Enum.map(labels, &%{label: &1["name"]}) ++
103
        List.wrap(if(hidden > 0, do: %{label: "+#{hidden}"}, else: nil))
104
105
    author = author_login(issue)
106
107
    %__MODULE__{
108
      kind: :issue,
109
      kicker: "#{repository_owner}/#{repository_name} · ##{number_string}",
110
      heading: issue.title || "(no title)",
111
      avatar: author,
112
      chips: chips,
113
      stats:
114
        clean_stats([
115
          opt_stat(author),
116
          dated_stat("Opened", issue.inserted_at),
117
          comments_stat(issue.comments)
118
        ]),
119
      title: meta_title("#{issue.title || "(no title)"} · Issue ##{number_string}"),
120
      page_path: "/#{repository_owner}/#{repository_name}/issues/#{number_string}",
121
      path_suffix: [repository_owner, repository_name, "issues", number_string]
122
    }
123
  end
124
125
  @doc """
126
  A file card — the layer GitHub does not have. `info` accepts `:ref`,
127
  `:size`, `:lines`, and `:truncated`; anything missing drops its stat.
128
  """
129
  def blob(repository_owner, repository_name, path, info) when is_map(info) do
130
    filename = basename(path)
131
    language = language_for_path(path)
132
133
    %__MODULE__{
134
      kind: :blob,
135
      kicker: "#{repository_owner}/#{repository_name}",
136
      heading: filename,
137
      description: display_path(path),
138
      chips: chip_wrap(language),
139
      stats:
140
        clean_stats([
141
          opt_stat(info[:ref]),
142
          opt_stat(size_stat(info[:size])),
143
          lines_stat(info[:lines], info[:truncated])
144
        ]),
145
      title: meta_title(filename),
146
      page_path: "/#{repository_owner}/#{repository_name}/blob/#{info[:ref]}/#{path}",
147
      path_suffix: [repository_owner, repository_name, "blob", info[:ref], path]
148
    }
149
  end
150
151
  @doc "A commit card: subject, author, date, and changed-file count."
152
  def commit(repository_owner, repository_name, commit_info, file_count) do
153
    subject = present(commit_info[:subject]) || present(commit_info[:sha]) || "Commit"
154
    sha = commit_info[:sha]
155
156
    %__MODULE__{
157
      kind: :commit,
158
      kicker: "#{repository_owner}/#{repository_name}",
159
      heading: subject,
160
      avatar: commit_info[:author],
161
      chips: chip_wrap(sha && short_sha(sha)),
162
      stats:
163
        clean_stats([
164
          opt_stat(commit_info[:author]),
165
          dated_stat("Committed", commit_info[:committed_at]),
166
          file_count && plural(file_count, "changed file", "changed files")
167
        ]),
168
      title: meta_title(subject),
169
      page_path: "/#{repository_owner}/#{repository_name}/commit/#{sha}",
170
      path_suffix: [repository_owner, repository_name, "commit", sha]
171
    }
172
  end
173
174
  defp clean_stats(stats), do: Enum.reject(stats, &(is_nil(&1) or &1 == ""))
175
176
  defp opt_stat(value), do: present(value)
177
178
  defp issue_stat(open, closed) when is_integer(open) and is_integer(closed),
179
    do: "#{open} open · #{closed} closed"
180
181
  defp issue_stat(open, _) when is_integer(open), do: plural(open, "open issue", "open issues")
182
  defp issue_stat(_, closed) when is_integer(closed), do: plural(closed, "closed", "closed")
183
  defp issue_stat(_, _), do: nil
184
185
  defp comments_stat(count) when is_integer(count) and count > 0,
186
    do: plural(count, "comment", "comments")
187
188
  defp comments_stat(_), do: nil
189
190
  defp size_stat(bytes) when is_integer(bytes), do: format_size(bytes)
191
  defp size_stat(_), do: nil
192
193
  defp lines_stat(lines, true) when is_integer(lines) and lines > 0, do: ">#{lines}+ lines"
194
195
  defp lines_stat(lines, _) when is_integer(lines) and lines > 0,
196
    do: plural(lines, "line", "lines")
197
198
  defp lines_stat(_, _), do: nil
199
200
  defp dated_stat(prefix, %Date{} = date),
201
    do: "#{prefix} #{Calendar.strftime(date, "%b %-d, %Y")}"
202
203
  defp dated_stat(prefix, %DateTime{} = dt),
204
    do: dated_stat(prefix, DateTime.shift_zone!(dt, "Etc/UTC") |> DateTime.to_date())
205
206
  defp dated_stat(prefix, %NaiveDateTime{} = dt),
207
    do: dated_stat(prefix, DateTime.from_naive!(dt, "Etc/UTC"))
208
209
  defp dated_stat(prefix, text) when is_binary(text) do
210
    case DateTime.from_iso8601(text) do
211
      {:ok, dt, _offset} -> dated_stat(prefix, dt)
212
      _ -> nil
213
    end
214
  end
215
216
  defp dated_stat(_prefix, _other), do: nil
217
218
  defp chip_wrap(nil), do: []
219
  defp chip_wrap(label), do: [%{label: label}]
220
221
  @doc """
222
  Repository card assembled defensively from an already-loaded repository:
223
  issue counts, last-commit date, and import provenance become best-effort
224
  stats. A repository whose Git lane or issues cannot be read still gets a
225
  complete-looking card rather than no card at all.
226
  """
227
  def repo_card_for(%OpenAgents.Repositories.Repository{} = repository) do
228
    repo(repository,
229
      default_branch: repository.default_branch,
230
      open_issues: safe(fn -> OpenAgents.Issues.count_issues(repository, state: "open") end),
231
      closed_issues: safe(fn -> OpenAgents.Issues.count_issues(repository, state: "closed") end),
232
      updated_at: latest_commit_date(repository),
233
      imported: imported?(repository)
234
    )
235
  end
236
237
  defp latest_commit_date(repository) do
238
    safe(fn ->
239
      case OpenAgents.Forge.Browse.log(repository, repository.default_branch, 1) do
240
        {:ok, [latest | _rest]} -> latest.committed_at
241
        _other -> nil
242
      end
243
    end)
244
  end
245
246
  defp imported?(repository) do
247
    case Map.get(repository, :repository_import) do
248
      %OpenAgents.Repositories.RepositoryImport{} -> true
249
      _other -> false
250
    end
251
  end
252
253
  # Stats are decoration over another context's data; any failure degrades
254
  # that stat to nil instead of failing the card.
255
  defp safe(fun) do
256
    fun.()
257
  rescue
258
    _error -> nil
259
  catch
260
    :exit, _reason -> nil
261
  end
262
263
  ## Meta-tag projection -------------------------------------------------------
264
265
  @doc """
266
  The map the root layout renders into `og:*` / `twitter:*` tags.
267
  `page_url` is the canonical absolute URL of the page emitting the tags.
268
  """
269
  def meta(%__MODULE__{} = card, page_url) do
270
    description = card.description || default_description()
271
272
    %{
273
      title: card.title || card.heading || "OpenAgents",
274
      description: description,
275
      type: "object",
276
      url: page_url,
277
      image_url: card_url(card),
278
      alt: description
279
    }
280
  end
281
282
  @doc "Absolute URL of the committed fallback card."
283
  def static_card_url, do: base_url() <> "/og/static/card.png"
284
285
  @doc "The configured absolute origin, e.g. https://openagents.com."
286
  def site_url, do: base_url()
287
288
  @doc "Meta tags for a card, canonicalized against the card's own page path."
289
  def meta(%__MODULE__{} = card), do: meta(card, base_url() <> (card.page_path || "/"))
290
291
  @doc "Absolute signed URL for a card's image."
292
  def card_url(%__MODULE__{kind: :site}), do: static_card_url()
293
294
  def card_url(%__MODULE__{} = card) do
295
    path = request_path(card)
296
    base_url() <> path <> "?sig=" <> signature(path)
297
  end
298
299
  @doc """
300
  The canonical request path for a card, versioned by its content digest:
301
302
      /og/v/{version}/repos/{owner}/{repo}[/{rest}].png
303
  """
304
  def request_path(%__MODULE__{} = card) do
305
    version = version(card)
306
307
    segments =
308
      ["og", "v", version, "repos" | Enum.map(card.path_suffix, &path_segment/1)]
309
310
    "/" <> Enum.join(segments, "/") <> ".png"
311
  end
312
313
  defp path_segment(value), do: URI.encode(value, &URI.char_unreserved?/1)
314
315
  @doc "Content digest over exactly what the template will draw."
316
  def version(%__MODULE__{} = card) do
317
    inputs = {
318
      card.kind,
319
      card.kicker,
320
      card.heading,
321
      card.description,
322
      card.avatar,
323
      card.provenance,
324
      Enum.map(card.chips, &{&1.label, Map.get(&1, :tone)}),
325
      card.stats,
326
      template_revision()
327
    }
328
329
    serialized =
330
      inputs
331
      |> :erlang.term_to_binary([:deterministic])
332
      |> then(&:crypto.hash(:sha256, &1))
333
      |> Base.encode16(case: :lower)
334
335
    binary_part(serialized, 0, @version_bytes)
336
  end
337
338
  @doc "HMAC over the request path; a query string never participates."
339
  def signature(request_path) when is_binary(request_path) do
340
    :crypto.mac(:hmac, :sha256, signing_key(), request_path)
341
    |> binary_part(0, @signature_bytes)
342
    |> Base.url_encode64(padding: false)
343
  end
344
345
  @doc "Constant-time signature check; anything malformed is simply invalid."
346
  def valid_signature?(request_path, sig) when is_binary(request_path) and is_binary(sig) do
347
    expected = signature(request_path)
348
349
    if byte_size(sig) == byte_size(expected) do
350
      Plug.Crypto.secure_compare(expected, sig)
351
    else
352
      false
353
    end
354
  end
355
356
  def valid_signature?(_request_path, _sig), do: false
357
358
  defp signing_key do
359
    secret =
360
      OpenAgentsWeb.Endpoint.config(:secret_key_base) ||
361
        Application.get_env(:openagents, :og_signing_key) ||
362
        "openagents-og-development-key"
363
364
    :crypto.mac(:hmac, :sha256, secret, "openagents-og-cards-v1")
365
  end
366
367
  defp template_revision, do: "1"
368
369
  defp base_url, do: String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/")
370
371
  defp default_description, do: "Code hosting, issues, and projects on the agent-native forge."
372
373
  defp meta_title(text) when is_binary(text), do: clamp(text, 120)
374
375
  defp present(nil), do: nil
376
  defp present(""), do: nil
377
  defp present(text) when is_binary(text), do: String.trim(text)
378
379
  defp namespace_slug(%{namespace: %{slug: slug}}) when is_binary(slug), do: slug
380
  defp namespace_slug(%{owner: owner}) when is_binary(owner), do: owner
381
382
  defp author_login(%{user: %{} = user}), do: user["login"] || user[:login]
383
  defp author_login(%{author: author}) when is_binary(author), do: author
384
  defp author_login(_), do: nil
385
386
  defp state_label(%{state: "closed", state_reason: "not_planned"}), do: "Closed as not planned"
387
  defp state_label(%{state: "closed", state_reason: "duplicate"}), do: "Closed as duplicate"
388
  defp state_label(%{state: "closed"}), do: "Closed"
389
  defp state_label(_), do: "Open"
390
391
  defp state_tone(%{state: "closed", state_reason: reason})
392
       when reason in ["not_planned", "duplicate"],
393
       do: :muted
394
395
  defp state_tone(%{state: "closed"}), do: :done
396
  defp state_tone(_), do: :open
397
398
  defp short_sha(sha) when is_binary(sha), do: String.slice(sha, 0, 7)
399
400
  defp plural(1, singular, _plural), do: "1 #{singular}"
401
  defp plural(n, _singular, plural_form) when is_integer(n), do: "#{n} #{plural_form}"
402
403
  defp format_size(bytes) when bytes < 1024, do: "#{bytes} B"
404
  defp format_size(bytes) when bytes < 1024 * 1024, do: "#{div(bytes, 1024)} KB"
405
406
  defp format_size(bytes),
407
    do: "#{Float.round(bytes / (1024 * 1024), 1)} MB"
408
409
  @doc """
410
  Language inferred from a path's extension or a well-known filename.
411
  Honest about ignorance: unknown shapes yield nil and the card shows no
412
  language chip.
413
  """
414
  def language_for_path(path) when is_binary(path) do
415
    filename = basename(path)
416
417
    well_known_language(filename) || extension_language(Path.extname(filename))
418
  end
419
420
  defp well_known_language("Dockerfile"), do: "Docker"
421
  defp well_known_language("Makefile"), do: "Makefile"
422
  defp well_known_language("mix.exs"), do: "Elixir"
423
  defp well_known_language(_), do: nil
424
425
  @extension_languages %{
426
    ".ex" => "Elixir",
427
    ".exs" => "Elixir",
428
    ".heex" => "HEEx",
429
    ".leex" => "HEEx",
430
    ".eex" => "EEx",
431
    ".erl" => "Erlang",
432
    ".hrl" => "Erlang",
433
    ".md" => "Markdown",
434
    ".markdown" => "Markdown",
435
    ".json" => "JSON",
436
    ".toml" => "TOML",
437
    ".yml" => "YAML",
438
    ".yaml" => "YAML",
439
    ".ts" => "TypeScript",
440
    ".tsx" => "TSX",
441
    ".js" => "JavaScript",
442
    ".jsx" => "JSX",
443
    ".mjs" => "JavaScript",
444
    ".cjs" => "JavaScript",
445
    ".rs" => "Rust",
446
    ".go" => "Go",
447
    ".py" => "Python",
448
    ".rb" => "Ruby",
449
    ".sh" => "Shell",
450
    ".bash" => "Shell",
451
    ".zsh" => "Shell",
452
    ".css" => "CSS",
453
    ".scss" => "SCSS",
454
    ".html" => "HTML",
455
    ".sql" => "SQL",
456
    ".swift" => "Swift",
457
    ".kt" => "Kotlin",
458
    ".java" => "Java",
459
    ".c" => "C",
460
    ".h" => "C",
461
    ".cpp" => "C++",
462
    ".hpp" => "C++",
463
    ".cs" => "C#",
464
    ".php" => "PHP",
465
    ".txt" => "Text",
466
    ".svg" => "SVG",
467
    ".xml" => "XML"
468
  }
469
470
  defp extension_language(ext) when is_binary(ext),
471
    do: Map.get(@extension_languages, String.downcase(ext))
472
473
  defp extension_language(_), do: nil
474
475
  defp basename(path) when is_binary(path),
476
    do: path |> String.split("/") |> Enum.reject(&(&1 == "")) |> List.last()
477
478
  # ── text safety and layout ──────────────────────────────────────────────────
479
480
  @control_pattern ~r/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
481
482
  @doc "XML-escapes untrusted text; control characters are stripped entirely."
483
  def escape(text) when is_binary(text) do
484
    text
485
    |> String.replace(@control_pattern, "")
486
    |> String.replace("&", "&amp;")
487
    |> String.replace("<", "&lt;")
488
    |> String.replace(">", "&gt;")
489
    |> String.replace("\"", "&quot;")
490
    |> String.replace("'", "&#39;")
491
  end
492
493
  def escape(nil), do: ""
494
495
  @doc "Hard cap on any dynamic string entering a template, grapheme-safe."
496
  def clamp(text, max_chars) when is_integer(max_chars) and max_chars >= 1 do
497
    cond do
498
      is_nil(text) ->
499
        nil
500
501
      String.length(text) <= max_chars ->
502
        String.trim(text)
503
504
      true ->
505
        text
506
        |> String.graphemes()
507
        |> Enum.take(max_chars - 1)
508
        |> Enum.concat(["…"])
509
        |> Enum.join()
510
    end
511
  end
512
513
  @doc """
514
  Greedy word-wrap to `max_lines` lines of at most `max_chars`, appending an
515
  ellipsis to the final line only when content actually remains. Empty input
516
  wraps to no lines.
517
  """
518
  def wrap(text, max_chars, max_lines)
519
      when is_integer(max_chars) and max_chars >= 1 and is_integer(max_lines) and max_lines >= 1 and
520
             is_binary(text) do
521
    words = String.split(String.trim(text), ~r/\s+/, trim: true)
522
    {lines, remaining?} = greedy_lines(words, max_chars, max_lines, [])
523
524
    if remaining?,
525
      do: List.replace_at(lines, -1, ellipsis_line(List.last(lines), max_chars)),
526
      else: lines
527
  end
528
529
  def wrap(nil, _max_chars, _max_lines), do: []
530
531
  defp greedy_lines([], _max_chars, _lines_left, acc), do: {Enum.reverse(acc), false}
532
  defp greedy_lines(_words, _max_chars, 0, acc), do: {Enum.reverse(acc), true}
533
534
  defp greedy_lines(words, max_chars, lines_left, acc) do
535
    {line, rest} = take_words(words, max_chars, "")
536
    greedy_lines(rest, max_chars, lines_left - 1, [line | acc])
537
  end
538
539
  defp take_words([], _max_chars, current), do: {String.trim_trailing(current), []}
540
541
  defp take_words([word | rest], max_chars, current) do
542
    cond do
543
      # A lone word longer than the budget is hard-split across lines: no
544
      # line may overflow its box, however long the unbroken token.
545
      current == "" and String.length(word) > max_chars ->
546
        {String.slice(word, 0, max_chars), [String.slice(word, max_chars..-1//1) | rest]}
547
548
      String.length(current <> " " <> word) > max_chars and current != "" ->
549
        {current, [word | rest]}
550
551
      current == "" ->
552
        take_words(rest, max_chars, word)
553
554
      true ->
555
        take_words(rest, max_chars, current <> " " <> word)
556
    end
557
  end
558
559
  defp ellipsis_line(line, max_chars) do
560
    keep = max(max_chars - 1, 1)
561
562
    line
563
    |> String.graphemes()
564
    |> Enum.take(keep)
565
    |> Enum.concat(["…"])
566
    |> Enum.join()
567
  end
568
569
  @doc """
570
  Flattens a path for display, always keeping the filename: long interiors
571
  collapse into a leading ellipsis plus the deepest directory that fits,
572
  never pushing the name off the card.
573
  """
574
  def display_path(path, max_chars \\ 64)
575
      when is_binary(path) and is_integer(max_chars) and max_chars >= 8 do
576
    segments = path |> String.split("/") |> Enum.reject(&(&1 == ""))
577
578
    if segments == [] do
579
      ""
580
    else
581
      filename = List.last(segments)
582
583
      if String.length(path) <= max_chars do
584
        path
585
      else
586
        interior = segments |> Enum.drop(-1) |> Enum.join("/")
587
        budget = max_chars - String.length(filename) - 2
588
589
        cond do
590
          interior == "" -> filename
591
          String.length(interior) <= budget -> interior <> "/" <> filename
592
          true -> "…/" <> tail_within(Enum.drop(segments, -1), budget) <> "/" <> filename
593
        end
594
      end
595
    end
596
  end
597
598
  # Deepest directories that still fit, read from the right.
599
  defp tail_within(segments, budget) do
600
    segments
601
    |> Enum.reverse()
602
    |> Enum.reduce_while([], fn segment, acc ->
603
      candidate = Enum.join([segment | acc], "/")
604
605
      if String.length(candidate) <= budget or acc == [] do
606
        {:cont, [segment | acc]}
607
      else
608
        {:halt, acc}
609
      end
610
    end)
611
    |> Enum.join("/")
612
  end
613
end
lib/openagents_web/og/brand_mark.ex added +19

@@ -0,0 +1,19 @@

1
defmodule OpenAgentsWeb.OG.BrandMark do
2
  @moduledoc """
3
  The brand mark's own path data, taken verbatim from
4
  `priv/static/images/logo.svg`. Kept as data so the card templates embed
5
  our real glyph without reading files at render time or emitting remote
6
  references.
7
  """
8
9
  @path "m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
10
11
  @doc "The raw SVG path data for the brand mark, on its own 71x48 canvas."
12
  def path, do: @path
13
14
  @doc "Width of the mark's native viewBox."
15
  def width, do: 71
16
17
  @doc "Height of the mark's native viewBox."
18
  def height, do: 48
19
end
lib/openagents_web/og/limiter.ex added +57

@@ -0,0 +1,57 @@

1
defmodule OpenAgentsWeb.OG.Limiter do
2
  @moduledoc """
3
  Bounds how many card rasterizations run at once.
4
5
  Rasterization is the only expensive step in the card pipeline, and misses
6
  are rare thanks to immutable caching — but a burst of cold requests (a link
7
  going viral, a cache flush) must not fan out into unbounded port processes.
8
  The counter lives in a named public ETS table so `update_counter/4` gives us
9
  an atomic increment-and-check without a process bottleneck: acquirers that
10
  find themselves over budget release immediately and report `:busy`, which
11
  callers turn into the static fallback card.
12
  """
13
14
  @table :og_rasterizer_limiter
15
  @key :active
16
17
  @doc """
18
  Takes one slot, or reports `:busy` when `max` rasterizations are already
19
  running. Always pair a successful acquire with `release/0`.
20
  """
21
  @spec acquire(pos_integer()) :: :ok | :busy
22
  def acquire(max \\ max_concurrent()) do
23
    ensure_table!()
24
25
    case :ets.update_counter(@table, @key, {2, 1}, {@key, 0}) do
26
      current when current <= max ->
27
        :ok
28
29
      _over_budget ->
30
        release()
31
        :busy
32
    end
33
  end
34
35
  @doc "Returns one slot. Safe to call even when no slot was taken."
36
  def release do
37
    ensure_table!()
38
    :ets.update_counter(@table, @key, {2, -1}, {@key, 0})
39
    :ok
40
  end
41
42
  defp ensure_table! do
43
    case :ets.info(@table) do
44
      :undefined ->
45
        try do
46
          :ets.new(@table, [:named_table, :public, read_concurrency: true])
47
        rescue
48
          ArgumentError -> :ok
49
        end
50
51
      _ ->
52
        :ok
53
    end
54
  end
55
56
  defp max_concurrent, do: Application.get_env(:openagents, :og_max_concurrent, 4)
57
end
lib/openagents_web/og/rasterizer.ex added +127

@@ -0,0 +1,127 @@

1
defmodule OpenAgentsWeb.OG.Rasterizer do
2
  @moduledoc """
3
  SVG to PNG, the one impure step in the card pipeline.
4
5
  The production path shells out to `rsvg-convert` (librsvg) with a fixed
6
  width/height and no user-influenced flags: the SVG itself is the only input,
7
  and every dynamic string inside it was escaped and clamped by
8
  `OpenAgentsWeb.OG` long before it got here. Fonts come from the release
9
  image's fontconfig; when the binary is absent the module reports
10
  `:unavailable` and callers serve the committed fallback card instead of
11
  erroring.
12
13
  Work is bounded twice: a concurrency limiter (`OpenAgentsWeb.OG.Limiter`)
14
  caps simultaneous ports, and each run has a hard timeout. Tests can replace
15
  the whole behavior with `:og_rasterizer_mfa`, an MFA applied to the SVG.
16
  """
17
18
  require Logger
19
20
  @timeout_ms 5_000
21
22
  @type error :: :unavailable | :busy | :rasterizer_failed | :timeout | {:exit, term()}
23
24
  @doc """
25
  Renders one card's SVG to PNG bytes.
26
27
  Test seam: set `Application.put_env(:openagents, :og_rasterizer_mfa,
28
  {Mod, :fun, []})` and it receives the SVG instead of the port pipeline.
29
  """
30
  @spec rasterize(String.t(), keyword()) :: {:ok, binary()} | {:error, error()}
31
  def rasterize(svg, opts \\ []) do
32
    case Application.get_env(:openagents, :og_rasterizer_mfa) do
33
      {mod, fun, args} when is_atom(mod) and is_atom(fun) and is_list(args) ->
34
        apply(mod, fun, [svg | args])
35
36
      _mfa_absent ->
37
        port_rasterize(svg, opts)
38
    end
39
  end
40
41
  @doc "Whether the configured rasterizer binary exists on this node."
42
  def available? do
43
    case System.find_executable(binary_name()) do
44
      nil -> false
45
      _path -> true
46
    end
47
  end
48
49
  defp port_rasterize(svg, opts) do
50
    bin = binary_name()
51
52
    unless executable?(bin) do
53
      {:error, :unavailable}
54
    else
55
      case OpenAgentsWeb.OG.Limiter.acquire() do
56
        :ok ->
57
          try do
58
            run(bin, svg, opts)
59
          after
60
            OpenAgentsWeb.OG.Limiter.release()
61
          end
62
63
        :busy ->
64
          {:error, :busy}
65
      end
66
    end
67
  end
68
69
  # find_executable walks PATH per call; cache the verdict per binary name so
70
  # a burst of cold card requests does not turn into a burst of scans.
71
  defp executable?(bin) do
72
    key = {__MODULE__, :executable, bin}
73
74
    case :persistent_term.get(key, nil) do
75
      nil ->
76
        found = System.find_executable(bin) != nil
77
        :persistent_term.put(key, found)
78
        found
79
80
      verdict ->
81
        verdict
82
    end
83
  end
84
85
  defp run(bin, svg, opts) do
86
    timeout = Keyword.get(opts, :timeout_ms, @timeout_ms)
87
    source = temp_path("svg")
88
89
    try do
90
      File.write!(source, svg)
91
92
      task = Task.async(fn -> System.cmd(bin, port_args(source), stderr_to_stdout: true) end)
93
94
      case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
95
        {:ok, {png, 0}} when is_binary(png) and byte_size(png) > 0 ->
96
          {:ok, png}
97
98
        {:ok, {_output, status}} ->
99
          Logger.warning("og_rasterizer_failed exit=#{status}")
100
          {:error, :rasterizer_failed}
101
102
        nil ->
103
          Logger.warning("og_rasterizer_timeout")
104
          {:error, :timeout}
105
106
        {:exit, reason} ->
107
          {:error, {:exit, reason}}
108
      end
109
    rescue
110
      error in File.Error -> {:error, {:file, error.reason}}
111
    after
112
      File.rm(source)
113
    end
114
  end
115
116
  defp port_args(source),
117
    do: ["-f", "png", "-w", "1200", "-h", "630", "--keep-aspect-ratio", source]
118
119
  defp temp_path(extension),
120
    do:
121
      Path.join(
122
        System.tmp_dir!(),
123
        "og-" <> Integer.to_string(System.unique_integer([:positive])) <> "." <> extension
124
      )
125
126
  defp binary_name, do: Application.get_env(:openagents, :og_rasterizer_bin, "rsvg-convert")
127
end
lib/openagents_web/og/templates.ex added +332

@@ -0,0 +1,332 @@

1
defmodule OpenAgentsWeb.OG.Templates do
2
  @moduledoc """
3
  SVG templates for Open Graph cards: one pure function per card kind, all
4
  sharing a frame. Every dynamic string has already passed through
5
  `OpenAgentsWeb.OG.escape/1`, `clamp/2`, and `wrap/3` by the time it lands
6
  here — this module adds no trust of its own.
7
8
  The palette is the dark ladder from `assets/css/app.css` pinned to hex
9
  values: a crawler renders the image once, so the card cannot follow the
10
  visitor's theme. No remote references are emitted, and the only path data
11
  embedded is our own brand mark.
12
  """
13
14
  alias OpenAgentsWeb.OG
15
  alias OpenAgentsWeb.OG.BrandMark
16
17
  @width 1200
18
  @height 630
19
  @margin 72
20
21
  # Attribute-ready font stack: single quotes sit inside a double-quoted XML
22
  # attribute, which needs no further escaping.
23
  @font ~S('Geist Sans', 'Segoe UI', system-ui, -apple-system, sans-serif)
24
25
  @background "#08090a"
26
  @surface "#141516"
27
  @text "#f7f8f8"
28
  @muted "#8a8f98"
29
  @line "#23252a"
30
  @accent "#5e6ad2"
31
  @accent_bright "#828fff"
32
33
  # One vertical grid for every resource card: kicker, up to two heading
34
  # lines beside an optional avatar disc, up to two description lines, a chip
35
  # row, one stats line, and the brand footer.
36
  @kicker_y 118
37
  @heading_top 150
38
  @description_ys [358, 402]
39
  @chips_y 436
40
  @stats_y 524
41
  @footer_y @height - 44
42
43
  @state_colors %{open: "#3fb950", done: "#8957e5", muted: @muted}
44
45
  @doc "Render a card to an SVG string at the canonical 1200x630 size."
46
  def render(%OG{} = card) do
47
    inner =
48
      case card.kind do
49
        :site -> site_body(card)
50
        _kind -> resource_body(card)
51
      end
52
53
    [
54
      ~s(<svg xmlns="http://www.w3.org/2000/svg" width="#{@width}" height="#{@height}" viewBox="0 0 #{@width} #{@height}" role="img">),
55
      ~s(<rect x="1" y="1" width="#{@width - 2}" height="#{@height - 2}" rx="24" fill="#{@background}" stroke="#{@line}" stroke-width="2"/>),
56
      inner,
57
      footer(),
58
      "</svg>"
59
    ]
60
    |> List.flatten()
61
    |> Enum.reject(&is_nil/1)
62
    |> Enum.join("\n")
63
  end
64
65
  ## Bodies -------------------------------------------------------------------
66
67
  defp site_body(card) do
68
    compact([
69
      kicker(card.kicker, 188),
70
      heading_block(OG.wrap(card.heading || "", 30, 2), 64, 224, @margin),
71
      text_lines(OG.wrap(card.description || "", 48, 2), 32, @muted, [420, 466])
72
    ])
73
  end
74
75
  defp resource_body(%OG{} = card) do
76
    repo_card? = card.kind == :repo
77
    size = if repo_card?, do: 84, else: 56
78
    budget = if repo_card?, do: 24, else: 36
79
80
    compact([
81
      kicker(card.kicker, @kicker_y),
82
      avatar_disc(card.avatar, @heading_top),
83
      heading_block(OG.wrap(card.heading || "", budget, 2), size, @heading_top, heading_x(card)),
84
      text_lines(OG.wrap(card.description || "", 54, 2), 31, @muted, @description_ys),
85
      chips_row(@chips_y, card.chips),
86
      stats_row(@stats_y, card.stats),
87
      provenance(card.provenance)
88
    ])
89
  end
90
91
  defp compact(parts) do
92
    parts
93
    |> List.flatten()
94
    |> Enum.reject(&is_nil/1)
95
    |> Enum.join("\n")
96
  end
97
98
  ## Pieces -------------------------------------------------------------------
99
100
  defp kicker(nil, _y), do: nil
101
102
  defp kicker(text, y) do
103
    escaped = text |> String.upcase() |> OG.clamp(64) |> OG.escape()
104
105
    tag("text",
106
      x: @margin,
107
      y: y,
108
      "font-family": @font,
109
      "font-size": 27,
110
      "letter-spacing": 3,
111
      fill: @muted,
112
      content: escaped
113
    )
114
  end
115
116
  # The heading indents past the avatar disc when one is present.
117
  defp heading_x(%OG{avatar: nil}), do: @margin
118
  defp heading_x(%OG{avatar: login}) when is_binary(login), do: @margin + 100
119
120
  defp avatar_disc(nil, _top), do: nil
121
122
  defp avatar_disc(login, top) when is_binary(login) do
123
    radius = 40
124
    cx = @margin + radius
125
    cy = top + radius
126
127
    initial =
128
      case login |> String.replace(~r/[^\w]/, "") |> String.first() do
129
        nil -> "?"
130
        "" -> "?"
131
        char -> String.upcase(char)
132
      end
133
134
    circle =
135
      tag("circle",
136
        cx: cx,
137
        cy: cy,
138
        r: radius,
139
        fill: @accent,
140
        "fill-opacity": 0.35,
141
        stroke: @accent_bright,
142
        "stroke-width": 2
143
      )
144
145
    glyph =
146
      tag("text",
147
        x: cx,
148
        y: cy + 15,
149
        "text-anchor": "middle",
150
        "font-family": @font,
151
        "font-size": 44,
152
        "font-weight": 600,
153
        fill: @accent_bright,
154
        content: initial
155
      )
156
157
    [circle, glyph]
158
  end
159
160
  # `top` marks where the first line's ink begins; SVG baselines sit a full em
161
  # below that, and successive lines step by 1.18em.
162
  defp heading_block([], _size, _top, _x), do: nil
163
164
  defp heading_block(lines, size, top, x) when is_list(lines) do
165
    Enum.map(Enum.with_index(lines), fn {line, index} ->
166
      baseline = trunc(top + size * (1 + index * 1.18))
167
168
      tag("text",
169
        x: x,
170
        y: baseline,
171
        "font-family": @font,
172
        "font-size": size,
173
        "font-weight": 600,
174
        fill: @text,
175
        content: OG.escape(line)
176
      )
177
    end)
178
  end
179
180
  defp text_lines([], _size, _fill, _ys), do: nil
181
182
  defp text_lines(lines, size, fill, ys) when is_list(lines) and is_list(ys) do
183
    Enum.map(Enum.zip(lines, ys), fn {line, y} ->
184
      tag("text",
185
        x: @margin,
186
        y: y,
187
        "font-family": @font,
188
        "font-size": size,
189
        fill: fill,
190
        content: OG.escape(line)
191
      )
192
    end)
193
  end
194
195
  defp chips_row(_y, []), do: nil
196
197
  defp chips_row(y, chips) do
198
    {elements, _final_x} =
199
      chips
200
      |> Enum.take(5)
201
      |> Enum.map_reduce(@margin, fn chip, x ->
202
        label = OG.clamp(chip.label, 34)
203
        color = Map.get(chip, :tone) && Map.get(@state_colors, chip.tone)
204
205
        width = max(String.length(label) * 14 + 46, 96)
206
207
        rect_attrs =
208
          if color do
209
            [fill: color, "fill-opacity": 0.16, stroke: color, "stroke-width": 2]
210
          else
211
            [fill: @surface, stroke: @line, "stroke-width": 2]
212
          end
213
214
        rect =
215
          tag("rect", Keyword.merge([x: x, y: y, width: width, height: 50, rx: 25], rect_attrs))
216
217
        label_text =
218
          tag("text",
219
            x: x + div(width, 2),
220
            y: y + 33,
221
            "text-anchor": "middle",
222
            "font-family": @font,
223
            "font-size": 26,
224
            fill: color || @muted,
225
            content: OG.escape(label)
226
          )
227
228
        {[rect, label_text], x + width + 18}
229
      end)
230
231
    elements
232
  end
233
234
  defp stats_row(_y, []), do: nil
235
236
  defp stats_row(y, stats) do
237
    joined =
238
      stats
239
      |> Enum.take(4)
240
      |> Enum.map(&OG.clamp(&1, 44))
241
      |> Enum.join("   ·   ")
242
243
    [
244
      tag("text",
245
        x: @margin,
246
        y: y,
247
        "font-family": @font,
248
        "font-size": 29,
249
        fill: @muted,
250
        content: OG.escape(joined)
251
      )
252
    ]
253
  end
254
255
  # Provenance sits right-aligned on the stats line: quiet, but present.
256
  defp provenance(nil), do: nil
257
  defp provenance(""), do: nil
258
259
  defp provenance(text) do
260
    [
261
      tag("text",
262
        x: @width - @margin,
263
        y: @stats_y,
264
        "text-anchor": "end",
265
        "font-family": @font,
266
        "font-size": 27,
267
        fill: @muted,
268
        content: OG.escape(OG.clamp(text, 40))
269
      )
270
    ]
271
  end
272
273
  defp footer do
274
    scale = 0.62
275
    translate_y = @footer_y - trunc(BrandMark.height() * scale)
276
277
    mark =
278
      tag("g",
279
        transform: "translate(#{@margin}, #{translate_y}) scale(#{scale})",
280
        fill: @muted,
281
        content: [tag("path", d: BrandMark.path())]
282
      )
283
284
    wordmark =
285
      tag("text",
286
        x: @width - @margin,
287
        y: @footer_y,
288
        "text-anchor": "end",
289
        "font-family": @font,
290
        "font-size": 27,
291
        fill: @muted,
292
        content: "openagents.com"
293
      )
294
295
    [mark, wordmark]
296
  end
297
298
  ## Emitter ------------------------------------------------------------------
299
300
  # One element builder so every attribute value passes through a single
301
  # quoting rule; nothing string-concatenates half-escaped fragments.
302
  defp tag(name, attrs) when is_binary(name) and is_list(attrs) do
303
    {content, attributes} = Keyword.pop(attrs, :content)
304
305
    rendered =
306
      Enum.map(attributes, fn {key, value} ->
307
        ~s( #{key}=") <> escape_attr_value(attr_string(value)) <> ~s(")
308
      end)
309
310
    open = "<" <> name <> Enum.join(rendered)
311
312
    case content do
313
      nil -> [open <> "/>"]
314
      parts when is_list(parts) -> [[open <> ">"] ++ parts ++ ["</" <> name <> ">"]]
315
      part when is_binary(part) -> [[open <> ">" <> part <> "</" <> name <> ">"]]
316
    end
317
  end
318
319
  defp attr_string(value) when is_integer(value), do: Integer.to_string(value)
320
  defp attr_string(value) when is_float(value), do: Float.to_string(value)
321
  defp attr_string(:middle), do: "middle"
322
  defp attr_string(:end), do: "end"
323
  defp attr_string(value) when is_binary(value), do: value
324
325
  defp escape_attr_value(text) do
326
    text
327
    |> String.replace("&", "&amp;")
328
    |> String.replace("<", "&lt;")
329
    |> String.replace(">", "&gt;")
330
    |> String.replace("\"", "&quot;")
331
  end
332
end
lib/openagents_web/route_authority.ex modified +5

@@ -202,6 +202,11 @@ defmodule OpenAgentsWeb.RouteAuthority do

202 202
203 203
  defp policy(%{path: path, verb: verb}) do
204 204
    cond do
205
      String.starts_with?(path, "/og/") and verb in [:get, :head] ->
206
        # Card images are public by construction (they describe only what
207
        # anonymous pages already show) and mutate nothing.
208
        declaration(:public_read, "anonymous crawler", "published:og-card", false)
209
205 210
      Enum.any?(@authenticated_browser_prefixes, &String.starts_with?(path, &1)) ->
206 211
        declaration(
207 212
          :authenticated_browser,
lib/openagents_web/router.ex modified +12

@@ -305,6 +305,18 @@ defmodule OpenAgentsWeb.Router do

305 305
    end
306 306
  end
307 307
308
  # Open Graph card images: content-versioned, HMAC-signed, public-only.
309
  # The version segment is advisory cache-busting; the signature gates the
310
  # endpoint against third-party rendering abuse; private and missing
311
  # repositories are indistinguishable 404s.
312
  scope "/og", OpenAgentsWeb do
313
    get "/static/card.png", OgImageController, :static
314
    get "/v/:version/repos/:owner/:repo", OgImageController, :repo
315
    get "/v/:version/repos/:owner/:repo/issues/:number", OgImageController, :issue
316
    get "/v/:version/repos/:owner/:repo/commit/:sha", OgImageController, :commit
317
    get "/v/:version/repos/:owner/:repo/blob/:ref/*path", OgImageController, :blob
318
  end
319
308 320
  # Keep repository-shaped routes last. Every fixed product, API, operator,
309 321
  # Git, and development route above wins before a GitHub-backed namespace can
310 322
  # be interpreted from the first path segment.
priv/static/images/og-card-default.png added

Binary file. Nothing to show as text.

test/openagents_web/controllers/og_image_controller_test.exs added +331

@@ -0,0 +1,331 @@

1
defmodule OpenAgentsWeb.OgImageControllerTest do
2
  @moduledoc """
3
  The card endpoint's contract: signed paths, public-only resolution,
4
  immutable caching, advisory versions, and a fallback that never errors.
5
  """
6
7
  use OpenAgentsWeb.ConnCase, async: false
8
  import Phoenix.LiveViewTest
9
10
  alias OpenAgents.Forge.Repos
11
  alias OpenAgents.Issues
12
  alias OpenAgents.Repositories
13
14
  @marker_png <<0x89, 0x50, 0x4E, 0x47, "FAKE-CARD-RENDER">>
15
16
  setup do
17
    base = Path.join(System.tmp_dir!(), "og-controller-#{System.unique_integer([:positive])}")
18
    File.mkdir_p!(base)
19
20
    previous = %{
21
      data: Application.get_env(:openagents, :forge_data_dir),
22
      wal: Application.get_env(:openagents, :forge_wal_dir),
23
      visibility: Application.get_env(:openagents, :forge_public_visibility),
24
      paths: Application.get_env(:openagents, :forge_public_paths),
25
      renderer: Application.get_env(:openagents, :og_rasterizer_mfa)
26
    }
27
28
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
29
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
30
    Application.put_env(:openagents, :forge_public_visibility, %{"openagents.com" => :l3})
31
    Application.delete_env(:openagents, :forge_public_paths)
32
33
    # Hermetic rendering: the endpoint contract does not depend on librsvg.
34
    Application.put_env(:openagents, :og_rasterizer_mfa, {__MODULE__, :fake_render, []})
35
36
    on_exit(fn ->
37
      if previous.data,
38
        do: Application.put_env(:openagents, :forge_data_dir, previous.data),
39
        else: Application.delete_env(:openagents, :forge_data_dir)
40
41
      if previous.wal,
42
        do: Application.put_env(:openagents, :forge_wal_dir, previous.wal),
43
        else: Application.delete_env(:openagents, :forge_wal_dir)
44
45
      if previous.visibility,
46
        do: Application.put_env(:openagents, :forge_public_visibility, previous.visibility),
47
        else: Application.delete_env(:openagents, :forge_public_visibility)
48
49
      if previous.paths,
50
        do: Application.put_env(:openagents, :forge_public_paths, previous.paths),
51
        else: Application.delete_env(:openagents, :forge_public_paths)
52
53
      if previous.renderer,
54
        do: Application.put_env(:openagents, :og_rasterizer_mfa, previous.renderer),
55
        else: Application.delete_env(:openagents, :og_rasterizer_mfa)
56
57
      File.rm_rf(base)
58
    end)
59
60
    shas = seed_repo("openagents.com")
61
    repository = Repositories.initial_repository!()
62
63
    {:ok, repository: repository, shas: shas}
64
  end
65
66
  def fake_render(_svg), do: {:ok, @marker_png}
67
68
  defp signed_url(card) do
69
    path = OpenAgentsWeb.OG.request_path(card)
70
    path <> "?sig=" <> OpenAgentsWeb.OG.signature(path)
71
  end
72
73
  test "the static route serves the committed brand card", %{conn: conn} do
74
    conn = get(conn, "/og/static/card.png")
75
76
    assert response(conn, 200) == File.read!(committed_asset_path())
77
    assert resp_content_type(conn) == "image/png"
78
    assert cache_control(conn) == "public, max-age=21600, immutable"
79
  end
80
81
  test "a repository card renders with the endpoint's headers and cache policy", %{
82
    conn: conn,
83
    repository: repository
84
  } do
85
    conn = get(conn, signed_url(OpenAgentsWeb.OG.repo_card_for(repository)))
86
87
    assert response(conn, 200) == @marker_png
88
    assert resp_content_type(conn) == "image/png"
89
    assert cache_control(conn) == "public, max-age=21600, immutable"
90
    assert get_resp_header(conn, "x-content-type-options") == ["nosniff"]
91
  end
92
93
  test "issue cards render from the public issue path", %{conn: conn} do
94
    {:ok, issue} = Issues.create_issue(%{"title" => "Carded issue"})
95
    card = OpenAgentsWeb.OG.issue("OpenAgentsInc", "openagents.com", issue)
96
97
    assert response(get(conn, signed_url(card)), 200) == @marker_png
98
  end
99
100
  test "blob cards pass through the same disclosure gate as the file page", %{
101
    conn: conn,
102
    repository: _repository
103
  } do
104
    card =
105
      OpenAgentsWeb.OG.blob("OpenAgentsInc", "openagents.com", "README.md", %{
106
        ref: "main",
107
        size: 42,
108
        lines: 2,
109
        truncated: false
110
      })
111
112
    assert response(get(conn, signed_url(card)), 200) == @marker_png
113
  end
114
115
  test "commit cards render for seeded commits", %{conn: conn, shas: shas} do
116
    card =
117
      OpenAgentsWeb.OG.commit(
118
        "OpenAgentsInc",
119
        "openagents.com",
120
        %{sha: shas.first, subject: "First commit", author: "Test Author", committed_at: nil},
121
        nil
122
      )
123
124
    assert response(get(conn, signed_url(card)), 200) == @marker_png
125
  end
126
127
  test "an invalid signature is refused like every other refusal", %{
128
    conn: conn,
129
    repository: repository
130
  } do
131
    path = OpenAgentsWeb.OG.request_path(OpenAgentsWeb.OG.repo_card_for(repository))
132
    unsigned = path
133
    forged = path <> "?sig=" <> String.duplicate("A", 22)
134
135
    refused_unsigned = get(conn, unsigned)
136
    refused_forged = get(conn, forged)
137
138
    assert response(refused_unsigned, 404) == ""
139
    assert response(refused_forged, 404) == ""
140
    assert get_resp_header(refused_unsigned, "cache-control") == ["public, max-age=60"]
141
  end
142
143
  test "private and unknown repositories are indistinguishable from signature refusals", %{
144
    conn: conn
145
  } do
146
    {:ok, _private} =
147
      Repositories.create_repository(%{
148
        owner: "SecondOrg",
149
        name: "secret-plans",
150
        visibility: "private"
151
      })
152
153
    private_card = repo_card_for_path(["SecondOrg", "secret-plans"])
154
    unknown_card = repo_card_for_path(["NobodyOrg", "never-existed"])
155
156
    real_path =
157
      OpenAgentsWeb.OG.request_path(
158
        OpenAgentsWeb.OG.repo_card_for(Repositories.initial_repository!())
159
      )
160
161
    bad_signature = real_path <> "?sig=bogus"
162
163
    refusals = [
164
      get(conn, signed_url(private_card)),
165
      get(conn, signed_url(unknown_card)),
166
      get(conn, bad_signature)
167
    ]
168
169
    assert Enum.all?(refusals, &(response(&1, 404) == ""))
170
171
    bodies = Enum.map(refusals, &response(&1, 404))
172
    assert length(Enum.uniq(bodies)) == 1
173
  end
174
175
  # The version segment exists so emitted URLs are content-addressed; it is
176
  # not an authorization input. A stale share with a wrong version still
177
  # heals to current data as long as its signature covers the path.
178
  test "versions are advisory: wrong version, valid signature, current card", %{
179
    conn: conn,
180
    repository: repository
181
  } do
182
    real_path = OpenAgentsWeb.OG.request_path(OpenAgentsWeb.OG.repo_card_for(repository))
183
184
    stale_path =
185
      Regex.replace(~r|^/og/v/[0-9a-f]+|, real_path, "/og/v/deadbeefcafe")
186
187
    stale_signed = stale_path <> "?sig=" <> OpenAgentsWeb.OG.signature(stale_path)
188
189
    conn = get(conn, stale_signed)
190
    assert response(conn, 200) == @marker_png
191
  end
192
193
  test "rasterizer failures degrade to the committed fallback card", %{
194
    conn: conn,
195
    repository: repository
196
  } do
197
    # Environment-independent: a failing renderer must produce the fallback
198
    # bytes whether or not the host has librsvg installed.
199
    Application.put_env(:openagents, :og_rasterizer_mfa, {__MODULE__, :failing_render, []})
200
201
    conn = get(conn, signed_url(OpenAgentsWeb.OG.repo_card_for(repository)))
202
203
    assert response(conn, 200) == File.read!(committed_asset_path())
204
    assert resp_content_type(conn) == "image/png"
205
206
    Application.put_env(:openagents, :og_rasterizer_mfa, {__MODULE__, :fake_render, []})
207
  end
208
209
  def failing_render(_svg), do: {:error, :rasterizer_failed}
210
211
  ## Meta-tag presence on the pages that emit them ----------------------------
212
213
  test "pages without a card emit honest site-level tags", %{conn: conn} do
214
    {:ok, _view, html} = live(conn, ~p"/")
215
216
    assert html =~ ~s(property="og:title" content="OpenAgents")
217
    assert html =~ ~s(name="twitter:card" content="summary_large_image")
218
    assert html =~ "og:image"
219
  end
220
221
  test "the repository page emits its own card URL", %{conn: conn} do
222
    {:ok, _view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com")
223
224
    assert html =~
225
             ~r|property="og:image" content="[^"]*/og/v/[0-9a-f]{12}/repos/OpenAgentsInc/openagents\.com\.png\?sig=|
226
227
    assert html =~ ~s(OpenAgentsInc/openagents.com)
228
  end
229
230
  test "an issue page emits an issue-specific card URL", %{conn: conn} do
231
    {:ok, issue} = Issues.create_issue(%{"title" => "Shared on social"})
232
233
    {:ok, _view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}")
234
235
    assert html =~
236
             ~r|/og/v/[0-9a-f]{12}/repos/OpenAgentsInc/openagents\.com/issues/#{issue.number}\.png\?sig=|
237
  end
238
239
  ## Helpers ------------------------------------------------------------------
240
241
  defp committed_asset_path do
242
    Application.app_dir(:openagents, "priv/static/images/og-card-default.png")
243
  end
244
245
  defp repo_card_for_path([owner, name]) do
246
    suffix_card = %OpenAgentsWeb.OG{
247
      kind: :repo,
248
      kicker: "#{owner} /",
249
      heading: name,
250
      description: nil,
251
      title: "#{owner}/#{name}",
252
      page_path: "/#{owner}/#{name}",
253
      path_suffix: [owner, name]
254
    }
255
256
    suffix_card
257
  end
258
259
  defp cache_control(conn), do: conn |> get_resp_header("cache-control") |> List.first()
260
261
  defp resp_content_type(conn) do
262
    case get_resp_header(conn, "content-type") do
263
      [value | _] -> value
264
      [] -> nil
265
    end
266
  end
267
268
  ## Git fixture ---------------------------------------------------------------
269
270
  defp seed_repo(repo_name) do
271
    path = Repos.ensure_repo!(repo_name)
272
273
    readme = write_blob(path, "# OpenAgents\n\nCard fixture.\n")
274
    sample = write_blob(path, "defmodule Sample do\nend\n")
275
276
    lib_tree = mktree(path, "100644 blob #{sample}\tog_sample.ex\n")
277
278
    tree =
279
      mktree(
280
        path,
281
        "100644 blob #{readme}\tREADME.md\n" <>
282
          "040000 tree #{lib_tree}\tlib\n"
283
      )
284
285
    first = commit_tree(path, tree, [], "First commit\n")
286
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", first])
287
288
    %{first: first}
289
  end
290
291
  defp write_blob(path, content) do
292
    {sha, 0} = git_in(path, ["hash-object", "-w", "--stdin"], content)
293
    String.trim(sha)
294
  end
295
296
  defp mktree(path, listing) do
297
    {sha, 0} = git_in(path, ["mktree"], listing)
298
    String.trim(sha)
299
  end
300
301
  defp commit_tree(path, tree, parent_args, message) do
302
    {sha, 0} =
303
      git_in(path, ["commit-tree", tree] ++ parent_args, message,
304
        env: [
305
          {"GIT_AUTHOR_NAME", "Test Author"},
306
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
307
          {"GIT_COMMITTER_NAME", "Test Author"},
308
          {"GIT_COMMITTER_EMAIL", "author@example.test"}
309
        ]
310
      )
311
312
    String.trim(sha)
313
  end
314
315
  defp git_in(path, args, stdin \\ "", opts \\ []) do
316
    input = Path.join(System.tmp_dir!(), "og-stdin-#{System.unique_integer([:positive])}")
317
    File.write!(input, stdin)
318
319
    try do
320
      System.cmd(
321
        "sh",
322
        ["-c", ~s(exec git "$@" < "$IN"), "git"] ++ args,
323
        cd: path,
324
        env: [{"IN", input}] ++ Keyword.get(opts, :env, []),
325
        stderr_to_stdout: true
326
      )
327
    after
328
      File.rm(input)
329
    end
330
  end
331
end
test/openagents_web/icon_affordances_test.exs modified +6 -1

@@ -78,7 +78,12 @@ defmodule OpenAgentsWeb.IconAffordancesTest do

78 78
      # test defends is "one icon set", not "no vector output", and a graph node
79 79
      # is not an icon. Any glyph *inside* a graph surface still goes through
80 80
      # `icon/1`.
81
      exempt = ["ui.ex", "icons.ex", "graph.ex"]
81
      #
82
      # `og/templates.ex` is exempt for the same class of reason: it emits
83
      # whole Open Graph card *images* (1200x630 SVG documents rasterized to
84
      # PNG for crawlers), never in-page affordances. Nothing it draws appears
85
      # in the product UI.
86
      exempt = ["ui.ex", "icons.ex", "graph.ex", "templates.ex"]
82 87
83 88
      offenders =
84 89
        "lib/openagents_web/**/*.{ex,heex}"
test/openagents_web/og_test.exs added +294

@@ -0,0 +1,294 @@

1
defmodule OpenAgentsWeb.OGTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgentsWeb.OG
5
6
  # ── text safety ────────────────────────────────────────────────────────────
7
  test "escape XML-escapes the dangerous five and strips control characters" do
8
    assert OG.escape(~s(<a href="x">&'")) ==
9
             "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&quot;"
10
11
    assert OG.escape("zero\x00one\x1Ftwo") == "zeroonetwo"
12
    assert OG.escape("plain words 123") == "plain words 123"
13
    assert OG.escape(nil) == ""
14
  end
15
16
  test "clamp caps length grapheme-safe and marks the cut" do
17
    assert OG.clamp("short", 10) == "short"
18
19
    clamped = OG.clamp(String.duplicate("ab", 50), 20)
20
    assert String.length(clamped) == 20
21
    assert String.ends_with?(clamped, "…")
22
    assert OG.clamp("  padded  ", 20) == "padded"
23
  end
24
25
  test "wrap fits within the budget and ellipsizes only real overflow" do
26
    lines = OG.wrap("one two three four five", 8, 2)
27
    assert length(lines) == 2
28
    assert Enum.all?(lines, &(String.length(&1) <= 9))
29
30
    # Fits exactly: no ellipsis invented.
31
    exact = OG.wrap("aaa bbb ccc", 3, 3)
32
    assert exact == ["aaa", "bbb", "ccc"]
33
34
    overflow = OG.wrap("aaa bbb ccc ddd eee", 7, 2)
35
    assert length(overflow) == 2
36
    assert List.last(overflow) =~ "…"
37
38
    assert OG.wrap("", 10, 2) == []
39
    assert OG.wrap(nil, 10, 2) == []
40
41
    # A single word longer than the budget survives intact on its own line.
42
    long = OG.wrap(String.duplicate("x", 40), 10, 1)
43
    assert String.length(hd(long)) in 10..11
44
  end
45
46
  test "display_path keeps the filename and collapses deep interiors" do
47
    short = "lib/og.ex"
48
    assert OG.display_path(short) == short
49
50
    deep = "a/b/c/d/e/f/g/really_long_filename_here.ex"
51
    display = OG.display_path(deep, 24)
52
53
    refute String.starts_with?(display, "a/")
54
    assert display =~ "…/"
55
    assert String.ends_with?(display, "/really_long_filename_here.ex")
56
  end
57
58
  # ── language inference ─────────────────────────────────────────────────────
59
60
  test "language_for_path covers extensions, well-known files, and ignorance" do
61
    assert OG.language_for_path("lib/openagents/og.ex") == "Elixir"
62
    assert OG.language_for_path("assets/css/app.css") == "CSS"
63
    assert OG.language_for_path("Dockerfile") == "Docker"
64
    assert OG.language_for_path("Makefile") == "Makefile"
65
    assert OG.language_for_path("src/Main.Swift.swift") == "Swift"
66
    assert OG.language_for_path("README") == nil
67
    assert OG.language_for_path("data/blob.unknownext") == nil
68
  end
69
70
  # ── versioning and signing ─────────────────────────────────────────────────
71
72
  defp sample_card do
73
    OG.repo(%OpenAgents.Repositories.Repository{
74
      name: "openagents.com",
75
      description: "sample",
76
      namespace: %{slug: "OpenAgentsInc"}
77
    })
78
  end
79
80
  test "version is stable for identical inputs and moves when inputs move" do
81
    card = sample_card()
82
    assert OG.version(card) == OG.version(sample_card())
83
84
    moved = %{card | heading: "different"}
85
    refute OG.version(card) == OG.version(moved)
86
  end
87
88
  test "request_path is versioned, png-suffixed, and percent-encodes segments" do
89
    path = OG.request_path(sample_card())
90
91
    assert path =~ ~r|^/og/v/[0-9a-f]{12}/repos/OpenAgentsInc/openagents\.com\.png$|
92
93
    spaced = %{sample_card() | path_suffix: ["O wner", "a repo"]}
94
    encoded_path = OG.request_path(spaced)
95
    assert encoded_path =~ "O%20wner/a%20repo"
96
    refute encoded_path =~ "+"
97
  end
98
99
  test "signatures verify only their own path" do
100
    path = OG.request_path(sample_card())
101
    sig = OG.signature(path)
102
103
    assert OG.valid_signature?(path, sig)
104
    refute OG.valid_signature?(path <> "tampered", sig)
105
    refute OG.valid_signature?(path, sig <> "x")
106
    refute OG.valid_signature?(path, nil)
107
    refute OG.valid_signature?(path, "")
108
  end
109
110
  test "card_url appends a signature over its own path" do
111
    url = OG.card_url(sample_card())
112
    assert String.starts_with?(url, OG.site_url())
113
114
    [origin_and_path, query] = String.split(url, "?sig=")
115
    sig = String.replace_prefix(query, "sig=", "")
116
    path = String.replace_prefix(origin_and_path, OG.site_url(), "")
117
118
    assert OG.valid_signature?(path, sig)
119
  end
120
121
  # ── builders ───────────────────────────────────────────────────────────────
122
123
  test "the issue builder derives state tone, label chips, and an overflow chip" do
124
    issue = %{
125
      number: 12,
126
      title: "Broken thing",
127
      user: %{"login" => "ada"},
128
      state: "closed",
129
      state_reason: "completed",
130
      labels: [%{"name" => "bug"}, %{"name" => "ui"}, %{"name" => "css"}, %{"name" => "extra"}],
131
      comments: 4,
132
      inserted_at: ~U[2026-08-01 10:00:00Z]
133
    }
134
135
    card = OG.issue("OpenAgentsInc", "openagents.com", issue)
136
137
    assert card.heading == "Broken thing"
138
139
    assert [
140
             %{label: "Closed", tone: :done},
141
             %{label: "bug"},
142
             %{label: "ui"},
143
             %{label: "css"},
144
             %{label: "+1"}
145
           ] = card.chips
146
147
    assert card.avatar == "ada"
148
    assert "4 comments" in card.stats
149
    assert card.page_path == "/OpenAgentsInc/openagents.com/issues/12"
150
  end
151
152
  test "an open issue carries the open tone; not_planned closes are muted" do
153
    open =
154
      OG.issue("o", "r", %{
155
        number: 1,
156
        state: "open",
157
        labels: [],
158
        title: "t",
159
        user: %{"login" => "a"},
160
        comments: 0,
161
        inserted_at: ~U[2026-08-01 10:00:00Z]
162
      })
163
164
    assert hd(open.chips).tone == :open
165
166
    wontfix =
167
      OG.issue("o", "r", %{
168
        number: 2,
169
        state: "closed",
170
        state_reason: "not_planned",
171
        labels: [],
172
        title: "t",
173
        user: nil,
174
        comments: 0,
175
        inserted_at: ~U[2026-08-01 10:00:00Z]
176
      })
177
178
    assert hd(wontfix.chips) == %{label: "Closed as not planned", tone: :muted}
179
  end
180
181
  test "the blob builder infers language and formats size honestly" do
182
    card =
183
      OG.blob("OpenAgentsInc", "openagents.com", "lib/openagents/og.ex", %{
184
        ref: "main",
185
        size: 20480,
186
        lines: 512,
187
        truncated: false
188
      })
189
190
    assert card.heading == "og.ex"
191
    assert [%{label: "Elixir"}] = card.chips
192
    assert "20 KB" in card.stats
193
    assert "512 lines" in card.stats
194
195
    truncated =
196
      OG.blob("o", "r", "big.bin", %{ref: "main", size: 100, lines: 5, truncated: true})
197
198
    assert ">5+ lines" in truncated.stats
199
    refute "512 lines" in truncated.stats
200
  end
201
202
  test "the commit builder shortens the sha chip and counts files" do
203
    card =
204
      OG.commit(
205
        "OpenAgentsInc",
206
        "openagents.com",
207
        %{
208
          sha: String.duplicate("a", 40),
209
          subject: "Serve static files",
210
          author: "ada",
211
          committed_at: "2026-08-21T12:00:00Z"
212
        },
213
        7
214
      )
215
216
    assert [%{label: "aaaaaaa"}] = card.chips
217
    assert "7 changed files" in card.stats
218
  end
219
220
  # ── templates ──────────────────────────────────────────────────────────────
221
222
  test "templates escape hostile content and never embed remote references" do
223
    hostile =
224
      OG.repo(%OpenAgents.Repositories.Repository{
225
        name: "<script>alert(1)</script>",
226
        description: ~s("&><'\x00) || "evil",
227
        namespace: %{slug: "O<w>"}
228
      })
229
      |> Map.put(:description, ~s("&><'))
230
231
    svg = OG.Templates.render(hostile)
232
233
    refute svg =~ "<script"
234
    refute svg =~ "<image"
235
    refute svg =~ "href="
236
    assert svg =~ "&lt;script&gt;"
237
    assert svg =~ ~s(width="1200")
238
    assert svg =~ ~s(height="630")
239
  end
240
241
  test "template output stays valid XML against control characters" do
242
    card =
243
      OG.issue("o", "r", %{
244
        number: 1,
245
        title: "bad \x01\x02 title",
246
        user: %{"login" => "a\x03da"},
247
        state: "open",
248
        labels: [],
249
        comments: 0,
250
        inserted_at: ~U[2026-08-01 10:00:00Z]
251
      })
252
253
    svg = OG.Templates.render(card)
254
255
    # A malformed document exits via xmerl's fatal path; success yields the
256
    # root element. Byte list: xmerl sniffs the UTF-8 itself.
257
    {root, _state} = :xmerl_scan.string(:binary.bin_to_list(svg))
258
    assert elem(root, 0) == :xmlElement
259
    assert elem(root, 1) == :svg
260
  end
261
end
262
263
defmodule OpenAgentsWeb.OGLimiterTest do
264
  use ExUnit.Case, async: false
265
266
  alias OpenAgentsWeb.OG.Limiter
267
268
  setup do
269
    previous = Application.get_env(:openagents, :og_max_concurrent)
270
    Application.put_env(:openagents, :og_max_concurrent, 1)
271
272
    on_exit(fn ->
273
      if previous,
274
        do: Application.put_env(:openagents, :og_max_concurrent, previous),
275
        else: Application.delete_env(:openagents, :og_max_concurrent)
276
    end)
277
278
    :ok
279
  end
280
281
  test "acquire bounds concurrency and release frees the slot" do
282
    assert Limiter.acquire() == :ok
283
    assert Limiter.acquire() == :busy
284
285
    :ok = Limiter.release()
286
    assert Limiter.acquire() == :ok
287
    :ok = Limiter.release()
288
289
    # Release without acquire must not push the counter below zero.
290
    :ok = Limiter.release()
291
    assert Limiter.acquire() == :ok
292
    :ok = Limiter.release()
293
  end
294
end

This page updates live while a promote is in flight · changelog