FTStore Admin

Automated iOS IPA distribution from Telegram to FTStore.

--- FTStore Admin watches Telegram channels for IPA files, extracts metadata, stores them on your own server, and serves the FTStore feed — on autopilot. It is the `web/` half of the [FTStore monorepo](../README.md); the iOS app it feeds lives in [`ios/`](../ios). The dashboard is served at **`/admin`** — `ftstore.app/admin` in production. `/` is the [public landing page](#landing-page); every other path is either the feed (`/ftstore.json`), an IPA download (`/d//…`), or a dead host. > **Upgrading from FTRepo?** This used to be a separate repository. See > [Migrating from FTRepo](#migrating-from-ftrepo) — the Docker images, the > database name and the session cookie all changed. ## How It Works ``` Telegram channels │ ▼ ┌───────────────┐ │ Scanner │ worker polls channels for new IPAs └───────┬───────┘ ▼ ┌───────────────┐ │ Pipeline │ extract → detect tweaks → enrich └───────┬───────┘ via iTunes API │ ┌──────┴──────┐ ▼ ▼ ┌──────────┐ ┌──────────┐ │ Local │ │ ftstore │ │ storage │ │ .json │ └──────────┘ └─────┬────┘ ▼ FTStore ``` ## Features - **Telegram scanning** of channels and forum topics for new IPA uploads - **Manual IPA upload** from the dashboard, straight into the repo and the feed - **IPA analysis** — bundle info, injected tweaks, entitlements, privacy manifests - **FTStore feed** — `ftstore.json`, rendered on demand from the database - **Local disk storage** for IPAs, with a configurable size cap and automatic retention cleanup - **iTunes enrichment** — icons, screenshots, developer info - **Dashboard** with metrics, queue, logs, and settings - **Settings backup** — export the whole configuration as JSON and import it anywhere ## Quick Start **Prerequisites:** Docker and a [Telegram API ID & Hash](https://my.telegram.org/apps). No GitHub account is needed — IPAs, the feed and tweak icons are all served from your own server. ```bash curl -O https://raw.githubusercontent.com/FTStuff/FTStore/main/web/docker-compose.yml docker compose up -d ``` 1. Open and register — the first account is admin. 2. Fill in **Settings** → Telegram and the channels to scan. Set **Max Storage (GB)** under Settings → General if you want the scraper to stop at a size limit. 3. Click **Connect** under Settings → Integrations and complete the Telegram login. The Telegram session persists in the `tdlib-data` volume. Use `docker compose down` (without `-v`) to keep it. ## Services | Service | Role | |---------|------| | **app** | Next.js web UI + API on port 3000 | | **worker** | Telegram client, scanner, and pipeline processor | | **db** | PostgreSQL 16 | All settings beyond `DATABASE_URL` are managed in the web UI. ### FTStore Manager proxy The public `/api/ftstore-app/*` endpoints are used by FTStore Manager to fetch the private FTStore IPA without exposing a PAT to the iOS app. The private FTStore app repo is hardcoded to `FTStuff/FTStore`. In the dashboard, open **Settings** -> **Integrations** -> **FTStore Manager Proxy** and paste a GitHub token with access to the private FTStore repo. If that field is empty, the proxy falls back to the main GitHub token. You can also provide a server-side env fallback: ```bash FTSTORE_APP_GITHUB_TOKEN=TOKEN ``` The manager uses `channel=stable` for the newest non-`dev` release and `channel=dev` for the release tagged exactly `dev`. ### docker-compose.yml ```yaml services: db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: ftstore POSTGRES_PASSWORD: ftstore POSTGRES_DB: ftstore volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ftstore"] interval: 5s timeout: 5s retries: 5 app: image: ghcr.io/ftstuff/ftstore-admin-app:latest restart: unless-stopped stop_grace_period: 30s ports: - "3000:3000" environment: DATABASE_URL: postgresql://ftstore:ftstore@db:5432/ftstore?schema=public volumes: - tdlib-data:/app/tdlib-data - ipa-storage:/app/storage - upload-staging:/tmp/ftstore depends_on: db: condition: service_healthy worker: image: ghcr.io/ftstuff/ftstore-admin-worker:latest restart: unless-stopped stop_grace_period: 30s environment: DATABASE_URL: postgresql://ftstore:ftstore@db:5432/ftstore?schema=public volumes: - tdlib-data:/app/tdlib-data - ipa-storage:/app/storage - temp-downloads:/tmp/ftstore depends_on: db: condition: service_healthy volumes: pgdata: tdlib-data: ipa-storage: temp-downloads: upload-staging: ``` `ipa-storage` holds the IPA files and tweak icons, and **must be the same volume in both services** — the worker writes what the scraper downloads and the app serves it. ### Host directories instead of named volumes If you bind-mount host paths, the storage path has to be identical in both services, and the directories must be owned by uid/gid `1001` *before* the first start (Docker creates a missing bind-mount path as root, which the containers cannot write to): ```bash sudo mkdir -p /opt/docker/ftstore/{storage,tmp,tmp-app,data,postgres} sudo chown -R 1001:1001 /opt/docker/ftstore/{storage,tmp,tmp-app,data} sudo chown -R 70:70 /opt/docker/ftstore/postgres ``` ```yaml app: volumes: - /opt/docker/ftstore/data:/app/tdlib-data - /opt/docker/ftstore/storage:/app/storage - /opt/docker/ftstore/tmp-app:/tmp/ftstore worker: volumes: - /opt/docker/ftstore/data:/app/tdlib-data - /opt/docker/ftstore/storage:/app/storage # same host path as app - /opt/docker/ftstore/tmp:/tmp/ftstore ``` Give the app its own `/tmp/ftstore` mount rather than sharing the worker's: the app's stale-upload sweeper would otherwise delete files the worker is still downloading. ## Uploading an IPA by hand **Library** -> **Upload IPA** takes any `.ipa` that never came through a scan. It runs the same pipeline as a scanned file — metadata extraction, tweak detection, iTunes enrichment, storing the file on the server — and refreshes the feed, so the app is live in FTStore as soon as the dialog reports success. Manual uploads are never blocked by the storage limit; only the scraper is. Files are sent in 8 MB chunks and reassembled server-side, so builds larger than a reverse proxy's request-body cap (Cloudflare stops at 100 MB) still get through. The limit per file is 2 GB. By default an upload is filed under its own `Manual upload` source. Pick a channel in **Attribute to source** to file it under that channel instead — which also applies any known-tweak channel lock, so the build groups with the rest of that tweak's versions rather than appearing as a separate app. ## Landing page `/` is **ftstore.app** — the one page on this deployment a browser is allowed to load. It hands out FTStore Manager and explains the certificate step; everything else keeps the gate it already had. **What it says.** The pitch is the three things FTStore is genuinely better at than doing this by hand: it notifies you when a tweak updates, it marks which build is worth installing (the Recommended tag), and it needs no understanding of sideloading. Keep new copy to claims that are true of the shipped app — an earlier draft advertised "open source", which this is not. **What it exposes.** `/`, its static assets, `/robots.txt`, and two endpoints under `/api/public/`: | Route | Serves | |-------|--------| | `GET /api/public/manager` | Version, size and build date of the latest **stable** Manager. | | `GET /api/public/manager/ipa` | That IPA's bytes, exactly as published. | Both are stable-only and read-only. The dev channel stays behind the UA-gated `/api/ftstore-manager/*` proxy — a public page has no business steering people onto rolling prereleases — and the GitHub PAT stays server-side in both cases. `/admin`, the feed, `/d//…` and every other `/api` route are untouched, so opening `/` costs nothing else. The list lives in `PUBLIC_PAGES` and `PUBLIC_API` in `src/middleware.ts`; keep it exact, since a prefix match there would lift the dead-host camouflage for everything underneath it. **The certificate flow.** The page's default option asks for a `.p12`, a `.mobileprovision` and the password, and gives back a Manager IPA with those files already inside — so the first launch on the phone is already set up instead of starting with an import screen. **This happens entirely in the visitor's browser.** The IPA is fetched, the seed is spliced into the ZIP client-side, and the result goes to a blob download. There is no upload endpoint, because there is nothing to upload — the certificate and the password never reach this server. - `src/lib/landing/zip-append.ts` appends ZIP entries without touching the existing ones. It is hand-rolled rather than an unzip/rezip through a library because a round trip loses the external-attribute field carrying each entry's Unix mode — the executable bit on the Mach-O, the symlink flag on the framework aliases — which signing tools depend on. - `src/lib/landing/ipa-seed.ts` finds `Payload/.app/` and writes `FTStoreSeed/{seed.json,certificate.p12,certificate.mobileprovision}`. - The Manager reads it once at first launch — `manager/FTStore/Manager/ManagerSeedImporter.swift`. `SEED_FORMAT_VERSION` here and `_supportedVersion` there must be bumped together. > A personalised IPA contains the certificate **and its password in the clear**. > That is the point of it, and the download says so: it is exactly as sensitive > as the `.p12` and must not be re-shared. **The stat row is live.** `src/lib/landing/stats.ts` counts the same tables the dashboard does, but publishes aggregates only — the storage figure, queue depth, worker state and last-scan time stay behind the session. It falls back to the `catalogue.ts` snapshot when the database is unreachable or empty, so a Postgres hiccup costs a stale number rather than an empty row. **The activity feed publishes commit subjects from this private repository.** `src/lib/github/activity.ts` is the disclosure boundary and is covered by `activity.test.ts`; the rule for writing commit messages is in the root [AGENTS.md](../AGENTS.md#-commit-subjects-on-main-are-public). It emits type, scope, subject and date, and nothing else — no author, no SHA, no body. **Nothing on the page depends on an animation running.** The entrances are CSS (`.ft-rise`, `.ft-reveal` in `landing.css`), not JS: an element whose resting state is `opacity: 0` disappears entirely if its animation never runs, and a tab that loads in the background has its frame loop paused. Keep new reveals on those classes rather than reaching for `whileInView` for anything that carries content. The card tilt and spotlight are the exception and are allowed to be JS-driven, because they add nothing a reader needs. > There was a WebGL scene here. It was removed: it looked good on a fast desktop > and was dead weight everywhere else. `three` and `@react-three/fiber` are no > longer dependencies — do not reintroduce them without a reason that survives a > mid-range phone. ## Feed FTStore reads `ftstore.json`, served at `/ftstore` and `/ftstore.json`. It is the only feed FTStore Admin publishes — there is nothing to configure per client. The feed is rendered on demand from the database, with a 60-second cache. Each app's `downloadURL` points at `/d//.ipa` on this server, where the random token is what authorises the download. Both the feed and the download route are restricted to clients sending the FTStore User-Agent. If the download link is handed to an external downloader and the transfer times out, turn off **Restrict downloads to FTStore** under Settings → General — the token in the URL still protects it. ## Backup & Restore **Settings → General → Backup & Restore** exports the entire configuration as a single JSON file: every setting on the Settings page, the Telegram channels with their forum topics, and the GitHub release sources. Scan progress, stored IPAs, the queue and users are not part of it — a backup is configuration, not state. Secrets (the Telegram API hash and both GitHub tokens) are left out unless you tick **Include secrets**, so the file is safe to hand around by default. The export always reads the *saved* settings, so save before exporting. Importing merges into the current install: what the file names is overwritten, anything it leaves out is kept — including credentials the file doesn't carry. Custom tweak icons are referenced by path, not embedded, so they don't travel with the file. The same file is available over the API: ```bash curl -b cookies.txt http://localhost:3000/api/settings/backup?secrets=1 -o ftstore-settings.json ``` ## Storage IPAs and tweak icons live under `STORAGE_DIR` (default `/app/storage`), shared between the app and worker containers. Set **Max Storage (GB)** in Settings → General to cap it. When stored IPAs reach the limit, the worker stops downloading and queue entries stay `pending` until space frees up — the Queue page shows a banner and the Storage tile turns red. Manual dashboard uploads keep working. `0` means no limit. **Rebuild Local Storage** (Settings → Scraper) deletes every stored IPA and the entire scan history and rewinds each channel, so the scraper re-downloads everything. Channels, sources, feed renames and settings are kept. This is the migration path from the old GitHub Releases backend, whose files no longer exist. ### Upgrading an existing install Fresh installs need nothing — the containers run `prisma db push` at start and create the table with its indexes in one go. An **existing** database is a different story: `db push` classifies adding the unique index on `download_token` as a possible data-loss change and refuses, so `app` and `worker` restart-loop with ``` Error: Use the --accept-data-loss flag to ignore the data loss warnings ``` The column is `NULL` on every existing row and Postgres allows any number of NULLs in a unique index, so the change is in fact safe — Prisma just can't tell. Apply it once by hand, on the database container: ```bash docker compose exec -T db psql -U ftstore -d ftstore -c 'ALTER TABLE "downloaded_ipas" ADD COLUMN IF NOT EXISTS "storage_path" TEXT; ALTER TABLE "downloaded_ipas" ADD COLUMN IF NOT EXISTS "download_token" TEXT; CREATE UNIQUE INDEX IF NOT EXISTS "downloaded_ipas_download_token_key" ON "downloaded_ipas"("download_token"); CREATE INDEX IF NOT EXISTS "downloaded_ipas_storage_path_idx" ON "downloaded_ipas"("storage_path");' ``` The index names are Prisma's own conventions on purpose — anything else reads as drift and gets recreated on the next push. With `restart: unless-stopped` the app and worker recover by themselves on their next attempt. Do **not** add `--accept-data-loss` to the startup command to work around this: it would also wave through genuine `DROP COLUMN`s on every future deploy. ## Migrating from FTRepo FTRepo became `web/` in the FTStore monorepo and was renamed to FTStore Admin. Fresh installs need nothing from this section. An **existing** deployment has four things to change, all of them in your `docker-compose.yml`: | Was | Is now | |-----|--------| | `ghcr.io//ftrepo-app` | `ghcr.io/ftstuff/ftstore-admin-app` | | `ghcr.io//ftrepo-worker` | `ghcr.io/ftstuff/ftstore-admin-worker` | | Postgres role/database `ftrepo` | `ftstore` | | `/tmp/ftrepo` mount target | `/tmp/ftstore` | > **The registry owner moved too.** Images are published from this repository, so > they follow its owner: they are now under `ghcr.io/ftstuff/`, not the owner > FTRepo was published under. A compose file still pointing at the old owner keeps > running the last image it pulled and silently stops seeing updates — swap both > image lines even if you already migrated off FTRepo. **The images and the temp path are safe to just swap.** `TEMP_DIR` only holds in-flight downloads, and the stored setting is re-read at start — if yours still says `/tmp/ftrepo`, either update it under Settings → Scraper or keep the old mount target. It has to match the container path, not this document. **The database rename is not automatic.** `POSTGRES_USER`/`POSTGRES_DB` only apply when Postgres initialises an empty data directory, so pointing the new `DATABASE_URL` at an existing `pgdata` volume fails to authenticate. Pick one: - *Simplest — keep your existing names.* Leave the `db` service and `DATABASE_URL` exactly as they are and only swap the two image names. Nothing in the app cares what the role is called. - *Rename to match this README.* On the running stack, before switching: ```bash docker compose exec -T db psql -U ftrepo -d postgres \ -c 'ALTER ROLE ftrepo RENAME TO ftstore;' \ -c "ALTER ROLE ftstore WITH PASSWORD 'ftstore';" \ -c 'ALTER DATABASE ftrepo RENAME TO ftstore;' ``` Renaming a role invalidates its `md5` password hash, which is why the password is set again in the same call. Then update `DATABASE_URL` in both services. Two smaller effects, neither of which needs action: - **Everyone is logged out once.** The session cookie is now `ftstore_session`; existing `ftrepo_session` cookies are ignored. Sessions in the database are untouched — just log in again. - **Old settings backups still import.** The file type changed to `ftstore-settings`, but `ftrepo-settings` is still accepted. Compose is pinned to `name: ftstore`, so the volume prefix stays `ftstore_` whatever the checkout directory is called. If your old stack ran from a folder named `ftrepo`, its volumes are `ftrepo_*` — rename them, or point the services at the old names explicitly, or Compose will start against empty volumes. ## Troubleshooting If you see permission errors on first boot, fix volume ownership: ```bash sudo chown -R 1001:1001 $(docker volume inspect ftstore_tdlib-data --format '{{ .Mountpoint }}') sudo chown -R 1001:1001 $(docker volume inspect ftstore_ipa-storage --format '{{ .Mountpoint }}') sudo chown -R 1001:1001 $(docker volume inspect ftstore_temp-downloads --format '{{ .Mountpoint }}') sudo chown -R 1001:1001 $(docker volume inspect ftstore_upload-staging --format '{{ .Mountpoint }}') sudo chown -R 70:70 $(docker volume inspect ftstore_pgdata --format '{{ .Mountpoint }}') ``` ## Tech Stack Next.js · React · TypeScript · Tailwind · Prisma · PostgreSQL · TDLib · Octokit ## License GPL-3.0, same as the rest of the monorepo — see [LICENSE](../LICENSE).