neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Show an org what its registries actually serve
b82e0a0 · Gabriel Morell · 2026-09-13 00:55
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