Private mode asked for a six-digit code, minted per person and traded for a pass that a browser kept in a cookie and the app in its keychain. Accounts arrived later, for the boards and the setup backup, and from that point the code was a second credential doing a worse version of the same job. It said you may look and nothing about who was looking, so it could not carry what somebody wrote, it could not be signed out of from another device, and six digits is a space small enough that it had to be defended by a throttle doing most of the work. So the account is the credential now. The landing page asks for a name and a password instead of drawing a keypad, the feed and the token downloads want the same session, and the store's own screen for being shut out is the sign-in it already had. One credential, on both sides of the wire, and the same one somebody needs before they can post or keep a backup. What changes on the way in is who may register. Public: anybody makes an account from the app, as before. Private: an operator mints an invitation and sends the link, and opening it is the only way a new account happens. One link, one account, spent the moment it works, so a forwarded link is not a second way in. The redeem is filtered on being unspent, so two people racing one link end with one account and one refusal. It is shown once, it can be withdrawn while nobody has used it, and it expires if nobody does. The token is 128 bits and stored as a digest, which is the opposite of the choice the codes had to make: a code was a candidate list somebody could work through offline, so it needed a slow hash and could not be looked up, where a token is no list at all. Withdrawing a link is deliberately not the same action as removing somebody. Once it has been redeemed there is an account, and taking that away is muting or deleting it on the board's own screen, where the things they wrote are in view. This locks out every device a code had let in, and there is no way around it: a pass records that a code was answered on some device and nothing about who held it, which is exactly the gap accounts close. The two tables are dropped in pre-push.sql, where every destructive change here is written out. A public deployment is untouched. The throttle stays and gains a second half. The ladder against the address is what it was, minus a ceiling that came down when the codes did; the ladder against the name being signed in to already existed for accounts and now bounds somebody working through one person's password from anywhere they like. Only a wrong answer climbs either, so being told a name is taken is not a step towards a lockout. Both consoles move together, as they have to: the invitations, the accounts and the devices are the same five sections in the same order in the dashboard and on the phone. The alert for a new device compares a moment rather than an id, since a session's key is the digest of a token and there is no id to compare.
624 lines
28 KiB
Markdown
624 lines
28 KiB
Markdown
<h1 align="center">FTStore Admin</h1>
|
|
|
|
<p align="center">
|
|
<strong>Automated iOS IPA distribution from Telegram to FTStore.</strong>
|
|
</p>
|
|
|
|
---
|
|
|
|
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/<token>/…`), 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
|
|
- **Tweak import**: take one tweak out of a channel without scraping the rest of it
|
|
- **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
|
|
- **Private mode** — close the whole server behind a code per person, with a
|
|
device list you can revoke from and a country breakdown of who is opening it
|
|
|
|
## 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 <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:
|
|
|
|
```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.
|
|
|
|
## Taking one tweak out of a channel
|
|
|
|
A channel added under **Settings** -> **Channels** is a channel to scrape: every
|
|
scan walks its history and queues every IPA in it. That is rarely what you want
|
|
from a channel that carries the one tweak you are after and a hundred apps you
|
|
are not.
|
|
|
|
**Settings** -> **General** -> **Known Tweaks** -> **Import from channel** is the
|
|
other way in. Name the channel and the tweak, and the server:
|
|
|
|
1. registers the channel as **on demand**, so no scan ever walks it,
|
|
2. adds the tweak to Known Tweaks, locked to that channel unless you untick it,
|
|
3. searches that channel for that name and imports the builds that match, up to
|
|
**Max Versions Per App**.
|
|
|
|
Nothing else in the channel is downloaded, then or later. An existing channel
|
|
keeps the mode it already has, so this can add a targeted import to a channel
|
|
you already scrape in full. Any channel can be switched between **Full scan**
|
|
and **On demand** from its row under Channels; switching to on demand leaves
|
|
what has already been imported alone and only stops the next sweep.
|
|
|
|
## 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/<token>/…` 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>.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
|
|
[CLAUDE.md](../CLAUDE.md#-commit-messages-on-main-are-public--subject-and-body).
|
|
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.
|
|
|
|
## Private mode
|
|
|
|
**Access** in the sidebar (`/admin/access`, and Setup & Diagnostics → Access in
|
|
the FTStore Admin app) closes the whole deployment behind an account. It is for
|
|
the case where the server is for you and a handful of friends rather than for
|
|
anybody who finds the address.
|
|
|
|
With it on:
|
|
|
|
- `ftstore.app` draws a sign-in form instead of the landing page, and the page's
|
|
own HTML is never sent to a browser that has not been let in;
|
|
- the public install routes behind it refuse, so gating the page is not theatre;
|
|
- **the feed refuses too**, so an FTStore that was never let in shows an empty
|
|
store rather than a catalogue;
|
|
- `robots.txt` disallows everything and the page reports `noindex`, so a link
|
|
pasted anywhere unfurls as a locked door;
|
|
- **nobody can make an account for themselves.** You hand them out.
|
|
|
|
### Accounts, not codes
|
|
|
|
It used to be a six-digit code per person. It is the account somebody already
|
|
has — the one they post, request tweaks and keep a setup backup under — and the
|
|
same name and password work on the website and in the app.
|
|
|
|
That is a real simplification rather than a rename. A code said *you may look*
|
|
and nothing about who was looking, so it could not carry what somebody wrote, it
|
|
could not be signed out of from another device, and it had to be short enough to
|
|
read down a phone, which meant it had to be defended by a throttle. An account
|
|
says who you are, survives a reinstall, and is already the thing somebody needs
|
|
before they can do anything but read.
|
|
|
|
> **Upgrading locks out every device a code had let in.** There is no way to turn
|
|
> a pass into an account: a pass records that a code was answered on some device
|
|
> and nothing about who was holding it. On a private deployment everybody signs
|
|
> in once more, with an invitation from you. On a public one nothing changes.
|
|
|
|
### Invitations
|
|
|
|
**One link, one account.** Make one in **Access**, named after whoever it is for,
|
|
and send it to them. Opening it asks them for a name and a password, and the
|
|
link is spent the moment it works — a forwarded link is not a second way in.
|
|
|
|
The link is shown **once**, when you make it. Only a fingerprint is kept, so if
|
|
it gets lost the answer is to make another. An unused one can be withdrawn, and
|
|
one nobody has used stops working after **An invitation lasts (days)**, a year by
|
|
default.
|
|
|
|
Withdrawing a link is not the same as removing somebody. Once it has been
|
|
redeemed there is an account, and taking that away is muting or deleting the
|
|
account, on the feedback board's own screen for it, where the things they wrote
|
|
are also in view.
|
|
|
|
### Set it up
|
|
|
|
1. Open **Access** and invite yourself first, then everybody else. Send each
|
|
person their link; you will not see it again.
|
|
2. Turn **Ask everybody to sign in** on. It refuses to turn on while nobody has
|
|
an account and nothing is outstanding, because private mode with neither
|
|
would let nobody in at all.
|
|
3. Optionally set **Devices per account**. Beyond it, signing in on one more
|
|
device signs out the one that has been idle longest, so nobody is ever turned
|
|
away for owning a phone and a laptop.
|
|
|
|
### Wrong answers
|
|
|
|
The form behaves like a phone's lock screen. Five tries are free, then the wait
|
|
goes one minute, five, fifteen, and an hour for everything after that, counted
|
|
down on screen with the fields dead. It stops at an hour rather than locking for
|
|
good, because an address can be a whole household and one wrong guesser should
|
|
not be able to shut everybody else out permanently.
|
|
|
|
That ladder counts against the address. A second one counts against the *name*
|
|
being signed in to, so somebody working through one person's password from a
|
|
hundred addresses climbs that one instead. Behind both sits a ceiling across
|
|
every caller at once, so somebody rotating addresses does not simply walk around
|
|
the ladder. Every refusal is counted in the country breakdown, which is where you
|
|
find out this is happening at all.
|
|
|
|
### Watching it
|
|
|
|
**Accounts** lists everybody who can sign in. **Devices** names every browser and
|
|
phone currently signed in, whose it is, its country and when it was last seen.
|
|
Whatever you do not recognise is worth asking about. **Sign every device out** is
|
|
the blunt instrument for when you do not know whose password got out; signing one
|
|
device out is usually the smaller answer.
|
|
|
|
The country breakdown counts the last 30 days, by country and by whether the
|
|
request got in or was turned away. It is a counter and not a log: the server
|
|
keeps no addresses, so it can say how much and from where and never who or what
|
|
they looked at.
|
|
|
|
> **Countries come from the reverse proxy**, which is where the address actually
|
|
> is. Cloudflare sets `CF-IPCountry` on every proxied request with no
|
|
> configuration at all; `x-vercel-ip-country`, `x-country-code`,
|
|
> `x-geo-country` and `fastly-client-country` are read too. Without one of them
|
|
> everything lands under Unknown, which is honest rather than broken. Nothing is
|
|
> ever looked up from a third party.
|
|
|
|
### If you get locked out
|
|
|
|
A settings backup carries the switch and never the accounts, so **restoring a
|
|
backup taken with private mode on lands on a server nobody can enter**. That is
|
|
the deliberate direction to fail in: the alternative is a server that was meant
|
|
to be private quietly becoming public. `/admin` is unaffected either way, so sign
|
|
in there and make an invitation, and it opens.
|
|
|
|
Being signed in to `/admin` also gets you past the form on that browser, so an
|
|
operator is never locked out of their own landing page.
|
|
|
|
> Which is why **your own browser is the wrong one to test the gate with**: it is
|
|
> the one browser the site will not ask. Open a private window and load `/` there
|
|
> to see what a stranger sees.
|
|
|
|
### What stays open
|
|
|
|
Deliberately, and it is worth knowing:
|
|
|
|
- **FTStore Manager's proxy** (`/api/ftstore-manager/*`) and FTStore's own
|
|
self-update manifest. Those are how a device repairs itself, they hand over
|
|
the app rather than the catalogue, and the Manager has nowhere to sign in.
|
|
Somebody who installs FTStore this way gets an app with an empty store until
|
|
they sign in.
|
|
- **Signing in** (`/api/ftstore-app/account/*`). A door that only opened from
|
|
the inside would be no door: a phone that has never been let in is exactly the
|
|
one that needs to sign in.
|
|
- **Invitation links** (`/join/<token>`). It is the page somebody with no
|
|
account is sent to, so hiding it behind the gate would leave a private
|
|
deployment with no way to let a first person in. The token is the credential.
|
|
- **The install hand-off** (`/i/<token>` and the manifest and IPA under it).
|
|
iOS fetches those itself, out of process and without the browser's cookies,
|
|
and the slot only exists because somebody already got past the gate to make
|
|
it. It expires in minutes.
|
|
- **The feedback and tweak request boards**, which have their own lock under
|
|
Feedback → Board Users.
|
|
|
|
## 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, unconditionally. There used to be a **Restrict downloads to
|
|
FTStore** switch in front of that check, and turning it off put every build on
|
|
the open web behind nothing but the token in the URL, so the switch is gone.
|
|
A copied download link is therefore a dead link anywhere but the app.
|
|
|
|
With **private mode** on, both also require a pass. See below.
|
|
|
|
## 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 scan mode and 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
|
|
|
|
Nothing to do: pull the new images and `docker compose up -d`. Both containers
|
|
boot with
|
|
|
|
```
|
|
prisma db execute --file prisma/pre-push.sql && prisma db push
|
|
```
|
|
|
|
and the first half is what makes the second half work. `db push` reconciles the
|
|
database *to* the schema and stops the moment a change could destroy data — a
|
|
dropped column, a dropped table, a unique index over rows it has not seen — and
|
|
it stops for the *whole* push, so one refused drop used to leave the containers
|
|
restart-looping on
|
|
|
|
```
|
|
Error: Use the --accept-data-loss flag to ignore the data loss warnings
|
|
```
|
|
|
|
[`prisma/pre-push.sql`](prisma/pre-push.sql) is where each of those changes is
|
|
written out in full and applied first, so the push that follows only sees work
|
|
it considers safe. It is idempotent, it is a no-op on a fresh database, and it
|
|
carries every such change since the beginning — a deployment that skipped four
|
|
releases catches up in one boot. The app and the worker both run it; an advisory
|
|
lock makes the second one wait rather than race the first.
|
|
|
|
To run it by hand against a database the containers are not currently booting
|
|
against — a restore, a staging copy, a local checkout:
|
|
|
|
```bash
|
|
npm run db:pre-push
|
|
```
|
|
|
|
Two things it is deliberately not. It is **not** `--accept-data-loss` on the
|
|
startup command: that flag would wave through every future drop, including the
|
|
accidental ones, and the point here is that a destructive change is something
|
|
somebody wrote down. And it is **not** a replacement for `prisma/migrations/` —
|
|
those stay the record of what each change was; `pre-push.sql` is the same change
|
|
rewritten to survive being run against a database that may already be part-way
|
|
there.
|
|
|
|
**Adding to it.** Any schema change that makes `db push` warn needs an entry:
|
|
name indexes and constraints exactly what Prisma would name them (anything else
|
|
reads as drift and gets recreated), guard every block so it no-ops on a database
|
|
that already has it, and check the table exists before touching it so a fresh
|
|
install skips the whole thing.
|
|
|
|
## 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/<old owner>/ftrepo-app` | `ghcr.io/ftstuff/ftstore-admin-app` |
|
|
| `ghcr.io/<old owner>/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).
|