Files
FTStore/web
Felitendo 7b8b28f0c2 fix(web): resolve the Manager proxy against the monorepo, not the dead repo
`MANAGER_TARGET` still pointed at `FTStuff/FTStore-Manager`. That repository
stopped receiving releases when the Manager moved into `manager/`, so
`/api/ftstore-manager/manifest` — the endpoint FTStore's "My Apps" row uses to
offer the Manager — has been resolving against a repo with nothing in it.

Point it at `FTStuff/FTStore`. Both apps now release from one repository, which
means the repo no longer identifies the target and the **tag namespace** has to:

- `ownsTag` splits the release list — `manager-*` is the Manager's, everything
  else is the app's. Without it the stable scan would hand whichever release is
  newer to whichever app asked.
- `devTag` replaces the hardcoded `dev`; the Manager's rolling prerelease is
  `manager-dev`, deliberately distinct so the two dev builds don't overwrite
  each other's assets.
- `devRepoAsset` replaces the hardcoded `devrepo.json`; the Manager's dev
  workflow publishes `manager-devrepo.json`. A bare lookup would have read the
  app's version string onto a Manager build.

Asset-name matching still runs on top, but it is no longer what separates the
two — it was never meant to carry that weight.
2026-08-03 15:24:30 +02:00
..

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; the iOS app it feeds lives in ios/. The dashboard is served at /admin — ftstore.app/admin in production. Every other path is either the feed (/ftstore.json), an IPA download (/d/<token>/…), or a dead host.

Upgrading from FTRepo? This used to be a separate repository. See 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. No GitHub account is needed — IPAs, the feed and tweak icons are all served from your own server.

curl -O https://raw.githubusercontent.com/FTStuff/FTStore/main/web/docker-compose.yml
docker compose up -d
  1. Open http://localhost:3000 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:

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

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):

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
  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.

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/<token>/<name>.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:

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:

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 COLUMNs 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/felitendo/ftrepo-app ghcr.io/ftstuff/ftstore-admin-app
ghcr.io/felitendo/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 ghcr.io/felitendo/. 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:

    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:

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.