Serve the path F-Droid actually requests

413e987 · Gabriel Morell · 2026-09-12 18:45

18 files +99 -20
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