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