neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Serve the path F-Droid actually requests
413e987 · Gabriel Morell · 2026-09-12 18:45
Message
{commit_body(@commit)}
Files changed
modified
docs/fdroid-registry.md
+15
−8
@@ -330,16 +330,23 @@ The existing `deb` job is the template, down to the verify step:
| 330 | 330 | Consuming it is one repo entry: |
| 331 | 331 | |
| 332 | 332 | ``` |
| 333 | −$FORGE_URL/pkg/fdroid/<owner>/_all?fingerprint=<sha256 of the repo cert> | |
| 333 | +$FORGE_URL/pkg/fdroid/<owner>/_all/repo?fingerprint=<sha256 of the repo cert> | |
| 334 | 334 | ``` |
| 335 | 335 | |
| 336 | −**Open question — confirm before building UI on it.** The `fingerprint` | |
| 337 | −query parameter is how `fdroidserver` hands out repo URLs and how the | |
| 338 | −QR path works, but neither `All_our_APIs` nor `Setup_an_F-Droid_App_Repo` | |
| 339 | −documents it (the latter is stale — it still describes `index.xml`). | |
| 340 | −Confirm against `fdroidserver` source. If it holds, the packages page | |
| 341 | −should emit a **QR** as well as the URL: F-Droid adds repos by scan, and | |
| 342 | −`EQRCode` is already a dependency. | |
| 336 | +**The trailing `/repo` is required.** F-Droid rewrites the address you | |
| 337 | +give it: `RepoUriGetter` appends `/fdroid/repo` to anything not already | |
| 338 | +ending in `/repo` or `/archive`. Without it the client fetches | |
| 339 | +`…/_all/fdroid/repo/entry.jar`, gets a 404, falls back to v1, gets | |
| 340 | +another 404, and reports **"invalid repository"** — while every URL you | |
| 341 | +try by hand returns 200. The adapter accepts both prefixes anyway, so a | |
| 342 | +device that stored the rewritten address recovers on its own. | |
| 343 | + | |
| 344 | +**Confirmed against the client source** (`RepoUriGetter.getUri`): the | |
| 345 | +`fingerprint` query parameter is read, lowercased, and stripped from | |
| 346 | +the stored address, and `fdroidrepos://` / `fdroid.link` forms are | |
| 347 | +normalised the same way. The packages page emits a QR alongside the | |
| 348 | +URL, since F-Droid's add-repository screen has a scanner and nobody | |
| 349 | +types 64 hex characters correctly. | |
| 343 | 350 | |
| 344 | 351 | ### Prerequisite: a release keystore |
| 345 | 352 |
modified
lib/git_gud/packages/formats/fdroid.ex
+36
−6
@@ -3,10 +3,12 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 3 | 3 | An F-Droid app repository, per git repository *and* per namespace. |
| 4 | 4 | |
| 5 | 5 | # everything neiam publishes — one repo entry on the phone |
| 6 | − https://<host>/pkg/fdroid/neiam/_all | |
| 6 | + https://<host>/pkg/fdroid/neiam/_all/repo | |
| 7 | 7 | |
| 8 | 8 | # or one repository on its own |
| 9 | − https://<host>/pkg/fdroid/neiam/gitgud | |
| 9 | + https://<host>/pkg/fdroid/neiam/gitgud/repo | |
| 10 | + | |
| 11 | + The trailing `/repo` is required, not stylistic — see `repo_url/1`. | |
| 10 | 12 | |
| 11 | 13 | Like the apt adapter, this is static documents over HTTP with one |
| 12 | 14 | signature on top. `_all` is the shape that matters: a phone should |
@@ -81,11 +83,39 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 81 | 83 | # ── reads ──────────────────────────────────────────────────────────── |
| 82 | 84 | |
| 83 | 85 | @impl true |
| 84 | − def index(repo, ctx), do: serve([repo], repo_label(repo), repo, ctx) | |
| 86 | + def index(repo, ctx), do: serve([repo], repo_label(repo), repo, strip_repo_prefix(ctx)) | |
| 85 | 87 | |
| 86 | 88 | @impl true |
| 87 | 89 | def namespace_index(namespace, repos, ctx), |
| 88 | − do: serve(repos, Storage.owner_handle(namespace), namespace, ctx) | |
| 90 | + do: serve(repos, Storage.owner_handle(namespace), namespace, strip_repo_prefix(ctx)) | |
| 91 | + | |
| 92 | + @doc """ | |
| 93 | + Drop the `repo/` or `fdroid/repo/` the client adds for itself. | |
| 94 | + | |
| 95 | + F-Droid rewrites the URL you give it. `RepoUriGetter` appends | |
| 96 | + `/fdroid/repo` to any address that doesn't already end in `/repo` or | |
| 97 | + `/archive` — so `…/pkg/fdroid/neiam/_all` is fetched as | |
| 98 | + `…/pkg/fdroid/neiam/_all/fdroid/repo/entry.jar`, which 404s, which | |
| 99 | + the client reports as "invalid repository". | |
| 100 | + | |
| 101 | + So `repo_url/1` advertises an address ending in `/repo`, which the | |
| 102 | + client leaves alone. This accepts both anyway: anyone who added the | |
| 103 | + bare URL before has the rewritten form stored as their repository | |
| 104 | + address and would otherwise never recover. | |
| 105 | + """ | |
| 106 | + def strip_repo_prefix(%{path: ["fdroid", "repo" | rest]} = ctx), do: %{ctx | path: rest} | |
| 107 | + def strip_repo_prefix(%{path: ["repo" | rest]} = ctx), do: %{ctx | path: rest} | |
| 108 | + def strip_repo_prefix(ctx), do: ctx | |
| 109 | + | |
| 110 | + @doc """ | |
| 111 | + The address to hand a client, which is `base_url` plus `/repo`. | |
| 112 | + | |
| 113 | + The suffix is not decoration. Without it F-Droid rewrites the address | |
| 114 | + and fetches a path that doesn't exist; with it, `pathSegments.last() | |
| 115 | + == "repo"` and the client uses what it was given. | |
| 116 | + """ | |
| 117 | + @spec repo_url(String.t()) :: String.t() | |
| 118 | + def repo_url(base_url), do: String.trim_trailing(base_url, "/") <> "/repo" | |
| 89 | 119 | |
| 90 | 120 | defp serve(repos, label, scope, ctx) |
| 91 | 121 |
@@ -162,7 +192,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 162 | 192 | "repo" => %{ |
| 163 | 193 | "name" => %{"en-US" => label}, |
| 164 | 194 | "description" => %{"en-US" => description(repos, label)}, |
| 165 | − "address" => ctx.base_url, | |
| 195 | + "address" => repo_url(ctx.base_url), | |
| 166 | 196 | "timestamp" => repo_timestamp(apks), |
| 167 | 197 | "icon" => %{}, |
| 168 | 198 | "categories" => %{}, |
@@ -230,7 +260,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 230 | 260 | "version" => @index_v1_version, |
| 231 | 261 | "name" => label, |
| 232 | 262 | "icon" => "", |
| 233 | − "address" => ctx.base_url, | |
| 263 | + "address" => repo_url(ctx.base_url), | |
| 234 | 264 | "description" => description(repos, label) |
| 235 | 265 | }, |
| 236 | 266 | # Present and empty. The client reads them unconditionally. |
modified
lib/git_gud/packages/usage.ex
+1
−1
@@ -413,7 +413,7 @@ defmodule GitGud.Packages.Usage do
| 413 | 413 | "…or add just this repository, with the same fingerprint", |
| 414 | 414 | "shell", |
| 415 | 415 | """ |
| 416 | − #{url}?fingerprint=$(curl -fsSL #{key_url(ctx, "fdroid")}) | |
| 416 | + #{url}/repo?fingerprint=$(curl -fsSL #{key_url(ctx, "fdroid")}) | |
| 417 | 417 | """ |
| 418 | 418 | ), |
| 419 | 419 | step( |
modified
lib/git_gud_web/live/repo_live/packages.ex
+5
−2
@@ -26,6 +26,7 @@ defmodule GitGudWeb.RepoLive.Packages do
| 26 | 26 | |
| 27 | 27 | alias GitGud.Packages |
| 28 | 28 | alias GitGud.Packages.DiskUsage |
| 29 | + alias GitGud.Packages.Formats.Fdroid | |
| 29 | 30 | alias GitGud.Packages.Retention |
| 30 | 31 | alias GitGud.Packages.Usage |
| 31 | 32 | alias GitGud.Repositories |
@@ -617,9 +618,11 @@ defmodule GitGudWeb.RepoLive.Packages do
| 617 | 618 | else |
| 618 | 619 | ctx = socket.assigns.usage_ctx |
| 619 | 620 | |
| 620 | − case GitGud.Packages.Formats.Fdroid.fingerprint(socket.assigns.repo) do | |
| 621 | + case Fdroid.fingerprint(socket.assigns.repo) do | |
| 621 | 622 | {:ok, fingerprint} -> |
| 622 | − url = "#{Usage.namespace_url(ctx, "fdroid")}?fingerprint=#{fingerprint}" | |
| 623 | + url = | |
| 624 | + "#{Fdroid.repo_url(Usage.namespace_url(ctx, "fdroid"))}?fingerprint=#{fingerprint}" | |
| 625 | + | |
| 623 | 626 | assign(socket, :fdroid_repo, %{url: url, fingerprint: fingerprint, qr: qr_svg(url)}) |
| 624 | 627 | |
| 625 | 628 | # A sealed or unreachable OpenBao, most likely. The rest of the |
modified
test/git_gud/packages/fdroid_format_test.exs
+41
−2
@@ -135,7 +135,7 @@ defmodule GitGud.Packages.Formats.FdroidTest do
| 135 | 135 | assert %{"repo" => r, "packages" => packages} = |
| 136 | 136 | decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))) |
| 137 | 137 | |
| 138 | − assert r["address"] == "https://forge.test/pkg/fdroid/acme/_all" | |
| 138 | + assert r["address"] == "https://forge.test/pkg/fdroid/acme/_all/repo" | |
| 139 | 139 | # Milliseconds. Seconds would read as 1970 and every client would |
| 140 | 140 | # treat the repository as permanently stale. |
| 141 | 141 | assert r["timestamp"] > 1_700_000_000_000 |
@@ -266,7 +266,7 @@ defmodule GitGud.Packages.Formats.FdroidTest do
| 266 | 266 | index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]}))) |
| 267 | 267 | |
| 268 | 268 | assert index["repo"]["version"] == 20_001 |
| 269 | − assert index["repo"]["address"] == "https://forge.test/pkg/fdroid/acme/_all" | |
| 269 | + assert index["repo"]["address"] == "https://forge.test/pkg/fdroid/acme/_all/repo" | |
| 270 | 270 | # Read unconditionally by the client even when empty. |
| 271 | 271 | assert index["requests"] == %{"install" => [], "uninstall" => []} |
| 272 | 272 |
@@ -308,6 +308,45 @@ defmodule GitGud.Packages.Formats.FdroidTest do
| 308 | 308 | end |
| 309 | 309 | end |
| 310 | 310 | |
| 311 | + describe "the /repo suffix F-Droid insists on" do | |
| 312 | + setup %{repo: repo} do | |
| 313 | + {:ok, _, _} = publish(repo, build_apk()) | |
| 314 | + :ok | |
| 315 | + end | |
| 316 | + | |
| 317 | + test "serves the paths the client actually requests", %{repo: repo} do | |
| 318 | + # RepoUriGetter appends /fdroid/repo to any address not already | |
| 319 | + # ending in /repo or /archive, so these are the real request | |
| 320 | + # paths — not a courtesy. | |
| 321 | + for path <- [["entry.jar"], ["repo", "entry.jar"], ["fdroid", "repo", "entry.jar"]] do | |
| 322 | + assert {:ok, "application/java-archive", _} = | |
| 323 | + Fdroid.index(repo, ctx(%{path: path})), | |
| 324 | + "#{Enum.join(path, "/")} should serve entry.jar" | |
| 325 | + end | |
| 326 | + end | |
| 327 | + | |
| 328 | + test "advertises an address the client won't rewrite", %{repo: repo} do | |
| 329 | + index = decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))) | |
| 330 | + | |
| 331 | + # Ends in /repo, so `pathSegments.last() == "repo"` and the | |
| 332 | + # client uses it as given instead of appending /fdroid/repo. | |
| 333 | + assert String.ends_with?(index["repo"]["address"], "/repo") | |
| 334 | + end | |
| 335 | + | |
| 336 | + test "index-v1 advertises the same address", %{repo: repo} do | |
| 337 | + index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]}))) | |
| 338 | + | |
| 339 | + assert String.ends_with?(index["repo"]["address"], "/repo") | |
| 340 | + end | |
| 341 | + | |
| 342 | + test "APKs are reachable under the prefix too", %{repo: repo} do | |
| 343 | + # The index points at /<file>.apk relative to an address that now | |
| 344 | + # ends in /repo, so this is the path the download uses. | |
| 345 | + assert {:blob, _} = | |
| 346 | + Fdroid.index(repo, ctx(%{path: ["repo", "org.neiam.gitgud.app_1.apk"]})) | |
| 347 | + end | |
| 348 | + end | |
| 349 | + | |
| 311 | 350 | describe "_all" do |
| 312 | 351 | test "aggregates every repository in the namespace", %{user: user, repo: repo} do |
| 313 | 352 | {:ok, other} = |
modified
test/git_gud_web/package_usage_test.exs
+1
−1
@@ -191,7 +191,7 @@ defmodule GitGudWeb.PackageUsageTest do
| 191 | 191 | |
| 192 | 192 | test "points at the namespace repository, not this one", %{html: html, handle: handle} do |
| 193 | 193 | # One entry per publisher, not one per Android project. |
| 194 | − assert html =~ "/pkg/fdroid/#{handle}/_all?fingerprint=" | |
| 194 | + assert html =~ "/pkg/fdroid/#{handle}/_all/repo?fingerprint=" | |
| 195 | 195 | end |
| 196 | 196 | |
| 197 | 197 | test "the code encodes the same URL it prints", %{html: html, user: user} do |
Parents: 4230fe5