https://github.com/dim145/fdroid-store
A self-hosted F-Droid repo with a modern app-store UI, public + private apps, invite-only signups and SSO. Hardened, rootless images on GHCR.
https://github.com/dim145/fdroid-store
android-store docker f-droid f-droidrepos fastapi fdroid fdroid-repo material-design nextjs self-hosted store
Last synced: about 17 hours ago
JSON representation
A self-hosted F-Droid repo with a modern app-store UI, public + private apps, invite-only signups and SSO. Hardened, rootless images on GHCR.
- Host: GitHub
- URL: https://github.com/dim145/fdroid-store
- Owner: Dim145
- License: mit
- Created: 2026-05-17T23:58:49.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-20T01:10:13.000Z (16 days ago)
- Last Synced: 2026-06-20T03:12:28.587Z (16 days ago)
- Topics: android-store, docker, f-droid, f-droidrepos, fastapi, fdroid, fdroid-repo, material-design, nextjs, self-hosted, store
- Language: TypeScript
- Homepage: https://dim145.github.io/fdroid-store/
- Size: 7.84 MB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# fdroid-store
A self-hosted [F-Droid](https://f-droid.org) repository with a modern admin
panel and a client area for users. Designed to be a simpler, opinionated
alternative to [Repomaker](https://gitlab.com/fdroid/repomaker).
- π’ **Compatible with the F-Droid client** β speaks the standard
`index-v1.jar` + `index-v2.json` protocol (both built side-by-side).
- π’ **Two-zone frontend** β a *client area* (account, history, API keys,
TOTP, manage your own apps) and an *admin area* (moderate, configure
repo, jobs, audit log, scans, stats).
- π’ **Filesystem _or_ S3 storage** β switchable via a single env var.
- π’ **Local auth, OIDC, _and_ TOTP MFA** β Keycloak / Authentik / Google /
GitLab / whichever OpenID Connect provider you prefer; optional
second-factor TOTP per account.
- π’ **Hybrid access** β apps can be `public` (in the regular repo URL) or
`private` (only available to users who pass an API key as HTTP Basic
auth, which the F-Droid Android client supports out of the box).
- π’ **CI publishing** β per-app deploy tokens (`fdci_β¦`) for CI runners
to push new APKs without handing over a full account credential.
- π’ **Multi-forge auto-ingest** β attach a **GitHub, GitLab, or
Gitea/Forgejo** (self-hosted) repository to an app and the worker
fetches new release APKs automatically. Per-source PAT stored
encrypted at rest (Fernet).
- π’ **Retention cap** β repo-wide default + admin per-app override
(tighten-only). FIFO eviction by `versionCode`, never touches the
suggested version.
- π’ **Setup wizard** β generate or import your repo signing keystore
from the UI on first run.
- π’ **Optional ClamAV** β opt-in compose profile streams every upload
through clamd; manual + scheduled rescans.
- π’ **i18n** β English + French ship in-tree.
## Stack
| Layer | Tech |
|---|---|
| Backend | Python 3.13 Β· FastAPI Β· SQLAlchemy 2 (async) Β· Alembic Β· PostgreSQL 16 Β· Redis 7 Β· arq |
| Auth / crypto | pwdlib (argon2 + legacy bcrypt) Β· PyJWT Β· Authlib (OIDC) Β· pyotp (TOTP) Β· Fernet (PAT at rest) |
| F-Droid tooling | androguard (manifest parsing) Β· apksigner (cert SHA-256) Β· jarsigner / keytool (repo signing) |
| Frontend | Next.js 16 (App Router, static export) Β· React 19 Β· TypeScript Β· Tailwind 4 Β· Radix UI Β· Zustand Β· i18next |
| Storage | Local FS or any S3-compatible object store (MinIO, Wasabi, Backblaze, β¦) |
| Hardening | slowapi rate limits Β· nginx-emitted CSP/HSTS Β· SSRF guards on forge fetches Β· read-only containers |
| Deploy | Docker Compose (single-host) β easily portable to k8s |
## Quick start
```bash
git clone fdroid-store
cd fdroid-store
cp .env.example .env
# Edit .env β at minimum set SECRET_KEY and INITIAL_ADMIN_PASSWORD
# Pulls prebuilt images from ghcr.io/dim145/*
docker compose up -d
# To enable on-upload ClamAV scanning, opt in via the profile:
docker compose --profile clamav up -d
```
Once everything is up:
| Component | URL | Notes |
|---|---|---|
| Frontend (admin + client UI) | http://localhost:3000 | static SPA served by nginx |
| Backend API (Swagger in dev) | http://localhost:8000/api/docs | disabled when `ENVIRONMENT=production` |
| F-Droid repo (point F-Droid client here) | http://localhost:8080/fdroid/repo | public variant by default |
Sign in with the initial admin credentials from `.env`, then:
1. **`/admin/setup`** β Run the setup wizard. Pick *Generate* to create a
fresh keystore, or *Import* to upload an existing `.p12` / `.jks`.
2. **`/admin/apps`** β Create / publish apps and upload APKs (or attach
a GitHub / GitLab / Gitea source for auto-ingest).
3. **`/admin/repo`** β Trigger the first reindex.
4. Add the F-Droid URL above to the F-Droid app on your phone.
## Configuration cheatsheet
All settings live in `.env` (see `.env.example` for the full annotated list).
The most important ones:
```bash
# --- secrets -----------------------------------------------------------------
SECRET_KEY=
INITIAL_ADMIN_EMAIL=you@example.com
INITIAL_ADMIN_PASSWORD=change-me-now
# --- public URLs (the F-Droid client + browser hit these) --------------------
PUBLIC_REPO_URL=https://apks.your-domain.tld/fdroid/repo
PUBLIC_APP_URL=https://apks.your-domain.tld
PUBLIC_API_URL=https://apks.your-domain.tld
# --- storage backend ---------------------------------------------------------
STORAGE_BACKEND=local # or "s3"
LOCAL_STORAGE_PATH=/data/storage
# S3 / MinIO / etc.
S3_ENDPOINT_URL=https://s3.eu-west-3.amazonaws.com
S3_BUCKET=my-fdroid-bucket
S3_ACCESS_KEY=AKIAβ¦
S3_SECRET_KEY=β¦
# --- auth --------------------------------------------------------------------
AUTH_METHODS=local,oidc # or just "local" / just "oidc"
ALLOW_SIGNUP=true
OIDC_ISSUER=https://auth.example.com/realms/myrealm
OIDC_CLIENT_ID=fdroid-store
OIDC_CLIENT_SECRET=β¦
OIDC_ADMIN_CLAIM=groups=fdroid-admins # optional: auto-promote on group match
# Reject callbacks whose ``email_verified`` claim is False or missing.
# Default true (anti-takeover). Set to false ONLY for IdPs that don't
# emit the claim (Defguard, some Keycloak realms).
OIDC_REQUIRE_EMAIL_VERIFIED=true
# --- optional: ClamAV (only used when the ``clamav`` profile is up) ----------
CLAMAV_HOST=clamav
CLAMAV_PORT=3310
CLAMAV_MAX_STREAM_MB=100
# --- optional: Trivy CVE/SBOM (only used when the ``trivy`` profile is up) ---
# Empty / unset β CVE/SBOM feature disabled, worker short-circuits to SKIPPED.
TRIVY_SERVER_URL=http://trivy:4954
# --- backup worker scratch dir ----------------------------------------------
# Bind a persistent volume here (the stock compose mounts ``backup_tmp``).
# The default ``/tmp`` tmpfs is 512 MB and too small for real-sized repos.
BACKUP_TMP_DIR=/data/backup-tmp
# --- optional: forge tokens (per-source PATs override these in the UI) -------
GITHUB_TOKEN=ghp_β¦
GITLAB_TOKEN=glpat-β¦
GITEA_TOKEN=β¦
```
## How the F-Droid index works here
The worker maintains **two** index variants under the storage prefix `repo/`:
- `repo/public/` β only **PUBLIC + PUBLISHED** apps.
- `repo/private/` β same as public **plus** PRIVATE apps.
The `/fdroid/repo/` HTTP endpoint serves whichever variant matches the
caller's credentials:
| Caller | Auth header sent | Index served |
|---|---|---|
| Anonymous F-Droid client | none | public |
| Logged-in F-Droid client (Basic auth, password = API key) | `Authorization: Basic ` | private |
The Android F-Droid app supports the `https://anyuser:@host/...`
URL form β that is the supported way to access private apps.
**Downloads always stream through the backend**, regardless of storage
backend. The earlier "302 to S3" shortcut bypassed audit, access checks
and rate limits, and broke against S3 backends that refuse anonymous
reads (Garage, private MinIO). `S3_PUBLIC_BASE_URL` is kept in the
schema for backward compatibility but no longer affects the download
path. Browser caches get sensible `Cache-Control` headers:
`no-cache` for index files, `max-age=1d` for icons/screenshots,
`immutable` for APKs (versionCode is in the filename).
## Auth modes
| Mode | Credential | Use case |
|---|---|---|
| Local password | argon2 (legacy bcrypt rows still verify) | normal browser sessions |
| OIDC | external IdP via Authlib | SSO with Keycloak, Google, etc. |
| TOTP | RFC 6238 (`pyotp`), QR enrolment under `/account` | optional second factor for local logins |
| WebAuthn / passkey | FIDO2 credential bound to the relying-party `PUBLIC_APP_URL` | passwordless sign-in, or MFA on top of a password. Per-role force toggles on `/admin/access`. |
| Personal API key | `fdr__` | F-Droid Basic-auth on private repo, CI scripts you own |
| Per-app deploy token | `fdci__` | CI runners that only need APK upload on a single app |
| Signed download URL | itsdangerous-signed, time-bounded | sharing a single private APK without an account |
Refresh tokens rotate on use (`jti` persisted) and can be revoked from
**Account β Sessions**.
## Publishing from CI
Each app has a **CI Publication** panel under
`/my-apps/{id}` that lets the owner (or co-maintainer) mint a deploy
token. The reveal modal hands you ready-to-paste snippets for `curl`,
GitHub Actions, and GitLab CI; an always-available *How to publish*
button shows the API spec (URL, method, auth header, body) with a
`` placeholder for cold reference.
```bash
curl -X POST "$REPO_URL/api/v1/apks/upload/$APP_ID" \
-H "Authorization: Bearer $FDROID_DEPLOY_TOKEN" \
-F "file=@build/outputs/apk/release/app-release.apk"
```
Same endpoint accepts a personal API key β the deploy token just
narrows the blast radius if it leaks.
## Auto-ingest from a forge
Attach a release source to an app under
`/my-apps/{id}` β **GitHub / GitLab / Gitea source**:
- Provider: GitHub, GitLab, Gitea/Forgejo (self-hosted `base_url`
supported).
- Asset pattern: glob applied to the release asset name
(e.g. `*-universal.apk`).
- Per-source PAT: optional, stored Fernet-encrypted (key derived from
`SECRET_KEY`). The env-wide tokens above are the fallback.
The worker polls each enabled source on a cron, fetches new releases
respecting the pattern, runs the same pipeline as a manual upload
(inspect β store β optional ClamAV scan β eviction β reindex), and
audits every state transition.
SSRF guards: hostnames resolve through a blocked-CIDR allow-list,
redirects are walked manually with the same checks, userinfo is
stripped from `base_url`, and `..`/`.` segments are rejected.
## Project structure
```
fdroid-store/
βββ backend/ # FastAPI app + worker (arq) + Alembic
β βββ app/
β β βββ api/v1/ # auth, me, totp, api_keys, deploy_tokens,
β β β # apps, apks, media, github_sources, collaborators,
β β β # categories, users, feeds, admin, setup, health
β β βββ core/ # config, db, security, logging
β β βββ fdroid/ # APK parser, keystore, index v1/v2, signing
β β βββ models/ # SQLAlchemy ORM
β β βββ schemas/ # Pydantic IO models
β β βββ services/ # auth, oidc, totp, bootstrap, github_releases,
β β β # apk_eviction, clamav, crypto, audit, β¦
β β βββ storage/ # FS / S3 abstraction
β β βββ workers/ # arq tasks: rebuild_index, scan_apks_periodic,
β β # scan_github_sources_periodic, fetch_github_source
β βββ alembic/ # migrations
β βββ Dockerfile
β βββ pyproject.toml
βββ frontend/ # Next.js 16, static-exported, nginx-served
β βββ app/
β β βββ (client)/ # /apps, /account, /history, /my-apps, /u/[username]
β β βββ admin/ # /admin/{apps,users,repo,jobs,audit,scans,
β β β # categories,setup,access}
β β βββ login/, signup/, auth/oidc-success/
β βββ components/
β βββ lib/
β βββ locales/ # en.json, fr.json
β βββ nginx.conf.template # serves SPA + reverse-proxies /api + /fdroid
βββ docker-compose.yml
βββ .env.example
```
## API summary
REST API at `http:///api/v1/`. Swagger docs at `/api/docs`
when `ENVIRONMENT=development`. Selected endpoints:
```
# auth (rate-limited)
POST /api/v1/auth/login # local login (5/min)
POST /api/v1/auth/login/mfa # follow-up if TOTP enrolled
POST /api/v1/auth/signup # (5/min, invite-code aware)
POST /api/v1/auth/refresh # rotates refresh jti
POST /api/v1/auth/logout
GET /api/v1/auth/methods # tells the UI what flows are on
GET /api/v1/auth/oidc/login # β IdP redirect (when OIDC enabled)
# me
GET /api/v1/me # who am I
POST /api/v1/me/change-password
GET /api/v1/me/downloads # my download history
GET /api/v1/me/apps # apps I own
GET /api/v1/me/sessions / DELETE # active refresh-token sessions
GET /api/v1/me/quotas
GET /api/v1/me/api-keys / POST / DELETE
GET /api/v1/me/totp/status
POST /api/v1/me/totp/{setup,confirm,disable}
POST /api/v1/me/export # GDPR JSON dump of everything tied to me
GET /api/v1/me/webauthn-credentials # passkey list
DELETE /api/v1/me/webauthn-credentials/{id}
# webauthn (passkeys)
POST /api/v1/webauthn/register/{start,finish}
POST /api/v1/webauthn/login/{start,finish}
# apps + apks
GET /api/v1/apps # browse public + my private
POST /api/v1/apps # create app (mine)
POST /api/v1/apps/with-apk # create + first APK in one shot
POST /api/v1/apps/with-github-source # create from a forge URL
POST /api/v1/apps/import-metadata # paste fdroiddata YAML
GET /api/v1/apps/{id|package_name}
GET /api/v1/apps/{id}/metadata.yml # F-Droid binary-only YAML export
POST /api/v1/apks/upload/{app_id} # bearer JWT, API key, or deploy token
POST /api/v1/apks/{id}/download-url # signed URL for private APK
POST /api/v1/apks/{id}/reproducibility # set status / hash / notes
POST /api/v1/apks/{id}/reproducibility/verify-from-url # fetch + auto-decide
GET /api/v1/apks/{id}/sbom # CVE / SBOM detail (private)
POST /api/v1/apks/{id}/sbom/rescan
# per-app config
GET/POST/DELETE /api/v1/apps/{id}/deploy-tokens
GET/PUT/DELETE /api/v1/apps/{id}/github-source
POST /api/v1/apps/{id}/github-source/scan
GET/POST/DELETE /api/v1/apps/{id}/collaborators
POST/DELETE /api/v1/apps/{id}/{icon,feature-graphic,promo-graphic,tv-banner,screenshots}
# feeds
GET /api/v1/feed/new # Atom / RSS β every new app
GET /api/v1/feed/updates # Atom / RSS β every new APK version
GET /api/v1/feed/apps/{package_name} # Atom / RSS β per-app release feed
# stats (visibility: public / admin per ``public_stats`` toggle)
GET /api/v1/stats
# setup
GET /api/v1/setup/status # has setup been done?
POST /api/v1/setup/wizard # generate/import keystore
# admin only
GET/POST/PATCH/DELETE /api/v1/admin/users
GET/PATCH /api/v1/admin/apps
POST /api/v1/admin/apks/{id}/publish (or /reject)
GET/PATCH /api/v1/admin/repo # ClamAV + Trivy + RB toggles live here
POST /api/v1/admin/repo/reindex
GET /api/v1/admin/jobs # arq run history
GET /api/v1/admin/audit # audit log
GET /api/v1/admin/scans # ClamAV results (UI: /admin/scanning)
POST /api/v1/admin/apks/rescan
GET /api/v1/admin/stats
GET /api/v1/admin/clamav/ping
# admin Β· encrypted backup + restore
POST /api/v1/admin/backup/jobs # start a job (components + passphrase)
GET /api/v1/admin/backup/jobs # history
GET /api/v1/admin/backup/jobs/{id}/download # encrypted .tar.enc
POST /api/v1/admin/backup/restore # multipart: archive + passphrase
```
## Development
```bash
# Backend
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
uvicorn app.main:app --reload
# Worker
python -m arq app.workers.tasks.WorkerSettings
# Frontend
cd frontend
npm install
npm run dev
```
The backend will create the schema automatically on first boot. For
production, prefer `alembic revision --autogenerate -m "init"` followed by
`alembic upgrade head`.
## Migrating from Repomaker
`fdroid-store` is *not* a drop-in replacement: it does not run the F-Droid
build pipeline (it hosts pre-built APKs). If you need source-to-APK builds,
keep using `fdroidserver` to produce APKs and import them here via
`/api/v1/apks/upload/{app_id}` from CI β or attach a GitHub / GitLab /
Gitea release source if your builds already publish APK assets to a
forge release.
The paste-metadata endpoint (`POST /api/v1/apps/import-metadata`) takes
an upstream `fdroiddata`-style YAML blob so you can seed an app's
fields (name, description, links, categories) without re-typing.
## Security notes
- **Repo signing keystore** lives in a Docker volume; treat it like a
TLS key.
- **Passwords** are argon2-hashed via pwdlib. Legacy bcrypt rows still
verify (with a UTF-8-safe 72-byte truncation for the original passlib
behaviour); new hashes use argon2.
- **API keys / deploy tokens** carry a short public prefix; only the
prefix is stored alongside `sha256(secret)`. The plaintext secret is
256 bits from `secrets.token_urlsafe(32)` and is shown exactly once
on creation.
- **Refresh tokens** carry a `jti` mapped to a persisted row, are
marked consumed on use (rotation), and are revocable from the
sessions UI.
- **Per-source forge PATs** are encrypted at rest with Fernet (key
derived from `SECRET_KEY` via SHA-256). They are never returned by
the API; the read schema only reports a `has_access_token` boolean.
- **Encrypted backup archives** use AES-256-CBC + HMAC-SHA256 with a
key derived from an operator-supplied passphrase (PBKDF2 over a
per-archive random salt). The passphrase is never persisted; lose
it and the archive is unrecoverable.
- **WebAuthn / passkeys** are bound to the `PUBLIC_APP_URL`
relying-party ID. Only the credential's public key is stored; the
authenticator never leaks anything secret over the wire. Per-role
force toggles (`/admin/access`) gate sign-in on enrolment.
- **CVE / SBOM data** is private β owner / collaborator / admin only.
The Trivy server is reached over the compose network and never
exposed publicly (no auth on its API).
- **Reproducibility verification fetches** go through the same SSRF
guard as the forge-release fetcher: DNS allow-list, RFC 1918 +
metadata-IP rejection, redirects refused, HTTPS only.
- **Audit log** records actor + target + summary on every privileged
action (upload, publish, reject, token mint/revoke, source upsert,
admin role changes). Plaintext credentials are never logged β only
prefixes / state transitions.
- **SSRF defenses** on forge fetches: DNS-resolved IP allow-list,
blocked metadata services, scheme allow-list, manual redirect
walking, userinfo stripping, `..`/`.` segment rejection.
- **Rate limits** (slowapi): login/signup/MFA `5/min`, refresh
`10/min`, OIDC `20/min`, app list `10/min`, source upsert `10/min`,
scan-now `20/min`, APK inspect `10/min`.
- **CSP, HSTS, X-Frame-Options, X-Content-Type-Options** are emitted by
the frontend nginx layer; HSTS is opt-in via `ENABLE_HSTS=1`.
- **Containers** run `read_only` with `no-new-privileges` and all caps
dropped. The first-run bootstrap is guarded by a Postgres advisory
lock so concurrent boots can't double-create the admin.
- **OIDC** state lives in a SESSION cookie signed by `SECRET_KEY`;
rotate the key if compromised (this invalidates all JWTs too).
## Changelog
Notable changes between 1.0.0 and 1.4.5 β pure bug fixes are omitted,
this is the operator-relevant summary.
### 1.4.5
- **Staged APK uploads work again.** The path-injection allowlist that
validates the internal temp-file name in `parse_apk` only permitted
`[A-Za-z0-9_]`. The staged-upload path names its temp file with a
`fdroid-staged-` prefix, so the hyphens were rejected and every upload
that redeemed a staging token β the inspect-then-upload flow the web UI
uses, plus creating an app from a staged APK β failed with
`400 "APK basename must be a tempfile-style filename"`. (Direct uploads
were unaffected: they use the default `tmp` prefix.) The allowlist now
also accepts `-`, which stays traversal-safe since `.` and `/` remain
excluded.
### 1.4.4
- **Large-APK downloads no longer truncate.** Big APKs (100s of MB) were
streamed through Python and proxy-buffered, so a slow client could be
cut mid-transfer (the browser shows `NS_BINDING_ABORTED` / a truncated
file) when a proxy in the chain buffered the response to a temp file
that filled up β with nothing in the app logs. Two changes:
- `/fdroid/` + `/r/` in the bundled nginx now set `proxy_buffering off`
with 3600 s read/send/client timeouts, so downloads stream
client-paced instead of being buffered to a size-limited temp file.
- New opt-in `X_ACCEL_REDIRECT_ENABLED` (on by default in the reference
compose): local-storage APK downloads are handed to nginx via
`X-Accel-Redirect` to an internal `/_protected/` location β served
with sendfile, a real `Content-Length`, and native Range/resume, with
no Python nor backend read-timeout in the byte path. Requires the
serving nginx to define `/_protected/` and mount the storage volume
(both already true in the reference compose).
- **Operator note:** if you run your own reverse proxy / CDN in front
(nginx, BunkerWeb, Traefik, β¦), disable response buffering (or give
its temp dir room) and raise its timeouts for the download route too
β a short timeout or a small proxy temp filesystem *there* will still
truncate large downloads regardless of these app-side changes.
### 1.4.3
- **Vulnerable transitive deps bumped** (npm `overrides`), clearing 8
Dependabot alerts in the frontend lockfile: `dompurify` 3.4.5 β 3.4.11
(7 advisories β mostly IN_PLACE-mode bypasses we don't use; the
markdown-view XSS neutralisation was re-verified on 3.4.11) and
`markdown-it` 14.1.1 β 14.2.0 (smartquotes quadratic-complexity DoS,
CVE-2026-48988). Also pulled `undici` 7.25.0 β 7.28.0 (high; via jsdom,
unreachable since DOMPurify makes no network calls) and `@babel/core`
β 7.29.7 (low, build-time). `npm audit` now reports zero.
### 1.4.2
- **Index signing finds `jarsigner` robustly** β `rebuild_index` invoked
`jarsigner` by bare name, so a worker whose `PATH` didn't include the
JDK bin dir (custom entrypoint / `environment:` override, or an image
missing the JDK entirely) failed mid-rebuild with an opaque
`FileNotFoundError: 'jarsigner'`. It's now resolved explicitly via
`JAVA_HOME/bin` β `PATH` β the worker image's bundled JRE, with a clear
error that names the cause (the task must run in the *worker* image,
which bundles the JDK; the API image doesn't). Reminder: index signing
only works in the worker image β never run `rebuild_index` from the API
image.
### 1.4.1
- **CodeQL `py/unsafe-deserialization` cleared** β the 1.4.0 billion-laughs
hardening parsed metadata.yml via `yaml.load()` with a `SafeLoader`
*subclass*, which CodeQL flags as unsafe deserialisation (it doesn't
recognise the subclass as safe). Reworked to plain `yaml.safe_load`
with a separate `yaml.scan` token pass that rejects YAML aliases β same
DoS protection (bomb rejected in <1 ms), no flagged sink.
### 1.4.0
Security + hardening release β no new features, but a broad audit-driven
sweep across both ends and the dependency tree.
- **Stored-XSS fix on the public app page** β Markdown descriptions are
rendered through `marked` + DOMPurify, but the anchor `target`/`rel`
hardening used to run as a regex over DOMPurify's *output*. DOMPurify
legitimately emits a literal `>` inside a quoted `href`, so the regex
split the tag and re-injected a trailing `
` as live
markup β a crafted description ran JS in every visitor's session. The
rewrite is now a DOMPurify `afterSanitizeAttributes` hook, so
sanitisation is always the last word.
- **SSRF hardening, end to end** β a DNS-rebinding-safe HTTP transport
resolves each outbound host once, refuses any blocked address, and
pins the connection to the validated IP (forge polling + APK-proxy
fetches). Forge calls also re-validate at fetch time and no longer
follow redirects. The metadata-IP blocklist now recognises IPv4-mapped
IPv6 and decimal/hex/octal spellings, and the private-range check
covers CGNAT (100.64/10).
- **At-rest secret keys** derived with HKDF-SHA256 (domain-separated from
the JWT signing key) instead of a bare `SHA-256(secret_key)`; existing
encrypted tokens keep decrypting via a legacy fallback (no
re-encryption needed).
- **Rate-limit + auth hardening** β the API port now binds to loopback so
`X-Forwarded-For` can't be forged past the frontend to evade per-IP
limits; MFA (TOTP confirm, passkey/enrol begin+finish) endpoints are
rate-limited; the metadata.yml importer refuses YAML aliases (closes a
~250 B β 54 M node "billion laughs" DoS); screenshot uploads validate
the `locale` path segment and cap the batch; proxy-supplied download
headers are allowlisted.
- **F-Droid index timestamp fix** β `entry.json` and `index-v2.json`
now share a single, strictly-monotonic timestamp per rebuild, and the
index files publish atomically (temp + rename). This fixes the
intermittent "Repo timestamp expected X, but was Y" error in the
F-Droid client.
- **Dependency refresh** β Tiptap v2 β v3 (editor), `marked` 14 β 18,
`isomorphic-dompurify` 2 β 3, plus security-floor bumps on PyJWT
(2.13), authlib, python-multipart, pwdlib and others; dev tooling on
mypy 2 / pytest 9 / ruff 0.15. A mypy-2 audit fixed six latent bugs
(incl. a reachable IPv4/IPv6 comparison `TypeError`).
- **Ops** β worker container healthcheck (arq heartbeat); worker image
re-copies the app code so a parallel `docker compose build` can't ship
it stale.
### 1.3.0
- **APK source proxy v1** β admins can register external "proxy"
sidecars (`/admin/proxies`) that resolve release URLs on the
backend's behalf for sources the platform doesn't speak natively
(Patreon, GitHub-Releases assets behind auth, custom drops, β¦).
The protocol is small and documented (`docs/proxy-protocol.md`):
`GET /healthz`, `GET /sources`, `POST /resolve`, and a streaming
`GET ` download. The backend treats proxies as untrusted:
every URL they hand back is run through the SSRF guard (RFC1918 +
loopback + link-local + cloud-metadata blocklist, with IPv4 weird-
form normalisation) before any byte leaves the worker. A reference
F-Droid proxy ships under `proxy/fdroid/` for self-hosters to
fork. The per-app wizard (`/my-apps/new`) and the per-source
picker on `/my-apps/[id]` pick proxy-backed sources up
transparently and surface OAuth popups when a source needs them.
- **F-Droid-compatible Markdown editor + rendering** β `apps.description`
and per-locale overrides are now edited in a Tiptap WYSIWYG bound
to a Markdown string, restricted to the F-Droid v1/v2 subset
(bold / italic / inline code / bullet + ordered lists / block
quotes / links β headings, images, tables and inline HTML are
stripped on paste). Public pages render the same Markdown through
`marked` + DOMPurify with a deny-everything-then-allowlist sanitize
config, so what the owner sees in the editor is exactly what every
F-Droid client will display.
- **CodeQL security hardening** β fixed two GitHub code-scanning
alerts: a reflective-XSS sink on the OAuth proxy callback (now
emits the payload as an `html.escape`d `data-payload` attribute
parsed at runtime, with a strict opaque-ID regex on the popup
message), and a stack-trace exposure on `/apks` fetch errors
(replaced free-form exception messages with a static lookup keyed
by exception code so internal traces never leak to the client).
- **Audit-driven hardening pass** β single sweep covering the
surfaces the proxy work touched. Proxy `base_url` validator
refuses userinfo and normalises IPv4 weird forms (decimal,
hex, trailing dot) via `ipaddress.ip_address(int(_, 0))` so the
cloud-metadata blocklist can't be bypassed with `2852039166` or
`0xa9fea9fe`. Reference proxy walks redirects with
`client.send(stream=True)` (single-RTT happy path), checks the
shared bearer with `hmac.compare_digest`, resolves DNS through
`loop.getaddrinfo` so the event loop isn't blocked, and validates
caller-supplied SHA-256 hints with a fullmatch regex. Frontend
popup `setInterval`s are now tracked in refs and cleaned up on
unmount / before re-trigger to stop leaks across the
`/my-apps/new` and proxy-section flows.
### 1.2.0
- **Per-APK Reproducible Builds verification** β every APK row now
carries a four-state RB flag (`unknown` / `not_attempted` /
`verified` / `failed`) plus an optional reference SHA-256, the URL
it was sourced from, and free-form notes. Two write paths: a
declarative `POST /apks/{id}/reproducibility` and an automatic
`verify-from-url` that fetches a published hash (typically the
F-Droid verification server) through the same SSRF guard the
release fetcher uses and auto-decides verified/failed by hash
comparison. Surfaces as a discrete badge on `/apps/[package]` and
a per-APK editor on `/my-apps/[id]`. Admin-toggleable from
`/admin/scanning` β when off, endpoints 403 and the badge/editor
are hidden but historical data is preserved.
- **CVE / SBOM scanning via Trivy** β optional `aquasec/trivy`
container (compose profile `trivy`) provides CycloneDX SBOM
extraction + vulnerability lookup for every published APK. The
worker talks to the trivy server in CLIENT mode so the DB stays
in one place. Results are private (owner / collaborator / admin
only) β never on the public catalogue. Per-severity counts and
the full CVE table show up in a side-sheet on the APK row. Admin
toggle on `/admin/scanning` next to ClamAV.
- **WebAuthn / passkeys** β passwordless sign-in via FIDO2
authenticators, with both account-level enrolment (under
`/account/security`) and per-role force toggles
(`/admin/access` β "require passkey for admins / uploaders").
Forced enrolment is offered at first login after the toggle
flips; existing sessions stay valid. Compatible with platform
authenticators (Touch ID / Windows Hello) and roaming keys.
- **Encrypted backup + restore** β `/admin/backup` ships full-repo
archives (DB dump + storage + keystore + assets) encrypted with a
key derived from the operator's passphrase. Selective restore:
pick any subset of the four components on the way in. Runs on
the arq worker so an 80 GB repo doesn't pin the API. Job history
lists every backup with size, components, status and a download
button.
- **F-Droid `metadata.yml` export** β owners can grab a binary-only
`.yml` for their app from the `/my-apps/[id]` Hero. The
file round-trips with the existing New-App YAML importer: every
field the importer reads is also written here, with `Builds[]`
emitted in F-Droid's "binary:" shape pointing at this repo's APK
URLs so the file can drop into an `fdroiddata` fork unchanged.
- **`/stats` redesign** β single-page magazine spread. Variable
serif display (Fraunces) on the hero downloads number, masthead
strip + Roman-numeral chapter dividers, SVG area chart with
motion path-draw + hover read-out, leaderboard with proportional
bar fills animated on mount, and a subtle paper-grain background.
- **Section nav URL hash on `/my-apps/[id]`** β clicks AND natural
scroll mirror the active section into `location.hash`, so a
refresh (or a shared link) lands the user where they were.
Settling-loop re-aligns after image-heavy sections finish
decoding their thumbnails.
- **Side-sheet refactor for APK row editors** β the per-APK
Reproducibility / CVE-details / Notes panels used to expand
inline, eating vertical space and breaking the version list's
reading rhythm. They now open in a right-side drawer (portal +
motion slide-in, ESC + backdrop-close, body-scroll lock) anchored
to the version they belong to. Cleaner reads, more room for
forms and tables.
- **Setup wizard regression fix** β the Dockerfile split (API β
worker) had stripped the JDK from the API image, breaking
`keytool` keystore generation with a 500. The wizard now builds
PKCS#12 in-process via `cryptography` (RSA-3072 + self-signed
X.509 + AES-256-CBC encryption). ~150 ms vs. the previous ~2 s
JVM cold-start; apksigner on the worker still consumes the
output unchanged.
- **App icon rendering** β adaptive-icon foregrounds (most modern
apps) rendered with a visible rounded-square halo behind the
artwork in both themes. Two culprits identified: a
`bg-surface-2` placeholder bleeding through the PNG's alpha, and
`box-shadow` drawing on the rectangular bounding box instead of
following the alpha channel. Swapped for `filter: drop-shadow()`
on the `
` so shadows now hug the actual silhouette.
- **Visual polish** β the active rail item on `/my-apps/[id]`
finally has a properly centred indicator dot, the
Trivy/ClamAV/RB master toggles all live on a single
`/admin/scanning` page, the CVE Details button opens even when a
scan completed with zero findings (it shows the friendly "all
clear" message), and the site footer drops the duplicate "All
apps" / "My apps" links that were already in the navbar.
### 1.1.1
- **Service Worker media cache** β icons and screenshots now survive
page navigation. Chrome refuses to reuse responses to
Authorization-bearing fetches in its HTTP cache, so the SW used to
re-download every `
` on every navigation between `/apps/β¦`,
`/my-apps/β¦`, and `/history`. The SW now owns a dedicated
`fdroid-media-v1` CacheStorage bucket (purged on logout). After
the first visit, follow-ups serve zero bytes from the backend.
- **/history icon cache key matches /apps** β `/me/downloads` now
exposes `updated_at` so the `?v=β¦` cache-buster is identical to
the one on `/apps`. Without it, the two pages stored the icon
under two distinct cache slots and re-fetched on every round-trip.
### 1.1.0
- **Screenshot lightbox** β clicking a screenshot tile on
`/apps/[package]` now opens an in-page viewer (backdrop blur,
keyboard navigation `β β Esc`, neighbour-preload) instead of
spawning a new tab.
- **Progressive screenshot upload** β uploads on the editor page now
run one file per request with per-file placeholders that walk
through queued β uploading β done / error. A header chip surfaces a
running count (3/5 β 4/5 β 5/5) so the user knows where they are
in a batch; a failure on file 3/5 no longer atom-fails the rest.
- **Download origin chips on /history** β each app row now shows a
per-origin breakdown (F-Droid client / web / CLI / other) so the
user can tell which downloads came from their phone vs the SPA.
Backend classifies the User-Agent into a coarse bucket; no
fingerprinting, no migration, no new index.
- **Keystore metadata read 3β10Γ faster** β `/admin/repo` was
noticeably laggy on every open because the `/setup/keystore`
endpoint shelled out to `keytool` (JVM cold-start, ~500ms steady,
multi-second on first hit). Now we parse PKCS#12 in-process via
`cryptography`. `keytool` is still used for key generation and
import, where the JVM cost is justified.
- **Visual polish pass** β stat digits across `/history` and the
admin dashboards no longer render Geist Mono's slashed-zero glyph
(which looked like a crosshair at display sizes), `/admin/categories`
uses a curated 10-tone palette instead of free 360Β°-hue hashes
with a uniform `--primary` usage bar, the `/apps/[package]`
screenshots rail has a right-edge fade indicator and a "N captures"
counter, the `/admin/scans` "Analyser maintenant" CTA now states
why it's disabled when ClamAV is unreachable, and `/my-apps/new`'s
draft watermark is localised.
### 1.0.0 β 1.0.9
- **OIDC email_verified toggle** β new `OIDC_REQUIRE_EMAIL_VERIFIED`
env var (default `true`). Set to `false` for IdPs that don't emit
the claim at all (Defguard, some Keycloak realms). A `WARNING` is
logged on every boot when the gate is off.
- **S3 downloads stream through the backend** β no more 302 to a
public S3 URL. Fixes the "anonymous-S3-refused" wall (Garage,
private MinIO) AND closes the audit / rate-limit / signed-URL
bypass the old shortcut created. `S3_PUBLIC_BASE_URL` becomes a
no-op for downloads (kept in the schema for backward compat).
- **Signed media tokens for private apps** β the web UI now gets
per-app, one-hour `?t=` URLs for icons / screenshots /
banners. `
` tags can finally render private-app images that
used to 404 against the owner's own browser.
- **Browser cache headers** β `Cache-Control` is now set on every
asset: `no-cache` for index files, `max-age=1d` for icons / screen-
shots / banners (frontend cache-busts with `?v=updated_at`),
`immutable` for APKs.
- **Reindex coalescing fix** β auto-enqueues used to dedup against
arq's 24h result key, so uploads after the day's first rebuild
were invisible until tomorrow. Default job id is now a per-minute
bucket; the admin "Trigger reindex" button bypasses dedup entirely.
- **Per-app retention cap** (1.0.1) β admin sets a repo-wide default,
admins can tighten per-app. FIFO eviction by versionCode, never
touches the suggested version. Owners can't widen the cap.
- **Security review batch** (1.0.1 β 1.0.5) β SSRF guards on forge
fetches, scheme allow-list on URL fields, audit on deploy-token +
API-key uploads, logout endpoint, signed-URL β private-app gating,
CSP/HSTS/X-Frame-Options at the nginx layer, bootstrap advisory
lock, postcss XSS bump.
- **Smaller polish** β readable Pydantic-validation toasts (no more
`[object Object]`), keystore error path surfaces rc + stderr,
GitHub repo description truncates to fit `summary`, joserfc JWS
header cap raised so Defguard's larger ID-token headers parse,
signed-URL downloads attribute to the logged-in user in the audit
log, en-US fallback for missing per-locale media.
The docs site at `docs/` reflects the same shape.
## License
MIT β see `LICENSE` (TBD).