Show an org what its registries actually serve

b82e0a0 · Gabriel Morell · 2026-09-13 00:55

11 files +317 -1
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages.ex
+68 −0
@@ -449,6 +449,74 @@ defmodule GitGud.Packages do
449 449 |> Repo.preload([:owner, :organization])
450 450 end
451 451
452 + @doc """
453 + What the namespace-wide registries would actually serve `principal`,
454 + grouped by format.
455 +
456 + This is the `_all` view, not a summary of what exists: it filters
457 + repositories through `Packages.Auth.authorize/3` exactly as
458 + `GitGudWeb.PackageRegistryController` does, so the page and the
459 + endpoint cannot drift into disagreeing. A dashboard that lists a
460 + package a consumer can't fetch is worse than no dashboard.
461 +
462 + Returns a map of format to:
463 +
464 + * `:packages` — `%{package:, repository:, versions:}`, sorted
465 + * `:repositories` — the repositories contributing to that format
466 +
467 + Formats with nothing published are absent rather than empty, so the
468 + page shows what is there rather than a column of zeroes.
469 + """
470 + @spec namespace_catalog(struct(), term()) :: %{String.t() => map()}
471 + def namespace_catalog(namespace, principal) do
472 + repos =
473 + namespace
474 + |> namespace_repositories()
475 + |> Enum.filter(&GitGud.Packages.Auth.authorize(principal, &1, :read))
476 +
477 + by_id = Map.new(repos, &{&1.id, &1})
478 + ids = Map.keys(by_id)
479 +
480 + if ids == [] do
481 + %{}
482 + else
483 + # One query rather than repositories × formats. An org with
484 + # thirty repos and five formats would otherwise be 150 of them to
485 + # render one page.
486 + counts =
487 + from(v in PackageVersion,
488 + group_by: v.package_id,
489 + select: {v.package_id, count(v.id)}
490 + )
491 + |> Repo.all()
492 + |> Map.new()
493 +
494 + from(p in Package, where: p.repository_id in ^ids, order_by: [asc: p.name])
495 + |> Repo.all()
496 + |> Enum.group_by(& &1.format)
497 + |> Map.new(fn {format, packages} ->
498 + entries =
499 + Enum.map(packages, fn package ->
500 + %{
501 + package: package,
502 + repository: Map.fetch!(by_id, package.repository_id),
503 + versions: Map.get(counts, package.id, 0)
504 + }
505 + end)
506 +
507 + {format,
508 + %{
509 + packages: entries,
510 + repositories:
511 + entries
512 + |> Enum.map(& &1.repository)
513 + |> Enum.uniq_by(& &1.id)
514 + |> Enum.sort_by(& &1.name)
515 + }}
516 + end)
517 + end
518 + end
519 +
452 520 defp generate_signing_key(owner, format) do
453 521 {:ok, public_pem, private_pem} = generate_keypair(owner, format)
454 522 {:ok, wrapped} = GitGud.Secrets.encrypt(private_pem)
modified lib/git_gud_web/live/org_live/packages.ex
+88 −1
@@ -1,6 +1,11 @@
1 1 defmodule GitGudWeb.OrgLive.Packages do
2 2 @moduledoc """
3 − What an organization's registries are costing it, and where.
3 + What an organization publishes, and what its registries cost.
4 +
5 + Two questions that belong on one page. "What would someone get if
6 + they pointed a package manager at us" is answered from the same
7 + visibility filter the `_all` endpoints use, so the listing can't
8 + promise something a consumer then can't fetch.
4 9
5 10 The same numbers as the admin view, narrowed to one org and visible
6 11 to its members rather than only to instance admins — an org that
@@ -15,7 +20,10 @@ defmodule GitGudWeb.OrgLive.Packages do
15 20 use GitGudWeb, :live_view
16 21
17 22 alias GitGud.Organizations
23 + alias GitGud.Packages
18 24 alias GitGud.Packages.DiskUsage
25 + alias GitGud.Packages.Format
26 + alias GitGud.Packages.Usage
19 27 alias GitGud.Packages.Pruner
20 28 alias GitGud.Repositories
21 29
@@ -49,9 +57,32 @@ defmodule GitGudWeb.OrgLive.Packages do
49 57 |> assign(:by_format, merge_rows(repo_ids, &DiskUsage.by_format/1))
50 58 |> assign(:by_repository, merge_rows(repo_ids, &DiskUsage.by_repository/1))
51 59 |> assign(:top_packages, top_packages(repo_ids))
60 + |> assign(:catalog, Packages.namespace_catalog(org, principal(user)))
61 + |> assign(:registry_root, registry_root())
52 62 |> assign_async(:plan, fn -> {:ok, %{plan: plan_for(repo_ids)}} end)
53 63 end
54 64
65 + # The same principal shape the registry controller authorizes with, so
66 + # this page and the `_all` endpoints filter identically. Anything else
67 + # risks listing a package a visitor then can't fetch.
68 + defp principal(nil), do: :anonymous
69 + defp principal(user), do: {:user, user}
70 +
71 + defp registry_root do
72 + (Application.get_env(:git_gud, :package_registry_url) || GitGudWeb.Endpoint.url())
73 + |> String.trim_trailing("/")
74 + end
75 +
76 + defp namespace_url(root, handle, format),
77 + do: "#{root}/pkg/#{format}/#{handle}/#{Format.namespace_marker()}"
78 +
79 + # F-Droid's address has to end in /repo or the client rewrites it —
80 + # see GitGud.Packages.Formats.Fdroid.repo_url/1.
81 + defp consumer_url(root, handle, "fdroid"),
82 + do: GitGud.Packages.Formats.Fdroid.repo_url(namespace_url(root, handle, "fdroid"))
83 +
84 + defp consumer_url(root, handle, format), do: namespace_url(root, handle, format)
85 +
55 86 # `DiskUsage` scopes by one repository at a time; an org is a list of
56 87 # them, so the rows are summed here rather than teaching the query a
57 88 # shape only this page needs.
@@ -115,6 +146,62 @@ defmodule GitGudWeb.OrgLive.Packages do
115 146 </p>
116 147 </div>
117 148
149 + <section class="space-y-3">
150 + <div>
151 + <h2 class="font-semibold">What consumers see</h2>
152 + <p class="text-xs opacity-70">
153 + Each registry's namespace-wide view — one URL per format, covering every
154 + repository @{@org.handle} publishes. Filtered to what <strong>you</strong>
155 + can read, so someone with less access sees less than this.
156 + </p>
157 + </div>
158 +
159 + <p :if={@catalog == %{}} class="text-sm opacity-60">
160 + Nothing published yet. The per-repository packages pages have the
161 + instructions for each format.
162 + </p>
163 +
164 + <div
165 + :for={{format, entry} <- Enum.sort_by(@catalog, &elem(&1, 0))}
166 + class="border border-base-300 rounded-lg overflow-hidden"
167 + >
168 + <div class="px-3 py-2 bg-base-200 space-y-1">
169 + <div class="flex flex-wrap items-baseline gap-2">
170 + <span class="badge badge-sm badge-ghost">{format}</span>
171 + <span class="text-xs opacity-70">
172 + {length(entry.packages)} packages · {Enum.count(entry.repositories)} repositories
173 + </span>
174 + </div>
175 + <p :if={Usage.summary(format)} class="text-xs opacity-60">{Usage.summary(format)}</p>
176 + <pre class="text-[11px] bg-base-100 rounded px-2 py-1 overflow-x-auto select-all"><code>{consumer_url(@registry_root, @org.handle, format)}</code></pre>
177 + </div>
178 +
179 + <table class="table table-sm">
180 + <thead>
181 + <tr>
182 + <th>Package</th>
183 + <th>From</th>
184 + <th class="text-right">Versions</th>
185 + </tr>
186 + </thead>
187 + <tbody>
188 + <tr :for={row <- entry.packages}>
189 + <td class="font-mono text-xs">{row.package.name}</td>
190 + <td class="text-xs opacity-70">
191 + <.link
192 + navigate={~p"/r/#{@org.handle}/#{row.repository.name}/packages"}
193 + class="link link-hover"
194 + >
195 + {row.repository.name}
196 + </.link>
197 + </td>
198 + <td class="text-right text-xs tabular-nums">{row.versions}</td>
199 + </tr>
200 + </tbody>
201 + </table>
202 + </div>
203 + </section>
204 +
118 205 <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
119 206 <div :for={row <- @by_format} class="border border-base-300 rounded-lg px-3 py-2">
120 207 <div class="text-xs opacity-60 uppercase tracking-wide">{row.label}</div>
added test/git_gud_web/live/org_packages_catalog_test.exs
+161 −0
@@ -0,0 +1,161 @@
1 +defmodule GitGudWeb.OrgPackagesCatalogTest do
2 + @moduledoc """
3 + The org packages page's "what consumers see" view.
4 +
5 + The property worth defending is that it agrees with the `_all`
6 + endpoints. A dashboard that lists a package a visitor then can't
7 + fetch is worse than no dashboard, so the filtering here goes through
8 + the same `Packages.Auth.authorize/3` the registry controller uses and
9 + the tests check both ends rather than only the page.
10 + """
11 + use GitGudWeb.ConnCase, async: false
12 +
13 + import GitGud.AccountsFixtures
14 + import Phoenix.LiveViewTest
15 +
16 + alias GitGud.Organizations
17 + alias GitGud.Packages
18 +
19 + setup do
20 + owner = user_fixture()
21 + # Unique per run: a fixed handle collides with the on-disk repo
22 + # directories an earlier run left behind.
23 + handle = "acme#{System.unique_integer([:positive])}"
24 + {:ok, org} = Organizations.create_organization(owner, %{"handle" => handle, "name" => "Acme"})
25 +
26 + {:ok, public} =
27 + GitGud.Repositories.create_repository_for_org(org, owner, %{
28 + "name" => "tools",
29 + "visibility" => "public"
30 + })
31 +
32 + {:ok, private} =
33 + GitGud.Repositories.create_repository_for_org(org, owner, %{
34 + "name" => "secrets",
35 + "visibility" => "private"
36 + })
37 +
38 + publish(public, "deb", "public-tool")
39 + publish(private, "deb", "private-tool")
40 + publish(public, "pypi", "widget")
41 +
42 + %{owner: owner, org: org, handle: handle, public: public, private: private}
43 + end
44 +
45 + defp publish(repo, format, name) do
46 + {:ok, _p, _v, _f} =
47 + Packages.publish_file(repo, format, name, "1.0.0", %{
48 + filename: "#{name}-1.0.0.bin",
49 + body: "contents of #{name}",
50 + content_type: "application/octet-stream",
51 + metadata: %{},
52 + version_metadata: %{}
53 + })
54 + end
55 +
56 + describe "the catalog" do
57 + test "groups by format and counts versions", %{org: org, owner: owner} do
58 + catalog = Packages.namespace_catalog(org, {:user, owner})
59 +
60 + assert Map.keys(catalog) |> Enum.sort() == ["deb", "pypi"]
61 +
62 + names = catalog["deb"].packages |> Enum.map(& &1.package.name) |> Enum.sort()
63 + assert names == ["private-tool", "public-tool"]
64 +
65 + assert [%{versions: 1}] = catalog["pypi"].packages
66 + end
67 +
68 + test "names the repositories a format comes from", %{org: org, owner: owner} do
69 + catalog = Packages.namespace_catalog(org, {:user, owner})
70 +
71 + assert catalog["deb"].repositories |> Enum.map(& &1.name) |> Enum.sort() ==
72 + ["secrets", "tools"]
73 + end
74 +
75 + test "formats with nothing published are absent, not empty", %{org: org, owner: owner} do
76 + catalog = Packages.namespace_catalog(org, {:user, owner})
77 +
78 + # A column of zeroes for every format the forge can serve would be
79 + # noise rather than information.
80 + refute Map.has_key?(catalog, "cargo")
81 + refute Map.has_key?(catalog, "fdroid")
82 + end
83 + end
84 +
85 + describe "visibility matches the _all endpoints" do
86 + test "an anonymous viewer sees only what the endpoint would serve", %{org: org} do
87 + catalog = Packages.namespace_catalog(org, :anonymous)
88 +
89 + assert catalog["deb"].packages |> Enum.map(& &1.package.name) == ["public-tool"]
90 + refute catalog["deb"].repositories |> Enum.any?(&(&1.name == "secrets"))
91 + end
92 +
93 + test "a stranger sees the same as anonymous", %{org: org} do
94 + stranger = user_fixture()
95 + catalog = Packages.namespace_catalog(org, {:user, stranger})
96 +
97 + assert catalog["deb"].packages |> Enum.map(& &1.package.name) == ["public-tool"]
98 + end
99 +
100 + test "an org member sees the private repository too", %{org: org, owner: owner} do
101 + catalog = Packages.namespace_catalog(org, {:user, owner})
102 +
103 + assert "private-tool" in Enum.map(catalog["deb"].packages, & &1.package.name)
104 + end
105 + end
106 +
107 + describe "the page" do
108 + test "lists what an owner would get, with the URL to point at", %{
109 + conn: conn,
110 + owner: owner,
111 + org: org
112 + } do
113 + conn = log_in_user(conn, owner)
114 + {:ok, _view, html} = live(conn, ~p"/orgs/#{org.handle}/packages")
115 +
116 + assert html =~ "What consumers see"
117 + assert html =~ "/pkg/deb/#{org.handle}/_all"
118 + assert html =~ "public-tool"
119 + assert html =~ "private-tool"
120 + end
121 +
122 + test "hides the private package from someone who can't read it", %{conn: conn, org: org} do
123 + stranger = user_fixture()
124 + {:ok, _view, html} = live(log_in_user(conn, stranger), ~p"/orgs/#{org.handle}/packages")
125 +
126 + assert html =~ "public-tool"
127 + refute html =~ "private-tool"
128 + end
129 +
130 + test "says whose view it is", %{conn: conn, owner: owner, org: org} do
131 + {:ok, _view, html} = live(log_in_user(conn, owner), ~p"/orgs/#{org.handle}/packages")
132 +
133 + # Without this the listing reads as "what exists", and someone
134 + # sharing a screenshot promises access they can't grant.
135 + assert html =~ "someone with less access sees less"
136 + end
137 + end
138 +
139 + describe "the F-Droid URL" do
140 + test "ends in /repo, which the client requires", %{
141 + conn: conn,
142 + owner: owner,
143 + org: org,
144 + public: public
145 + } do
146 + Packages.publish_file(public, "fdroid", "org.example.app", "1", %{
147 + filename: "org.example.app_1.apk",
148 + body: "not a real apk",
149 + content_type: "application/vnd.android.package-archive",
150 + metadata: %{"package" => "org.example.app", "version_code" => 1},
151 + version_metadata: %{}
152 + })
153 +
154 + {:ok, _view, html} = live(log_in_user(conn, owner), ~p"/orgs/#{org.handle}/packages")
155 +
156 + # F-Droid rewrites an address that doesn't end in /repo, so
157 + # printing the bare one here would hand out a URL that 404s.
158 + assert html =~ "/pkg/fdroid/#{org.handle}/_all/repo"
159 + end
160 + end
161 +end

Parents: 709545e