From cbc161d8d6dad351f9d94e00851e621dd40479b8 Mon Sep 17 00:00:00 2001
From: dim145
Date: Mon, 31 Aug 2026 23:34:01 +0200
Subject: [PATCH 01/96] feat(torznab): the seeding rules a client was never
told, and rates that were a lie
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three attributes the spec has always had room for, and one number that was
simply wrong.
`minimumratio` and `minimumseedtime` exist in Torznab for exactly this: a
tracker stating its seeding requirements so a client can honour them by
itself. We enforce both already — a minimum ratio at announce time, a
required seed time as a hit-and-run sanction — and told nobody in advance.
A member found out when the gate closed or when the sanction landed. The
same two numbers, sent through the channel Sonarr and Radarr already read,
turn hit-and-run from a trap into a contract.
Both are omitted rather than sent as `0` when the site imposes neither. A
stated zero and an absent value mean the same thing to a client, and only
one of them can be misread as "seed for zero seconds".
`minimumseedtime` is sent only when hit-and-run is actually switched on.
The required seed time has a value either way — 86 400 by default — but
announcing a rule the site will never enforce would have clients seed
against something that does not exist.
`infohash` is the third, and it costs nothing: it is the `guid` of every
local item already. Prowlarr reads it to match a release against torrents
a client already holds, without fetching the `.torrent` first.
The volume factors are the correction. They were hard-coded to `1` with a
note about freeleech "enhancement" — but a site-wide bonus event IS that,
and it was already being applied on the announce hot path. So during a
freeleech the feed told every *Arr client "normal rates" while the tracker
counted nothing. They now read the running event: `0` and `2` under a
freeleech with double upload, `1` otherwise. Per-torrent multipliers still
do not exist, and this does not invent them; it reflects what the site is
doing right now, which is the part that was wrong.
All three site-wide values are resolved once per request rather than
inside the item map — one cached read instead of `limit` of them.
Mirrored (federated) items carry the hash and neither obligation. A
release announced to another instance answers to that instance's rules,
and guessing would tell a client to honour a rule from the wrong site.
Co-Authored-By: Claude Opus 5
---
README.md | 1 +
apps/api/routes/api/torznab/api/index.get.ts | 54 +++++++++++++++++++-
apps/api/routes/api/torznab/utils/xml.ts | 45 ++++++++++++++++
apps/api/test/xml.test.ts | 45 ++++++++++++++++
doc/integrations/torznab.md | 41 +++++++++++++++
5 files changed, 184 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 20982fe4..774b39f5 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre
- **UDP announce (BEP 15)** on `6969/udp`; ~6×–8× cheaper on the wire than HTTP; stateless `connection_id` = HMAC-SHA256(secret, ip ‖ minute), so no per-id memory.
- **BEP 41 URL_DATA passkey** — `udp://host:6969/announce/PASSKEY` works as-is in every modern client.
- **Multi-tier `.torrent` files** — generator advertises HTTP + UDP independently. `TRACKER_UDP_ENABLED=false` disables UDP and drops it from new `.torrent` files in one go.
+- **Torznab that tells the truth** — `minimumratio` and `minimumseedtime` carry the site's real obligations to Sonarr / Radarr, and the volume factors follow the running bonus event instead of claiming normal rates during a freeleech.
### Bonus economy & resilience
diff --git a/apps/api/routes/api/torznab/api/index.get.ts b/apps/api/routes/api/torznab/api/index.get.ts
index 7e878d38..b7031384 100644
--- a/apps/api/routes/api/torznab/api/index.get.ts
+++ b/apps/api/routes/api/torznab/api/index.get.ts
@@ -46,6 +46,12 @@ import {
trackRateLimitHit,
} from '~~/utils/torznabStats';
import { normalizeMediaId, tmdbIdBare } from '~~/utils/mediaIds';
+import {
+ getHnrRequiredSeedTime,
+ getMinRatio,
+ isHnrEnabled,
+} from '~~/utils/settings';
+import { getActiveSnapshot } from '~~/utils/bonusEvents';
import { escapeLike } from '~~/utils/sql';
import { adultCategoryIds } from '~~/utils/adultContent';
@@ -377,6 +383,41 @@ async function performSearch(
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
+ /**
+ * What this site asks of every release, resolved once for the whole page.
+ *
+ * These are site-wide settings, not per-torrent columns, so they are read
+ * here and stamped onto every item rather than looked up inside the map —
+ * one Redis-cached read instead of `limit` of them.
+ *
+ * `minimumseedtime` is only sent when hit-and-run is actually switched on.
+ * The required seed time has a value either way (86 400 s by default), but
+ * announcing a requirement the site will never enforce would have clients
+ * seed against a rule that does not exist.
+ *
+ * The volume factors used to be hard-coded to 1 with a note about freeleech
+ * "enhancement". A site-wide bonus event is exactly that, and it was already
+ * being applied on the announce hot path — so the feed was telling *Arr
+ * clients "normal rates" during a freeleech. Per-torrent multipliers still
+ * do not exist (there is no column for them); this reflects what the site is
+ * doing right now, which is the part that was wrong.
+ */
+ const [minRatio, hnrOn, requiredSeedTime, activeEvent] = await Promise.all([
+ getMinRatio(),
+ isHnrEnabled(),
+ getHnrRequiredSeedTime(),
+ getActiveSnapshot(),
+ ]);
+ const minimumSeedTime = hnrOn ? requiredSeedTime : 0;
+ // Multipliers are stored in basis points (100 = 1.00x); Torznab wants the
+ // plain factor.
+ const downloadVolumeFactor = activeEvent
+ ? activeEvent.downloadMultiplier / 100
+ : 1;
+ const uploadVolumeFactor = activeEvent
+ ? activeEvent.uploadMultiplier / 100
+ : 1;
+
// Fetch torrents
const torrents = await db.query.torrents.findMany({
where: whereClause,
@@ -408,8 +449,11 @@ async function performSearch(
leechers: stats.leechers,
grabs: stats.completed,
downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.passkey}`,
- downloadVolumeFactor: 1, // Could be enhanced with freeleech support
- uploadVolumeFactor: 1,
+ downloadVolumeFactor,
+ uploadVolumeFactor,
+ infoHash: torrent.infoHash,
+ minimumRatio: minRatio,
+ minimumSeedTime,
imdbId: torrent.imdbId ?? undefined,
// Strip any `tv/` / `movie/` prefix before emitting — the
// Torznab spec expects bare digits and *Arr clients won't
@@ -453,8 +497,14 @@ async function performSearch(
leechers: r.leechers,
grabs: 0,
downloadUrl: magnetLink(r.infoHash, r.name),
+ // A mirrored release is announced to the instance that holds it, so
+ // its rates and its seeding requirements are that instance's, not
+ // ours. We do not mirror either, and guessing would tell a client to
+ // honour a rule from the wrong site — so both are left at the neutral
+ // value and the obligations are omitted entirely.
downloadVolumeFactor: 1,
uploadVolumeFactor: 1,
+ infoHash: r.infoHash,
});
}
}
diff --git a/apps/api/routes/api/torznab/utils/xml.ts b/apps/api/routes/api/torznab/utils/xml.ts
index 755080cf..104aeaed 100644
--- a/apps/api/routes/api/torznab/utils/xml.ts
+++ b/apps/api/routes/api/torznab/utils/xml.ts
@@ -123,6 +123,32 @@ export interface TorznabItem {
downloadUrl: string;
downloadVolumeFactor?: number; // 0 = freeleech, 1 = normal
uploadVolumeFactor?: number; // 1 = normal, 2 = double upload
+ /**
+ * The v1 infohash, hex. Spec'd as an enumerated Torznab attribute and read
+ * by Prowlarr to match a release against a client's existing torrents
+ * without downloading the .torrent first. We already have it — it is the
+ * `guid` of every local item — so emitting it costs nothing and saves the
+ * consumer a round trip.
+ */
+ infoHash?: string;
+ /**
+ * What the site will require of this release once it is grabbed.
+ *
+ * Both are spec'd for exactly this: a tracker stating its seeding
+ * requirements per torrent so the client can honour them by itself. We
+ * enforce a minimum ratio (announce-time gate) and a hit-and-run seed time
+ * (a sanction, after the fact) and until now told nobody in advance — the
+ * member found out when the gate closed or the sanction landed. The same
+ * two numbers, sent through the channel Sonarr / Radarr already read, turn
+ * hit-and-run from a trap into a contract.
+ *
+ * Omitted (rather than sent as 0) when the site does not impose them, since
+ * a stated 0 and an absent value are the same instruction and the shorter
+ * one cannot be misread as "seed for zero seconds".
+ */
+ minimumRatio?: number;
+ /** Seconds. */
+ minimumSeedTime?: number;
// Torznab predefined external-id attributes (issue #47). Sonarr /
// Radarr / Lidarr use these to match a release against their own
// library. We pass them through as-is — IMDb keeps its `tt` prefix,
@@ -153,6 +179,25 @@ export function buildSearchXml(feed: TorznabFeed): string {
` `,
];
+ if (item.infoHash) {
+ attrs.push(
+ ` `
+ );
+ }
+ // `> 0` and not `!= null`: a site with no ratio requirement stores 0,
+ // and forwarding that would read as a requirement of zero rather than
+ // as the absence of one.
+ if (item.minimumRatio && item.minimumRatio > 0) {
+ attrs.push(
+ ` `
+ );
+ }
+ if (item.minimumSeedTime && item.minimumSeedTime > 0) {
+ attrs.push(
+ ` `
+ );
+ }
+
if (item.imdbId) {
attrs.push(
` `
diff --git a/apps/api/test/xml.test.ts b/apps/api/test/xml.test.ts
index 9028d295..f974183a 100644
--- a/apps/api/test/xml.test.ts
+++ b/apps/api/test/xml.test.ts
@@ -79,3 +79,48 @@ describe('buildSearchXml — CDATA breakout (finding M5)', () => {
expect(xml).not.toContain('Rls ');
});
});
+
+describe('buildSearchXml — seeding obligations and infohash', () => {
+ it('emits infohash, minimumratio and minimumseedtime when the site sets them', () => {
+ const xml = buildSearchXml(
+ feed([
+ item({
+ infoHash: 'a'.repeat(40),
+ minimumRatio: 0.8,
+ minimumSeedTime: 172800,
+ }),
+ ])
+ );
+
+ expect(xml).toContain(``);
+ expect(xml).toContain('');
+ expect(xml).toContain('');
+ });
+
+ it('omits an obligation the site does not impose rather than sending 0', () => {
+ // A stated 0 and an absent value mean the same thing to a client, and the
+ // stated one can be misread as "seed for zero seconds".
+ const xml = buildSearchXml(
+ feed([item({ infoHash: 'b'.repeat(40), minimumRatio: 0, minimumSeedTime: 0 })])
+ );
+
+ expect(xml).toContain('name="infohash"');
+ expect(xml).not.toContain('name="minimumratio"');
+ expect(xml).not.toContain('name="minimumseedtime"');
+ });
+
+ it('omits infohash entirely when the item carries none', () => {
+ // Mirrored (federated) releases are the case: we hold the hash, but the
+ // obligations belong to the origin instance, so only the hash is sent.
+ const xml = buildSearchXml(feed([item()]));
+ expect(xml).not.toContain('name="infohash"');
+ });
+
+ it('carries the volume factors through unchanged', () => {
+ const xml = buildSearchXml(
+ feed([item({ downloadVolumeFactor: 0, uploadVolumeFactor: 2 })])
+ );
+ expect(xml).toContain('');
+ expect(xml).toContain('');
+ });
+});
diff --git a/doc/integrations/torznab.md b/doc/integrations/torznab.md
index 6f3b801d..573cc297 100644
--- a/doc/integrations/torznab.md
+++ b/doc/integrations/torznab.md
@@ -198,14 +198,55 @@ Search results are returned as RSS 2.0 with Torznab namespace extensions:
abc123...Wed, 08 Jan 2025 10:00:00 +00004500000000
+
+
+
+
+
+
+
+
+
```
+### Attributes
+
+| Attribute | Source | Notes |
+| --- | --- | --- |
+| `category` | mapped Newznab id | `8000` (Other) when nothing maps |
+| `size`, `seeders`, `peers`, `grabs` | catalogue + live swarm | — |
+| `downloadvolumefactor` | the **running bonus event** | `0` during a freeleech, `0.5` during a silverleech, `1` otherwise |
+| `uploadvolumefactor` | the **running bonus event** | `1` normally |
+| `infohash` | `torrents.info_hash` | v1 hash, hex. Lets a consumer match a release against torrents it already holds without fetching the `.torrent` |
+| `minimumratio` | `min_ratio` setting | **Omitted** when the site sets no minimum |
+| `minimumseedtime` | `hnr_required_seed_time` setting | Seconds. **Omitted** unless hit-and-run is enabled |
+| `imdbid`, `tmdbid`, `tvdbid` | stored external ids | TMDb is emitted bare (no `tv/` or `movie/` prefix) |
+
+The two obligation attributes are the same numbers the tracker already
+enforces — a minimum ratio at announce time, a required seed time as a
+hit-and-run sanction. Sending them in the feed lets a client honour them
+by itself instead of finding out when the gate closes.
+
+> [!NOTE]
+> Both are **site-wide** settings, so every item on a page carries the same
+> pair. Per-torrent multipliers and per-torrent seeding requirements do not
+> exist yet; when they do, these attributes are already the right channel.
+
+Volume factors reflect what the site is doing **at the moment of the
+request**. A client that grabbed during a freeleech and re-reads the feed
+afterwards will see `1` — the attribute describes the site's current rates,
+not a promise attached to the release.
+
+Mirrored (federated) items carry `infohash` but **no** obligations and
+neutral volume factors: those belong to the instance that actually holds
+the release, and this instance does not mirror them.
+
## Error Handling
Errors are returned as XML:
From efe984542ba089567fdbf6a31ab3d48b01ab03c9 Mon Sep 17 00:00:00 2001
From: dim145
Date: Mon, 31 Aug 2026 23:35:11 +0200
Subject: [PATCH 02/96] feat(tracker): the second infohash a hybrid torrent
announces under
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A v2 or hybrid torrent has two infohashes, and the announce path knew one.
BEP 52 kept SHA-256 for content addressing but the tracker protocol has no
room for 32 bytes, so a v2 client announces the SHA-256 truncated to 20. A
hybrid torrent carries both formats and a v2-capable client joins BOTH
swarms — two announces, two hashes, one release. The lookup was
`WHERE info_hash = $1` and nothing else, so the second one found no row.
What that produced: a torrent that worked, and beside it an announce
erroring every interval; and a swarm cut in half, because v1-only peers and
v2-capable peers were keyed apart in Redis and could not be handed each
other's addresses. Both halves were seeding the same bytes and neither
could see the other.
`ResolveAnnouncedTorrent` tries v1 first and falls back to the v2 form only
on a miss. The v1 arm is the unique-index hit it always was and pays
nothing for any of this; the fallback is a partial expression index over
`left(info_hash_v2, 40)`, which is exactly what a client sends. A v2
announce costs two lookups where a v1 announce costs one, which is the
right way round — the rare case pays.
It returns the CANONICAL v1 hash, and `ProcessAnnounce` reassigns
`infoHashHex` to it. That one substitution is what merges the swarm: every
keyed operation downstream — dedup window, peer set, completed counter,
seed time, anti-cheat — reads that variable and therefore agrees. Both
transports share the processor, so UDP gets it for free.
`/scrape` needed its own answer. It had cost zero queries: each hash read
straight out of Redis, up to 64 per request. Resolving all of them would
be a denial of service handed out for free; resolving none would leave a v2
client scraping zeroes forever, since the swarm now lives under the other
key. So only hashes Redis has never heard of are resolved, at most 8 per
request, and past that budget the answer is what it was before.
The spec does not say how a client should announce when it joins both
swarms, so the choice is stated rather than assumed: nothing deduplicates a
peer that announces both under two different peer_ids. libtorrent reuses
one, so the Redis key collapses the pair by itself and the common case is
exact. A client that rotated its id is counted twice — the same as a member
running two clients today, bounded by the same per-announce cap and the
same heuristics. Deduplicating by (user, torrent) would mean rebuilding the
peer store around a different key, which is far more than this warrants.
And a latent bug this turned into a live one. `infoHashV2` was hashed over
a RE-ENCODE of the decoded info dict. The note above it said that was
affordable because nothing matched on the value — true when it was written,
false the moment the announce path did. A re-encode equals the real
infohash for a canonical torrent and diverges for one whose keys are
unsorted or whose paths are not valid UTF-8, which is the failure nobody
would ever find: one member, one unusual client, one hybrid torrent
announcing into a swarm that does not exist, on a site where every other
hybrid torrent works.
So the bytes are located instead. `infoDictRange` walks bencode structure
without interpreting a single value — it only needs to know where each one
ends — and returns the half-open range of the top-level `info`. Bounded by
the buffer on every path, so a truncated or hostile file cannot make it
loop; anything it cannot walk is unaddressable, which is the same answer
those files already got.
Existing rows were written under the old rule and there is no way to tell
the wrong ones from the right ones by looking. The backfill cursor is
versioned instead, which sends the sweep over the catalogue once more,
re-deriving from the `.torrent` bytes still stored. Cheaper than a data
migration and it cannot lock the table: the same capped, locked, resumable
walk that filled the columns in the first place.
Verified against a real Postgres: the planner picks the partial index, a
v2 announce resolves to the canonical hash, and a v1-only torrent stays out
of reach of that arm.
Co-Authored-By: Claude Opus 5
---
README.md | 1 +
apps/api/plugins/backfill-content-roots.ts | 21 +-
apps/api/test/bittorrentV2.test.ts | 107 +-
apps/api/utils/bittorrentV2.ts | 140 +-
apps/tracker/db/queries/torrents.sql | 26 +
apps/tracker/db/schema.sql | 5 +
apps/tracker/internal/db/cache.go | 61 +
apps/tracker/internal/queries/querier.go | 20 +
apps/tracker/internal/queries/torrents.sql.go | 39 +
apps/tracker/internal/server/handler.go | 77 +-
apps/tracker/internal/udp/server.go | 7 +-
doc/.vitepress/config.ts | 1 +
doc/guide/bittorrent-v2.md | 90 +
.../db/src/migrations/0058_bep52_announce.sql | 1 +
.../db/src/migrations/meta/0058_snapshot.json | 9543 +++++++++++++++++
packages/db/src/migrations/meta/_journal.json | 7 +
packages/db/src/schema.ts | 17 +
17 files changed, 10145 insertions(+), 18 deletions(-)
create mode 100644 doc/guide/bittorrent-v2.md
create mode 100644 packages/db/src/migrations/0058_bep52_announce.sql
create mode 100644 packages/db/src/migrations/meta/0058_snapshot.json
diff --git a/README.md b/README.md
index 774b39f5..fa031f3b 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre
- **UDP announce (BEP 15)** on `6969/udp`; ~6×–8× cheaper on the wire than HTTP; stateless `connection_id` = HMAC-SHA256(secret, ip ‖ minute), so no per-id memory.
- **BEP 41 URL_DATA passkey** — `udp://host:6969/announce/PASSKEY` works as-is in every modern client.
- **Multi-tier `.torrent` files** — generator advertises HTTP + UDP independently. `TRACKER_UDP_ENABLED=false` disables UDP and drops it from new `.torrent` files in one go.
+- **BitTorrent v2 (BEP 52)** — a v2 or hybrid torrent announces under a second, truncated SHA-256 infohash; the tracker resolves either form and keys both on the canonical hash, so a hybrid torrent's two swarms are one. `content_root_v2` gives cross-tracker content addressing that piece length and the private flag cannot move. See [BitTorrent v2](doc/guide/bittorrent-v2.md).
- **Torznab that tells the truth** — `minimumratio` and `minimumseedtime` carry the site's real obligations to Sonarr / Radarr, and the volume factors follow the running bonus event instead of claiming normal rates during a freeleech.
### Bonus economy & resilience
diff --git a/apps/api/plugins/backfill-content-roots.ts b/apps/api/plugins/backfill-content-roots.ts
index 698aa419..19740d36 100644
--- a/apps/api/plugins/backfill-content-roots.ts
+++ b/apps/api/plugins/backfill-content-roots.ts
@@ -11,6 +11,23 @@
*
* The parse is the cost, so the batch is capped and a cross-replica lock keeps
* one replica doing the work.
+ *
+ * ## Why the cursor key is versioned
+ *
+ * `info_hash_v2` used to be hashed over a RE-ENCODE of the decoded info dict.
+ * That equals the real BEP 52 infohash for a canonical torrent and diverges for
+ * one whose keys are unsorted or whose paths are not valid UTF-8 — which was
+ * affordable while nothing matched on the value, and stopped being affordable
+ * the moment the announce path did (see `utils/bittorrentV2`).
+ *
+ * Rows written under the old rule are therefore wrong for a minority of
+ * torrents, and there is no way to tell which from the stored value alone. So
+ * the cursor key carries a version: bumping it sends the sweep over the
+ * catalogue once more, re-deriving every row from the bytes it still holds.
+ * A row that was already right is rewritten with the same value.
+ *
+ * Cheaper than a data migration and it cannot lock the table: the same capped,
+ * locked, resumable walk that filled the columns in the first place.
*/
import { db, schema } from '@trackarr/db';
import { and, asc, eq, gt, isNotNull } from 'drizzle-orm';
@@ -21,7 +38,9 @@ import { withCronLock } from '~~/utils/cronLock';
const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
const FIRST_RUN_DELAY_MS = 75 * 1000;
const BATCH_SIZE = 50;
-const CURSOR_KEY = 'content_root_v2_backfill_cursor';
+// v2: re-derives `info_hash_v2` from the original info-dict bytes rather than a
+// re-encode. Bump this (and say why above) whenever the derivation changes.
+const CURSOR_KEY = 'content_root_v2_backfill_cursor_v2';
async function tick(): Promise<{ processed: number; v2: number }> {
const cursor = (await getSetting(CURSOR_KEY)) ?? '';
diff --git a/apps/api/test/bittorrentV2.test.ts b/apps/api/test/bittorrentV2.test.ts
index 188016d5..ad451a54 100644
--- a/apps/api/test/bittorrentV2.test.ts
+++ b/apps/api/test/bittorrentV2.test.ts
@@ -1,7 +1,11 @@
import { describe, it, expect } from 'vitest';
import bencode from 'bencode';
import { createHash } from 'node:crypto';
-import { extractV2 } from '../utils/bittorrentV2';
+import {
+ extractV2,
+ infoDictRange,
+ truncateV2,
+} from '../utils/bittorrentV2';
// The whole value of this module is crypto correctness, so the tests build
// bencoded torrents by hand and assert the exact bytes-derived values — above
@@ -109,3 +113,104 @@ describe('extractV2', () => {
expect(extractV2(Buffer.from('not bencode'))).toBeNull();
});
});
+
+// The announce path matches on the v2 hash, so it has to be the hash a CLIENT
+// computes — which is the SHA-256 of the info dict's original bytes, not of a
+// re-encoding of the decoded value. The two agree for a canonical torrent, so
+// the tests above cannot tell them apart; these can.
+describe('infoDictRange', () => {
+ const raw = (s: string) => Buffer.from(s, 'latin1');
+ const bstr = (s: string) =>
+ raw(`${Buffer.byteLength(s, 'latin1')}:${s}`);
+
+ /** A bencoded dict with the keys in the order given — canonical or not. */
+ function dictOf(pairs: Array<[string, Buffer]>): Buffer {
+ return Buffer.concat([
+ raw('d'),
+ ...pairs.flatMap(([k, v]) => [bstr(k), v]),
+ raw('e'),
+ ]);
+ }
+
+ const v2InfoBytes = (reversed = false) => {
+ const tree = dictOf([
+ [
+ 'a.bin',
+ dictOf([
+ [
+ '',
+ dictOf([
+ ['length', raw('i100e')],
+ ['pieces root', Buffer.concat([raw('32:'), Buffer.alloc(32, 0xab)])],
+ ]),
+ ],
+ ]),
+ ],
+ ]);
+ const pairs: Array<[string, Buffer]> = [
+ ['file tree', tree],
+ ['meta version', raw('i2e')],
+ ['name', bstr('rel')],
+ ['piece length', raw('i16384e')],
+ ];
+ return dictOf(reversed ? [...pairs].reverse() : pairs);
+ };
+
+ const fileOf = (info: Buffer) =>
+ Buffer.concat([
+ raw('d'),
+ bstr('announce'),
+ bstr('http://t/a'),
+ bstr('info'),
+ info,
+ // A key AFTER `info`, so a scanner that ran to the end of the file
+ // instead of the end of the value would be caught.
+ bstr('zz'),
+ raw('i1e'),
+ raw('e'),
+ ]);
+
+ it('locates the info dict exactly, and stops at its end', () => {
+ const info = v2InfoBytes();
+ const file = fileOf(info);
+ const range = infoDictRange(file)!;
+ expect(range).not.toBeNull();
+ expect(file.subarray(range.start, range.end).equals(info)).toBe(true);
+ });
+
+ it('hashes the file bytes even when the info keys are out of order', () => {
+ // The case a re-encode gets wrong: a client that emits keys unsorted.
+ const info = v2InfoBytes(true);
+ const file = fileOf(info);
+ const fromFile = createHash('sha256').update(info).digest('hex');
+ const r = extractV2(file);
+ expect(r!.infoHashV2).toBe(fromFile);
+ // And NOT the re-encoded (canonically re-sorted) value.
+ expect(r!.infoHashV2).not.toBe(
+ sha256(bencode.encode(bencode.decode(info) as Record))
+ );
+ });
+
+ it('derives the 20-byte announce form from it', () => {
+ const r = extractV2(fileOf(v2InfoBytes()))!;
+ expect(r.infoHashV2Short).toHaveLength(40);
+ expect(r.infoHashV2Short).toBe(r.infoHashV2.slice(0, 40));
+ expect(truncateV2(r.infoHashV2)).toBe(r.infoHashV2Short);
+ });
+
+ it('returns null on malformed input rather than reading past the end', () => {
+ expect(infoDictRange(Buffer.alloc(0))).toBeNull();
+ expect(infoDictRange(raw('l4:infoi1ee'))).toBeNull(); // list root
+ expect(infoDictRange(raw('d4:info999:ab'))).toBeNull(); // length past EOF
+ expect(infoDictRange(raw('d8:announce4:hehee'))).toBeNull(); // no info key
+ expect(infoDictRange(raw('d4:infod'))).toBeNull(); // unterminated
+ });
+
+ it('terminates on deeply nested junk', () => {
+ // Every branch is bounded by the buffer, so this is linear rather than a
+ // place to hang the announce path.
+ const started = Date.now();
+ expect(infoDictRange(raw('d' + 'd'.repeat(4000)))).toBeNull();
+ expect(Date.now() - started).toBeLessThan(1000);
+ });
+});
diff --git a/apps/api/utils/bittorrentV2.ts b/apps/api/utils/bittorrentV2.ts
index 4c662de0..5e0fcecd 100644
--- a/apps/api/utils/bittorrentV2.ts
+++ b/apps/api/utils/bittorrentV2.ts
@@ -41,6 +41,16 @@ import { createHash } from 'node:crypto';
export interface V2Content {
/** SHA-256 of the bencoded `info` dict, hex. Torrent-specific (hybrid announce). */
infoHashV2: string;
+ /**
+ * The first 20 bytes of `infoHashV2`, hex — what a v2 or hybrid client
+ * actually sends to the tracker.
+ *
+ * BEP 52 keeps the SHA-256 for content addressing but the tracker and DHT
+ * protocols were built around 20-byte hashes, so a v2 announce carries the
+ * SHA-256 truncated to 20 bytes. That is the value the announce path matches
+ * on; it is derived rather than stored, so the two can never disagree.
+ */
+ infoHashV2Short: string;
/** Cross-tracker content key over the sorted per-file roots, hex. */
contentRootV2: string;
/** Per-file Merkle roots, sorted by path. `root` is '' for a zero-length file. */
@@ -109,6 +119,99 @@ function collectRoots(
return true;
}
+/**
+ * Where the `info` dictionary's bytes start and end inside a `.torrent`.
+ *
+ * An infohash — v1 or v2 — is the hash of the ORIGINAL bytes of the info dict,
+ * not of a re-encoding of the decoded value. The two agree for a canonical
+ * torrent (sorted keys, valid UTF-8 paths) and diverge for everything else, and
+ * "everything else" exists in the wild: a client that emits keys out of order,
+ * or a path that is not valid UTF-8, which a decoder surfaces as something it
+ * cannot round-trip.
+ *
+ * The divergence used to be affordable because nothing matched on the v2 hash
+ * — the note further down said so. The announce path matches on it now, and a
+ * hash that is *usually* right is exactly the failure mode nobody would find:
+ * a member with an unusual client whose hybrid torrent announces into a swarm
+ * that does not exist, on a site where every other hybrid torrent works.
+ *
+ * So the bytes are located instead. This is a bencode scanner that walks
+ * structure without interpreting it — it does not need to understand a single
+ * value, only where each one ends — and returns the half-open range of the
+ * top-level `info` value.
+ *
+ * Returns null for anything it cannot walk: a truncated file, a non-dict root,
+ * no `info` key. Bounded by the buffer length on every path, so a hostile file
+ * cannot make it loop.
+ */
+export function infoDictRange(
+ bytes: Buffer
+): { start: number; end: number } | null {
+ // `skip` returns the index one past the value that starts at `i`, or -1.
+ const skip = (i: number): number => {
+ if (i >= bytes.length) return -1;
+ const c = bytes[i]!;
+
+ // Integer: `ie`.
+ if (c === 0x69 /* i */) {
+ const e = bytes.indexOf(0x65 /* e */, i + 1);
+ return e === -1 ? -1 : e + 1;
+ }
+
+ // Dict or list: recurse until the matching `e`.
+ if (c === 0x64 /* d */ || c === 0x6c /* l */) {
+ let j = i + 1;
+ while (j < bytes.length && bytes[j] !== 0x65 /* e */) {
+ const next = skip(j);
+ if (next <= j) return -1; // no progress: malformed
+ j = next;
+ }
+ return j < bytes.length ? j + 1 : -1;
+ }
+
+ // Byte string: `:`. Digits only — a leading `-` or a
+ // missing colon is malformed, not a negative length.
+ if (c >= 0x30 && c <= 0x39) {
+ const colon = bytes.indexOf(0x3a /* : */, i);
+ if (colon === -1) return -1;
+ const digits = bytes.toString('latin1', i, colon);
+ if (!/^[0-9]+$/.test(digits)) return -1;
+ const len = Number.parseInt(digits, 10);
+ // `Number.parseInt` on a 20-digit length yields something past any real
+ // buffer; the bound below rejects it either way.
+ const end = colon + 1 + len;
+ return end <= bytes.length ? end : -1;
+ }
+
+ return -1;
+ };
+
+ if (bytes.length < 2 || bytes[0] !== 0x64 /* d */) return null;
+
+ let i = 1;
+ while (i < bytes.length && bytes[i] !== 0x65 /* e */) {
+ // Every key in a bencoded dict is a byte string.
+ const keyStart = i;
+ const keyEnd = skip(keyStart);
+ if (keyEnd <= keyStart) return null;
+ const colon = bytes.indexOf(0x3a /* : */, keyStart);
+ if (colon === -1 || colon >= keyEnd) return null;
+ const key = bytes.toString('latin1', colon + 1, keyEnd);
+
+ const valEnd = skip(keyEnd);
+ if (valEnd <= keyEnd) return null;
+
+ if (key === 'info') return { start: keyEnd, end: valEnd };
+ i = valEnd;
+ }
+ return null;
+}
+
+/** The 20-byte truncation BEP 52 announces, as hex. */
+export function truncateV2(infoHashV2Hex: string): string {
+ return infoHashV2Hex.slice(0, 40);
+}
+
/**
* Derive v2 content addressing from raw `.torrent` bytes. Compute it from the
* exact bytes you store and serve (post-normalisation), so a client re-deriving
@@ -138,18 +241,28 @@ export function extractV2(torrentBytes: Buffer | Uint8Array): V2Content | null {
roots.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
- // NOTE: this hashes a RE-ENCODE of the decoded info dict, not the original
- // byte slice. For a canonical torrent (sorted keys, UTF-8 paths) that equals
- // the true BEP-52 infohash; for a non-canonical dict, or a path that is not
- // valid UTF-8 (which `bencode` surfaces as a hex string), it diverges. We can
- // afford that because `infoHashV2` is only stored and carried in the record —
- // nothing joins or matches on it — and `contentRootV2` (the key that IS
- // matched) stays deterministic within this codebase, so opentracker↔opentracker
- // matching is unaffected. If a future consumer needs the portable, exact v2
- // infohash, compute it from the original info-dict byte range instead.
+ // Hashed over the ORIGINAL info-dict bytes, located by `infoDictRange`.
+ //
+ // This used to hash a re-encode of the decoded dict, which is the same thing
+ // for a canonical torrent and a different thing for one whose keys are out of
+ // order or whose paths are not valid UTF-8. That was affordable while nothing
+ // matched on the value; the announce path matches on it now, so it has to be
+ // the hash a client computes rather than a hash that usually is.
+ //
+ // A file we cannot locate the range in is treated as unaddressable — the same
+ // answer as a malformed file, and the same answer as before for anything that
+ // was never going to work.
+ const raw = Buffer.isBuffer(torrentBytes)
+ ? torrentBytes
+ : Buffer.from(torrentBytes);
+ const range = infoDictRange(raw);
+ if (!range) return null;
+
let infoHashV2: string;
try {
- infoHashV2 = createHash('sha256').update(bencode.encode(info)).digest('hex');
+ infoHashV2 = createHash('sha256')
+ .update(raw.subarray(range.start, range.end))
+ .digest('hex');
} catch {
return null;
}
@@ -157,5 +270,10 @@ export function extractV2(torrentBytes: Buffer | Uint8Array): V2Content | null {
.update(JSON.stringify(roots))
.digest('hex');
- return { infoHashV2, contentRootV2, fileRoots: roots };
+ return {
+ infoHashV2,
+ infoHashV2Short: truncateV2(infoHashV2),
+ contentRootV2,
+ fileRoots: roots,
+ };
}
diff --git a/apps/tracker/db/queries/torrents.sql b/apps/tracker/db/queries/torrents.sql
index d76753dd..162633d4 100644
--- a/apps/tracker/db/queries/torrents.sql
+++ b/apps/tracker/db/queries/torrents.sql
@@ -6,3 +6,29 @@ SELECT id
WHERE info_hash = $1
AND is_active = true
LIMIT 1;
+
+-- name: FindActiveTorrentByInfoHashV2Short :one
+-- The BEP 52 second swarm.
+--
+-- A v2 or hybrid torrent has two infohashes and a client that supports v2
+-- announces the SHA-256 one — truncated to 20 bytes, because the tracker
+-- protocol has no room for 32. That truncation is the first 40 hex characters
+-- of `info_hash_v2`, which is what this matches.
+--
+-- Returns the canonical `info_hash` alongside the id, and the caller switches
+-- to it as the swarm key. That is what merges the two halves of a hybrid
+-- torrent's swarm instead of leaving v1-only and v2-capable peers unable to
+-- see each other.
+--
+-- Served by `torrents_info_hash_v2_short_idx`, a partial expression index — so
+-- this costs an index lookup, not a scan, and only v2 rows are in it.
+--
+-- The parameter is cast: `info_hash_v2` is nullable, so sqlc infers a nullable
+-- argument from a bare comparison and generates `*string` for a value the
+-- caller always has. The cast is on the parameter, not on the column, so the
+-- expression index still serves the predicate.
+SELECT id, info_hash
+ FROM torrents
+ WHERE left(info_hash_v2, 40) = sqlc.arg(announced_hash)::text
+ AND is_active = true
+ LIMIT 1;
diff --git a/apps/tracker/db/schema.sql b/apps/tracker/db/schema.sql
index 3f65208b..f1511413 100644
--- a/apps/tracker/db/schema.sql
+++ b/apps/tracker/db/schema.sql
@@ -29,6 +29,11 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS torrents (
id text PRIMARY KEY,
info_hash text NOT NULL UNIQUE,
+ -- SHA-256 of the v2 info dict, hex, for a v2 or hybrid torrent; NULL for
+ -- a v1-only one. A client announcing into the v2 swarm sends the first 20
+ -- bytes of this, so the announce path looks up `left(info_hash_v2, 40)`.
+ -- Written by the api at upload time; the tracker only reads it.
+ info_hash_v2 text,
name text NOT NULL,
size bigint NOT NULL,
description text,
diff --git a/apps/tracker/internal/db/cache.go b/apps/tracker/internal/db/cache.go
index b545e02c..760272b0 100644
--- a/apps/tracker/internal/db/cache.go
+++ b/apps/tracker/internal/db/cache.go
@@ -177,6 +177,67 @@ func (d *DB) IsIpBanned(ctx context.Context, ip string) (bool, error) {
return banned, nil
}
+// ResolveAnnouncedTorrent maps the infohash a client announced onto a torrent
+// row and the swarm key its peers belong under.
+//
+// One infohash used to be the whole story. BEP 52 gave a torrent two: the v1
+// SHA-1 and the v2 SHA-256, the latter truncated to 20 bytes on the wire
+// because the tracker protocol has no room for 32. A hybrid torrent carries
+// both, and a client that speaks v2 joins BOTH swarms — so it announces twice,
+// under two different hashes, for the same content.
+//
+// Before this, the second announce found no row: the lookup was `info_hash`
+// and nothing else. What the member saw was a torrent that worked and, beside
+// it, an announce erroring every interval; what the swarm got was two halves
+// that could not see each other, since v1-only peers and v2-capable peers were
+// keyed apart in Redis.
+//
+// So: try v1 first, and only fall back to the v2 form when that misses.
+//
+// - The v1 lookup is a unique-index hit and the overwhelmingly common case.
+// It is unchanged, and pays nothing for any of this.
+// - The fallback is a partial expression index over the v2 rows only. A v2
+// announce therefore costs two lookups where a v1 announce costs one,
+// which is the right way round: the rare case pays.
+//
+// The returned `swarmKey` is the CANONICAL `info_hash` in both cases. Callers
+// use it for every keyed operation — peer set, dedup window, completed
+// counter, seed-time bookkeeping — and that single substitution is what merges
+// a hybrid torrent's two swarms into one.
+//
+// A note on what this deliberately does not do: it does not deduplicate a peer
+// that announces both swarms with two different peer_ids. libtorrent reuses one
+// peer_id, so the Redis key (swarm, peer) collapses the pair by itself and the
+// common case is exact. A client that rotated its id would be counted twice —
+// the same as a member running two clients today, and bounded by the same
+// per-announce cap and anti-cheat heuristics. Deduplicating by (user, torrent)
+// instead would mean rebuilding the peer store around a different key, which is
+// a much larger change than the bug warrants.
+func (d *DB) ResolveAnnouncedTorrent(
+ ctx context.Context,
+ announcedHex string,
+) (torrentID string, swarmKey string, err error) {
+ id, err := d.Q.FindActiveTorrentByInfoHash(ctx, announcedHex)
+ if err == nil {
+ return id, announcedHex, nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return "", "", err
+ }
+
+ row, v2Err := d.Q.FindActiveTorrentByInfoHashV2Short(ctx, announcedHex)
+ if v2Err != nil {
+ // Report the v1 miss, not the v2 one: pgx.ErrNoRows from either arm
+ // means the same thing to the caller ("no such torrent"), and a
+ // transient v2 failure would otherwise mask a clean not-found.
+ if errors.Is(v2Err, pgx.ErrNoRows) {
+ return "", "", err
+ }
+ return "", "", v2Err
+ }
+ return row.ID, row.InfoHash, nil
+}
+
// InvalidateCache drops every cached setting. Used in tests.
func (d *DB) InvalidateCache() {
d.cacheMu.Lock()
diff --git a/apps/tracker/internal/queries/querier.go b/apps/tracker/internal/queries/querier.go
index 984ed234..84809f95 100644
--- a/apps/tracker/internal/queries/querier.go
+++ b/apps/tracker/internal/queries/querier.go
@@ -28,6 +28,26 @@ type Querier interface {
// Returns the active torrent matching the given hex info_hash, or no rows
// if either it doesn't exist or it's been deactivated.
FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (string, error)
+ // The BEP 52 second swarm.
+ //
+ // A v2 or hybrid torrent has two infohashes and a client that supports v2
+ // announces the SHA-256 one — truncated to 20 bytes, because the tracker
+ // protocol has no room for 32. That truncation is the first 40 hex characters
+ // of `info_hash_v2`, which is what this matches.
+ //
+ // Returns the canonical `info_hash` alongside the id, and the caller switches
+ // to it as the swarm key. That is what merges the two halves of a hybrid
+ // torrent's swarm instead of leaving v1-only and v2-capable peers unable to
+ // see each other.
+ //
+ // Served by `torrents_info_hash_v2_short_idx`, a partial expression index — so
+ // this costs an index lookup, not a scan, and only v2 rows are in it.
+ //
+ // The parameter is cast: `info_hash_v2` is nullable, so sqlc infers a nullable
+ // argument from a bare comparison and generates `*string` for a value the
+ // caller always has. The cast is on the parameter, not on the column, so the
+ // expression index still serves the predicate.
+ FindActiveTorrentByInfoHashV2Short(ctx context.Context, announcedHash string) (FindActiveTorrentByInfoHashV2ShortRow, error)
// Resolves (passkey, info_hash) to (user_id, torrent_id). Used right
// before HnR writes so we don't have to round-trip twice.
FindUserAndTorrentByPasskeyAndHash(ctx context.Context, arg FindUserAndTorrentByPasskeyAndHashParams) (FindUserAndTorrentByPasskeyAndHashRow, error)
diff --git a/apps/tracker/internal/queries/torrents.sql.go b/apps/tracker/internal/queries/torrents.sql.go
index d7c8b324..6d1eb5ca 100644
--- a/apps/tracker/internal/queries/torrents.sql.go
+++ b/apps/tracker/internal/queries/torrents.sql.go
@@ -25,3 +25,42 @@ func (q *Queries) FindActiveTorrentByInfoHash(ctx context.Context, infoHash stri
err := row.Scan(&id)
return id, err
}
+
+const findActiveTorrentByInfoHashV2Short = `-- name: FindActiveTorrentByInfoHashV2Short :one
+SELECT id, info_hash
+ FROM torrents
+ WHERE left(info_hash_v2, 40) = $1::text
+ AND is_active = true
+ LIMIT 1
+`
+
+type FindActiveTorrentByInfoHashV2ShortRow struct {
+ ID string
+ InfoHash string
+}
+
+// The BEP 52 second swarm.
+//
+// A v2 or hybrid torrent has two infohashes and a client that supports v2
+// announces the SHA-256 one — truncated to 20 bytes, because the tracker
+// protocol has no room for 32. That truncation is the first 40 hex characters
+// of `info_hash_v2`, which is what this matches.
+//
+// Returns the canonical `info_hash` alongside the id, and the caller switches
+// to it as the swarm key. That is what merges the two halves of a hybrid
+// torrent's swarm instead of leaving v1-only and v2-capable peers unable to
+// see each other.
+//
+// Served by `torrents_info_hash_v2_short_idx`, a partial expression index — so
+// this costs an index lookup, not a scan, and only v2 rows are in it.
+//
+// The parameter is cast: `info_hash_v2` is nullable, so sqlc infers a nullable
+// argument from a bare comparison and generates `*string` for a value the
+// caller always has. The cast is on the parameter, not on the column, so the
+// expression index still serves the predicate.
+func (q *Queries) FindActiveTorrentByInfoHashV2Short(ctx context.Context, announcedHash string) (FindActiveTorrentByInfoHashV2ShortRow, error) {
+ row := q.db.QueryRow(ctx, findActiveTorrentByInfoHashV2Short, announcedHash)
+ var i FindActiveTorrentByInfoHashV2ShortRow
+ err := row.Scan(&i.ID, &i.InfoHash)
+ return i, err
+}
diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go
index ec2bbb10..3fc96def 100644
--- a/apps/tracker/internal/server/handler.go
+++ b/apps/tracker/internal/server/handler.go
@@ -314,7 +314,15 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli
// 3. Torrent must exist and be active. We capture the row's id —
// previously discarded — so step 6 can persist per-(user, torrent)
// byte deltas into hnr_tracking without an extra round-trip.
- torrentID, err := s.db.Q.FindActiveTorrentByInfoHash(ctx, infoHashHex)
+ //
+ // The announced hash is not necessarily the swarm key. A hybrid torrent
+ // (BEP 52) has a v1 and a v2 infohash and a v2-capable client announces
+ // under both; the resolver maps either onto the row and hands back the
+ // CANONICAL v1 hash. Reassigning `infoHashHex` to it here is what puts both
+ // halves of that swarm under one Redis key — every keyed operation below
+ // (dedup window, peer set, completed counter, seed time, anti-cheat) reads
+ // this variable and therefore agrees. See db.ResolveAnnouncedTorrent.
+ torrentID, swarmKey, err := s.db.ResolveAnnouncedTorrent(ctx, infoHashHex)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AnnounceOutcome{Failure: "Torrent not found or inactive"}
@@ -322,6 +330,14 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli
slog.Error("internal error", "where", "find torrent", "err", err)
return AnnounceOutcome{Failure: "Internal tracker error"}
}
+ if swarmKey != infoHashHex {
+ // A v2 announce. Logged at debug rather than info: it is entirely
+ // normal, happens every interval for every v2-capable peer, and the
+ // only reason to want it is diagnosing a swarm that looks split.
+ slog.Debug("v2 announce folded into the v1 swarm",
+ "announced", infoHashHex, "swarm", swarmKey)
+ infoHashHex = swarmKey
+ }
// 4. Dedup window — skip if same {hash,peer,event} fired within 2 seconds
peerHex := hexBytes(req.PeerID[:])
@@ -877,6 +893,58 @@ func (s *Server) recordSeedTime(passkey, infoHashHex string, secondsToAdd int32)
// /scrape
// ----------------------------------------------------------------------------
+// MaxScrapeResolves bounds how many v2 lookups one scrape may trigger.
+//
+// A scrape carries up to 64 hashes and, historically, cost zero database
+// queries: each hash was read straight out of Redis. Resolving every hash
+// would turn one packet into 64 queries, which is a denial-of-service handed
+// out for free. Resolving none would leave a v2 client's scrape permanently
+// answering zero, since the swarm now lives under the canonical key.
+//
+// So only hashes Redis has never heard of are resolved, and only this many per
+// request. Past the budget the answer is what it was before this existed —
+// zeroes — never something worse.
+//
+// Exported so the UDP transport shares the same ceiling.
+const MaxScrapeResolves = 8
+
+// ScrapeStats answers one hash of a scrape, folding the BEP 52 second swarm in.
+//
+// `resolveBudget` is decremented on each database lookup and is shared across
+// one scrape request; pass a pointer to a single counter for the whole batch.
+// Exported because the UDP transport has its own scrape framing but needs the
+// same answer — the two must not drift.
+func (s *Server) ScrapeStats(
+ ctx context.Context,
+ announcedHex string,
+ resolveBudget *int,
+) (seeders, leechers int, completed int64) {
+ seeders, leechers, _ = s.peers.Counts(ctx, announcedHex)
+ completed, _ = s.peers.CompletedCount(ctx, announcedHex)
+
+ // All zero is the only case worth a query, and it is ambiguous: a dead v1
+ // torrent looks exactly like a live v2 one scraped under the wrong key.
+ // The lookup is what tells them apart, and a dead torrent pays one index
+ // probe for it.
+ if seeders != 0 || leechers != 0 || completed != 0 {
+ return seeders, leechers, completed
+ }
+ if resolveBudget == nil || *resolveBudget <= 0 {
+ return seeders, leechers, completed
+ }
+ *resolveBudget--
+
+ _, swarmKey, err := s.db.ResolveAnnouncedTorrent(ctx, announcedHex)
+ if err != nil || swarmKey == announcedHex {
+ // Unknown, or a v1 hash that really has no peers. Either way the
+ // zeroes above are the honest answer.
+ return seeders, leechers, completed
+ }
+ seeders, leechers, _ = s.peers.Counts(ctx, swarmKey)
+ completed, _ = s.peers.CompletedCount(ctx, swarmKey)
+ return seeders, leechers, completed
+}
+
func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -892,6 +960,8 @@ func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stats := make([]ScrapeStat, 0, len(hashes))
+ // One budget for the whole batch — see MaxScrapeResolves.
+ resolveBudget := MaxScrapeResolves
for _, h := range hashes {
if len(h) != announce.InfoHashLen {
continue
@@ -900,9 +970,10 @@ func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
copy(raw[:], h)
hex := hexBytes(raw[:])
- seeders, leechers, _ := s.peers.Counts(ctx, hex)
- completed, _ := s.peers.CompletedCount(ctx, hex)
+ seeders, leechers, completed := s.ScrapeStats(ctx, hex, &resolveBudget)
stats = append(stats, ScrapeStat{
+ // Echoed back as announced, not as resolved: the client asked
+ // about this hash and matches the reply to it.
InfoHashRaw: raw,
Seeders: seeders,
Leechers: leechers,
diff --git a/apps/tracker/internal/udp/server.go b/apps/tracker/internal/udp/server.go
index 944033fd..f02f1013 100644
--- a/apps/tracker/internal/udp/server.go
+++ b/apps/tracker/internal/udp/server.go
@@ -365,11 +365,14 @@ func (s *Server) handleScrape(ctx context.Context, data []byte, raddr *net.UDPAd
count = maxScrapeHashes
}
stats := make([]ScrapeStat, count)
+ // Delegated to the shared processor so a v2 hash resolves here exactly as
+ // it does over HTTP — the framing differs between the two transports, the
+ // answer must not. One budget for the whole packet.
+ resolveBudget := server.MaxScrapeResolves
for i := 0; i < count; i++ {
ih := hashes[i*infoHashSize : (i+1)*infoHashSize]
hex := hexBytes(ih)
- seeders, leechers, _ := s.store.Counts(ctx, hex)
- completed, _ := s.store.CompletedCount(ctx, hex)
+ seeders, leechers, completed := s.proc.ScrapeStats(ctx, hex, &resolveBudget)
stats[i] = ScrapeStat{
Seeders: seeders,
Completed: completed,
diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts
index cb6eff50..ecd85b86 100644
--- a/doc/.vitepress/config.ts
+++ b/doc/.vitepress/config.ts
@@ -95,6 +95,7 @@ export default defineConfig({
{ text: 'Seed Bonus Economy', link: '/guide/seed-bonus' },
{ text: 'Freeleech Pool', link: '/guide/freeleech-pool' },
{ text: 'UDP Tracker (BEP 15)', link: '/guide/udp-tracker' },
+ { text: 'BitTorrent v2 (BEP 52)', link: '/guide/bittorrent-v2' },
{ text: 'Metadata Providers', link: '/guide/metadata-providers' },
{ text: 'Notifications', link: '/guide/notifications' },
{ text: 'Messaging', link: '/guide/messaging' },
diff --git a/doc/guide/bittorrent-v2.md b/doc/guide/bittorrent-v2.md
new file mode 100644
index 00000000..e262c325
--- /dev/null
+++ b/doc/guide/bittorrent-v2.md
@@ -0,0 +1,90 @@
+# BitTorrent v2 (BEP 52)
+
+A v2 or hybrid torrent announces under a **second infohash**. The tracker
+recognises it, and folds both halves of a hybrid torrent's swarm into one.
+
+## The two hashes
+
+BitTorrent v1 identifies a torrent by the SHA-1 of its `info` dictionary. v2
+moved to SHA-256 — but the tracker and DHT protocols were built around 20-byte
+hashes and have no room for 32, so a v2 client announces the **SHA-256
+truncated to 20 bytes**.
+
+A **hybrid** torrent carries the fields of both formats and therefore has both
+hashes. A client that speaks v2 joins both swarms, which means it sends two
+announces, under two different hashes, for the same content.
+
+## What used to happen
+
+The announce path looked a torrent up by `info_hash` and nothing else. So the
+second announce found no row:
+
+- the member saw a torrent that worked and, beside it, an announce erroring
+ every interval;
+- the swarm was split in two, because v1-only peers and v2-capable peers were
+ keyed apart in Redis and could not be handed each other's addresses.
+
+## What happens now
+
+The announce path resolves the hash it was given in two steps:
+
+1. **v1 first** — a unique-index lookup on `info_hash`, the overwhelmingly
+ common case, unchanged and paying nothing for any of this.
+2. **v2 on a miss** — a partial expression index over `left(info_hash_v2, 40)`,
+ which is exactly the truncation a client sends.
+
+Either way the resolver hands back the torrent's **canonical v1 hash**, and the
+announce uses it as the swarm key from there on — peer set, dedup window,
+completed counter, seed-time bookkeeping, anti-cheat. That single substitution
+is what merges the swarm.
+
+A v2 announce therefore costs two lookups where a v1 announce costs one, which
+is the right way round: the rare case pays.
+
+`/scrape` does the same, for both HTTP and UDP, but only for hashes Redis has
+never heard of and at most 8 per request. A scrape carries up to 64 hashes and
+used to cost zero queries; resolving all of them would be a denial of service
+handed out for free. Past that budget the answer is what it was before —
+zeroes — never something worse.
+
+## Peers announcing under both hashes
+
+The spec does not say how a client should announce when it joins both swarms of
+a hybrid torrent, so this is a choice, and it is stated rather than assumed:
+**nothing deduplicates a peer that announces both swarms under two different
+`peer_id`s.**
+
+libtorrent reuses one `peer_id`, so the Redis key `(swarm, peer)` collapses the
+pair by itself and the common case is exact. A client that rotated its id would
+be counted twice — the same as a member running two clients today, and bounded
+by the same per-announce cap and the same anti-cheat heuristics. Deduplicating
+by `(user, torrent)` instead would mean rebuilding the peer store around a
+different key, which is a much larger change than the problem warrants.
+
+## Content addressing
+
+Two more values are derived at upload time and stored on the torrent:
+
+- **`info_hash_v2`** — the SHA-256 of the `info` dictionary, hashed over the
+ file's **original bytes**. Not over a re-encoding of the decoded value: the
+ two agree for a canonical torrent and diverge for one whose keys are unsorted
+ or whose paths are not valid UTF-8, and the announce path has to match the
+ hash a client actually computes.
+- **`content_root_v2`** — a cross-tracker content key over the sorted per-file
+ Merkle roots. Because a v2 file root is a property of the file's content
+ alone — independent of piece length, of the `private` flag, and of every
+ other file in the torrent — two uploads of the same release on two different
+ trackers derive the **same** value.
+
+That second one is the cryptographic upgrade of `content_signature`, which the
+[Cross-seed](./cross-seed.md) surface uses today: same shape, but the root
+proves the bytes where paths-and-sizes only guessed them.
+
+## Upgrading
+
+Nothing to do. Uploads are addressed as they arrive, and a boot-mounted sweep
+walks the existing catalogue once, re-deriving both values from the `.torrent`
+bytes already stored. It is capped, cross-replica locked and resumable, so it
+cannot lock the table or run twice.
+
+A v1-only torrent yields neither value and is unaffected.
diff --git a/packages/db/src/migrations/0058_bep52_announce.sql b/packages/db/src/migrations/0058_bep52_announce.sql
new file mode 100644
index 00000000..81e847cf
--- /dev/null
+++ b/packages/db/src/migrations/0058_bep52_announce.sql
@@ -0,0 +1 @@
+CREATE INDEX "torrents_info_hash_v2_short_idx" ON "torrents" USING btree (left("info_hash_v2", 40)) WHERE "torrents"."info_hash_v2" IS NOT NULL;
\ No newline at end of file
diff --git a/packages/db/src/migrations/meta/0058_snapshot.json b/packages/db/src/migrations/meta/0058_snapshot.json
new file mode 100644
index 00000000..07f67a65
--- /dev/null
+++ b/packages/db/src/migrations/meta/0058_snapshot.json
@@ -0,0 +1,9543 @@
+{
+ "id": "e152909d-3510-445e-a554-924f81f5b41e",
+ "prevId": "ceeb3f42-8bcb-4f7e-860a-d81e67481b18",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.announce_log": {
+ "name": "announce_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "info_hash": {
+ "name": "info_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ip_hash": {
+ "name": "ip_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded": {
+ "name": "uploaded",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "downloaded": {
+ "name": "downloaded",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "left": {
+ "name": "left",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "announce_log_info_hash_idx": {
+ "name": "announce_log_info_hash_idx",
+ "columns": [
+ {
+ "expression": "info_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "announce_log_created_at_idx": {
+ "name": "announce_log_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anticheat_flags": {
+ "name": "anticheat_flags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_hash": {
+ "name": "info_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_by_id": {
+ "name": "reviewed_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_verdict": {
+ "name": "review_verdict",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_note": {
+ "name": "review_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "anticheat_flags_user_idx": {
+ "name": "anticheat_flags_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anticheat_flags_unreviewed_idx": {
+ "name": "anticheat_flags_unreviewed_idx",
+ "columns": [
+ {
+ "expression": "reviewed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anticheat_flags_kind_idx": {
+ "name": "anticheat_flags_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anticheat_flags_no_leecher_open_unique": {
+ "name": "anticheat_flags_no_leecher_open_unique",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "torrent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "kind = 'no_leecher' AND reviewed_at IS NULL AND torrent_id IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "anticheat_flags_user_id_users_id_fk": {
+ "name": "anticheat_flags_user_id_users_id_fk",
+ "tableFrom": "anticheat_flags",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "anticheat_flags_torrent_id_torrents_id_fk": {
+ "name": "anticheat_flags_torrent_id_torrents_id_fk",
+ "tableFrom": "anticheat_flags",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "anticheat_flags_reviewed_by_id_users_id_fk": {
+ "name": "anticheat_flags_reviewed_by_id_users_id_fk",
+ "tableFrom": "anticheat_flags",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewed_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.banned_ips": {
+ "name": "banned_ips",
+ "schema": "",
+ "columns": {
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bonus_age_tiers": {
+ "name": "bonus_age_tiers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "min_age_days": {
+ "name": "min_age_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "multiplier": {
+ "name": "multiplier",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "bonus_age_tiers_threshold_idx": {
+ "name": "bonus_age_tiers_threshold_idx",
+ "columns": [
+ {
+ "expression": "min_age_days",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bonus_age_tiers_enabled_idx": {
+ "name": "bonus_age_tiers_enabled_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bonus_events": {
+ "name": "bonus_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "long_description": {
+ "name": "long_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "download_multiplier": {
+ "name": "download_multiplier",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upload_multiplier": {
+ "name": "upload_multiplier",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "starts_at": {
+ "name": "starts_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ends_at": {
+ "name": "ends_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "bonus_events_window_idx": {
+ "name": "bonus_events_window_idx",
+ "columns": [
+ {
+ "expression": "starts_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ends_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bonus_events_enabled_idx": {
+ "name": "bonus_events_enabled_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "bonus_events_created_by_id_users_id_fk": {
+ "name": "bonus_events_created_by_id_users_id_fk",
+ "tableFrom": "bonus_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bonus_grants": {
+ "name": "bonus_grants",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "amount": {
+ "name": "amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "bonus_grants_user_idx": {
+ "name": "bonus_grants_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bonus_grants_source_user_torrent_idx": {
+ "name": "bonus_grants_source_user_torrent_idx",
+ "columns": [
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "torrent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bonus_grants_first_seeder_unique_idx": {
+ "name": "bonus_grants_first_seeder_unique_idx",
+ "columns": [
+ {
+ "expression": "torrent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "source = 'first_seeder' AND torrent_id IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "bonus_grants_user_id_users_id_fk": {
+ "name": "bonus_grants_user_id_users_id_fk",
+ "tableFrom": "bonus_grants",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bonus_grants_torrent_id_torrents_id_fk": {
+ "name": "bonus_grants_torrent_id_torrents_id_fk",
+ "tableFrom": "bonus_grants",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bonus_rules": {
+ "name": "bonus_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "bonus_rules_kind_idx": {
+ "name": "bonus_rules_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "bonus_rules_kind_unique": {
+ "name": "bonus_rules_kind_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "kind"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bonus_seed_count_tiers": {
+ "name": "bonus_seed_count_tiers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "max_seeders": {
+ "name": "max_seeders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "multiplier": {
+ "name": "multiplier",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "bonus_seed_count_tiers_threshold_idx": {
+ "name": "bonus_seed_count_tiers_threshold_idx",
+ "columns": [
+ {
+ "expression": "max_seeders",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bonus_seed_count_tiers_enabled_idx": {
+ "name": "bonus_seed_count_tiers_enabled_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.catalog_records": {
+ "name": "catalog_records",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_hash": {
+ "name": "info_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'torrent'"
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local'"
+ },
+ "hops": {
+ "name": "hops",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "via": {
+ "name": "via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "supersedes": {
+ "name": "supersedes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "superseded_at": {
+ "name": "superseded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "catalog_records_info_hash_idx": {
+ "name": "catalog_records_info_hash_idx",
+ "columns": [
+ {
+ "expression": "info_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "catalog_records_created_idx": {
+ "name": "catalog_records_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "catalog_records_relayable_idx": {
+ "name": "catalog_records_relayable_idx",
+ "columns": [
+ {
+ "expression": "origin",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "hops",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "catalog_records_live_idx": {
+ "name": "catalog_records_live_idx",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "superseded_at IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "catalog_records_current_idx": {
+ "name": "catalog_records_current_idx",
+ "columns": [
+ {
+ "expression": "torrent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "superseded_at IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.categories": {
+ "name": "categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "newznab_id": {
+ "name": "newznab_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_adult": {
+ "name": "is_adult",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "categories_parent_idx": {
+ "name": "categories_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "categories_slug_unique": {
+ "name": "categories_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.conversation_participants": {
+ "name": "conversation_participants",
+ "schema": "",
+ "columns": {
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "unread_count": {
+ "name": "unread_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "muted_until": {
+ "name": "muted_until",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ }
+ },
+ "indexes": {
+ "conversation_participants_inbox_idx": {
+ "name": "conversation_participants_inbox_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "archived_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "conversation_participants_conversation_id_conversations_id_fk": {
+ "name": "conversation_participants_conversation_id_conversations_id_fk",
+ "tableFrom": "conversation_participants",
+ "tableTo": "conversations",
+ "columnsFrom": [
+ "conversation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "conversation_participants_user_id_users_id_fk": {
+ "name": "conversation_participants_user_id_users_id_fk",
+ "tableFrom": "conversation_participants",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "conversation_participants_conversation_id_user_id_pk": {
+ "name": "conversation_participants_conversation_id_user_id_pk",
+ "columns": [
+ "conversation_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "conversation_participants_state_ck": {
+ "name": "conversation_participants_state_ck",
+ "value": "\"conversation_participants\".\"state\" IN ('active', 'pending', 'blocked')"
+ },
+ "conversation_participants_unread_ck": {
+ "name": "conversation_participants_unread_ck",
+ "value": "\"conversation_participants\".\"unread_count\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.conversations": {
+ "name": "conversations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pinned_message_id": {
+ "name": "pinned_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pinned_by_id": {
+ "name": "pinned_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encrypted": {
+ "name": "encrypted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "conversations_slug_idx": {
+ "name": "conversations_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "conversations_last_message_idx": {
+ "name": "conversations_last_message_idx",
+ "columns": [
+ {
+ "expression": "last_message_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "conversations_created_by_id_users_id_fk": {
+ "name": "conversations_created_by_id_users_id_fk",
+ "tableFrom": "conversations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "conversations_kind_ck": {
+ "name": "conversations_kind_ck",
+ "value": "\"conversations\".\"kind\" IN ('dm', 'room')"
+ },
+ "conversations_room_slug_ck": {
+ "name": "conversations_room_slug_ck",
+ "value": "(\"conversations\".\"kind\" = 'room') = (\"conversations\".\"slug\" IS NOT NULL)"
+ },
+ "conversations_room_plain_ck": {
+ "name": "conversations_room_plain_ck",
+ "value": "NOT (\"conversations\".\"kind\" = 'room' AND \"conversations\".\"encrypted\")"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.federated_follows": {
+ "name": "federated_follows",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "local_user_id": {
+ "name": "local_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_username": {
+ "name": "remote_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "federated_follows_unique": {
+ "name": "federated_follows_unique",
+ "columns": [
+ {
+ "expression": "local_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "peer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "federated_follows_target_idx": {
+ "name": "federated_follows_target_idx",
+ "columns": [
+ {
+ "expression": "peer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "federated_follows_local_user_id_users_id_fk": {
+ "name": "federated_follows_local_user_id_users_id_fk",
+ "tableFrom": "federated_follows",
+ "tableTo": "users",
+ "columnsFrom": [
+ "local_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "federated_follows_peer_id_federation_peers_id_fk": {
+ "name": "federated_follows_peer_id_federation_peers_id_fk",
+ "tableFrom": "federated_follows",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.federated_identities": {
+ "name": "federated_identities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "local_user_id": {
+ "name": "local_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_username": {
+ "name": "remote_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'bio'"
+ },
+ "subject_did": {
+ "name": "subject_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "evidence": {
+ "name": "evidence",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verify_code": {
+ "name": "verify_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "federated_identities_unique": {
+ "name": "federated_identities_unique",
+ "columns": [
+ {
+ "expression": "local_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "peer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "federated_identities_local_user_id_users_id_fk": {
+ "name": "federated_identities_local_user_id_users_id_fk",
+ "tableFrom": "federated_identities",
+ "tableTo": "users",
+ "columnsFrom": [
+ "local_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "federated_identities_peer_id_federation_peers_id_fk": {
+ "name": "federated_identities_peer_id_federation_peers_id_fk",
+ "tableFrom": "federated_identities",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.federation_config": {
+ "name": "federation_config",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "'singleton'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "relay_enabled": {
+ "name": "relay_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "discoverable": {
+ "name": "discoverable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "instance_name": {
+ "name": "instance_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_url": {
+ "name": "public_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_scopes": {
+ "name": "default_scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"catalog\":false,\"social\":false,\"accounts\":false,\"swarm\":false}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.federation_credit_grants": {
+ "name": "federation_credit_grants",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject_did": {
+ "name": "subject_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_user_id": {
+ "name": "local_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_start": {
+ "name": "period_start",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_end": {
+ "name": "period_end",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "federation_credit_grants_user_idx": {
+ "name": "federation_credit_grants_user_idx",
+ "columns": [
+ {
+ "expression": "local_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "federation_credit_grants_period_idx": {
+ "name": "federation_credit_grants_period_idx",
+ "columns": [
+ {
+ "expression": "peer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "subject_did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "period_end",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "federation_credit_grants_created_idx": {
+ "name": "federation_credit_grants_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "federation_credit_grants_peer_id_federation_peers_id_fk": {
+ "name": "federation_credit_grants_peer_id_federation_peers_id_fk",
+ "tableFrom": "federation_credit_grants",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "federation_credit_grants_local_user_id_users_id_fk": {
+ "name": "federation_credit_grants_local_user_id_users_id_fk",
+ "tableFrom": "federation_credit_grants",
+ "tableTo": "users",
+ "columnsFrom": [
+ "local_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.federation_peers": {
+ "name": "federation_peers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending_out'"
+ },
+ "shares_with_them": {
+ "name": "shares_with_them",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"catalog\":false,\"social\":false,\"accounts\":false,\"swarm\":false}'::jsonb"
+ },
+ "accepts_from_them": {
+ "name": "accepts_from_them",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"catalog\":false,\"social\":false,\"accounts\":false,\"swarm\":false}'::jsonb"
+ },
+ "last_handshake_at": {
+ "name": "last_handshake_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "protocol_version": {
+ "name": "protocol_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "federation_peers_status_idx": {
+ "name": "federation_peers_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "federation_peers_instance_id_unique": {
+ "name": "federation_peers_instance_id_unique",
+ "columns": [
+ {
+ "expression": "instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "instance_id IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "federation_peers_created_by_users_id_fk": {
+ "name": "federation_peers_created_by_users_id_fk",
+ "tableFrom": "federation_peers",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "federation_peers_base_url_unique": {
+ "name": "federation_peers_base_url_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "base_url"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.federation_sync_state": {
+ "name": "federation_sync_state",
+ "schema": "",
+ "columns": {
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cursor": {
+ "name": "cursor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "items_synced": {
+ "name": "items_synced",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "federation_sync_state_peer_id_federation_peers_id_fk": {
+ "name": "federation_sync_state_peer_id_federation_peers_id_fk",
+ "tableFrom": "federation_sync_state",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "federation_sync_state_peer_id_resource_pk": {
+ "name": "federation_sync_state_peer_id_resource_pk",
+ "columns": [
+ "peer_id",
+ "resource"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forum_categories": {
+ "name": "forum_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forum_posts": {
+ "name": "forum_posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "topic_id": {
+ "name": "topic_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "forum_posts_topic_id_forum_topics_id_fk": {
+ "name": "forum_posts_topic_id_forum_topics_id_fk",
+ "tableFrom": "forum_posts",
+ "tableTo": "forum_topics",
+ "columnsFrom": [
+ "topic_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "forum_posts_author_id_users_id_fk": {
+ "name": "forum_posts_author_id_users_id_fk",
+ "tableFrom": "forum_posts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forum_topics": {
+ "name": "forum_topics",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_pinned": {
+ "name": "is_pinned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_locked": {
+ "name": "is_locked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "forum_topics_category_id_forum_categories_id_fk": {
+ "name": "forum_topics_category_id_forum_categories_id_fk",
+ "tableFrom": "forum_topics",
+ "tableTo": "forum_categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "forum_topics_author_id_users_id_fk": {
+ "name": "forum_topics_author_id_users_id_fk",
+ "tableFrom": "forum_topics",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.freeleech_pool_config": {
+ "name": "freeleech_pool_config",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "points_target": {
+ "name": "points_target",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "freeleech_duration_days": {
+ "name": "freeleech_duration_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "contribution_min": {
+ "name": "contribution_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "max_per_user_bp": {
+ "name": "max_per_user_bp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "preset_amounts": {
+ "name": "preset_amounts",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "event_title_template": {
+ "name": "event_title_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_description_template": {
+ "name": "event_description_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_long_description_template": {
+ "name": "event_long_description_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "freeleech_pool_config_singleton": {
+ "name": "freeleech_pool_config_singleton",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "id = 1",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.freeleech_pool_contributions": {
+ "name": "freeleech_pool_contributions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cycle_id": {
+ "name": "cycle_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "freeleech_pool_contributions_cycle_idx": {
+ "name": "freeleech_pool_contributions_cycle_idx",
+ "columns": [
+ {
+ "expression": "cycle_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "freeleech_pool_contributions_cycle_user_idx": {
+ "name": "freeleech_pool_contributions_cycle_user_idx",
+ "columns": [
+ {
+ "expression": "cycle_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "freeleech_pool_contributions_cycle_id_freeleech_pool_cycles_id_fk": {
+ "name": "freeleech_pool_contributions_cycle_id_freeleech_pool_cycles_id_fk",
+ "tableFrom": "freeleech_pool_contributions",
+ "tableTo": "freeleech_pool_cycles",
+ "columnsFrom": [
+ "cycle_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "freeleech_pool_contributions_user_id_users_id_fk": {
+ "name": "freeleech_pool_contributions_user_id_users_id_fk",
+ "tableFrom": "freeleech_pool_contributions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.freeleech_pool_cycles": {
+ "name": "freeleech_pool_cycles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'filling'"
+ },
+ "target_snapshot": {
+ "name": "target_snapshot",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_days_snapshot": {
+ "name": "duration_days_snapshot",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total_contributed": {
+ "name": "total_contributed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ends_at": {
+ "name": "ends_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "triggered_event_id": {
+ "name": "triggered_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_event_title": {
+ "name": "paused_event_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_event_description": {
+ "name": "paused_event_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_event_long_description": {
+ "name": "paused_event_long_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_event_download_multiplier": {
+ "name": "paused_event_download_multiplier",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_event_upload_multiplier": {
+ "name": "paused_event_upload_multiplier",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_event_remaining_ms": {
+ "name": "paused_event_remaining_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_event_created_by_id": {
+ "name": "paused_event_created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "waiting_on_event_id": {
+ "name": "waiting_on_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "freeleech_pool_cycles_status_idx": {
+ "name": "freeleech_pool_cycles_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "freeleech_pool_cycles_open_unique": {
+ "name": "freeleech_pool_cycles_open_unique",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "status IN ('filling', 'full_queued', 'active')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "freeleech_pool_cycles_triggered_event_id_bonus_events_id_fk": {
+ "name": "freeleech_pool_cycles_triggered_event_id_bonus_events_id_fk",
+ "tableFrom": "freeleech_pool_cycles",
+ "tableTo": "bonus_events",
+ "columnsFrom": [
+ "triggered_event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "freeleech_pool_cycles_paused_event_created_by_id_users_id_fk": {
+ "name": "freeleech_pool_cycles_paused_event_created_by_id_users_id_fk",
+ "tableFrom": "freeleech_pool_cycles",
+ "tableTo": "users",
+ "columnsFrom": [
+ "paused_event_created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "freeleech_pool_cycles_waiting_on_event_id_bonus_events_id_fk": {
+ "name": "freeleech_pool_cycles_waiting_on_event_id_bonus_events_id_fk",
+ "tableFrom": "freeleech_pool_cycles",
+ "tableTo": "bonus_events",
+ "columnsFrom": [
+ "waiting_on_event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.freeleech_pool_windows": {
+ "name": "freeleech_pool_windows",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "starts_at": {
+ "name": "starts_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ends_at": {
+ "name": "ends_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weekday_start": {
+ "name": "weekday_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weekday_end": {
+ "name": "weekday_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "minute_start": {
+ "name": "minute_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "minute_end": {
+ "name": "minute_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monthly_days": {
+ "name": "monthly_days",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "year_month_start": {
+ "name": "year_month_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "year_day_start": {
+ "name": "year_day_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "year_month_end": {
+ "name": "year_month_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "year_day_end": {
+ "name": "year_day_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "freeleech_pool_windows_kind_idx": {
+ "name": "freeleech_pool_windows_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "freeleech_pool_windows_oneoff_idx": {
+ "name": "freeleech_pool_windows_oneoff_idx",
+ "columns": [
+ {
+ "expression": "starts_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ends_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.hnr_tracking": {
+ "name": "hnr_tracking",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "downloaded_at": {
+ "name": "downloaded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "seed_time": {
+ "name": "seed_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "required_seed_time": {
+ "name": "required_seed_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 86400
+ },
+ "is_hnr": {
+ "name": "is_hnr",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_exempt": {
+ "name": "is_exempt",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded": {
+ "name": "uploaded",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "downloaded": {
+ "name": "downloaded",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "hnr_torrent_idx": {
+ "name": "hnr_torrent_idx",
+ "columns": [
+ {
+ "expression": "torrent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "hnr_status_idx": {
+ "name": "hnr_status_idx",
+ "columns": [
+ {
+ "expression": "is_hnr",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"hnr_tracking\".\"is_hnr\"",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "hnr_user_torrent_idx": {
+ "name": "hnr_user_torrent_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "torrent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "hnr_user_is_hnr_idx": {
+ "name": "hnr_user_is_hnr_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_hnr",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "hnr_tracking_user_id_users_id_fk": {
+ "name": "hnr_tracking_user_id_users_id_fk",
+ "tableFrom": "hnr_tracking",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "hnr_tracking_torrent_id_torrents_id_fk": {
+ "name": "hnr_tracking_torrent_id_torrents_id_fk",
+ "tableFrom": "hnr_tracking",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitations": {
+ "name": "invitations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_by": {
+ "name": "used_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "invitations_created_by_idx": {
+ "name": "invitations_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitations_code_idx": {
+ "name": "invitations_code_idx",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitations_created_by_users_id_fk": {
+ "name": "invitations_created_by_users_id_fk",
+ "tableFrom": "invitations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitations_used_by_users_id_fk": {
+ "name": "invitations_used_by_users_id_fk",
+ "tableFrom": "invitations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "used_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "invitations_code_unique": {
+ "name": "invitations_code_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "code"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.message_reactions": {
+ "name": "message_reactions",
+ "schema": "",
+ "columns": {
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "message_reactions_message_idx": {
+ "name": "message_reactions_message_idx",
+ "columns": [
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "message_reactions_message_id_messages_id_fk": {
+ "name": "message_reactions_message_id_messages_id_fk",
+ "tableFrom": "message_reactions",
+ "tableTo": "messages",
+ "columnsFrom": [
+ "message_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "message_reactions_user_id_users_id_fk": {
+ "name": "message_reactions_user_id_users_id_fk",
+ "tableFrom": "message_reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "message_reactions_message_id_user_id_key_pk": {
+ "name": "message_reactions_message_id_user_id_key_pk",
+ "columns": [
+ "message_id",
+ "user_id",
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "message_reactions_key_ck": {
+ "name": "message_reactions_key_ck",
+ "value": "\"message_reactions\".\"key\" IN ('up', 'heart', 'haha', 'wow', 'thanks', 'done')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.message_read_log": {
+ "name": "message_read_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reader_id": {
+ "name": "reader_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reader_name": {
+ "name": "reader_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disclosed": {
+ "name": "disclosed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "message_read_log_created_idx": {
+ "name": "message_read_log_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "message_read_log_reader_idx": {
+ "name": "message_read_log_reader_idx",
+ "columns": [
+ {
+ "expression": "reader_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "message_read_log_message_idx": {
+ "name": "message_read_log_message_idx",
+ "columns": [
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "message_read_log_reader_id_users_id_fk": {
+ "name": "message_read_log_reader_id_users_id_fk",
+ "tableFrom": "message_read_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reader_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.messages": {
+ "name": "messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reply_to_id": {
+ "name": "reply_to_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cipher": {
+ "name": "cipher",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "iv": {
+ "name": "iv",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "edited_at": {
+ "name": "edited_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_by_id": {
+ "name": "deleted_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "messages_conversation_idx": {
+ "name": "messages_conversation_idx",
+ "columns": [
+ {
+ "expression": "conversation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "messages_conversation_id_conversations_id_fk": {
+ "name": "messages_conversation_id_conversations_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "conversations",
+ "columnsFrom": [
+ "conversation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "messages_author_id_users_id_fk": {
+ "name": "messages_author_id_users_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "messages_reply_to_id_messages_id_fk": {
+ "name": "messages_reply_to_id_messages_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "messages",
+ "columnsFrom": [
+ "reply_to_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "messages_deleted_by_id_users_id_fk": {
+ "name": "messages_deleted_by_id_users_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "deleted_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "messages_payload_ck": {
+ "name": "messages_payload_ck",
+ "value": "\"messages\".\"deleted_at\" IS NOT NULL OR ((\"messages\".\"body\" IS NOT NULL) <> (\"messages\".\"cipher\" IS NOT NULL))"
+ },
+ "messages_cipher_iv_ck": {
+ "name": "messages_cipher_iv_ck",
+ "value": "(\"messages\".\"cipher\" IS NULL) = (\"messages\".\"iv\" IS NULL)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.messaging_blocks": {
+ "name": "messaging_blocks",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_id": {
+ "name": "blocked_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "messaging_blocks_blocked_idx": {
+ "name": "messaging_blocks_blocked_idx",
+ "columns": [
+ {
+ "expression": "blocked_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "messaging_blocks_user_id_users_id_fk": {
+ "name": "messaging_blocks_user_id_users_id_fk",
+ "tableFrom": "messaging_blocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "messaging_blocks_blocked_id_users_id_fk": {
+ "name": "messaging_blocks_blocked_id_users_id_fk",
+ "tableFrom": "messaging_blocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "blocked_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messaging_blocks_user_id_blocked_id_pk": {
+ "name": "messaging_blocks_user_id_blocked_id_pk",
+ "columns": [
+ "user_id",
+ "blocked_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "messaging_blocks_self_ck": {
+ "name": "messaging_blocks_self_ck",
+ "value": "\"messaging_blocks\".\"user_id\" <> \"messaging_blocks\".\"blocked_id\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.messaging_broadcasts": {
+ "name": "messaging_broadcasts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "audience": {
+ "name": "audience",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total": {
+ "name": "total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sent": {
+ "name": "sent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "messaging_broadcasts_created_idx": {
+ "name": "messaging_broadcasts_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "messaging_broadcasts_created_by_id_users_id_fk": {
+ "name": "messaging_broadcasts_created_by_id_users_id_fk",
+ "tableFrom": "messaging_broadcasts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notification_channels": {
+ "name": "notification_channels",
+ "schema": "",
+ "columns": {
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "server_config": {
+ "name": "server_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "last_test_status": {
+ "name": "last_test_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_error": {
+ "name": "last_test_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_tested_at": {
+ "name": "last_tested_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_channels_updated_by_users_id_fk": {
+ "name": "notification_channels_updated_by_users_id_fk",
+ "tableFrom": "notification_channels",
+ "tableTo": "users",
+ "columnsFrom": [
+ "updated_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_user_created_idx": {
+ "name": "notifications_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notifications_user_unread_idx": {
+ "name": "notifications_user_unread_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "read_at IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.panic_state": {
+ "name": "panic_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "'singleton'"
+ },
+ "is_encrypted": {
+ "name": "is_encrypted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "encrypted_at": {
+ "name": "encrypted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encryption_salt": {
+ "name": "encryption_salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encryption_iv": {
+ "name": "encryption_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kdf_version": {
+ "name": "kdf_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.presentation_templates": {
+ "name": "presentation_templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'universal'"
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'private'"
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "presentation_templates_owner_idx": {
+ "name": "presentation_templates_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "presentation_templates_site_idx": {
+ "name": "presentation_templates_site_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"presentation_templates\".\"visibility\" = 'site'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "presentation_templates_default_unique": {
+ "name": "presentation_templates_default_unique",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"presentation_templates\".\"is_default\"",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "presentation_templates_owner_id_users_id_fk": {
+ "name": "presentation_templates_owner_id_users_id_fk",
+ "tableFrom": "presentation_templates",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "presentation_templates_created_by_users_id_fk": {
+ "name": "presentation_templates_created_by_users_id_fk",
+ "tableFrom": "presentation_templates",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "presentation_templates_scope_ck": {
+ "name": "presentation_templates_scope_ck",
+ "value": "(\"presentation_templates\".\"visibility\" = 'private' AND \"presentation_templates\".\"owner_id\" IS NOT NULL)\n OR (\"presentation_templates\".\"visibility\" = 'site' AND \"presentation_templates\".\"owner_id\" IS NULL AND \"presentation_templates\".\"is_default\" = false)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.record_sources": {
+ "name": "record_sources",
+ "schema": "",
+ "columns": {
+ "record_id": {
+ "name": "record_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'torrent'"
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "record_sources_record_idx": {
+ "name": "record_sources_record_idx",
+ "columns": [
+ {
+ "expression": "record_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "record_sources_peer_id_federation_peers_id_fk": {
+ "name": "record_sources_peer_id_federation_peers_id_fk",
+ "tableFrom": "record_sources",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "record_sources_peer_id_record_id_pk": {
+ "name": "record_sources_peer_id_record_id_pk",
+ "columns": [
+ "peer_id",
+ "record_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recovery_codes": {
+ "name": "recovery_codes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_hash": {
+ "name": "code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "recovery_codes_user_idx": {
+ "name": "recovery_codes_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "recovery_codes_user_id_users_id_fk": {
+ "name": "recovery_codes_user_id_users_id_fk",
+ "tableFrom": "recovery_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.remote_category_map": {
+ "name": "remote_category_map",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "remote_slug": {
+ "name": "remote_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_category_id": {
+ "name": "local_category_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "remote_category_map_slug_idx": {
+ "name": "remote_category_map_slug_idx",
+ "columns": [
+ {
+ "expression": "remote_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_category_map_category_idx": {
+ "name": "remote_category_map_category_idx",
+ "columns": [
+ {
+ "expression": "local_category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "remote_category_map_local_category_id_categories_id_fk": {
+ "name": "remote_category_map_local_category_id_categories_id_fk",
+ "tableFrom": "remote_category_map",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "local_category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "remote_category_map_created_by_users_id_fk": {
+ "name": "remote_category_map_created_by_users_id_fk",
+ "tableFrom": "remote_category_map",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.remote_identity_links": {
+ "name": "remote_identity_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject_did": {
+ "name": "subject_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "alias_did": {
+ "name": "alias_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "evidence": {
+ "name": "evidence",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_id": {
+ "name": "record_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "remote_identity_links_unique": {
+ "name": "remote_identity_links_unique",
+ "columns": [
+ {
+ "expression": "peer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "subject_did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "alias_did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_identity_links_subject_idx": {
+ "name": "remote_identity_links_subject_idx",
+ "columns": [
+ {
+ "expression": "subject_did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_identity_links_alias_idx": {
+ "name": "remote_identity_links_alias_idx",
+ "columns": [
+ {
+ "expression": "alias_did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "remote_identity_links_peer_id_federation_peers_id_fk": {
+ "name": "remote_identity_links_peer_id_federation_peers_id_fk",
+ "tableFrom": "remote_identity_links",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.remote_masks": {
+ "name": "remote_masks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "remote_masks_scope_value_idx": {
+ "name": "remote_masks_scope_value_idx",
+ "columns": [
+ {
+ "expression": "scope",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "value",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_masks_value_idx": {
+ "name": "remote_masks_value_idx",
+ "columns": [
+ {
+ "expression": "value",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "remote_masks_created_by_users_id_fk": {
+ "name": "remote_masks_created_by_users_id_fk",
+ "tableFrom": "remote_masks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.remote_torrents": {
+ "name": "remote_torrents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_id": {
+ "name": "remote_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "info_hash": {
+ "name": "info_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_signature": {
+ "name": "content_signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_hash_v2": {
+ "name": "info_hash_v2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content_root_v2": {
+ "name": "content_root_v2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category_slug": {
+ "name": "category_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category_type": {
+ "name": "category_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_adult": {
+ "name": "is_adult",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "igdb_id": {
+ "name": "igdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "openlibrary_id": {
+ "name": "openlibrary_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "season": {
+ "name": "season",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "episode": {
+ "name": "episode",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seeders": {
+ "name": "seeders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "leechers": {
+ "name": "leechers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "completed": {
+ "name": "completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "record_id": {
+ "name": "record_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "uploader_name": {
+ "name": "uploader_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remote_created_at": {
+ "name": "remote_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remote_detail_url": {
+ "name": "remote_detail_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remote_download_url": {
+ "name": "remote_download_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "remote_torrents_peer_remote_unique": {
+ "name": "remote_torrents_peer_remote_unique",
+ "columns": [
+ {
+ "expression": "peer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_info_hash_idx": {
+ "name": "remote_torrents_info_hash_idx",
+ "columns": [
+ {
+ "expression": "info_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_content_root_v2_idx": {
+ "name": "remote_torrents_content_root_v2_idx",
+ "columns": [
+ {
+ "expression": "content_root_v2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_content_sig_idx": {
+ "name": "remote_torrents_content_sig_idx",
+ "columns": [
+ {
+ "expression": "content_signature",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_imdb_idx": {
+ "name": "remote_torrents_imdb_idx",
+ "columns": [
+ {
+ "expression": "imdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_tmdb_idx": {
+ "name": "remote_torrents_tmdb_idx",
+ "columns": [
+ {
+ "expression": "tmdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_igdb_idx": {
+ "name": "remote_torrents_igdb_idx",
+ "columns": [
+ {
+ "expression": "igdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_openlibrary_idx": {
+ "name": "remote_torrents_openlibrary_idx",
+ "columns": [
+ {
+ "expression": "openlibrary_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_record_idx": {
+ "name": "remote_torrents_record_idx",
+ "columns": [
+ {
+ "expression": "record_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_author_idx": {
+ "name": "remote_torrents_author_idx",
+ "columns": [
+ {
+ "expression": "author_did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "remote_torrents_name_idx": {
+ "name": "remote_torrents_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "remote_torrents_peer_id_federation_peers_id_fk": {
+ "name": "remote_torrents_peer_id_federation_peers_id_fk",
+ "tableFrom": "remote_torrents",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reports": {
+ "name": "reports",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reporter_id": {
+ "name": "reporter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "details": {
+ "name": "details",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "withdrawn_at": {
+ "name": "withdrawn_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by": {
+ "name": "resolved_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolution": {
+ "name": "resolution",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "reports_reporter_idx": {
+ "name": "reports_reporter_idx",
+ "columns": [
+ {
+ "expression": "reporter_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reports_target_idx": {
+ "name": "reports_target_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reports_status_idx": {
+ "name": "reports_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reports_reporter_id_users_id_fk": {
+ "name": "reports_reporter_id_users_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reporter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reports_resolved_by_users_id_fk": {
+ "name": "reports_resolved_by_users_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "resolved_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revoked_identities": {
+ "name": "revoked_identities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "did": {
+ "name": "did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "succeeded_by": {
+ "name": "succeeded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_id": {
+ "name": "record_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "revoked_identities_unique": {
+ "name": "revoked_identities_unique",
+ "columns": [
+ {
+ "expression": "issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revoked_identities_did_idx": {
+ "name": "revoked_identities_did_idx",
+ "columns": [
+ {
+ "expression": "did",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.roles": {
+ "name": "roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#6b7280'"
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "show_as_badge": {
+ "name": "show_as_badge",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "assignment_mode": {
+ "name": "assignment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "rules": {
+ "name": "rules",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "can_upload_without_moderation": {
+ "name": "can_upload_without_moderation",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "roles_name_unique": {
+ "name": "roles_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.room_message_reactions": {
+ "name": "room_message_reactions",
+ "schema": "",
+ "columns": {
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message_created_at": {
+ "name": "message_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "room_message_reactions_message_idx": {
+ "name": "room_message_reactions_message_idx",
+ "columns": [
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "message_created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "room_message_reactions_user_id_users_id_fk": {
+ "name": "room_message_reactions_user_id_users_id_fk",
+ "tableFrom": "room_message_reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "room_message_reactions_message_id_message_created_at_user_id_key_pk": {
+ "name": "room_message_reactions_message_id_message_created_at_user_id_key_pk",
+ "columns": [
+ "message_id",
+ "message_created_at",
+ "user_id",
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "room_message_reactions_key_ck": {
+ "name": "room_message_reactions_key_ck",
+ "value": "\"room_message_reactions\".\"key\" IN ('up', 'heart', 'haha', 'wow', 'thanks', 'done')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.room_messages": {
+ "name": "room_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "reply_to_id": {
+ "name": "reply_to_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "edited_at": {
+ "name": "edited_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_by_id": {
+ "name": "deleted_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "room_messages_conversation_idx": {
+ "name": "room_messages_conversation_idx",
+ "columns": [
+ {
+ "expression": "conversation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "room_messages_conversation_id_conversations_id_fk": {
+ "name": "room_messages_conversation_id_conversations_id_fk",
+ "tableFrom": "room_messages",
+ "tableTo": "conversations",
+ "columnsFrom": [
+ "conversation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "room_messages_author_id_users_id_fk": {
+ "name": "room_messages_author_id_users_id_fk",
+ "tableFrom": "room_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "room_messages_deleted_by_id_users_id_fk": {
+ "name": "room_messages_deleted_by_id_users_id_fk",
+ "tableFrom": "room_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "deleted_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "room_messages_id_created_at_pk": {
+ "name": "room_messages_id_created_at_pk",
+ "columns": [
+ "id",
+ "created_at"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.room_mutes": {
+ "name": "room_mutes",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "until": {
+ "name": "until",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "by_id": {
+ "name": "by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "room_mutes_until_idx": {
+ "name": "room_mutes_until_idx",
+ "columns": [
+ {
+ "expression": "until",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "room_mutes_user_id_users_id_fk": {
+ "name": "room_mutes_user_id_users_id_fk",
+ "tableFrom": "room_mutes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "room_mutes_by_id_users_id_fk": {
+ "name": "room_mutes_by_id_users_id_fk",
+ "tableFrom": "room_mutes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.shop_items": {
+ "name": "shop_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cost": {
+ "name": "cost",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stock": {
+ "name": "stock",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "shop_items_enabled_idx": {
+ "name": "shop_items_enabled_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.shop_purchases": {
+ "name": "shop_purchases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_name_snapshot": {
+ "name": "item_name_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_type_snapshot": {
+ "name": "item_type_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cost_paid": {
+ "name": "cost_paid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "shop_purchases_user_idx": {
+ "name": "shop_purchases_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "shop_purchases_item_idx": {
+ "name": "shop_purchases_item_idx",
+ "columns": [
+ {
+ "expression": "item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "shop_purchases_user_id_users_id_fk": {
+ "name": "shop_purchases_user_id_users_id_fk",
+ "tableFrom": "shop_purchases",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "shop_purchases_item_id_shop_items_id_fk": {
+ "name": "shop_purchases_item_id_shop_items_id_fk",
+ "tableFrom": "shop_purchases",
+ "tableTo": "shop_items",
+ "columnsFrom": [
+ "item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.site_stats": {
+ "name": "site_stats",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "users_count": {
+ "name": "users_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "torrents_count": {
+ "name": "torrents_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "peers_count": {
+ "name": "peers_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seeders_count": {
+ "name": "seeders_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redis_memory_usage": {
+ "name": "redis_memory_usage",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "db_size": {
+ "name": "db_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total_uploaded_bytes": {
+ "name": "total_uploaded_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "site_stats_created_at_idx": {
+ "name": "site_stats_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#6b7280'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tags_fts_name_idx": {
+ "name": "tags_fts_name_idx",
+ "columns": [
+ {
+ "expression": "to_tsvector('simple', coalesce(\"name\", ''))",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tags_name_unique": {
+ "name": "tags_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ },
+ "tags_slug_unique": {
+ "name": "tags_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.themes": {
+ "name": "themes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base": {
+ "name": "base",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dark'"
+ },
+ "tokens": {
+ "name": "tokens",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'site'"
+ },
+ "required_roles": {
+ "name": "required_roles",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "themes_enabled_idx": {
+ "name": "themes_enabled_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "themes_created_by_users_id_fk": {
+ "name": "themes_created_by_users_id_fk",
+ "tableFrom": "themes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "themes_slug_unique": {
+ "name": "themes_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "themes_visibility_ck": {
+ "name": "themes_visibility_ck",
+ "value": "(\"themes\".\"visibility\" = 'site' AND \"themes\".\"required_roles\" IS NULL)\n OR (\"themes\".\"visibility\" = 'roles' AND jsonb_array_length(\"themes\".\"required_roles\") > 0)"
+ },
+ "themes_base_ck": {
+ "name": "themes_base_ck",
+ "value": "\"themes\".\"base\" IN ('light', 'dark')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.ticket_messages": {
+ "name": "ticket_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "ticket_id": {
+ "name": "ticket_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_name": {
+ "name": "author_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_staff": {
+ "name": "from_staff",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "ticket_messages_ticket_idx": {
+ "name": "ticket_messages_ticket_idx",
+ "columns": [
+ {
+ "expression": "ticket_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "ticket_messages_ticket_id_tickets_id_fk": {
+ "name": "ticket_messages_ticket_id_tickets_id_fk",
+ "tableFrom": "ticket_messages",
+ "tableTo": "tickets",
+ "columnsFrom": [
+ "ticket_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ticket_messages_author_id_users_id_fk": {
+ "name": "ticket_messages_author_id_users_id_fk",
+ "tableFrom": "ticket_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tickets": {
+ "name": "tickets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "number": {
+ "name": "number",
+ "type": "serial",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "opened_by_id": {
+ "name": "opened_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "opened_by_name": {
+ "name": "opened_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'other'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "closure_reason": {
+ "name": "closure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idle_notice_at": {
+ "name": "idle_notice_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assigned_to_id": {
+ "name": "assigned_to_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assigned_to_name": {
+ "name": "assigned_to_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assigned_at": {
+ "name": "assigned_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closed_by_id": {
+ "name": "closed_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closed_by_name": {
+ "name": "closed_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closing_note": {
+ "name": "closing_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_message_by": {
+ "name": "last_message_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ }
+ },
+ "indexes": {
+ "tickets_number_idx": {
+ "name": "tickets_number_idx",
+ "columns": [
+ {
+ "expression": "number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tickets_status_idx": {
+ "name": "tickets_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_message_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tickets_opened_by_idx": {
+ "name": "tickets_opened_by_idx",
+ "columns": [
+ {
+ "expression": "opened_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tickets_opened_by_id_users_id_fk": {
+ "name": "tickets_opened_by_id_users_id_fk",
+ "tableFrom": "tickets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "opened_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tickets_assigned_to_id_users_id_fk": {
+ "name": "tickets_assigned_to_id_users_id_fk",
+ "tableFrom": "tickets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "assigned_to_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tickets_closed_by_id_users_id_fk": {
+ "name": "tickets_closed_by_id_users_id_fk",
+ "tableFrom": "tickets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "closed_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "tickets_status_ck": {
+ "name": "tickets_status_ck",
+ "value": "\"tickets\".\"status\" IN ('open', 'closed')"
+ },
+ "tickets_closure_ck": {
+ "name": "tickets_closure_ck",
+ "value": "\"tickets\".\"closure_reason\" IS NULL\n OR \"tickets\".\"closure_reason\"\n IN ('resolved', 'rejected', 'stale', 'withdrawn')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.torrent_comments": {
+ "name": "torrent_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "torrent_comments_torrent_id_torrents_id_fk": {
+ "name": "torrent_comments_torrent_id_torrents_id_fk",
+ "tableFrom": "torrent_comments",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "torrent_comments_author_id_users_id_fk": {
+ "name": "torrent_comments_author_id_users_id_fk",
+ "tableFrom": "torrent_comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.torrent_favorites": {
+ "name": "torrent_favorites",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "torrent_favorites_user_created_idx": {
+ "name": "torrent_favorites_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "torrent_favorites_user_id_users_id_fk": {
+ "name": "torrent_favorites_user_id_users_id_fk",
+ "tableFrom": "torrent_favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "torrent_favorites_torrent_id_torrents_id_fk": {
+ "name": "torrent_favorites_torrent_id_torrents_id_fk",
+ "tableFrom": "torrent_favorites",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "torrent_favorites_user_id_torrent_id_pk": {
+ "name": "torrent_favorites_user_id_torrent_id_pk",
+ "columns": [
+ "user_id",
+ "torrent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.torrent_moderation_messages": {
+ "name": "torrent_moderation_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status_change": {
+ "name": "status_change",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "torrent_mod_messages_torrent_idx": {
+ "name": "torrent_mod_messages_torrent_idx",
+ "columns": [
+ {
+ "expression": "torrent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrent_mod_messages_author_idx": {
+ "name": "torrent_mod_messages_author_idx",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "torrent_moderation_messages_torrent_id_torrents_id_fk": {
+ "name": "torrent_moderation_messages_torrent_id_torrents_id_fk",
+ "tableFrom": "torrent_moderation_messages",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "torrent_moderation_messages_author_id_users_id_fk": {
+ "name": "torrent_moderation_messages_author_id_users_id_fk",
+ "tableFrom": "torrent_moderation_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.torrent_stats": {
+ "name": "torrent_stats",
+ "schema": "",
+ "columns": {
+ "info_hash": {
+ "name": "info_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "seeders": {
+ "name": "seeders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "leechers": {
+ "name": "leechers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "completed": {
+ "name": "completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "torrent_stats_info_hash_torrents_info_hash_fk": {
+ "name": "torrent_stats_info_hash_torrents_info_hash_fk",
+ "tableFrom": "torrent_stats",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "info_hash"
+ ],
+ "columnsTo": [
+ "info_hash"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.torrent_tags": {
+ "name": "torrent_tags",
+ "schema": "",
+ "columns": {
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "torrent_tags_tag_idx": {
+ "name": "torrent_tags_tag_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "torrent_tags_torrent_id_torrents_id_fk": {
+ "name": "torrent_tags_torrent_id_torrents_id_fk",
+ "tableFrom": "torrent_tags",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "torrent_tags_tag_id_tags_id_fk": {
+ "name": "torrent_tags_tag_id_tags_id_fk",
+ "tableFrom": "torrent_tags",
+ "tableTo": "tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "torrent_tags_torrent_id_tag_id_pk": {
+ "name": "torrent_tags_torrent_id_tag_id_pk",
+ "columns": [
+ "torrent_id",
+ "tag_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.torrents": {
+ "name": "torrents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "info_hash": {
+ "name": "info_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "nfo": {
+ "name": "nfo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "torrent_data": {
+ "name": "torrent_data",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploader_id": {
+ "name": "uploader_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "imdb_id": {
+ "name": "imdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tmdb_id": {
+ "name": "tmdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tvdb_id": {
+ "name": "tvdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "igdb_id": {
+ "name": "igdb_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "openlibrary_id": {
+ "name": "openlibrary_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "season": {
+ "name": "season",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "episode": {
+ "name": "episode",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content_signature": {
+ "name": "content_signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_hash_v2": {
+ "name": "info_hash_v2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content_root_v2": {
+ "name": "content_root_v2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "federate_swarm": {
+ "name": "federate_swarm",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "moderation_status": {
+ "name": "moderation_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "moderated_by_id": {
+ "name": "moderated_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "moderated_at": {
+ "name": "moderated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "torrents_info_hash_idx": {
+ "name": "torrents_info_hash_idx",
+ "columns": [
+ {
+ "expression": "info_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_updated_at_idx": {
+ "name": "torrents_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_uploader_idx": {
+ "name": "torrents_uploader_idx",
+ "columns": [
+ {
+ "expression": "uploader_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_category_idx": {
+ "name": "torrents_category_idx",
+ "columns": [
+ {
+ "expression": "category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_imdb_idx": {
+ "name": "torrents_imdb_idx",
+ "columns": [
+ {
+ "expression": "imdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_tmdb_idx": {
+ "name": "torrents_tmdb_idx",
+ "columns": [
+ {
+ "expression": "tmdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_tmdb_season_idx": {
+ "name": "torrents_tmdb_season_idx",
+ "columns": [
+ {
+ "expression": "tmdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "season",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_grouped_idx": {
+ "name": "torrents_grouped_idx",
+ "columns": [
+ {
+ "expression": "coalesce(\"moderated_at\", \"created_at\") DESC",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"torrents\".\"moderation_status\" = 'accepted' AND \"torrents\".\"is_active\" AND (\"torrents\".\"tmdb_id\" IS NOT NULL OR \"torrents\".\"igdb_id\" IS NOT NULL OR \"torrents\".\"openlibrary_id\" IS NOT NULL)",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_ungrouped_idx": {
+ "name": "torrents_ungrouped_idx",
+ "columns": [
+ {
+ "expression": "coalesce(\"moderated_at\", \"created_at\") DESC",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"torrents\".\"moderation_status\" = 'accepted' AND \"torrents\".\"is_active\" AND \"torrents\".\"tmdb_id\" IS NULL AND \"torrents\".\"igdb_id\" IS NULL AND \"torrents\".\"openlibrary_id\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_tvdb_idx": {
+ "name": "torrents_tvdb_idx",
+ "columns": [
+ {
+ "expression": "tvdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_igdb_idx": {
+ "name": "torrents_igdb_idx",
+ "columns": [
+ {
+ "expression": "igdb_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_content_signature_idx": {
+ "name": "torrents_content_signature_idx",
+ "columns": [
+ {
+ "expression": "content_signature",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_content_root_v2_idx": {
+ "name": "torrents_content_root_v2_idx",
+ "columns": [
+ {
+ "expression": "content_root_v2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_info_hash_v2_short_idx": {
+ "name": "torrents_info_hash_v2_short_idx",
+ "columns": [
+ {
+ "expression": "left(\"info_hash_v2\", 40)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"torrents\".\"info_hash_v2\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_openlibrary_idx": {
+ "name": "torrents_openlibrary_idx",
+ "columns": [
+ {
+ "expression": "openlibrary_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_moderation_status_idx": {
+ "name": "torrents_moderation_status_idx",
+ "columns": [
+ {
+ "expression": "moderation_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "torrents_name_trgm_idx": {
+ "name": "torrents_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "torrents_fts_name_idx": {
+ "name": "torrents_fts_name_idx",
+ "columns": [
+ {
+ "expression": "to_tsvector('simple', coalesce(\"name\", ''))",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "torrents_fts_description_idx": {
+ "name": "torrents_fts_description_idx",
+ "columns": [
+ {
+ "expression": "to_tsvector('simple', coalesce(\"description\", ''))",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "torrents_fts_nfo_idx": {
+ "name": "torrents_fts_nfo_idx",
+ "columns": [
+ {
+ "expression": "to_tsvector('simple', coalesce(\"nfo\", ''))",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "torrents_mint_cursor_idx": {
+ "name": "torrents_mint_cursor_idx",
+ "columns": [
+ {
+ "expression": "coalesce(\"updated_at\", \"created_at\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "torrents_uploader_id_users_id_fk": {
+ "name": "torrents_uploader_id_users_id_fk",
+ "tableFrom": "torrents",
+ "tableTo": "users",
+ "columnsFrom": [
+ "uploader_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "torrents_category_id_categories_id_fk": {
+ "name": "torrents_category_id_categories_id_fk",
+ "tableFrom": "torrents",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "torrents_moderated_by_id_users_id_fk": {
+ "name": "torrents_moderated_by_id_users_id_fk",
+ "tableFrom": "torrents",
+ "tableTo": "users",
+ "columnsFrom": [
+ "moderated_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "torrents_info_hash_unique": {
+ "name": "torrents_info_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "info_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.trusted_devices": {
+ "name": "trusted_devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "trusted_devices_user_idx": {
+ "name": "trusted_devices_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "trusted_devices_expires_idx": {
+ "name": "trusted_devices_expires_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "trusted_devices_user_id_users_id_fk": {
+ "name": "trusted_devices_user_id_users_id_fk",
+ "tableFrom": "trusted_devices",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "trusted_devices_token_hash_unique": {
+ "name": "trusted_devices_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upload_request_comments": {
+ "name": "upload_request_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "edited_at": {
+ "name": "edited_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_by_id": {
+ "name": "deleted_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "upload_request_comments_request_idx": {
+ "name": "upload_request_comments_request_idx",
+ "columns": [
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "upload_request_comments_request_id_upload_requests_id_fk": {
+ "name": "upload_request_comments_request_id_upload_requests_id_fk",
+ "tableFrom": "upload_request_comments",
+ "tableTo": "upload_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "upload_request_comments_author_id_users_id_fk": {
+ "name": "upload_request_comments_author_id_users_id_fk",
+ "tableFrom": "upload_request_comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "upload_request_comments_deleted_by_id_users_id_fk": {
+ "name": "upload_request_comments_deleted_by_id_users_id_fk",
+ "tableFrom": "upload_request_comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "deleted_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upload_request_fill_attempts": {
+ "name": "upload_request_fill_attempts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "torrent_id": {
+ "name": "torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'proposed'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "upload_request_fill_attempts_request_user_idx": {
+ "name": "upload_request_fill_attempts_request_user_idx",
+ "columns": [
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "upload_request_fill_attempts_active_unique": {
+ "name": "upload_request_fill_attempts_active_unique",
+ "columns": [
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "status = 'proposed'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "upload_request_fill_attempts_request_id_upload_requests_id_fk": {
+ "name": "upload_request_fill_attempts_request_id_upload_requests_id_fk",
+ "tableFrom": "upload_request_fill_attempts",
+ "tableTo": "upload_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "upload_request_fill_attempts_user_id_users_id_fk": {
+ "name": "upload_request_fill_attempts_user_id_users_id_fk",
+ "tableFrom": "upload_request_fill_attempts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "upload_request_fill_attempts_torrent_id_torrents_id_fk": {
+ "name": "upload_request_fill_attempts_torrent_id_torrents_id_fk",
+ "tableFrom": "upload_request_fill_attempts",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upload_requests": {
+ "name": "upload_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "requester_id": {
+ "name": "requester_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reward_points": {
+ "name": "reward_points",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'requested'"
+ },
+ "filled_by_id": {
+ "name": "filled_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filled_torrent_id": {
+ "name": "filled_torrent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filled_at": {
+ "name": "filled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "validated_at": {
+ "name": "validated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "federated_peer_id": {
+ "name": "federated_peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "federated_info_hash": {
+ "name": "federated_info_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "federated_content_root_v2": {
+ "name": "federated_content_root_v2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "upload_requests_status_idx": {
+ "name": "upload_requests_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "upload_requests_federated_info_hash_idx": {
+ "name": "upload_requests_federated_info_hash_idx",
+ "columns": [
+ {
+ "expression": "federated_info_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "upload_requests_requester_idx": {
+ "name": "upload_requests_requester_idx",
+ "columns": [
+ {
+ "expression": "requester_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "upload_requests_filled_at_idx": {
+ "name": "upload_requests_filled_at_idx",
+ "columns": [
+ {
+ "expression": "filled_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "upload_requests_requester_id_users_id_fk": {
+ "name": "upload_requests_requester_id_users_id_fk",
+ "tableFrom": "upload_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "requester_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "upload_requests_category_id_categories_id_fk": {
+ "name": "upload_requests_category_id_categories_id_fk",
+ "tableFrom": "upload_requests",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "upload_requests_filled_by_id_users_id_fk": {
+ "name": "upload_requests_filled_by_id_users_id_fk",
+ "tableFrom": "upload_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "filled_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "upload_requests_filled_torrent_id_torrents_id_fk": {
+ "name": "upload_requests_filled_torrent_id_torrents_id_fk",
+ "tableFrom": "upload_requests",
+ "tableTo": "torrents",
+ "columnsFrom": [
+ "filled_torrent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "upload_requests_federated_peer_id_federation_peers_id_fk": {
+ "name": "upload_requests_federated_peer_id_federation_peers_id_fk",
+ "tableFrom": "upload_requests",
+ "tableTo": "federation_peers",
+ "columnsFrom": [
+ "federated_peer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upload_rule_category_patterns": {
+ "name": "upload_rule_category_patterns",
+ "schema": "",
+ "columns": {
+ "category_id": {
+ "name": "category_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pattern": {
+ "name": "pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "upload_rule_category_patterns_category_id_categories_id_fk": {
+ "name": "upload_rule_category_patterns_category_id_categories_id_fk",
+ "tableFrom": "upload_rule_category_patterns",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upload_rules": {
+ "name": "upload_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "'singleton'"
+ },
+ "nfo_required": {
+ "name": "nfo_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "description_required": {
+ "name": "description_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "description_min_length": {
+ "name": "description_min_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "title_pattern_enforced": {
+ "name": "title_pattern_enforced",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "title_blocklist": {
+ "name": "title_blocklist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tmdb_id_required": {
+ "name": "tmdb_id_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "max_torrent_size": {
+ "name": "max_torrent_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "staff_bypass": {
+ "name": "staff_bypass",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.uploaded_fonts": {
+ "name": "uploaded_fonts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "family": {
+ "name": "family",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sha256": {
+ "name": "sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "uploaded_fonts_role_idx": {
+ "name": "uploaded_fonts_role_idx",
+ "columns": [
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "uploaded_fonts_uploaded_by_users_id_fk": {
+ "name": "uploaded_fonts_uploaded_by_users_id_fk",
+ "tableFrom": "uploaded_fonts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "uploaded_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uploaded_fonts_sha256_unique": {
+ "name": "uploaded_fonts_sha256_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "sha256"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "uploaded_fonts_role_ck": {
+ "name": "uploaded_fonts_role_ck",
+ "value": "\"uploaded_fonts\".\"role\" IN ('sans', 'mono', 'display')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_follows": {
+ "name": "user_follows",
+ "schema": "",
+ "columns": {
+ "follower_id": {
+ "name": "follower_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "following_id": {
+ "name": "following_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_follows_following_idx": {
+ "name": "user_follows_following_idx",
+ "columns": [
+ {
+ "expression": "following_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_follows_follower_id_users_id_fk": {
+ "name": "user_follows_follower_id_users_id_fk",
+ "tableFrom": "user_follows",
+ "tableTo": "users",
+ "columnsFrom": [
+ "follower_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_follows_following_id_users_id_fk": {
+ "name": "user_follows_following_id_users_id_fk",
+ "tableFrom": "user_follows",
+ "tableTo": "users",
+ "columnsFrom": [
+ "following_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_follows_follower_id_following_id_pk": {
+ "name": "user_follows_follower_id_following_id_pk",
+ "columns": [
+ "follower_id",
+ "following_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_message_keys": {
+ "name": "user_message_keys",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "alg": {
+ "name": "alg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ECDH-P256'"
+ },
+ "device_label": {
+ "name": "device_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_message_keys_user_id_users_id_fk": {
+ "name": "user_message_keys_user_id_users_id_fk",
+ "tableFrom": "user_message_keys",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_notification_channels": {
+ "name": "user_notification_channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_type": {
+ "name": "channel_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "user_config": {
+ "name": "user_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "last_test_status": {
+ "name": "last_test_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_error": {
+ "name": "last_test_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_tested_at": {
+ "name": "last_tested_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consecutive_failures": {
+ "name": "consecutive_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_notification_channels_user_type_unique": {
+ "name": "user_notification_channels_user_type_unique",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "channel_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_notification_channels_user_id_users_id_fk": {
+ "name": "user_notification_channels_user_id_users_id_fk",
+ "tableFrom": "user_notification_channels",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_notification_routing": {
+ "name": "user_notification_routing",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_type": {
+ "name": "channel_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_notification_routing_pk": {
+ "name": "user_notification_routing_pk",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_notification_routing_user_idx": {
+ "name": "user_notification_routing_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_notification_routing_user_id_users_id_fk": {
+ "name": "user_notification_routing_user_id_users_id_fk",
+ "tableFrom": "user_notification_routing",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_roles": {
+ "name": "user_roles",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_at": {
+ "name": "assigned_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "assigned_manually": {
+ "name": "assigned_manually",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {
+ "user_roles_role_idx": {
+ "name": "user_roles_role_idx",
+ "columns": [
+ {
+ "expression": "role_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_roles_user_id_users_id_fk": {
+ "name": "user_roles_user_id_users_id_fk",
+ "tableFrom": "user_roles",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_roles_role_id_roles_id_fk": {
+ "name": "user_roles_role_id_roles_id_fk",
+ "tableFrom": "user_roles",
+ "tableTo": "roles",
+ "columnsFrom": [
+ "role_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_roles_user_id_role_id_pk": {
+ "name": "user_roles_user_id_role_id_pk",
+ "columns": [
+ "user_id",
+ "role_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_signing_keys": {
+ "name": "user_signing_keys",
+ "schema": "",
+ "columns": {
+ "did": {
+ "name": "did",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "succeeded_by": {
+ "name": "succeeded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_signing_keys_current": {
+ "name": "user_signing_keys_current",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "revoked_at IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_signing_keys_user_idx": {
+ "name": "user_signing_keys_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_signing_keys_user_id_users_id_fk": {
+ "name": "user_signing_keys_user_id_users_id_fk",
+ "tableFrom": "user_signing_keys",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_salt": {
+ "name": "auth_salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_verifier": {
+ "name": "auth_verifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "passkey": {
+ "name": "passkey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_admin": {
+ "name": "is_admin",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_moderator": {
+ "name": "is_moderator",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_owner": {
+ "name": "is_owner",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_banned": {
+ "name": "is_banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "banned_by_id": {
+ "name": "banned_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned_by_role": {
+ "name": "banned_by_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned_until": {
+ "name": "banned_until",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_ip": {
+ "name": "last_ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded": {
+ "name": "uploaded",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "bonus_uploaded": {
+ "name": "bonus_uploaded",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "downloaded": {
+ "name": "downloaded",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "invites_remaining": {
+ "name": "invites_remaining",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "bonus_points": {
+ "name": "bonus_points",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "panic_password_hash": {
+ "name": "panic_password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "show_last_seen": {
+ "name": "show_last_seen",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "show_adult_content": {
+ "name": "show_adult_content",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "messaging_read_receipts": {
+ "name": "messaging_read_receipts",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "anonymous_uploads": {
+ "name": "anonymous_uploads",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "hide_download_history": {
+ "name": "hide_download_history",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "restrict_comments": {
+ "name": "restrict_comments",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "share_reputation_federated": {
+ "name": "share_reputation_federated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "theme": {
+ "name": "theme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "totp_enabled": {
+ "name": "totp_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "trust_devices_enabled": {
+ "name": "trust_devices_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "users_owner_unique": {
+ "name": "users_owner_unique",
+ "columns": [
+ {
+ "expression": "is_owner",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"users\".\"is_owner\"",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "username"
+ ]
+ },
+ "users_passkey_unique": {
+ "name": "users_passkey_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "passkey"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webauthn_credentials": {
+ "name": "webauthn_credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "webauthn_credentials_user_idx": {
+ "name": "webauthn_credentials_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "webauthn_credentials_user_id_users_id_fk": {
+ "name": "webauthn_credentials_user_id_users_id_fk",
+ "tableFrom": "webauthn_credentials",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "webauthn_credentials_credential_id_unique": {
+ "name": "webauthn_credentials_credential_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "credential_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index 4a447997..d8480d24 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -407,6 +407,13 @@
"when": 1788121797064,
"tag": "0057_fine_meltdown",
"breakpoints": true
+ },
+ {
+ "idx": 58,
+ "version": "7",
+ "when": 1788198389400,
+ "tag": "0058_bep52_announce",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index d702bfd3..afa2fdc0 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -1095,6 +1095,23 @@ export const torrents = pgTable(
index('torrents_content_signature_idx').on(table.contentSignature),
// The cross-tracker content key drives cross-seed / fill matching joins.
index('torrents_content_root_v2_idx').on(table.contentRootV2),
+ /**
+ * The announce form of the v2 hash.
+ *
+ * BEP 52 keeps the SHA-256 for content addressing, but tracker and DHT
+ * protocols were built around 20-byte hashes — so a v2 or hybrid client
+ * announces the SHA-256 truncated to 20 bytes, i.e. the first 40 hex
+ * characters of `info_hash_v2`. The tracker looks a torrent up by exactly
+ * that when the v1 lookup misses.
+ *
+ * An expression index rather than a stored column: the value is a prefix
+ * of a column that is already here, and a second copy of a hash is a
+ * second thing to keep in step. Partial, because most rows are v1-only
+ * and a NULL entry per one of them is dead weight in the hot path's index.
+ */
+ index('torrents_info_hash_v2_short_idx')
+ .on(sql`left(${table.infoHashV2}, 40)`)
+ .where(sql`${table.infoHashV2} IS NOT NULL`),
index('torrents_openlibrary_idx').on(table.openlibraryId),
index('torrents_moderation_status_idx').on(table.moderationStatus),
// GIN rather than GiST: it is the recommended opclass for LIKE/ILIKE and,
From 44b5f41568ef84fc2b03b0f0bc9e362dbff7eb13 Mon Sep 17 00:00:00 2001
From: dim145
Date: Mon, 31 Aug 2026 23:36:03 +0200
Subject: [PATCH 03/96] feat(web): installable, with an icon size nobody made
up
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hard half of a PWA was already here and had been for a year: a service
worker, shipped as the Web Push receiver. What was missing was a manifest,
and a reason for the worker to exist before somebody turned notifications
on.
The manifest is a route, not a file in `public/`, for the same reason the
theme stylesheet is one. Everything in it — name, subtitle, the two
colours, the icon — is operator-configurable, and a static
`manifest.webmanifest` in the bundle would bake one instance's branding
into every instance's image: an operator who renamed their tracker would
still be installed as "Trackarr", in Trackarr's colours. It also has to
work in both shapes `apps/web` ships in, and `app.head` is the only place
that reaches the HTML of both.
A manifest's `scope` is resolved against its own URL but, unlike a service
worker's, is not confined to its directory — so one served from `/api/` can
legitimately claim `/`. The note is in the route, because the next person
to read it will assume otherwise.
The service worker is now registered on page load rather than when a member
enables push. A browser decides installability AT page load, so a site
whose worker only appears after a settings toggle is not installable for
anyone who never visits that toggle. `register()` is idempotent, so this
and `useWebPush()` can both call it.
It gained a `fetch` listener that handles nothing. Chrome will not offer to
install a site whose worker has none, and the check is for the listener's
existence rather than for what it does — so it never calls `respondWith`,
and the browser goes to the network exactly as it would with no worker at
all. What it is NOT is an offline cache, and that is a decision. Every page
here is a live view of a swarm: seeder counts, ratios, a moderation queue,
an inbox. A cache-first worker would serve yesterday's numbers with no way
for the reader to tell, and on a private tracker the wrong ratio is not a
cosmetic problem.
Then the icon, which is where the honest work is. `sizes` is a CLAIM, and
browsers act on the claim rather than on the file: Chrome installs a site
only when its manifest declares an icon of at least 512x512. Declaring that
over a 64-pixel logo buys an install prompt and a blurry home-screen icon —
worse than no prompt.
So the size is read from the image's own header at upload time, where the
bytes are already in hand. `imageDimensions` walks PNG (IHDR at a fixed
offset), GIF, all three WEBP sub-formats, and JPEG, which is the only one
needing a real walk since its frame header sits behind however much
metadata precedes it. It returns null for an SVG — which has no intrinsic
pixel size — and for anything it does not know, and `manifestIconSizes`
turns null into `any`. A non-square image is `any` too: `sizes` names
squares, and a 800x200 banner is not an 800-pixel icon.
Measured at upload rather than at render time on purpose: the file lives
behind a storage backend that may be S3, and re-fetching it on a route the
browser polls would be a network round trip per request for a number that
cannot change after the upload.
No default 512-pixel icon is shipped to paper over the gap. An invented
icon saying "Trackarr" on somebody else's tracker is not an improvement,
and the guide says plainly what to upload to make Chrome offer the install.
Co-Authored-By: Claude Opus 5
---
README.md | 1 +
apps/api/routes/api/admin/favicon.post.ts | 11 +-
apps/api/routes/api/admin/logo.post.ts | 14 +-
.../routes/api/manifest.webmanifest.get.ts | 188 ++++++++++++++++++
apps/api/test/imageSniff.test.ts | 107 +++++++++-
apps/api/utils/imageSniff.ts | 139 +++++++++++++
apps/api/utils/settings.ts | 25 +++
apps/web/app/plugins/service-worker.client.ts | 39 ++++
apps/web/nuxt.config.ts | 17 ++
apps/web/public/sw.js | 29 ++-
doc/.vitepress/config.ts | 1 +
doc/guide/install-as-app.md | 74 +++++++
12 files changed, 638 insertions(+), 7 deletions(-)
create mode 100644 apps/api/routes/api/manifest.webmanifest.get.ts
create mode 100644 apps/web/app/plugins/service-worker.client.ts
create mode 100644 doc/guide/install-as-app.md
diff --git a/README.md b/README.md
index fa031f3b..1401949a 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre
- **Dedicated upload page** — auto title + tags from filename, multi-source search picker, duplicate preflight, conditional ID block per category, Tiptap WYSIWYG description, NFO drag-drop (CP437 → UTF-8).
- **Release sheet builder** — a four-step wizard at `/torrents/fiche` turns a video file into a BBCode sheet, an NFO and a normalised release name, then hands all three to the upload form. MediaInfo runs **in the browser** through WebAssembly and reads only the chunks it asks for, so a 40 GB remux is analysed without ever being uploaded. Every dropdown keeps an "Other…" entry, and bitrate/size unit selectors change the frame of reference without touching the value.
- **Operator console** — `/admin` covers users, categories, roles, invites, branding, panic, tags, Torznab, reports, HnR.
+- **Installable (PWA)** — a manifest served by the API, so the app's name, colours and icon follow the instance's branding. No offline cache: every page here is a live view of a swarm.
- **Notification fan-out** — every event-emitting route hits Postgres + Redis pub/sub + the user's chosen external transport (SMTP, Telegram, Discord, ntfy, Gotify, Pushover, Slack, Mattermost, webhook, Apprise, **Web Push**).
### Tracker protocols
diff --git a/apps/api/routes/api/admin/favicon.post.ts b/apps/api/routes/api/admin/favicon.post.ts
index 0b9bfdd9..d09bb652 100644
--- a/apps/api/routes/api/admin/favicon.post.ts
+++ b/apps/api/routes/api/admin/favicon.post.ts
@@ -1,7 +1,11 @@
import { requireAdminSession } from '~~/utils/adminAuth';
import { setSetting, SETTINGS_KEYS } from '~~/utils/server';
import { randomBytes } from 'crypto';
-import { assertImageType } from '~~/utils/imageSniff';
+import {
+ assertImageType,
+ imageDimensions,
+ manifestIconSizes,
+} from '~~/utils/imageSniff';
import { getStorage } from '~~/utils/storage';
import { resolveObjectKey } from '~~/utils/storage/keys';
@@ -91,6 +95,11 @@ export default defineEventHandler(async (event) => {
// Save to settings
await setSetting(SETTINGS_KEYS.SITE_FAVICON, fileUrl);
+ // Measured, not assumed — see the note in the sibling logo route.
+ await setSetting(
+ SETTINGS_KEYS.SITE_FAVICON_SIZE,
+ manifestIconSizes(imageDimensions(file.data))
+ );
// Delete old favicon if it exists and is in uploads folder. The setting is
// written by this route, so the value should always be a plain filename —
diff --git a/apps/api/routes/api/admin/logo.post.ts b/apps/api/routes/api/admin/logo.post.ts
index 0b2b3144..b87d94ff 100644
--- a/apps/api/routes/api/admin/logo.post.ts
+++ b/apps/api/routes/api/admin/logo.post.ts
@@ -1,7 +1,11 @@
import { requireAdminSession } from '~~/utils/adminAuth';
import { setSetting, SETTINGS_KEYS } from '~~/utils/server';
import { randomBytes } from 'crypto';
-import { assertImageType } from '~~/utils/imageSniff';
+import {
+ assertImageType,
+ imageDimensions,
+ manifestIconSizes,
+} from '~~/utils/imageSniff';
import { getStorage } from '~~/utils/storage';
import { resolveObjectKey } from '~~/utils/storage/keys';
@@ -91,6 +95,14 @@ export default defineEventHandler(async (event) => {
// Save to settings
await setSetting(SETTINGS_KEYS.SITE_LOGO_IMAGE, fileUrl);
+ // And the pixel size, measured from the bytes we still hold. The web app
+ // manifest declares this as the icon's `sizes`, and a browser believes the
+ // declaration — so it is measured here, once, rather than guessed at render
+ // time or re-read from a storage backend that may be S3.
+ await setSetting(
+ SETTINGS_KEYS.SITE_LOGO_IMAGE_SIZE,
+ manifestIconSizes(imageDimensions(file.data))
+ );
// Delete old logo if it exists and is in uploads folder. The setting is
// written by this route, so the value should always be a plain filename —
diff --git a/apps/api/routes/api/manifest.webmanifest.get.ts b/apps/api/routes/api/manifest.webmanifest.get.ts
new file mode 100644
index 00000000..b36b0636
--- /dev/null
+++ b/apps/api/routes/api/manifest.webmanifest.get.ts
@@ -0,0 +1,188 @@
+/**
+ * GET /api/manifest.webmanifest — this instance, as an installable app.
+ *
+ * ## Why a route and not a file in `public/`
+ *
+ * The same reason `/api/theme.css` is a route. Everything a manifest says
+ * about a site — its name, its colours, its icon — is operator-configurable
+ * here, and a static JSON would hard-code one instance's branding into every
+ * instance's bundle. An operator who renamed their tracker would still be
+ * installed as "Trackarr", in Trackarr's colours.
+ *
+ * It also has to work in both shapes `apps/web` ships in: SSR, and the static
+ * SPA served by nginx with no server at all. A `` in
+ * `app.head` points at this URL in both, and only the API needs to know the
+ * branding.
+ *
+ * A manifest's `scope` is resolved against the manifest's own URL but is not
+ * confined to its directory — unlike a service worker's. So a manifest served
+ * from `/api/` can and does claim `/`.
+ *
+ * ## Icons, and the number that has to be true
+ *
+ * `sizes` is a claim, and browsers act on the claim rather than on the file:
+ * Chrome offers to install a site only when the manifest declares an icon of
+ * at least 512×512. Declaring that over a 64-pixel logo buys an install
+ * prompt and a blurry home-screen icon, which is worse than no prompt — so the
+ * value comes from the bytes, measured by the upload route
+ * (`utils/imageSniff.manifestIconSizes`) and stored beside the URL.
+ *
+ * `any` means we could not measure it: an SVG (no intrinsic size), a format we
+ * do not walk, or an image uploaded before the measurement existed. Firefox
+ * and iOS install from `any`; Chrome does not. Re-uploading the logo is what
+ * fixes it, and the operator guide says so.
+ *
+ * With no uploaded branding at all the only icon is the shipped `favicon.ico`
+ * at 32×32, which is enough to install on iOS and not enough for Chrome. We do
+ * not ship a 512-pixel default to paper over that: an invented icon that says
+ * "Trackarr" on somebody else's tracker is not an improvement.
+ *
+ * ## Caching
+ *
+ * Branding is settings-cached, so a hit costs no query. `max-age=60` matches
+ * that cache's TTL — the same envelope `/api/theme.css` advertises — and the
+ * ETag folds in every field the document contains, so a rename revalidates
+ * within the minute instead of waiting out a longer TTL.
+ */
+import { createHash } from 'node:crypto';
+import {
+ getSiteName,
+ getSiteSubtitle,
+ getSiteLogoImage,
+ getSiteLogoImageSizes,
+ getSiteFavicon,
+ getSiteFaviconSizes,
+} from '~~/utils/server';
+import { enabledThemes, getDefaultTheme } from '~~/utils/themes';
+import { resolveTokens } from '@trackarr/shared/theme';
+
+/**
+ * `"12 34 56"` → `"#0c2238"`.
+ *
+ * Theme tokens are stored as space-separated RGB triplets, the convention the
+ * stylesheet uses so a value can be dropped into `rgb(… / )`. A
+ * manifest wants a CSS colour, and hex is the form every browser has parsed
+ * for twenty years — `rgb(12 34 56)` is valid CSS Color 4 and not worth
+ * betting a theme colour on.
+ */
+function tripletToHex(triplet: string | undefined): string | null {
+ if (!triplet) return null;
+ const parts = triplet.trim().split(/[\s,]+/);
+ if (parts.length !== 3) return null;
+ const bytes = parts.map((p) => Number.parseInt(p, 10));
+ if (bytes.some((b) => !Number.isInteger(b) || b < 0 || b > 255)) return null;
+ return `#${bytes.map((b) => b.toString(16).padStart(2, '0')).join('')}`;
+}
+
+interface ManifestIcon {
+ src: string;
+ sizes: string;
+ type?: string;
+ purpose?: string;
+}
+
+/** Extension → MIME, for the four formats the branding uploads accept. */
+const ICON_TYPES: Record = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ webp: 'image/webp',
+ svg: 'image/svg+xml',
+ ico: 'image/x-icon',
+};
+
+function iconType(src: string): string | undefined {
+ const ext = src.split('?')[0]!.split('.').pop()?.toLowerCase();
+ return ext ? ICON_TYPES[ext] : undefined;
+}
+
+export default defineEventHandler(async (event) => {
+ const [siteName, subtitle, logo, logoSizes, favicon, faviconSizes, themeSlug] =
+ await Promise.all([
+ getSiteName(),
+ getSiteSubtitle(),
+ getSiteLogoImage(),
+ getSiteLogoImageSizes(),
+ getSiteFavicon(),
+ getSiteFaviconSizes(),
+ getDefaultTheme(),
+ ]);
+
+ /**
+ * The site default's own tokens drive the two colours. Not the visitor's
+ * theme: the manifest is fetched once at install time and the values are
+ * baked into the OS launcher, so a per-session answer would just mean
+ * whoever installed it picked the colour for everyone.
+ *
+ * An instance with no rows in `themes` is the common case, not an edge one —
+ * operator-authored themes are opt-in, and `getDefaultTheme()` then returns
+ * the built-in slug (`dark`, or `light`). Resolving that against the built-in
+ * token set is what `/api/theme.css` already does for the same situation, so
+ * both surfaces agree instead of this one falling back to black.
+ */
+ const themes = await enabledThemes();
+ const theme = themes.find((t) => t.slug === themeSlug);
+ const tokens = theme
+ ? resolveTokens(theme.base, theme.tokens)
+ : resolveTokens(themeSlug === 'light' ? 'light' : 'dark', null);
+ const themeColor = tripletToHex(tokens.accent) ?? '#000000';
+ const backgroundColor = tripletToHex(tokens['bg-base']) ?? '#000000';
+
+ // Most specific first — a browser picking one icon walks the list and the
+ // logo is the larger, more deliberate image. `favicon.ico` closes it out so
+ // the array is never empty, which would make the manifest unusable rather
+ // than merely imperfect.
+ const icons: ManifestIcon[] = [];
+ if (logo) icons.push({ src: logo, sizes: logoSizes, type: iconType(logo) });
+ if (favicon) {
+ icons.push({ src: favicon, sizes: faviconSizes, type: iconType(favicon) });
+ }
+ icons.push({ src: '/favicon.ico', sizes: '32x32', type: 'image/x-icon' });
+
+ const manifest = {
+ // `id` pins the app's identity across renames. Without it the identity is
+ // `start_url`, and an operator moving the site would strand every
+ // installed copy as a second, separate app.
+ id: '/',
+ name: siteName,
+ // Launchers truncate around 12 characters; the site name is what the
+ // operator chose to be called, so it is used as-is rather than cut here.
+ short_name: siteName,
+ description: subtitle || undefined,
+ start_url: '/',
+ scope: '/',
+ display: 'standalone',
+ orientation: 'any',
+ theme_color: themeColor,
+ background_color: backgroundColor,
+ icons,
+ // Deep links the launcher can offer on a long-press. Kept to the three
+ // surfaces a member opens without thinking; anything gated on a role would
+ // show a shortcut to a 403.
+ shortcuts: [
+ { name: 'Browse', url: '/torrents' },
+ { name: 'Upload', url: '/torrents/upload' },
+ { name: 'Notifications', url: '/notifications' },
+ ],
+ };
+
+ const body = JSON.stringify(manifest);
+ // Over the document, not over a version counter: branding has no counter to
+ // read, and hashing what we are about to send cannot drift from it.
+ const etag = `W/"manifest-${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`;
+
+ // The registered media type. Nitro would infer `application/json` from the
+ // body, which browsers accept — but the spec'd type is what a validator and
+ // a strict fetch check look for.
+ setHeader(event, 'Content-Type', 'application/manifest+json; charset=utf-8');
+ setHeader(event, 'Cache-Control', 'public, max-age=60, must-revalidate');
+ setHeader(event, 'ETag', etag);
+ setHeader(event, 'Vary', 'Accept-Encoding');
+
+ if (getHeader(event, 'if-none-match') === etag) {
+ setResponseStatus(event, 304);
+ return null;
+ }
+
+ return body;
+});
diff --git a/apps/api/test/imageSniff.test.ts b/apps/api/test/imageSniff.test.ts
index 5b32a9af..198f006a 100644
--- a/apps/api/test/imageSniff.test.ts
+++ b/apps/api/test/imageSniff.test.ts
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
-import { sniffImage, assertImageType } from '../utils/imageSniff';
+import {
+ sniffImage,
+ assertImageType,
+ imageDimensions,
+ manifestIconSizes,
+} from '../utils/imageSniff';
// Identifying an uploaded image from its bytes rather than its declared type.
//
@@ -108,3 +113,103 @@ describe('assertImageType', () => {
expect(() => assertImageType(Buffer.from(''), ['image/png'])).toThrow();
});
});
+
+// Measuring the image, which the web app manifest turns into a claim a browser
+// acts on: Chrome installs a site only when an icon declares ≥ 512×512, and it
+// reads the declaration rather than the file. A wrong number buys an install
+// prompt and a blurry icon.
+describe('imageDimensions', () => {
+ /** A PNG header with a real IHDR — the only chunk the reader looks at. */
+ function pngOf(width: number, height: number): Buffer {
+ const buf = Buffer.alloc(24);
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
+ buf.writeUInt32BE(13, 8); // IHDR length
+ buf.write('IHDR', 12, 'latin1');
+ buf.writeUInt32BE(width, 16);
+ buf.writeUInt32BE(height, 20);
+ return buf;
+ }
+
+ it('reads a PNG IHDR', () => {
+ expect(imageDimensions(pngOf(512, 512))).toEqual({
+ width: 512,
+ height: 512,
+ });
+ });
+
+ it('reads a GIF logical screen descriptor (little-endian)', () => {
+ const buf = Buffer.alloc(16);
+ buf.write('GIF89a', 0, 'latin1');
+ buf.writeUInt16LE(300, 6);
+ buf.writeUInt16LE(200, 8);
+ expect(imageDimensions(buf)).toEqual({ width: 300, height: 200 });
+ });
+
+ it('reads a WEBP VP8X canvas (24-bit, stored minus one)', () => {
+ const buf = Buffer.alloc(32);
+ buf.write('RIFF', 0, 'latin1');
+ buf.write('WEBP', 8, 'latin1');
+ buf.write('VP8X', 12, 'latin1');
+ // 192 and 96, each written as value-1 over three little-endian bytes.
+ buf[24] = 191;
+ buf[27] = 95;
+ expect(imageDimensions(buf)).toEqual({ width: 192, height: 96 });
+ });
+
+ it('walks past a JPEG APP segment to reach the frame header', () => {
+ // SOI, then an APP0 of declared length 8, then SOF0 carrying 64×32.
+ const buf = Buffer.concat([
+ Buffer.from([0xff, 0xd8]),
+ Buffer.from([0xff, 0xe0, 0x00, 0x08, 1, 2, 3, 4, 5, 6]),
+ Buffer.from([0xff, 0xc0, 0x00, 0x11, 0x08]),
+ (() => {
+ const d = Buffer.alloc(4);
+ d.writeUInt16BE(32, 0); // height first — JPEG's order
+ d.writeUInt16BE(64, 2);
+ return d;
+ })(),
+ Buffer.alloc(8),
+ ]);
+ expect(imageDimensions(buf)).toEqual({ width: 64, height: 32 });
+ });
+
+ it('gives up rather than guessing on a JPEG that reaches its scan data', () => {
+ // SOI then SOS: the entropy-coded data starts and no frame header follows.
+ const buf = Buffer.concat([
+ Buffer.from([0xff, 0xd8]),
+ Buffer.from([0xff, 0xda, 0x00, 0x08]),
+ Buffer.alloc(16),
+ ]);
+ expect(imageDimensions(buf)).toBeNull();
+ });
+
+ it('returns null for an SVG, which has no intrinsic pixel size', () => {
+ expect(imageDimensions(Buffer.from(''))).toBeNull();
+ });
+
+ it('returns null on a truncated header instead of reading past the end', () => {
+ expect(imageDimensions(pngOf(512, 512).subarray(0, 18))).toBeNull();
+ expect(imageDimensions(Buffer.alloc(4))).toBeNull();
+ });
+});
+
+describe('manifestIconSizes', () => {
+ it('states the square when there is one', () => {
+ expect(manifestIconSizes({ width: 512, height: 512 })).toBe('512x512');
+ });
+
+ it('falls back to `any` when the measurement is missing', () => {
+ // An SVG, an unwalked format, or an image uploaded before the measurement
+ // existed. Never a fabricated square.
+ expect(manifestIconSizes(null)).toBe('any');
+ });
+
+ it('refuses to call a non-square image an icon size', () => {
+ // `sizes` names squares. A banner is not an 800-pixel icon.
+ expect(manifestIconSizes({ width: 800, height: 200 })).toBe('any');
+ });
+
+ it('rejects degenerate dimensions', () => {
+ expect(manifestIconSizes({ width: 0, height: 0 })).toBe('any');
+ });
+});
diff --git a/apps/api/utils/imageSniff.ts b/apps/api/utils/imageSniff.ts
index 9b2c97f3..5a97cbc4 100644
--- a/apps/api/utils/imageSniff.ts
+++ b/apps/api/utils/imageSniff.ts
@@ -136,3 +136,142 @@ export function assertImageType(
}
return actual;
}
+
+/**
+ * How big the image actually is, read from its own header.
+ *
+ * The web app manifest has to state a `sizes` for each icon, and a browser
+ * takes that statement at face value: Chrome will only offer to install a site
+ * whose manifest declares an icon of at least 512×512, and it reads the
+ * declaration, not the file. So the number has to be true — declaring
+ * `512x512` over a 64-pixel logo produces an install prompt followed by a
+ * blurry icon, which is worse than no prompt.
+ *
+ * Measured at upload time, where the bytes are already in hand, rather than at
+ * manifest-render time: the file lives behind a storage backend that may be S3,
+ * and re-fetching it on a route the browser polls would be a network round trip
+ * per request for a number that cannot change after the upload.
+ *
+ * Returns null for a format whose header we do not walk, and for an SVG — which
+ * has no intrinsic pixel size at all, and whose honest `sizes` value is `any`.
+ * Callers treat null as "unknown", never as a failure: an operator whose logo
+ * we cannot measure still gets their logo, it just cannot claim a pixel size.
+ */
+export interface ImageDimensions {
+ width: number;
+ height: number;
+}
+
+export function imageDimensions(buf: Buffer): ImageDimensions | null {
+ if (!buf || buf.length < 16) return null;
+
+ // PNG — IHDR is always the first chunk, and its width/height are the two
+ // big-endian uint32s right after the chunk type. Fixed offsets, so no walk.
+ if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
+ if (buf.length < 24) return null;
+ if (buf.subarray(12, 16).toString('latin1') !== 'IHDR') return null;
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
+ }
+
+ // GIF — logical screen descriptor, little-endian, right after the signature.
+ if (startsWith(buf, [0x47, 0x49, 0x46, 0x38])) {
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
+ }
+
+ // WEBP — three sub-formats under the same RIFF wrapper, each storing the
+ // size differently. VP8X (the extended form an animated or alpha file uses)
+ // carries canvas size minus one, in 24-bit little-endian.
+ if (
+ startsWith(buf, [0x52, 0x49, 0x46, 0x46]) &&
+ startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)
+ ) {
+ const fourcc = buf.subarray(12, 16).toString('latin1');
+ if (fourcc === 'VP8X' && buf.length >= 30) {
+ const w = buf[24]! | (buf[25]! << 8) | (buf[26]! << 16);
+ const h = buf[27]! | (buf[28]! << 8) | (buf[29]! << 16);
+ return { width: w + 1, height: h + 1 };
+ }
+ if (fourcc === 'VP8 ' && buf.length >= 30) {
+ // Lossy: the keyframe header's 14-bit dimensions, masked out of two
+ // little-endian uint16s.
+ return {
+ width: buf.readUInt16LE(26) & 0x3fff,
+ height: buf.readUInt16LE(28) & 0x3fff,
+ };
+ }
+ if (fourcc === 'VP8L' && buf.length >= 25) {
+ // Lossless: 14 bits each, packed across four bytes after the 0x2f
+ // signature byte, both stored minus one.
+ const bits =
+ buf[21]! | (buf[22]! << 8) | (buf[23]! << 16) | (buf[24]! << 24);
+ return {
+ width: (bits & 0x3fff) + 1,
+ height: ((bits >> 14) & 0x3fff) + 1,
+ };
+ }
+ return null;
+ }
+
+ // JPEG — the only one that needs a walk: the size lives in a start-of-frame
+ // marker whose position depends on how much metadata precedes it.
+ if (startsWith(buf, [0xff, 0xd8, 0xff])) {
+ let i = 2;
+ // Bounded by the buffer, and every step advances by at least two bytes, so
+ // this terminates on any input including a truncated or hostile one.
+ while (i + 9 < buf.length) {
+ if (buf[i] !== 0xff) {
+ i++;
+ continue;
+ }
+ const marker = buf[i + 1]!;
+ // Padding fill bytes, and the standalone markers that carry no length.
+ if (marker === 0xff) {
+ i++;
+ continue;
+ }
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
+ i += 2;
+ continue;
+ }
+ // SOF0..SOF15, minus the four that are not frame headers (DHT 0xc4,
+ // JPG 0xc8, DAC 0xcc).
+ const isSof =
+ marker >= 0xc0 &&
+ marker <= 0xcf &&
+ marker !== 0xc4 &&
+ marker !== 0xc8 &&
+ marker !== 0xcc;
+ if (isSof) {
+ // height then width, both big-endian uint16, after the 2-byte segment
+ // length and the 1-byte sample precision.
+ return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
+ }
+ // Start of scan — the entropy-coded data begins and there is no frame
+ // header left to find.
+ if (marker === 0xda) return null;
+ const len = buf.readUInt16BE(i + 2);
+ if (len < 2) return null;
+ i += 2 + len;
+ }
+ return null;
+ }
+
+ return null;
+}
+
+/**
+ * The `sizes` value for a manifest icon: the measured pixel square, or `any`.
+ *
+ * `any` is the honest answer for an SVG (it has no intrinsic size), for a
+ * format we do not walk, and for a file uploaded before this measurement
+ * existed. It is also the honest answer for a non-square image: `sizes` names
+ * squares, and a 800×200 banner is not a 800×800 icon.
+ */
+export function manifestIconSizes(
+ dimensions: ImageDimensions | null
+): string {
+ if (!dimensions) return 'any';
+ const { width, height } = dimensions;
+ if (width < 1 || height < 1 || width !== height) return 'any';
+ return `${width}x${height}`;
+}
diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts
index a054c037..92be873e 100644
--- a/apps/api/utils/settings.ts
+++ b/apps/api/utils/settings.ts
@@ -123,6 +123,14 @@ export const SETTINGS_KEYS = {
SITE_LOGO: 'site_logo',
SITE_LOGO_IMAGE: 'site_logo_image',
SITE_FAVICON: 'site_favicon',
+ // Pixel size of the two uploaded images, as `WxH`, written by the upload
+ // routes from the bytes themselves. Only the web app manifest reads them:
+ // a browser trusts the `sizes` an icon declares, so the declaration has to
+ // be measured rather than assumed. Absent (or `any`) means "unknown" — an
+ // SVG, a format we do not walk, or an image uploaded before the
+ // measurement existed. See `utils/imageSniff.manifestIconSizes`.
+ SITE_LOGO_IMAGE_SIZE: 'site_logo_image_size',
+ SITE_FAVICON_SIZE: 'site_favicon_size',
SITE_SUBTITLE: 'site_subtitle',
SITE_NAME_COLOR: 'site_name_color',
SITE_NAME_BOLD: 'site_name_bold',
@@ -358,6 +366,23 @@ export async function getSiteLogoImage(): Promise {
return value || null;
}
+/**
+ * The `sizes` string for the uploaded logo / favicon, as measured at upload.
+ *
+ * `any` is both the fallback and a legitimate answer — see the note on the
+ * settings keys. Never fabricate a square here: the manifest's whole value to
+ * a browser is that the number can be trusted.
+ */
+export async function getSiteLogoImageSizes(): Promise {
+ const value = await getSetting(SETTINGS_KEYS.SITE_LOGO_IMAGE_SIZE);
+ return value || 'any';
+}
+
+export async function getSiteFaviconSizes(): Promise {
+ const value = await getSetting(SETTINGS_KEYS.SITE_FAVICON_SIZE);
+ return value || 'any';
+}
+
export async function getSiteFavicon(): Promise {
const value = await getSetting(SETTINGS_KEYS.SITE_FAVICON);
return value || null;
diff --git a/apps/web/app/plugins/service-worker.client.ts b/apps/web/app/plugins/service-worker.client.ts
new file mode 100644
index 00000000..3fb53b1c
--- /dev/null
+++ b/apps/web/app/plugins/service-worker.client.ts
@@ -0,0 +1,39 @@
+/**
+ * Register the service worker on boot, rather than only when a member
+ * turns on push notifications.
+ *
+ * The worker has always existed — `useWebPush()` registers it at the moment
+ * someone enables Web Push. That is too late for the other thing a worker is
+ * needed for: a browser decides whether a site is installable at page load,
+ * and a site whose worker only appears after a settings toggle is not
+ * installable for anyone who never visits that toggle.
+ *
+ * `register()` is idempotent — a browser that already holds this exact script
+ * at this scope keeps its registration and does not re-download. So this and
+ * `useWebPush()` can both call it; whichever runs first wins and the other
+ * gets the same registration back.
+ *
+ * Client-only, and deliberately not awaited by the boot: an app that cannot
+ * mount because a service worker was slow to register would be a poor trade
+ * for an install prompt. Failures are swallowed — the worker is unavailable
+ * in a private window, over plain HTTP, and behind some enterprise policies,
+ * and none of those are the application's problem to report.
+ */
+export default defineNuxtPlugin({
+ name: 'service-worker',
+ parallel: true,
+ setup() {
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
+ return;
+ }
+ // After load, so registration never competes with the first paint for
+ // bandwidth on the one request the reader is actually waiting for.
+ const register = () => {
+ navigator.serviceWorker.register('/sw.js').catch(() => {
+ /* unsupported, blocked, or insecure context — nothing to do */
+ });
+ };
+ if (document.readyState === 'complete') register();
+ else window.addEventListener('load', register, { once: true });
+ },
+});
diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts
index 962a5ed8..f979d6e2 100644
--- a/apps/web/nuxt.config.ts
+++ b/apps/web/nuxt.config.ts
@@ -650,6 +650,23 @@ export default defineNuxtConfig({
// default; the built-ins live in the bundle, so this has to sort later.
tagPriority: 65,
},
+ {
+ /**
+ * The web app manifest — what makes the site installable.
+ *
+ * Served by the API for the same reason the stylesheet above is:
+ * everything in it (name, colours, icon) is operator-configurable,
+ * and `app.head` is the only place that reaches the HTML of BOTH
+ * shapes this app ships in. A static `public/manifest.webmanifest`
+ * would bake one instance's branding into every instance's bundle.
+ *
+ * A manifest's `scope` is not confined to its own directory the way
+ * a service worker's is, so serving it from `/api/` and claiming `/`
+ * is legitimate — see the route for the rest.
+ */
+ rel: 'manifest',
+ href: '/api/manifest.webmanifest',
+ },
],
},
},
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
index b6e4b7ff..0b60a988 100644
--- a/apps/web/public/sw.js
+++ b/apps/web/public/sw.js
@@ -1,9 +1,11 @@
/*
- * Service worker — Web Push receiver.
+ * Service worker — Web Push receiver, and the installability gate.
*
- * Stays intentionally tiny. The page registers this script once
- * via `useWebPush()`; from then on the browser keeps a copy alive
- * to handle `push` events even when no tab is open.
+ * Stays intentionally tiny. A boot plugin registers this script on
+ * every load (`plugins/service-worker.client.ts`) and `useWebPush()`
+ * reuses the same registration when a member turns push on; from then
+ * on the browser keeps a copy alive to handle `push` events even when
+ * no tab is open.
*
* Payload shape (kept in sync with apps/api/utils/channels/webpush.ts):
*
@@ -25,6 +27,25 @@ self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
+/*
+ * A fetch handler that handles nothing, on purpose.
+ *
+ * Chrome will not offer to install a site whose service worker has no
+ * `fetch` listener — the check is for the listener's existence, not for
+ * what it does. So this exists to satisfy that, and deliberately never
+ * calls `event.respondWith()`: without it the browser goes to the
+ * network exactly as it would with no service worker at all.
+ *
+ * What it is NOT is an offline cache, and that omission is a decision
+ * rather than a gap. Every page here is a live view of a swarm — seeder
+ * counts, ratios, a moderation queue, an inbox. A cache-first worker
+ * would serve yesterday's numbers with no way for the reader to tell,
+ * and on a private tracker the wrong ratio is not a cosmetic problem.
+ * An offline shell is worth building the day there is something worth
+ * reading offline; a stale one is worth nothing.
+ */
+self.addEventListener('fetch', () => {});
+
self.addEventListener('push', (event) => {
if (!event.data) return;
diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts
index ecd85b86..7697759c 100644
--- a/doc/.vitepress/config.ts
+++ b/doc/.vitepress/config.ts
@@ -110,6 +110,7 @@ export default defineConfig({
{ text: 'Favorites', link: '/guide/favorites' },
{ text: 'Follows', link: '/guide/follows' },
{ text: 'Cross-seed', link: '/guide/cross-seed' },
+ { text: 'Install as an App', link: '/guide/install-as-app' },
{ text: 'Federation', link: '/guide/federation' },
],
},
diff --git a/doc/guide/install-as-app.md b/doc/guide/install-as-app.md
new file mode 100644
index 00000000..bfbfb71c
--- /dev/null
+++ b/doc/guide/install-as-app.md
@@ -0,0 +1,74 @@
+# Install as an app (PWA)
+
+The site is installable: **Add to Home Screen** on iOS and Android, **Install**
+in a desktop Chrome or Edge. It then opens in its own window, with your
+instance's name and icon, and no browser chrome.
+
+Nothing to switch on. The two pieces are served automatically.
+
+## What makes it installable
+
+**A web app manifest** at `/api/manifest.webmanifest`, served by the API rather
+than shipped as a static file — for the same reason the theme stylesheet is.
+Everything in it is operator-configurable, and a static `manifest.webmanifest`
+in the bundle would bake one instance's branding into every instance's image.
+It carries the site name and subtitle, the two theme colours resolved from the
+site's default theme, the uploaded logo and favicon, and shortcuts to Browse,
+Upload and Notifications.
+
+**A service worker** at `/sw.js`, registered on every page load. It has always
+existed — it is the Web Push receiver — but it used to be registered only when
+a member turned notifications on, which is too late: a browser decides whether
+a site is installable at page load, so a site whose worker appears after a
+settings toggle is not installable for anyone who never visits that toggle.
+
+## The icon, and what Chrome wants
+
+`sizes` is a claim, and browsers act on the claim rather than on the file.
+**Chrome offers to install a site only when its manifest declares an icon of at
+least 512×512.** So the value is measured from the uploaded image's own header
+at upload time and stored beside it — declaring `512x512` over a 64-pixel logo
+would buy an install prompt and a blurry home-screen icon, which is worse than
+no prompt.
+
+What that means in practice:
+
+| What you uploaded | Result |
+| --- | --- |
+| A **PNG / JPEG / WebP logo of 512×512 or more**, square | Installable everywhere, including Chrome |
+| A smaller square image | Installs on iOS and Firefox; Chrome will not offer it |
+| An **SVG**, or a non-square image | `sizes: any` — same as above. An SVG has no intrinsic pixel size, and `sizes` names squares: a 800×200 banner is not an 800-pixel icon |
+| Nothing | The shipped 32×32 `favicon.ico`. Enough for iOS, not for Chrome |
+
+Upload a square logo of at least 512×512 in **Admin → Branding** and Chrome
+starts offering the install.
+
+> [!NOTE]
+> An image uploaded **before** this measurement existed is recorded as
+> `any`, because the size was never read and is not being guessed after the
+> fact. Re-uploading it fixes that.
+
+No default 512-pixel icon is shipped to paper over the gap: an invented icon
+saying "Trackarr" on somebody else's tracker is not an improvement.
+
+## What it is not
+
+There is **no offline mode**, and that omission is a decision rather than a gap.
+The service worker's `fetch` handler exists to satisfy the installability check
+and deliberately never answers a request: without it the browser goes to the
+network exactly as it would with no worker at all.
+
+Every page here is a live view of a swarm — seeder counts, ratios, a moderation
+queue, an inbox. A cache-first worker would serve yesterday's numbers with no
+way for the reader to tell, and on a private tracker the wrong ratio is not a
+cosmetic problem. An offline shell is worth building the day there is something
+worth reading offline; a stale one is worth nothing.
+
+## Notifications
+
+Installing does not change how notifications work. Web Push is still opt-in per
+member from **Settings → Notifications**; see
+[Notifications](./notifications.md). An installed app on Android and desktop
+receives them the same way a tab does. iOS delivers Web Push **only** to a site
+that has been added to the Home Screen — so on iPhone, installing is what makes
+push possible at all.
From 083655d3525460b4fd45bb76d7d6abaf4708e124 Mon Sep 17 00:00:00 2001
From: dim145
Date: Mon, 31 Aug 2026 23:36:49 +0200
Subject: [PATCH 04/96] feat(me): a copy of your own record, and what it
deliberately leaves out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Erasure has been here since the account page existed. Access and
portability had not — which is the odd half to be missing, because the
hard part was already done. Deciding what counts as a member's data is
exactly what the erasure had to settle, and thirteen `/api/me/*` routes
were already reading most of it a page at a time. What did not exist was
one request that returns the record AS a record.
So `exportAccount` is written as a mirror of `eraseAccount`, table for
table, read instead of written. Not a second inventory of the same thing:
two independently-maintained lists drift, and the direction they drift in
is "the export forgot something the erasure knew about". A personal table
added later belongs in both, and the note at the top of each says so.
One JSON document. Art. 20 asks for a structured, commonly used,
machine-readable format and JSON is all three, without adding a zip writer
to a distroless image for the sake of a folder structure nobody needs.
Indented, which doubles the bytes and is the difference between a file a
person can read and one they have to run through a formatter first — which
is the whole point of a right of access.
Guarded like the erasure: a live session plus a fresh login. This endpoint
answers with a person's entire history in one response, which makes it the
most valuable single request on the site to a borrowed session. Rate
limited on the mutation bucket despite being a GET — it reads twenty-odd
tables and costs nothing like a page fetch.
Every collection is capped at 5 000 and declares its own true total, so a
large account gets a bounded document that SAYS it is bounded. One that
silently stopped at the cap would be worse than one that refused: the
reader would take it for the whole record.
Four things are left out, and the file lists all four with its reasons,
because an export whose omissions are undocumented is indistinguishable
from an incomplete one.
Other members' identities — a follower list, who used an invite, the other
side of a conversation, who reported this account. Theirs, not this
account's; counted where a count means something.
Credentials, including this account's own. Notification channels carry a
webhook URL or a chat token, encrypted at rest with a key this has no
business decrypting with. A live token in a Downloads folder is a worse
place for it than the database. Channel types and state are exported; the
credential is not.
Private message bodies. A conversation belongs to both parties, and an
encrypted one cannot be read server-side at all — the key never leaves the
members' browsers. Metadata counts only.
Anti-cheat findings. Art. 15 is not absolute; it yields where disclosure
would prejudice the detection of abuse. Handing somebody the heuristics
that flagged them is a recipe for evading the next check, and an operator
asked for them by a member or a regulator can produce them from the
console.
One judgement call worth naming: the snatch list is exported even when
`hideDownloadHistory` is on. That toggle keeps the list out of a browser
session, so a stolen cookie cannot enumerate it, and this route is behind a
step-up. Refusing a member their own record here would be the toggle
working against the person it protects.
The button sits in its own Settings section, ahead of the danger zone and
not inside it: downloading a copy of your own record is not a destructive
act and should not be dressed as one. Fetched as a blob rather than opened
as a navigation, so the step-up's 401 lands in the page as "log in again"
instead of replacing Settings with raw JSON.
Co-Authored-By: Claude Opus 5
---
README.md | 1 +
apps/api/routes/api/me/export.get.ts | 77 ++++
apps/api/utils/account/exportAccount.ts | 535 ++++++++++++++++++++++++
apps/web/app/pages/settings.vue | 78 +++-
apps/web/i18n/locales/en.json | 10 +-
apps/web/i18n/locales/fr.json | 10 +-
doc/.vitepress/config.ts | 1 +
doc/guide/your-data.md | 80 ++++
8 files changed, 789 insertions(+), 3 deletions(-)
create mode 100644 apps/api/routes/api/me/export.get.ts
create mode 100644 apps/api/utils/account/exportAccount.ts
create mode 100644 doc/guide/your-data.md
diff --git a/README.md b/README.md
index 1401949a..5fd2887f 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre
- **Proof of Work on registration** stops drive-by signup spam.
- **Hashed IPs** — SHA-256 with daily-rotating salt; no raw IP persisted. Banning a user atomically banlists their last-known IP.
- **Privacy toggles** — hide last-seen on public profile (mods/admins always see the truth).
+- **Your data, both ways** — one-click JSON export of everything the instance holds about you (GDPR Art. 15 / 20) alongside self-service erasure (Art. 17). Both behind a fresh-login step-up; the export names every omission and why.
### Browse, upload & operate
diff --git a/apps/api/routes/api/me/export.get.ts b/apps/api/routes/api/me/export.get.ts
new file mode 100644
index 00000000..94be8265
--- /dev/null
+++ b/apps/api/routes/api/me/export.get.ts
@@ -0,0 +1,77 @@
+/**
+ * GET /api/me/export — a copy of everything this instance holds about you.
+ *
+ * The GDPR right of access (Art. 15) and the right to data portability
+ * (Art. 20). Erasure (Art. 17) has been here since `DELETE /api/me`; this is
+ * the half that was missing, and the odd half to be missing — the difficult
+ * work of deciding what counts as personal data was already done for the
+ * erasure, and thirteen `/api/me/*` routes were already reading most of it a
+ * page at a time. What did not exist was one request that returns the record
+ * as a record.
+ *
+ * ## Guards
+ *
+ * Two, and the second is the one that matters:
+ *
+ * - a live, non-banned session (the standard gate), and
+ * - a *fresh* login, exactly like account erasure. This endpoint answers
+ * with a person's entire history in one response, which makes it the most
+ * valuable single request on the site to a borrowed session. A step-up
+ * costs the legitimate member one password prompt.
+ *
+ * Rate-limited on the mutation bucket rather than the read bucket, despite
+ * being a GET: it reads twenty-odd tables and its cost is nothing like that of
+ * a page fetch.
+ *
+ * ## Response
+ *
+ * `application/json` with `Content-Disposition: attachment`, so a browser
+ * saves it instead of rendering a wall of text. Deliberately NOT streamed and
+ * NOT paginated — a portability export that arrives in pages is a dataset the
+ * member has to reassemble, and every collection inside is capped and declares
+ * its own total (see `utils/account/exportAccount`).
+ *
+ * `Cache-Control: no-store`, and `Vary` is irrelevant here: this must never be
+ * held by a shared cache or replayed from a browser's back-forward cache.
+ */
+import { exportAccount } from '~~/utils/account/exportAccount';
+import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+
+/**
+ * `trackarr-export-alice-2026-08-31.json`.
+ *
+ * The username is sanitised even though the rules already constrain it: this
+ * value lands in a response header, and a newline or a quote in a filename is
+ * how header injection starts. Anything outside the safe set becomes `-`.
+ */
+function filenameFor(username: string): string {
+ const safe = username.replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 64) || 'account';
+ const day = new Date().toISOString().slice(0, 10);
+ return `trackarr-export-${safe}-${day}.json`;
+}
+
+export default defineEventHandler(async (event) => {
+ const { user } = await requireAuthSession(event);
+ await rateLimit(event, RATE_LIMITS.mutation);
+ await requireFreshAuth(event);
+
+ const payload = await exportAccount(user.id);
+ if (!payload) {
+ throw createError({ statusCode: 404, message: 'Account not found' });
+ }
+
+ // Two-space indent. It doubles the byte count and it is the difference
+ // between a file a person can read and one they have to run through a
+ // formatter first — which is the whole point of a right of access.
+ const body = JSON.stringify(payload, null, 2);
+
+ setHeader(event, 'Content-Type', 'application/json; charset=utf-8');
+ setHeader(
+ event,
+ 'Content-Disposition',
+ `attachment; filename="${filenameFor(user.username)}"`
+ );
+ setHeader(event, 'Cache-Control', 'no-store, max-age=0');
+ return body;
+});
diff --git a/apps/api/utils/account/exportAccount.ts b/apps/api/utils/account/exportAccount.ts
new file mode 100644
index 00000000..8638ef1c
--- /dev/null
+++ b/apps/api/utils/account/exportAccount.ts
@@ -0,0 +1,535 @@
+/**
+ * Export an account — the GDPR right of access (Art. 15) and the right to data
+ * portability (Art. 20).
+ *
+ * ## Why this is a mirror of `eraseAccount`
+ *
+ * Erasure already had to answer, precisely, "what of this person is held here"
+ * — it deletes some rows, scrubs fields on others, and states which retentions
+ * survive and on what basis. That inventory is the same one an export needs,
+ * read instead of written. So this file follows it table for table rather than
+ * inventing a second list, because two independently-maintained inventories of
+ * the same thing drift, and the direction they drift in is "the export forgot
+ * something the erasure knew about".
+ *
+ * If you add a personal table, it belongs in BOTH.
+ *
+ * ## Structured, machine-readable, and one file
+ *
+ * Art. 20 asks for a "structured, commonly used and machine-readable format".
+ * JSON is all three, and it is the only one this codebase can produce without
+ * taking a dependency — a zip writer would be a new package on a distroless
+ * image for the sake of a folder structure nobody needs. One document, one
+ * request, no archive to unpack.
+ *
+ * ## What is deliberately NOT in here
+ *
+ * Three exclusions, each with a reason, all of them declared in the payload
+ * itself under `notIncluded` so the reader is told rather than left to notice:
+ *
+ * - **Other people's data.** A follower list names members who followed this
+ * account; their identities are theirs, not this account's. Counted, never
+ * listed. Same for who used an invite, and for the other side of a
+ * conversation.
+ * - **Secrets, including this account's own.** Notification channels carry a
+ * webhook URL or a chat token, and they are encrypted at rest with a key
+ * this export has no business decrypting with. A file in a Downloads
+ * folder is a worse place for a live token than the database is. Channel
+ * *types* and their state are exported; the credential is not.
+ * - **Anti-cheat findings.** Art. 15 is not absolute — it yields where
+ * disclosure would prejudice the detection of abuse or the rights of
+ * others. Handing somebody the heuristics that flagged them is a recipe
+ * for evading the next check. An operator asked for these by a member (or
+ * by a regulator) can produce them from the moderation console.
+ *
+ * ## Bounded
+ *
+ * Every collection is capped and reports its own true total, so an account
+ * with 40 000 notifications produces a bounded document that SAYS it is
+ * bounded. An export that silently stops at the cap would be worse than one
+ * that refuses: the reader would take it for the whole record.
+ */
+import { and, count, desc, eq, isNull } from 'drizzle-orm';
+import { db, schema } from '@trackarr/db';
+
+/**
+ * Rows per collection. Generous enough that a normal account is exported in
+ * full, small enough that the biggest imaginable one still fits in memory and
+ * in a browser's download.
+ */
+const CAP = 5000;
+
+/** A capped list, honest about what it left out. */
+interface Capped {
+ total: number;
+ returned: number;
+ /** True when `total > returned` — the list is a prefix, not the record. */
+ truncated: boolean;
+ items: T[];
+}
+
+function capped(items: T[], total: number): Capped {
+ return {
+ total,
+ returned: items.length,
+ truncated: total > items.length,
+ items,
+ };
+}
+
+/**
+ * `SELECT count(*)` for one table/predicate, unwrapped.
+ *
+ * Untyped on purpose: it is called against a dozen different tables and the
+ * only thing it needs from each is that Drizzle accepts it in `from()`.
+ * Spelling that out generically buys a signature nobody reads.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function countOf(table: any, where: any): Promise {
+ const [row] = await db.select({ value: count() }).from(table).where(where);
+ return row?.value ?? 0;
+}
+
+export async function exportAccount(userId: string) {
+ const account = await db.query.users.findFirst({
+ where: eq(schema.users.id, userId),
+ columns: {
+ // Identity and profile — everything the member typed or chose.
+ id: true,
+ username: true,
+ displayName: true,
+ bio: true,
+ language: true,
+ theme: true,
+ createdAt: true,
+ lastSeen: true,
+ // The economy's view of this account.
+ uploaded: true,
+ bonusUploaded: true,
+ downloaded: true,
+ bonusPoints: true,
+ invitesRemaining: true,
+ // Privacy preferences, which are themselves personal data: they record
+ // a choice this person made.
+ showLastSeen: true,
+ showAdultContent: true,
+ messagingReadReceipts: true,
+ anonymousUploads: true,
+ hideDownloadHistory: true,
+ restrictComments: true,
+ shareReputationFederated: true,
+ trustDevicesEnabled: true,
+ // Standing with the site. A sanction is this account's data and is
+ // exported; who imposed it is staff data and is not.
+ isBanned: true,
+ banReason: true,
+ bannedUntil: true,
+ // Whether a second factor exists — never the secret itself.
+ totpEnabled: true,
+ // Set only on an already-erased account; present so an export taken
+ // after an erasure is self-explanatory rather than mysteriously empty.
+ deletedAt: true,
+ },
+ });
+
+ if (!account) return null;
+
+ const [
+ devices,
+ passkeys,
+ channels,
+ routing,
+ roles,
+ favourites,
+ following,
+ followerCount,
+ invitesCreated,
+ uploads,
+ uploadCount,
+ comments,
+ commentCount,
+ topics,
+ topicCount,
+ posts,
+ postCount,
+ snatches,
+ snatchCount,
+ bonus,
+ bonusCount,
+ purchases,
+ purchaseCount,
+ poolContributions,
+ reportsFiled,
+ requests,
+ requestFills,
+ ticketRows,
+ templates,
+ notificationRows,
+ notificationCount,
+ ] = await Promise.all([
+ db.query.trustedDevices.findMany({
+ where: eq(schema.trustedDevices.userId, userId),
+ // No `tokenHash`: it is a credential, and the label plus the dates are
+ // what tells a person which device this is.
+ columns: { label: true, createdAt: true, expiresAt: true, lastUsedAt: true },
+ }),
+ db.query.webauthnCredentials.findMany({
+ where: eq(schema.webauthnCredentials.userId, userId),
+ // No `publicKey`, no `credentialId` — identifiers of a key the browser
+ // holds, useless outside it and not something to copy around.
+ columns: { name: true, transports: true, createdAt: true, lastUsedAt: true },
+ }),
+ db.query.userNotificationChannels.findMany({
+ where: eq(schema.userNotificationChannels.userId, userId),
+ // `userConfig` is the encrypted destination + token. Excluded on
+ // purpose — see the note at the top of this file.
+ columns: {
+ channelType: true,
+ enabled: true,
+ lastTestStatus: true,
+ lastTestedAt: true,
+ createdAt: true,
+ },
+ }),
+ db.query.userNotificationRouting.findMany({
+ where: eq(schema.userNotificationRouting.userId, userId),
+ columns: { type: true, channelType: true },
+ }),
+ db
+ .select({
+ role: schema.roles.name,
+ assignedAt: schema.userRoles.assignedAt,
+ assignedManually: schema.userRoles.assignedManually,
+ })
+ .from(schema.userRoles)
+ .innerJoin(schema.roles, eq(schema.roles.id, schema.userRoles.roleId))
+ .where(eq(schema.userRoles.userId, userId)),
+ db
+ .select({
+ infoHash: schema.torrents.infoHash,
+ name: schema.torrents.name,
+ favouritedAt: schema.torrentFavorites.createdAt,
+ })
+ .from(schema.torrentFavorites)
+ .innerJoin(
+ schema.torrents,
+ eq(schema.torrents.id, schema.torrentFavorites.torrentId)
+ )
+ .where(eq(schema.torrentFavorites.userId, userId))
+ .limit(CAP),
+ // Who this account follows is its own choice, so it is exported by name.
+ db
+ .select({
+ username: schema.users.username,
+ since: schema.userFollows.createdAt,
+ })
+ .from(schema.userFollows)
+ .innerJoin(schema.users, eq(schema.users.id, schema.userFollows.followingId))
+ .where(eq(schema.userFollows.followerId, userId))
+ .limit(CAP),
+ // Followers are other people. Counted, not named.
+ countOf(schema.userFollows, eq(schema.userFollows.followingId, userId)),
+ db.query.invitations.findMany({
+ where: eq(schema.invitations.createdBy, userId),
+ // `usedBy` identifies somebody else; `usedAt` says the same thing about
+ // this account's invite without naming them.
+ columns: {
+ code: true,
+ createdAt: true,
+ usedAt: true,
+ expiresAt: true,
+ },
+ limit: CAP,
+ }),
+ db.query.torrents.findMany({
+ where: eq(schema.torrents.uploaderId, userId),
+ columns: {
+ infoHash: true,
+ name: true,
+ size: true,
+ moderationStatus: true,
+ isActive: true,
+ createdAt: true,
+ },
+ orderBy: [desc(schema.torrents.createdAt)],
+ limit: CAP,
+ }),
+ countOf(schema.torrents, eq(schema.torrents.uploaderId, userId)),
+ db.query.torrentComments.findMany({
+ where: eq(schema.torrentComments.authorId, userId),
+ columns: { content: true, createdAt: true, updatedAt: true },
+ orderBy: [desc(schema.torrentComments.createdAt)],
+ limit: CAP,
+ }),
+ countOf(schema.torrentComments, eq(schema.torrentComments.authorId, userId)),
+ db.query.forumTopics.findMany({
+ where: eq(schema.forumTopics.authorId, userId),
+ columns: { title: true, createdAt: true, updatedAt: true },
+ orderBy: [desc(schema.forumTopics.createdAt)],
+ limit: CAP,
+ }),
+ countOf(schema.forumTopics, eq(schema.forumTopics.authorId, userId)),
+ db.query.forumPosts.findMany({
+ where: eq(schema.forumPosts.authorId, userId),
+ columns: { content: true, createdAt: true, updatedAt: true },
+ orderBy: [desc(schema.forumPosts.createdAt)],
+ limit: CAP,
+ }),
+ countOf(schema.forumPosts, eq(schema.forumPosts.authorId, userId)),
+ // The snatch list. Exported regardless of `hideDownloadHistory`: that
+ // toggle hides the list from a browser session (a stolen cookie cannot
+ // enumerate it), and this route is behind a fresh-auth step-up. Refusing
+ // the member their own record here would be the toggle working against
+ // the person it protects.
+ db
+ .select({
+ infoHash: schema.torrents.infoHash,
+ name: schema.torrents.name,
+ downloadedAt: schema.hnrTracking.downloadedAt,
+ completedAt: schema.hnrTracking.completedAt,
+ seedTime: schema.hnrTracking.seedTime,
+ requiredSeedTime: schema.hnrTracking.requiredSeedTime,
+ isHnr: schema.hnrTracking.isHnr,
+ isExempt: schema.hnrTracking.isExempt,
+ uploaded: schema.hnrTracking.uploaded,
+ downloaded: schema.hnrTracking.downloaded,
+ })
+ .from(schema.hnrTracking)
+ .innerJoin(schema.torrents, eq(schema.torrents.id, schema.hnrTracking.torrentId))
+ .where(eq(schema.hnrTracking.userId, userId))
+ .orderBy(desc(schema.hnrTracking.downloadedAt))
+ .limit(CAP),
+ countOf(schema.hnrTracking, eq(schema.hnrTracking.userId, userId)),
+ db.query.bonusGrants.findMany({
+ where: eq(schema.bonusGrants.userId, userId),
+ columns: { source: true, amount: true, createdAt: true },
+ orderBy: [desc(schema.bonusGrants.createdAt)],
+ limit: CAP,
+ }),
+ countOf(schema.bonusGrants, eq(schema.bonusGrants.userId, userId)),
+ db.query.shopPurchases.findMany({
+ where: eq(schema.shopPurchases.userId, userId),
+ columns: {
+ itemNameSnapshot: true,
+ itemTypeSnapshot: true,
+ costPaid: true,
+ createdAt: true,
+ },
+ orderBy: [desc(schema.shopPurchases.createdAt)],
+ limit: CAP,
+ }),
+ countOf(schema.shopPurchases, eq(schema.shopPurchases.userId, userId)),
+ db.query.freeleechPoolContributions.findMany({
+ where: eq(schema.freeleechPoolContributions.userId, userId),
+ columns: { amount: true, createdAt: true },
+ orderBy: [desc(schema.freeleechPoolContributions.createdAt)],
+ limit: CAP,
+ }),
+ // Reports this account filed. Not reports filed ABOUT it: those are
+ // somebody else's statement, and disclosing them would identify the
+ // reporter — the one thing a reporting system must not do.
+ db.query.reports.findMany({
+ where: eq(schema.reports.reporterId, userId),
+ columns: {
+ targetType: true,
+ reason: true,
+ details: true,
+ status: true,
+ resolution: true,
+ withdrawnAt: true,
+ createdAt: true,
+ resolvedAt: true,
+ },
+ orderBy: [desc(schema.reports.createdAt)],
+ limit: CAP,
+ }),
+ db.query.uploadRequests.findMany({
+ where: eq(schema.uploadRequests.requesterId, userId),
+ columns: {
+ title: true,
+ description: true,
+ rewardPoints: true,
+ status: true,
+ createdAt: true,
+ filledAt: true,
+ validatedAt: true,
+ cancelledAt: true,
+ },
+ orderBy: [desc(schema.uploadRequests.createdAt)],
+ limit: CAP,
+ }),
+ db.query.uploadRequestFillAttempts.findMany({
+ where: eq(schema.uploadRequestFillAttempts.userId, userId),
+ columns: { status: true, createdAt: true, rejectedAt: true },
+ orderBy: [desc(schema.uploadRequestFillAttempts.createdAt)],
+ limit: CAP,
+ }),
+ db.query.tickets.findMany({
+ where: eq(schema.tickets.openedById, userId),
+ columns: {
+ number: true,
+ category: true,
+ subject: true,
+ status: true,
+ closureReason: true,
+ closingNote: true,
+ createdAt: true,
+ closedAt: true,
+ },
+ orderBy: [desc(schema.tickets.createdAt)],
+ limit: CAP,
+ }),
+ db.query.presentationTemplates.findMany({
+ where: eq(schema.presentationTemplates.ownerId, userId),
+ columns: {
+ name: true,
+ description: true,
+ category: true,
+ content: true,
+ visibility: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ limit: CAP,
+ }),
+ db.query.notifications.findMany({
+ where: eq(schema.notifications.userId, userId),
+ columns: {
+ type: true,
+ payload: true,
+ link: true,
+ readAt: true,
+ createdAt: true,
+ },
+ orderBy: [desc(schema.notifications.createdAt)],
+ limit: CAP,
+ }),
+ countOf(schema.notifications, eq(schema.notifications.userId, userId)),
+ ]);
+
+ // Ticket messages, for the tickets just read. Second query rather than a
+ // join so a ticket with 200 replies does not multiply the ticket rows, and
+ // scoped to this author: a staff reply is on the ticket but is staff's text.
+ const myTicketMessages = await db.query.ticketMessages.findMany({
+ where: and(
+ eq(schema.ticketMessages.authorId, userId),
+ eq(schema.ticketMessages.fromStaff, false)
+ ),
+ columns: { body: true, createdAt: true },
+ orderBy: [desc(schema.ticketMessages.createdAt)],
+ limit: CAP,
+ });
+
+ // Messaging. Conversations this account takes part in, with the count of its
+ // own messages — never the messages themselves, and never the other
+ // participant's. Encrypted conversations cannot be read server-side at all
+ // (the key never leaves the members' browsers), so even the willing case is
+ // not available; an unencrypted one COULD be read, and is excluded on the
+ // same grounds — a two-party conversation is not one party's record.
+ const conversationCount = await countOf(
+ schema.conversationParticipants,
+ eq(schema.conversationParticipants.userId, userId)
+ );
+ const sentMessageCount = await countOf(
+ schema.messages,
+ and(eq(schema.messages.authorId, userId), isNull(schema.messages.deletedAt))
+ );
+
+ return {
+ /**
+ * Metadata about the export itself, so a file found later can be dated and
+ * placed without guessing.
+ */
+ export: {
+ generatedAt: new Date().toISOString(),
+ /** Bump when the shape changes in a way a consumer would notice. */
+ schemaVersion: 1,
+ subject: account.username,
+ rowCapPerCollection: CAP,
+ basis: [
+ 'GDPR Art. 15 — right of access',
+ 'GDPR Art. 20 — right to data portability',
+ ],
+ },
+
+ account,
+ security: { trustedDevices: devices, passkeys, roles },
+ notificationSettings: { channels, routing },
+
+ social: {
+ following: capped(following, following.length),
+ /** Other people. A number, by design — see the note at the top. */
+ followerCount,
+ favourites: capped(favourites, favourites.length),
+ },
+
+ invitesCreated: capped(invitesCreated, invitesCreated.length),
+
+ contributions: {
+ uploads: capped(uploads, uploadCount),
+ torrentComments: capped(comments, commentCount),
+ forumTopics: capped(topics, topicCount),
+ forumPosts: capped(posts, postCount),
+ presentationTemplates: capped(templates, templates.length),
+ },
+
+ activity: {
+ snatches: capped(snatches, snatchCount),
+ notifications: capped(notificationRows, notificationCount),
+ },
+
+ economy: {
+ bonusLedger: capped(bonus, bonusCount),
+ shopPurchases: capped(purchases, purchaseCount),
+ freeleechPoolContributions: capped(
+ poolContributions,
+ poolContributions.length
+ ),
+ },
+
+ requests: {
+ opened: capped(requests, requests.length),
+ fillAttempts: capped(requestFills, requestFills.length),
+ },
+
+ support: {
+ tickets: capped(ticketRows, ticketRows.length),
+ myMessages: capped(myTicketMessages, myTicketMessages.length),
+ },
+
+ reportsFiled: capped(reportsFiled, reportsFiled.length),
+
+ messaging: {
+ conversationCount,
+ sentMessageCount,
+ note: 'Message bodies are not exported. See `notIncluded`.',
+ },
+
+ /**
+ * Said out loud rather than left to be noticed. An export whose omissions
+ * are undocumented is indistinguishable from an incomplete one.
+ */
+ notIncluded: [
+ {
+ what: 'Other members’ identities',
+ where: 'follower list, who used an invite, the other side of a conversation, who filed a report about this account',
+ why: 'Their data, not this account’s. Counted where a count is meaningful.',
+ },
+ {
+ what: 'Credentials and secrets',
+ where: 'password verifier, passkey material, trusted-device tokens, TOTP secret, notification-channel tokens and webhook URLs, the account passkey',
+ why: 'A live credential in a downloaded file is a worse risk than one in the database. Channel types and state are exported; the credential is not.',
+ },
+ {
+ what: 'Private message bodies',
+ where: 'direct messages and room messages',
+ why: 'A conversation belongs to both parties. Encrypted ones cannot be read server-side at all — the key never leaves the browser.',
+ },
+ {
+ what: 'Anti-cheat findings',
+ where: 'automated flags raised on announces from this account',
+ why: 'Art. 15 yields where disclosure would prejudice the detection of abuse. An operator can produce these from the moderation console on request.',
+ },
+ ],
+ };
+}
diff --git a/apps/web/app/pages/settings.vue b/apps/web/app/pages/settings.vue
index 0af9adaf..1beda402 100644
--- a/apps/web/app/pages/settings.vue
+++ b/apps/web/app/pages/settings.vue
@@ -614,6 +614,38 @@
+
+
+
+ 08
+
{{ $t('settings.sections.data') }}
+
+
+
+
+
{{ $t('settings.data.blurb') }}
+
+ {{ exportError }}
+
+
+
{{ $t('settings.data.excludedNote') }}
+
+
+
@@ -887,7 +919,8 @@ type SectionKey =
| 'security'
| 'notifications'
| 'account'
- | 'templates';
+ | 'templates'
+ | 'data';
const sections = computed<
Array<{ key: SectionKey; num: string; label: string; icon: string }>
>(() => [
@@ -898,6 +931,7 @@ const sections = computed<
{ key: 'notifications', num: '05', label: t('settings.sections.notifications'), icon: 'ph:bell-ringing' },
{ key: 'account', num: '06', label: t('settings.sections.accountInfo'), icon: 'ph:info' },
{ key: 'templates', num: '07', label: t('settings.sections.templates'), icon: 'ph:brackets-curly' },
+ { key: 'data', num: '08', label: t('settings.sections.data'), icon: 'ph:download-simple' },
]);
const activeSection = ref('identity');
onMounted(() => {
@@ -1184,6 +1218,48 @@ async function signOut() {
const deleteConfirm = ref('');
const deleting = ref(false);
const deleteError = ref('');
+// ── Data export (GDPR Art. 15 / 20) ─────────────────────────────
+const exporting = ref(false);
+const exportError = ref('');
+
+/**
+ * Fetched as a blob and saved from the browser, not opened as a navigation.
+ *
+ * A plain `window.location = '/api/me/export'` would carry the cookie and work
+ * — right up to the fresh-auth step-up the route requires, whose 401 would
+ * replace the settings page with a raw JSON error. Going through `$fetch` keeps
+ * the failure in this page, where it can say "log in again" like the erasure
+ * button below already does.
+ */
+async function downloadExport() {
+ exporting.value = true;
+ exportError.value = '';
+ try {
+ const blob = await $fetch('/api/me/export', { responseType: 'blob' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ // The server sends a Content-Disposition filename, but a blob: download
+ // cannot read it — so the name is rebuilt here from the same two parts.
+ a.download = `trackarr-export-${form.username}-${new Date().toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (err: unknown) {
+ const e = err as {
+ statusCode?: number;
+ data?: { message?: string; data?: { reauthRequired?: boolean } };
+ message?: string;
+ };
+ if (e?.data?.data?.reauthRequired || e?.statusCode === 401) {
+ exportError.value = t('settings.danger.reauthRequired');
+ } else {
+ exportError.value = e?.data?.message || e?.message || t('settings.data.error');
+ }
+ } finally {
+ exporting.value = false;
+ }
+}
+
async function deleteAccount() {
if (deleteConfirm.value !== form.username) return;
deleting.value = true;
diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json
index fce10bf7..5564bfbf 100644
--- a/apps/web/i18n/locales/en.json
+++ b/apps/web/i18n/locales/en.json
@@ -396,7 +396,8 @@
"notifications": "Notifications",
"accountInfo": "Account info",
"danger": "Danger zone",
- "templates": "Listing templates"
+ "templates": "Listing templates",
+ "data": "Your data"
},
"danger": {
"deleteTitle": "Delete this account",
@@ -575,6 +576,13 @@
"templates": {
"blurb": "The upload wizard builds a BBCode listing from a layout. Keep your own layouts here and pick which one the wizard starts from.",
"manage": "Manage listing templates"
+ },
+ "data": {
+ "blurb": "Download everything this tracker holds about you, as one JSON file: your profile, uploads, snatch history, bonus ledger, forum posts and the rest.",
+ "download": "Download my data",
+ "preparing": "Preparing…",
+ "error": "Could not build the export. Try again in a moment.",
+ "excludedNote": "Credentials, private-message bodies and other members’ identities are left out. The file lists every omission and why."
}
},
"torrents": {
diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json
index 22576523..71ebeeb5 100644
--- a/apps/web/i18n/locales/fr.json
+++ b/apps/web/i18n/locales/fr.json
@@ -396,7 +396,8 @@
"notifications": "Notifications",
"accountInfo": "Compte",
"danger": "Zone de danger",
- "templates": "Modèles de fiche"
+ "templates": "Modèles de fiche",
+ "data": "Vos données"
},
"danger": {
"deleteTitle": "Supprimer ce compte",
@@ -575,6 +576,13 @@
"templates": {
"blurb": "L’assistant d’upload construit une fiche BBCode à partir d’une mise en page. Conservez ici les vôtres et choisissez celle dont l’assistant part.",
"manage": "Gérer les modèles de fiche"
+ },
+ "data": {
+ "blurb": "Téléchargez tout ce que ce tracker détient sur vous, en un seul fichier JSON : profil, mises en ligne, historique de téléchargement, grand livre de bonus, messages du forum et le reste.",
+ "download": "Télécharger mes données",
+ "preparing": "Préparation…",
+ "error": "L'export n'a pas pu être constitué. Réessayez dans un instant.",
+ "excludedNote": "Les identifiants, le corps des messages privés et l'identité des autres membres n'y figurent pas. Le fichier énumère chaque omission et sa raison."
}
},
"torrents": {
diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts
index 7697759c..c9c30785 100644
--- a/doc/.vitepress/config.ts
+++ b/doc/.vitepress/config.ts
@@ -76,6 +76,7 @@ export default defineConfig({
{ text: 'Zero-Knowledge Auth', link: '/guide/zero-knowledge-auth' },
{ text: 'Two-Factor Auth', link: '/guide/two-factor-auth' },
{ text: 'Panic Mode', link: '/guide/panic-mode' },
+ { text: 'Your Data (GDPR)', link: '/guide/your-data' },
],
},
{
diff --git a/doc/guide/your-data.md b/doc/guide/your-data.md
new file mode 100644
index 00000000..f1a7927f
--- /dev/null
+++ b/doc/guide/your-data.md
@@ -0,0 +1,80 @@
+# Your data (GDPR)
+
+Two rights, both self-service, both from **Settings**:
+
+- **Download a copy** of everything this instance holds about you — Art. 15
+ (access) and Art. 20 (portability).
+- **Erase your account** — Art. 17.
+
+Erasure has been here since the account page existed. The export is the half
+that was missing, and the odd half to be missing: the hard part — deciding what
+counts as this person's data — had already been settled by the erasure, and a
+dozen `/api/me/*` routes were already reading most of it a page at a time. What
+did not exist was one request that returns the record *as* a record.
+
+## The export
+
+`GET /api/me/export` → one JSON document, saved as
+`trackarr-export--.json`.
+
+JSON because Art. 20 asks for a "structured, commonly used and machine-readable
+format" and it is all three, without adding a zip writer to a distroless image
+for the sake of a folder structure nobody needs.
+
+It covers: the profile and every preference; security metadata (trusted device
+labels, passkey names, whether a second factor exists); notification channel
+types and routing; the social graph you chose; invitations you created;
+uploads, comments, forum topics and posts, presentation templates; the snatch
+list with its hit-and-run bookkeeping; the bonus ledger, shop purchases and
+freeleech-pool contributions; requests opened and fills attempted; tickets and
+your own messages in them; reports you filed; and your notifications.
+
+Every collection is capped at 5 000 rows and **declares its own true total**, so
+a large account gets a bounded document that says it is bounded. A file that
+silently stopped at the cap would be worse than one that refused: the reader
+would take it for the whole record.
+
+### Guarded like the erasure
+
+The same two gates: a live session, and a **fresh login** (a step-up within the
+last ten minutes). This endpoint answers with a person's entire history in one
+response, which makes it the most valuable single request on the site to a
+borrowed session.
+
+### What is left out, and why
+
+The file lists this itself, under `notIncluded` — an export whose omissions are
+undocumented is indistinguishable from an incomplete one.
+
+| Left out | Why |
+| --- | --- |
+| Other members' identities — who follows you, who used your invite, the other party in a conversation, who reported you | Their data, not yours. Counted where a count means something. |
+| Credentials — the password verifier, passkey material, trusted-device tokens, the TOTP secret, notification-channel tokens and webhook URLs, your announce passkey | A live credential sitting in a Downloads folder is a worse risk than one in the database. Channel *types* and their state are exported; the credential is not. |
+| Private message bodies | A conversation belongs to both parties. Encrypted ones cannot be read server-side at all — the key never leaves the members' browsers. |
+| Anti-cheat findings | Art. 15 yields where disclosure would prejudice the detection of abuse. An operator can produce these from the moderation console on request. |
+
+### The snatch list is exported even when hidden
+
+`hideDownloadHistory` keeps the list out of a browser session — so a stolen
+cookie cannot enumerate it — and this route is behind a fresh-auth step-up.
+Refusing a member their own record here would be the toggle working against the
+person it protects.
+
+## Erasure
+
+Unchanged, and documented where it always was: the account row survives as a
+tombstone so the catalogue keeps standing, every personal field on it is
+scrubbed, the federated identity is retracted across the mesh, and the
+retentions that survive are stated rather than left to be discovered.
+
+One addition: where the member was **staff**, their entries in the
+[audit log](./audit-log.md) keep the name and lose the pointer. An act taken
+under authority with no author is indefensible.
+
+## For operators
+
+Nothing to configure. Both routes are always available to the account itself,
+rate-limited on the mutation bucket, and neither is reachable by staff on
+somebody else's behalf.
+
+What this instance keeps and for how long is published, live, at `/privacy`.
From 5e80e32fe1f4e047aad8cba97717b6731036802f Mon Sep 17 00:00:00 2001
From: dim145
Date: Mon, 31 Aug 2026 23:37:41 +0200
Subject: [PATCH 05/96] feat(admin): who did what, across the whole console
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This instance keeps a shelf of thematic ledgers, each good inside its own
lane. Freeleech-pool contributions are append-only. Every bonus credit has
a row. A moderated upload carries its discussion. A withdrawn report leaves
a tombstone. The one route that reads private mail logs itself — 8e41cb2,
three commits ago, which is the same need answered for the fourth time.
What none of them answers is the question asked across the site: who
banned this member, who changed that setting, who touched federation, who
fired panic mode, and when.
That gap sits badly beside everything else here. Members' IPs are hashed
under a rotating salt, passwords never leave the browser, the database can
be encrypted in an emergency, an account can be erased on request — and
nothing could say which moderator took which decision. Protection ran one
way. It also meant a compromised staff account left no trace.
One row per mutating request to `/api/admin/**` or `/api/mod/**`, written
by hooks rather than by each route. There are 339 operations in the
generated spec and dozens of them are staff mutations; a convention that
every one must remember to log itself holds until the next route. Here
coverage is structural — a route added tomorrow is audited before anybody
writes a line for it. What a route CAN do is sharpen its entry, and the
ones that move the most name themselves: ban, unban, role, panic, settings.
Reads are not recorded, and neither are member-facing writes. A register of
authority records decisions; recording who looked at which page would make
it a record of everybody's activity instead — the opposite of what the
privacy toggles elsewhere exist to protect.
Two hooks, not one, and the second is there because an end-to-end run
found the first was not enough. `afterResponse` fires for a request the
handler answered; it does NOT fire for one that threw. So the first version
recorded every successful ban and silently dropped every refusal — the
exact rows worth having, since a run of 403s from one account is the
pattern this table exists to surface. `error` covers those, `record` is
idempotent, and the actor is stamped in `requireAuthSession` rather than in
the staff gates so a member who aims at an admin route and takes a 403 is
recorded with their name on it.
The same run found the action key eating identifiers:
`admin.federation.peers.does-not-exist.delete`, because a slug-shaped peer
id looks exactly like a sub-resource name and no shape heuristic can tell
them apart. h3 knows which segments matched a route parameter, so those are
removed by value; the shape rules stay as a fallback.
Request bodies are never captured. They carry passwords, panic passwords,
channel tokens and 2FA secrets, and a log that swallowed them would be a
credential store with a listing page. A route wanting a diff passes the
fields it means — and the settings route, whose body is wide and grows with
every feature, records which settings were touched and never their values.
Query strings are stripped from the path for the same reason.
Append-only. No edit path, no per-row delete, no "mark as reviewed": a
register whose entries can be amended by the people it registers is not a
register. Rows leave only through the retention sweep — a year by default,
because the question an audit log answers tends to be asked late, and `0`
means keep them. The period is published on `/privacy` beside every other
one, since a member who was banned is the subject of one of these rows and
the period is theirs to know. The control sits on the register's own page:
a setting reachable only through a SQL prompt is one only its author can
change.
Admins only, and moderators deliberately not, though moderators fill it.
Knowing exactly what a colleague can see about you changes what you do in
front of them, and the value of the register is that it is read by the
people accountable for the console rather than by everyone holding a key.
The actor is denormalised — name and role copied at write time — because
the point of the row is what was true WHEN it happened. A moderator later
promoted, renamed or erased must not retroactively rewrite the record. On
erasure the pointer goes and the name stays, on exactly the rule the ticket
scrubbing already follows: an act taken under authority with no author is
indefensible, and an ex-moderator must not be able to un-sign their own
decisions by closing their account. Done by hand, because the row in
`users` survives an erasure and no ON DELETE ever fires.
The address is hashed like every other IP here, which means two rows are
comparable only within a day. Enough for "this admin's session came from
somewhere else than the rest of today's actions", not enough for a history
— the trade the rest of the codebase already makes, stated rather than
discovered.
What it cannot see, and the guide says so: post-panic recovery. After panic
mode there is no session, so there is no account to name. That endpoint is
rate-limited and globally capped instead.
Co-Authored-By: Claude Opus 5
---
README.md | 1 +
apps/api/plugins/audit-log.ts | 75 +
apps/api/plugins/audit-retention.ts | 63 +
.../api/routes/api/admin/audit/actions.get.ts | 35 +
apps/api/routes/api/admin/audit/index.get.ts | 118 +
.../routes/api/admin/panic/encrypt.post.ts | 7 +
apps/api/routes/api/admin/settings.get.ts | 6 +
apps/api/routes/api/admin/settings.put.ts | 30 +
.../routes/api/admin/users/[id]/ban.post.ts | 18 +
.../routes/api/admin/users/[id]/role.put.ts | 22 +-
.../routes/api/admin/users/[id]/unban.post.ts | 9 +
apps/api/routes/api/privacy.get.ts | 13 +
apps/api/test/audit.test.ts | 117 +
apps/api/utils/account/eraseAccount.ts | 33 +-
apps/api/utils/adminAuth.ts | 17 +
apps/api/utils/audit.ts | 232 +
apps/api/utils/schemas.ts | 7 +
apps/api/utils/settings.ts | 22 +
apps/web/app/composables/useAdminNav.ts | 10 +
apps/web/app/pages/admin/audit.vue | 667 ++
apps/web/app/pages/privacy.vue | 21 +
apps/web/i18n/locales/en.json | 57 +-
apps/web/i18n/locales/fr.json | 57 +-
doc/.vitepress/config.ts | 1 +
doc/guide/audit-log.md | 115 +
.../src/migrations/0059_staff_audit_log.sql | 22 +
.../db/src/migrations/meta/0059_snapshot.json | 9740 +++++++++++++++++
packages/db/src/migrations/meta/_journal.json | 7 +
packages/db/src/schema.ts | 112 +
29 files changed, 11626 insertions(+), 8 deletions(-)
create mode 100644 apps/api/plugins/audit-log.ts
create mode 100644 apps/api/plugins/audit-retention.ts
create mode 100644 apps/api/routes/api/admin/audit/actions.get.ts
create mode 100644 apps/api/routes/api/admin/audit/index.get.ts
create mode 100644 apps/api/test/audit.test.ts
create mode 100644 apps/api/utils/audit.ts
create mode 100644 apps/web/app/pages/admin/audit.vue
create mode 100644 doc/guide/audit-log.md
create mode 100644 packages/db/src/migrations/0059_staff_audit_log.sql
create mode 100644 packages/db/src/migrations/meta/0059_snapshot.json
diff --git a/README.md b/README.md
index 5fd2887f..2cb2ead3 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre
- **Dedicated upload page** — auto title + tags from filename, multi-source search picker, duplicate preflight, conditional ID block per category, Tiptap WYSIWYG description, NFO drag-drop (CP437 → UTF-8).
- **Release sheet builder** — a four-step wizard at `/torrents/fiche` turns a video file into a BBCode sheet, an NFO and a normalised release name, then hands all three to the upload form. MediaInfo runs **in the browser** through WebAssembly and reads only the chunks it asks for, so a 40 GB remux is analysed without ever being uploaded. Every dropdown keeps an "Other…" entry, and bitrate/size unit selectors change the frame of reference without touching the value.
- **Operator console** — `/admin` covers users, categories, roles, invites, branding, panic, tags, Torznab, reports, HnR.
+- **Staff audit log** — every mutating request to the admin and moderation consoles leaves one append-only row: actor and role as they were, action, target, what changed, and the status — refusals included. Written by a Nitro hook rather than per route, so a staff route added tomorrow is audited before anyone writes a line for it. Admins only. See [Staff audit log](doc/guide/audit-log.md).
- **Installable (PWA)** — a manifest served by the API, so the app's name, colours and icon follow the instance's branding. No offline cache: every page here is a live view of a swarm.
- **Notification fan-out** — every event-emitting route hits Postgres + Redis pub/sub + the user's chosen external transport (SMTP, Telegram, Discord, ntfy, Gotify, Pushover, Slack, Mattermost, webhook, Apprise, **Web Push**).
diff --git a/apps/api/plugins/audit-log.ts b/apps/api/plugins/audit-log.ts
new file mode 100644
index 00000000..552afdc0
--- /dev/null
+++ b/apps/api/plugins/audit-log.ts
@@ -0,0 +1,75 @@
+/**
+ * The staff audit log's write path.
+ *
+ * Two Nitro hooks, not one, and the second is there because the first is not
+ * enough — which an end-to-end check found rather than a review:
+ *
+ * - `afterResponse` fires for a request the handler answered normally.
+ * - `error` fires for one that threw, and `afterResponse` does NOT run for
+ * it. A single-hook version therefore recorded every successful ban and
+ * silently dropped every refusal — the exact rows worth having. A run of
+ * 403s from one account is the pattern this register exists to surface,
+ * and it would have been the one thing invisible in it.
+ *
+ * Both paths go through `record`, which is idempotent: a request that somehow
+ * reached both hooks writes one row, not two.
+ *
+ * The write sits after the response either way, so the insert never delays a
+ * reply or fails one.
+ *
+ * Why a hook rather than a call in each route: there are 243 operations in the
+ * generated OpenAPI spec, dozens of them staff mutations, and a convention that
+ * every one of them must remember to log itself is a convention that holds
+ * until the next route. Here, coverage is structural — a route added tomorrow
+ * is audited before it is written. What a route can still do is *sharpen* its
+ * entry, by calling `auditDetail`; see `utils/audit.ts`.
+ *
+ * The gate is deliberately narrow: a mutating method, a path under
+ * `/api/admin/` or `/api/mod/`, and an authenticated caller. Everything else —
+ * every GET, every member-facing write — is not a staff action and does not
+ * belong in a register of authority.
+ */
+import type { H3Event } from 'h3';
+import { isAuditable, writeAuditEntry, type AuditActor } from '~~/utils/audit';
+
+/**
+ * One row per request, whichever hook gets here first.
+ *
+ * `statusOverride` is for the error path: `event.node.res.statusCode` is not
+ * always the code that will be sent when a handler threw, so the error's own
+ * code is used when there is one.
+ */
+function record(event: H3Event, statusOverride?: number): void {
+ if (!event?.context || event.context.auditWritten) return;
+
+ const method = event.method ?? 'GET';
+ const path = (event.path ?? '').split('?')[0] ?? '';
+ if (!isAuditable(method, path)) return;
+
+ // Set by `requireAuthSession`. Absent means the request never got past
+ // authentication, which is the rate limiter's business and not this
+ // register's — there is no actor to name.
+ const actor = event.context.auditActor as AuditActor | undefined;
+ if (!actor?.id) return;
+
+ event.context.auditWritten = true;
+
+ const statusCode = statusOverride ?? event.node?.res?.statusCode ?? 200;
+
+ // Not awaited: the response has already gone out, and holding the hook open
+ // would keep the request's context alive for the length of an INSERT.
+ // `writeAuditEntry` swallows its own failures.
+ void writeAuditEntry(event, actor, statusCode);
+}
+
+export default defineNitroPlugin((nitro) => {
+ nitro.hooks.hook('afterResponse', (event) => {
+ record(event);
+ });
+
+ nitro.hooks.hook('error', (error, ctx) => {
+ if (!ctx?.event) return;
+ const status = (error as { statusCode?: number })?.statusCode;
+ record(ctx.event, typeof status === 'number' ? status : 500);
+ });
+});
diff --git a/apps/api/plugins/audit-retention.ts b/apps/api/plugins/audit-retention.ts
new file mode 100644
index 00000000..bee3ab26
--- /dev/null
+++ b/apps/api/plugins/audit-retention.ts
@@ -0,0 +1,63 @@
+/**
+ * Audit-log retention sweep.
+ *
+ * Entries survive `audit_log_retention_days` (default 365, `0` = forever) and
+ * are then deleted by age. This is the ONLY thing in the application that
+ * removes an audit row — see the note on the table: a register whose entries
+ * can be amended by the people it registers is not a register, so there is no
+ * edit path and no per-row delete, and the retention period is published on
+ * `/api/privacy` where a member can read it.
+ *
+ * A year rather than the 90 days notifications get, because the question an
+ * audit log answers tends to be asked late: after a member disputes a ban, or
+ * after a staff account turns out to have been borrowed weeks ago.
+ *
+ * Daily, with a delay after boot: the table is small next to `notifications`
+ * (one row per staff mutation, not one per member event) and nothing here is
+ * urgent enough to compete with cold start.
+ */
+import { lt } from 'drizzle-orm';
+import { db, schema } from '@trackarr/db';
+import { getAuditRetentionDays } from '~~/utils/server';
+import { withCronLock } from '~~/utils/cronLock';
+
+const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
+const FIRST_RUN_DELAY_MS = 5 * 60 * 1000;
+
+export default defineNitroPlugin(() => {
+ const tick = async () => {
+ try {
+ await withCronLock('audit_retention:lock', 10 * 60, async () => {
+ const days = await getAuditRetentionDays();
+ if (days <= 0) return; // keep indefinitely
+
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+ const deleted = await db
+ .delete(schema.auditLog)
+ .where(lt(schema.auditLog.createdAt, cutoff))
+ .returning({ id: schema.auditLog.id });
+
+ if (deleted.length > 0) {
+ console.log(
+ `[AuditRetention] swept ${deleted.length} entries older than ${days}d`
+ );
+ }
+ });
+ } catch (err) {
+ // `console.error` with the cause chain — the notification sweep failed
+ // silently for months behind a `warn` carrying only `err.message`, and
+ // drizzle's message can point at the wrong layer entirely.
+ const e = err as { message?: string; cause?: { code?: string; message?: string } };
+ console.error(
+ '[AuditRetention] sweep failed:',
+ e?.message,
+ e?.cause ? `| cause: ${e.cause.code ?? ''} ${e.cause.message ?? ''}` : ''
+ );
+ }
+ };
+
+ setTimeout(() => {
+ void tick();
+ setInterval(tick, SWEEP_INTERVAL_MS);
+ }, FIRST_RUN_DELAY_MS);
+});
diff --git a/apps/api/routes/api/admin/audit/actions.get.ts b/apps/api/routes/api/admin/audit/actions.get.ts
new file mode 100644
index 00000000..eed55feb
--- /dev/null
+++ b/apps/api/routes/api/admin/audit/actions.get.ts
@@ -0,0 +1,35 @@
+/**
+ * GET /api/admin/audit/actions — the action keys actually present, for the filter.
+ *
+ * Read from the table rather than from a hard-coded list. The action key of an
+ * un-enriched route is derived from its path (`utils/audit.deriveAction`), so a
+ * fixed list would be a second inventory of every staff route, maintained by
+ * hand, wrong within a release. This one cannot be wrong: it is what is there.
+ *
+ * Capped, because a derived key includes the path and a misconfigured proxy
+ * could in principle produce many — a filter dropdown with 5 000 entries is not
+ * a filter. The cap is stated in the response so the UI can say so.
+ */
+import { desc, sql } from 'drizzle-orm';
+import { db, schema } from '@trackarr/db';
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+
+const LIMIT = 200;
+
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ await rateLimit(event, RATE_LIMITS.public);
+
+ const rows = await db
+ .select({
+ action: schema.auditLog.action,
+ count: sql`count(*)::int`,
+ })
+ .from(schema.auditLog)
+ .groupBy(schema.auditLog.action)
+ .orderBy(desc(sql`count(*)`))
+ .limit(LIMIT);
+
+ return { items: rows, limit: LIMIT, truncated: rows.length === LIMIT };
+});
diff --git a/apps/api/routes/api/admin/audit/index.get.ts b/apps/api/routes/api/admin/audit/index.get.ts
new file mode 100644
index 00000000..e1ae0094
--- /dev/null
+++ b/apps/api/routes/api/admin/audit/index.get.ts
@@ -0,0 +1,118 @@
+/**
+ * GET /api/admin/audit — the staff register, read.
+ *
+ * ## Admins only, and moderators deliberately not
+ *
+ * `requireAdminSession`, not `requireModeratorSession`, even though moderator
+ * actions are what fills the table. A register read by everyone it registers is
+ * a register people write around: knowing exactly what your colleague can see
+ * about you changes what you do in front of them, and the value of an audit log
+ * is that it is read by the people accountable for the console, not by everyone
+ * with a key to it. The instance owner is an admin, so they are covered.
+ *
+ * ## Filters
+ *
+ * Four, and each has an index behind it (see the table): actor, action, target,
+ * and a date range. `q` is a free-text pass over actor name and target label —
+ * deliberately not over `changes`, whose JSON would need a GIN index nobody has
+ * asked for yet, and whose contents are the part most likely to hold a value
+ * that should not be searchable in bulk.
+ *
+ * ## What it does not do
+ *
+ * There is no write, no edit and no delete route beside this one. Rows leave
+ * only through the retention sweep (`plugins/audit-retention.ts`).
+ */
+import { and, count, desc, eq, gte, ilike, lte, or, type SQL } from 'drizzle-orm';
+import { z } from 'zod';
+import { db, schema } from '@trackarr/db';
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import { escapeLike } from '~~/utils/sql';
+
+const querySchema = z.object({
+ page: z.coerce.number().int().min(1).default(1),
+ pageSize: z.coerce.number().int().min(1).max(100).default(50),
+ /** Filter to one staffer. */
+ actorId: z.string().min(1).max(64).optional(),
+ /** Exact action key, e.g. `user.ban`. */
+ action: z.string().min(1).max(128).optional(),
+ targetType: z.string().min(1).max(64).optional(),
+ targetId: z.string().min(1).max(128).optional(),
+ /** Free text over actor name and target label. */
+ q: z.string().min(1).max(128).optional(),
+ /** ISO dates, inclusive. */
+ from: z.coerce.date().optional(),
+ to: z.coerce.date().optional(),
+ /** `true` keeps only entries whose request did not succeed. */
+ failuresOnly: z
+ .union([z.literal('true'), z.literal('false')])
+ .optional()
+ .transform((v) => v === 'true'),
+});
+
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ await rateLimit(event, RATE_LIMITS.public);
+
+ const params = querySchema.parse(getQuery(event));
+
+ const conditions: SQL[] = [];
+ if (params.actorId) {
+ conditions.push(eq(schema.auditLog.actorId, params.actorId));
+ }
+ if (params.action) {
+ conditions.push(eq(schema.auditLog.action, params.action));
+ }
+ if (params.targetType) {
+ conditions.push(eq(schema.auditLog.targetType, params.targetType));
+ }
+ if (params.targetId) {
+ conditions.push(eq(schema.auditLog.targetId, params.targetId));
+ }
+ if (params.from) {
+ conditions.push(gte(schema.auditLog.createdAt, params.from));
+ }
+ if (params.to) {
+ conditions.push(lte(schema.auditLog.createdAt, params.to));
+ }
+ if (params.failuresOnly) {
+ // Anything outside 2xx. A 403 run from one account is the signal this
+ // table exists for, so failures are first-class rather than noise.
+ conditions.push(
+ or(
+ lte(schema.auditLog.statusCode, 199),
+ gte(schema.auditLog.statusCode, 300)
+ )!
+ );
+ }
+ if (params.q) {
+ const needle = `%${escapeLike(params.q)}%`;
+ conditions.push(
+ or(
+ ilike(schema.auditLog.actorName, needle),
+ ilike(schema.auditLog.targetLabel, needle)
+ )!
+ );
+ }
+
+ const where = conditions.length ? and(...conditions) : undefined;
+
+ const [rows, [{ value: total } = { value: 0 }]] = await Promise.all([
+ db
+ .select()
+ .from(schema.auditLog)
+ .where(where)
+ .orderBy(desc(schema.auditLog.createdAt))
+ .limit(params.pageSize)
+ .offset((params.page - 1) * params.pageSize),
+ db.select({ value: count() }).from(schema.auditLog).where(where),
+ ]);
+
+ return {
+ items: rows,
+ total,
+ page: params.page,
+ pageSize: params.pageSize,
+ };
+});
diff --git a/apps/api/routes/api/admin/panic/encrypt.post.ts b/apps/api/routes/api/admin/panic/encrypt.post.ts
index 2a6b1441..b05cc8a5 100644
--- a/apps/api/routes/api/admin/panic/encrypt.post.ts
+++ b/apps/api/routes/api/admin/panic/encrypt.post.ts
@@ -8,6 +8,7 @@ import {
torrentComments,
} from '@trackarr/db/schema';
import { requireAdminSession } from '~~/utils/adminAuth';
+import { auditDetail } from '~~/utils/audit';
import {
deriveKey,
generateSalt,
@@ -22,6 +23,12 @@ import {
*/
export default defineEventHandler(async (event) => {
await requireAdminSession(event);
+ // Named before the body is read, so the entry exists even when a guard
+ // below rejects the request — an attempted panic is worth as much as a
+ // completed one, and the status code on the row says which it was.
+ //
+ // No `changes`: the body carries the raw panic password.
+ auditDetail(event, { action: 'panic.encrypt', targetType: 'instance' });
const body = await readBody(event);
diff --git a/apps/api/routes/api/admin/settings.get.ts b/apps/api/routes/api/admin/settings.get.ts
index f390c2ea..8c0d499c 100644
--- a/apps/api/routes/api/admin/settings.get.ts
+++ b/apps/api/routes/api/admin/settings.get.ts
@@ -45,6 +45,7 @@ import {
getFeature3Desc,
isInviteEnabled,
getDefaultInvites,
+ getAuditRetentionDays,
} from '~~/utils/server';
import {
getRequire2FAScope,
@@ -93,6 +94,10 @@ export default defineEventHandler(async (event) => {
const inviteEnabled = await isInviteEnabled();
const defaultInvites = await getDefaultInvites();
const require2FAScope = await getRequire2FAScope();
+ // Staff audit retention. Read here rather than only from `/api/privacy` so
+ // the admin console can offer the control next to the register itself — a
+ // setting with no way in is a setting only its author can change.
+ const auditRetentionDays = await getAuditRetentionDays();
const notificationsRetentionReadDays =
await getNotificationsRetentionReadDays();
const notificationsRetentionUnreadDays =
@@ -159,6 +164,7 @@ export default defineEventHandler(async (event) => {
inviteEnabled,
defaultInvites,
require2FAScope,
+ auditRetentionDays,
notificationsRetentionReadDays,
notificationsRetentionUnreadDays,
requestAutoValidateHours,
diff --git a/apps/api/routes/api/admin/settings.put.ts b/apps/api/routes/api/admin/settings.put.ts
index fc902880..65a2ef7a 100644
--- a/apps/api/routes/api/admin/settings.put.ts
+++ b/apps/api/routes/api/admin/settings.put.ts
@@ -10,6 +10,7 @@ import {
SETTINGS_KEYS,
} from '~~/utils/server';
import { validateBody, adminSettingsSchema } from '~~/utils/schemas';
+import { auditDetail } from '~~/utils/audit';
/**
* PUT /api/admin/settings
@@ -21,6 +22,25 @@ export default defineEventHandler(async (event) => {
// Validate request body with Zod
const body = await validateBody(event, adminSettingsSchema);
+ /**
+ * Which settings this request touched — the KEYS, never the values.
+ *
+ * "Who changed the registration mode, and when" is the question an operator
+ * asks, and the key answers it. Recording the values would mean a listing
+ * page that reproduces whatever an admin typed into any settings field,
+ * which is a category of leak nobody would notice until it mattered: this
+ * body is wide, it grows with every feature, and one future field carrying
+ * a token or a URL is all it takes.
+ *
+ * A route that wants a before/after on a specific, non-sensitive setting can
+ * say so explicitly — see the ban and role routes for the shape.
+ */
+ auditDetail(event, {
+ action: 'settings.update',
+ targetType: 'settings',
+ changes: { fields: Object.keys(body).filter((k) => body[k as keyof typeof body] !== undefined) },
+ });
+
if (body.searchFields !== undefined) {
// Deduplicated and stored as CSV: the list is short and a JSON array would
// add nothing but a format to parse on both sides.
@@ -252,6 +272,16 @@ export default defineEventHandler(async (event) => {
String(Math.floor(body.notificationsRetentionReadDays)),
);
}
+ if (
+ typeof body.auditRetentionDays === 'number' &&
+ body.auditRetentionDays >= 0 &&
+ body.auditRetentionDays <= 3650
+ ) {
+ await setSetting(
+ SETTINGS_KEYS.AUDIT_LOG_RETENTION_DAYS,
+ String(Math.floor(body.auditRetentionDays)),
+ );
+ }
if (
typeof body.notificationsRetentionUnreadDays === 'number' &&
body.notificationsRetentionUnreadDays >= 1 &&
diff --git a/apps/api/routes/api/admin/users/[id]/ban.post.ts b/apps/api/routes/api/admin/users/[id]/ban.post.ts
index a92306c3..abbedf01 100644
--- a/apps/api/routes/api/admin/users/[id]/ban.post.ts
+++ b/apps/api/routes/api/admin/users/[id]/ban.post.ts
@@ -27,6 +27,7 @@ import {
uuidSchema,
} from '~~/utils/schemas';
import { notify } from '~~/utils/notify';
+import { auditDetail } from '~~/utils/audit';
export default defineEventHandler(async (event) => {
const { user: actor } = await requireModeratorSession(event);
@@ -52,6 +53,23 @@ export default defineEventHandler(async (event) => {
});
}
+ // The audit row for this request. Named here rather than left to the
+ // path-derived fallback because a ban is the decision most often disputed
+ // later, and "who, whom, why, for how long" is what the dispute turns on.
+ auditDetail(event, {
+ action: 'user.ban',
+ targetType: 'user',
+ targetId: target.id,
+ targetLabel: target.username,
+ changes: {
+ isBanned: { from: target.isBanned, to: true },
+ reason,
+ // Present only when the caller asked for a timed ban; a permanent one
+ // carries no duration and recording `null` would read as "cleared".
+ ...(body.duration ? { durationSeconds: body.duration } : {}),
+ },
+ });
+
await db
.update(users)
.set({
diff --git a/apps/api/routes/api/admin/users/[id]/role.put.ts b/apps/api/routes/api/admin/users/[id]/role.put.ts
index 4466d7e8..5b5a9c9b 100644
--- a/apps/api/routes/api/admin/users/[id]/role.put.ts
+++ b/apps/api/routes/api/admin/users/[id]/role.put.ts
@@ -16,6 +16,7 @@ import { validateBody } from '~~/utils/schemas';
import { eq, and, ne, count } from 'drizzle-orm';
import { z } from 'zod';
import { notify } from '~~/utils/notify';
+import { auditDetail } from '~~/utils/audit';
const paramsSchema = z.object({ id: z.string().uuid() });
const bodySchema = z
@@ -39,7 +40,13 @@ export default defineEventHandler(async (event) => {
const target = await db.query.users.findFirst({
where: eq(schema.users.id, id),
- columns: { id: true, isAdmin: true, isModerator: true, isOwner: true },
+ columns: {
+ id: true,
+ username: true,
+ isAdmin: true,
+ isModerator: true,
+ isOwner: true,
+ },
});
if (!target) {
throw createError({ statusCode: 404, message: 'User not found' });
@@ -91,6 +98,19 @@ export default defineEventHandler(async (event) => {
}
}
+ // A privilege grant is the row an audit log exists for: it is how one
+ // compromised account becomes several. Both flags, both directions.
+ auditDetail(event, {
+ action: 'user.role',
+ targetType: 'user',
+ targetId: target.id,
+ targetLabel: target.username,
+ changes: {
+ isAdmin: { from: target.isAdmin, to: body.isAdmin },
+ isModerator: { from: target.isModerator, to: body.isModerator },
+ },
+ });
+
const [updated] = await db
.update(schema.users)
.set({
diff --git a/apps/api/routes/api/admin/users/[id]/unban.post.ts b/apps/api/routes/api/admin/users/[id]/unban.post.ts
index ca1ad551..de12a33b 100644
--- a/apps/api/routes/api/admin/users/[id]/unban.post.ts
+++ b/apps/api/routes/api/admin/users/[id]/unban.post.ts
@@ -18,6 +18,7 @@ import { users, bannedIps, torrents } from '@trackarr/db/schema';
import { invalidateBanCache, requireModeratorSession } from '~~/utils/adminAuth';
import { validateParam, uuidSchema } from '~~/utils/schemas';
import { notify } from '~~/utils/notify';
+import { auditDetail } from '~~/utils/audit';
export default defineEventHandler(async (event) => {
const { user: actor } = await requireModeratorSession(event);
@@ -38,6 +39,14 @@ export default defineEventHandler(async (event) => {
});
}
+ auditDetail(event, {
+ action: 'user.unban',
+ targetType: 'user',
+ targetId: target.id,
+ targetLabel: target.username,
+ changes: { isBanned: { from: true, to: false } },
+ });
+
await db
.update(users)
.set({ isBanned: false, bannedById: null, bannedByRole: null })
diff --git a/apps/api/routes/api/privacy.get.ts b/apps/api/routes/api/privacy.get.ts
index 282556e4..3319fd6a 100644
--- a/apps/api/routes/api/privacy.get.ts
+++ b/apps/api/routes/api/privacy.get.ts
@@ -15,6 +15,7 @@
*/
import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
import {
+ getAuditRetentionDays,
getDmRetentionDays,
getMessagingDmScope,
getMessagingRoomScope,
@@ -33,6 +34,7 @@ export default defineEventHandler(async (event) => {
roomMessageDays,
notificationsReadDays,
notificationsUnreadDays,
+ auditDays,
] = await Promise.all([
getMessagingDmScope(),
getMessagingRoomScope(),
@@ -40,6 +42,7 @@ export default defineEventHandler(async (event) => {
getRoomRetentionDays(),
getNotificationsRetentionReadDays(),
getNotificationsRetentionUnreadDays(),
+ getAuditRetentionDays(),
]);
return {
@@ -51,5 +54,15 @@ export default defineEventHandler(async (event) => {
roomMessageDays,
},
notifications: { notificationsReadDays, notificationsUnreadDays },
+ /**
+ * The staff audit log. Published for the same reason every other period
+ * here is: a retention nobody can read is a retention nobody was told
+ * about. What it records is staff actions, not member browsing — but a
+ * member who was banned, warned or had their upload rejected IS the target
+ * of one of those rows, so the period is theirs to know.
+ *
+ * `0` means kept indefinitely.
+ */
+ staffAudit: { retentionDays: auditDays },
};
});
diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts
new file mode 100644
index 00000000..9c5c35d2
--- /dev/null
+++ b/apps/api/test/audit.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect } from 'vitest';
+import { deriveAction, isAuditable } from '../utils/audit';
+
+// The audit log's coverage is structural: a hook logs every mutating staff
+// request whether or not the route says anything about itself. Which makes
+// these two pure functions the whole gate — one decides what gets a row, the
+// other decides what that row is called for the majority of routes that never
+// call `auditDetail`.
+
+describe('isAuditable', () => {
+ it('takes mutating methods on the staff consoles', () => {
+ expect(isAuditable('POST', '/api/admin/users/abc/ban')).toBe(true);
+ expect(isAuditable('PUT', '/api/admin/settings')).toBe(true);
+ expect(isAuditable('PATCH', '/api/mod/reports/1')).toBe(true);
+ expect(isAuditable('DELETE', '/api/admin/tags/9')).toBe(true);
+ expect(isAuditable('post', '/api/admin/panic/encrypt')).toBe(true);
+ });
+
+ it('ignores reads', () => {
+ // A register of authority records decisions, not who looked at a page.
+ expect(isAuditable('GET', '/api/admin/users')).toBe(false);
+ expect(isAuditable('HEAD', '/api/admin/users')).toBe(false);
+ });
+
+ it('ignores member-facing mutations', () => {
+ // Logging these would turn the register into a record of everybody's
+ // activity — the opposite of what the privacy toggles elsewhere protect.
+ expect(isAuditable('POST', '/api/torrents')).toBe(false);
+ expect(isAuditable('PATCH', '/api/me')).toBe(false);
+ expect(isAuditable('DELETE', '/api/me')).toBe(false);
+ expect(isAuditable('POST', '/api/messaging/conversations')).toBe(false);
+ });
+
+ it('is not fooled by a path that merely contains the prefix', () => {
+ expect(isAuditable('POST', '/api/torrents/admin/x')).toBe(false);
+ // No trailing slash: `/api/admin` itself is not a route, and matching it
+ // would be matching a prefix rather than a console.
+ expect(isAuditable('POST', '/api/administrators')).toBe(false);
+ });
+});
+
+describe('deriveAction', () => {
+ it('names the operation from the path, dropping identifiers', () => {
+ expect(
+ deriveAction('POST', '/api/admin/users/3f2b1c4d-1111-2222-3333-444455556666/ban')
+ ).toBe('admin.users.ban');
+ expect(deriveAction('PUT', '/api/admin/settings')).toBe('admin.settings.update');
+ expect(deriveAction('DELETE', '/api/admin/tags/42')).toBe('admin.tags.delete');
+ });
+
+ it('drops a 40-hex infohash the same way', () => {
+ // Otherwise every torrent is its own action and the filter is useless.
+ expect(deriveAction('PUT', `/api/mod/torrents/${'a'.repeat(40)}/approve`)).toBe(
+ 'mod.torrents.approve'
+ );
+ });
+
+ it('does not append a verb to a segment that is already one', () => {
+ // `admin.users.ban.create` reads worse than `admin.users.ban`.
+ expect(
+ deriveAction('POST', '/api/admin/users/3f2b1c4d-1111-2222-3333-444455556666/unban')
+ ).toBe('admin.users.unban');
+ expect(deriveAction('POST', '/api/admin/panic/encrypt')).toBe(
+ 'admin.panic.encrypt.create'
+ );
+ });
+
+ it('keeps slug-shaped segments — they name things', () => {
+ expect(deriveAction('PUT', '/api/admin/federation/peers')).toBe(
+ 'admin.federation.peers.update'
+ );
+ });
+
+ it('falls back rather than producing an empty key', () => {
+ expect(deriveAction('POST', '/api/')).toBe('unknown.create');
+ expect(deriveAction('POST', '')).toBe('unknown.create');
+ });
+
+ it('passes an unusual method through rather than guessing', () => {
+ expect(deriveAction('LOCK', '/api/admin/settings')).toBe('admin.settings.lock');
+ });
+});
+
+describe('deriveAction with route params', () => {
+ it('removes a slug-shaped id that shape heuristics cannot spot', () => {
+ // The case an end-to-end run found: a peer id that looks like a
+ // sub-resource name, turning every peer into its own action category.
+ expect(
+ deriveAction(
+ 'DELETE',
+ '/api/admin/federation/peers/does-not-exist',
+ ['does-not-exist']
+ )
+ ).toBe('admin.federation.peers.delete');
+ });
+
+ it('removes several parameters at once', () => {
+ expect(
+ deriveAction('DELETE', '/api/admin/users/alice/roles/uploader', [
+ 'alice',
+ 'uploader',
+ ])
+ ).toBe('admin.users.roles.delete');
+ });
+
+ it('leaves a genuine path segment that happens to equal no parameter', () => {
+ expect(deriveAction('POST', '/api/admin/panic/encrypt', [])).toBe(
+ 'admin.panic.encrypt.create'
+ );
+ });
+
+ it('ignores empty parameter values rather than stripping empty segments', () => {
+ expect(deriveAction('PUT', '/api/admin/settings', [''])).toBe(
+ 'admin.settings.update'
+ );
+ });
+});
diff --git a/apps/api/utils/account/eraseAccount.ts b/apps/api/utils/account/eraseAccount.ts
index 81a1a47c..982dc236 100644
--- a/apps/api/utils/account/eraseAccount.ts
+++ b/apps/api/utils/account/eraseAccount.ts
@@ -53,7 +53,8 @@
* ## What is kept, and on what basis
*
* Not everything touching the account goes. `notifications`, `hnr_tracking`,
- * `bonus_events`, `invitations` and `reports` survive, attached to the scrubbed
+ * `bonus_events`, `invitations`, `reports` and — where the member was staff —
+ * their entries in `audit_log` survive, attached to the scrubbed
* row. Each is either a record of an obligation between the tracker and other
* members (a hit-and-run, an invitation tree, a report somebody else filed) or
* part of the economy's audit trail, and none of them holds a raw identifier
@@ -311,6 +312,36 @@ export async function eraseAccount(userId: string): Promise {
)
);
+ // The staff audit log, on exactly the rule above: the pointer goes, the
+ // name stays. Banning a member is an act taken under authority, and an act
+ // under authority with no author is indefensible — an ex-moderator must not
+ // be able to un-sign their own decisions by closing their account.
+ //
+ // Done by hand rather than left to the FK: the row in `users` SURVIVES an
+ // erasure (that is the whole design — the catalogue hangs off it), so no
+ // ON DELETE ever fires and every reference has to be cleared here.
+ //
+ // What this costs, and it is the honest reading: the audit log keeps a
+ // username after erasure. It is kept on the same basis as the invitation
+ // tree and the reports the erasure already keeps — a record of an
+ // obligation between the tracker and OTHER members, which the person on
+ // one side of it cannot unilaterally erase.
+ await tx
+ .update(schema.auditLog)
+ .set({ actorId: null })
+ .where(eq(schema.auditLog.actorId, userId));
+ // Where they were the TARGET, though, the pointer and the label both go:
+ // being banned is not an act they took, it is a thing recorded about them.
+ await tx
+ .update(schema.auditLog)
+ .set({ targetId: null, targetLabel: erasedName })
+ .where(
+ and(
+ eq(schema.auditLog.targetType, 'user'),
+ eq(schema.auditLog.targetId, userId)
+ )
+ );
+
// 3. Scrub the row itself. The passkey is rotated to a fresh unusable value
// so any announce URL the member kept stops working; the SRP material is
// replaced with random bytes no client can reproduce; the profile text and
diff --git a/apps/api/utils/adminAuth.ts b/apps/api/utils/adminAuth.ts
index 9a0da3f4..2552d660 100644
--- a/apps/api/utils/adminAuth.ts
+++ b/apps/api/utils/adminAuth.ts
@@ -253,6 +253,23 @@ async function refreshSessionRoles(
export async function requireAuthSession(event: H3Event) {
const session = await requireUserSession(event);
+ /**
+ * Remember who is acting, for the audit log.
+ *
+ * Set HERE — on the plain authentication gate — and not in the staff gates
+ * below, on purpose. A member who aims a request at `/api/admin/**` and takes
+ * a 403 from `requireAdminSession` has already passed this line, so the
+ * attempt is recorded with their name on it. That is the row an operator
+ * most wants: a privilege escalation being tried is worth more than the
+ * hundredth successful ban.
+ *
+ * The staff flags are re-read from the live role a few lines further down in
+ * the staff gates, and they mutate `session.user` in place — so by the time
+ * the audit hook reads this object it holds the authoritative role, not the
+ * one the sealed cookie asserted.
+ */
+ event.context.auditActor = session.user;
+
// Skip DB check if already verified by middleware (per-request
// memoisation — distinct from the Redis cache).
if (event.context.authChecked) {
diff --git a/apps/api/utils/audit.ts b/apps/api/utils/audit.ts
new file mode 100644
index 00000000..bf89f616
--- /dev/null
+++ b/apps/api/utils/audit.ts
@@ -0,0 +1,232 @@
+/**
+ * The staff audit log — how a row gets written, and how a route sharpens it.
+ *
+ * ## Two halves
+ *
+ * **The floor** is a Nitro `afterResponse` hook (`plugins/audit-log.ts`). It
+ * sees every request, and for a mutating one under `/api/admin/**` or
+ * `/api/mod/**` by an authenticated staffer it writes a row: who, what method,
+ * what path, what status, when. No route has to do anything, so coverage is a
+ * property of the plumbing rather than of somebody remembering — which is the
+ * whole reason the previous per-route logs (the one on the route that reads
+ * private mail, the moderation thread, the report tombstone) each covered
+ * exactly one surface.
+ *
+ * **The ceiling** is `auditDetail(event, …)`. A route that knows more than the
+ * URL does — which member, which setting, from what to what — calls it, and the
+ * hook merges what it said over the derived values. Optional everywhere: a
+ * route that never calls it still appears in the log.
+ *
+ * ## What is never recorded
+ *
+ * Request bodies, wholesale. They carry passwords, panic passwords, channel
+ * tokens and 2FA secrets, and a log that swallowed them would be a credential
+ * store with a listing page. A route wanting a diff passes exactly the fields
+ * it means, through `changes`.
+ *
+ * Query strings are stripped from `path` for the same reason: `?q=` on a member
+ * search is somebody's name.
+ *
+ * ## Failure is silent, and that is deliberate
+ *
+ * A failed audit write must never fail the request that caused it. A moderator
+ * whose ban went through and whose log row did not is recoverable; a ban that
+ * 500s because the log table is full is an outage. The write is best-effort,
+ * after the response, and logs its own failure to stderr where the operator's
+ * log shipper will see it.
+ */
+import type { H3Event } from 'h3';
+import { randomUUID } from 'node:crypto';
+import { db, schema } from '@trackarr/db';
+import { hashIP } from './crypto';
+import { getClientIP } from './rateLimit';
+
+/** What a route may add to its own audit row. Every field is optional. */
+export interface AuditDetail {
+ /** Stable dotted key, e.g. `user.ban`. Overrides the derived action. */
+ action?: string;
+ targetType?: string;
+ targetId?: string;
+ targetLabel?: string;
+ /** `{ field: { from, to } }`, or whatever shape reads clearest. */
+ changes?: Record;
+}
+
+/**
+ * Attach (or extend) the audit detail for the request in flight.
+ *
+ * Merges rather than replaces, so a route can name its action early — before a
+ * guard can throw — and fill in the target once it has loaded it. Called on a
+ * request the hook will not log (a GET, a non-staff path), it is a no-op that
+ * costs one property write.
+ */
+export function auditDetail(event: H3Event, detail: AuditDetail): void {
+ const existing = (event.context.auditDetail ?? {}) as AuditDetail;
+ event.context.auditDetail = {
+ ...existing,
+ ...detail,
+ changes: detail.changes
+ ? { ...(existing.changes ?? {}), ...detail.changes }
+ : existing.changes,
+ };
+}
+
+/**
+ * `POST /api/admin/users/3f2b.../ban` → `admin.users.ban`.
+ *
+ * A fallback, and it has to be a decent one: most routes will never call
+ * `auditDetail`, so this is what the listing shows for them, and it is what the
+ * action filter groups on. An identifier left in the name would make every row
+ * its own category and the filter useless.
+ *
+ * `paramValues` is how that is done exactly rather than by guessing: h3 knows
+ * which segments matched a route parameter, so those are removed by value. An
+ * end-to-end run is what showed the guessing was not enough on its own —
+ * `DELETE /api/admin/federation/peers/does-not-exist` produced
+ * `admin.federation.peers.does-not-exist.delete`, because a slug-shaped peer id
+ * looks exactly like a sub-resource name.
+ *
+ * The shape heuristics stay as a fallback for a call site with no params to
+ * hand (a test, a route matched without them).
+ */
+export function deriveAction(
+ method: string,
+ path: string,
+ paramValues: readonly string[] = []
+): string {
+ const params = new Set(paramValues.filter(Boolean));
+ const parts = path
+ .replace(/^\/api\//, '')
+ .split('/')
+ .filter(Boolean)
+ // Matched a route parameter: an identifier by definition, whatever it
+ // looks like.
+ .filter((p) => !params.has(p))
+ // Identifier-shaped: a UUID, a 40-hex infohash, a long opaque id, or a
+ // bare number. `slug`-shaped segments are kept — they name things.
+ .filter(
+ (p) =>
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p) &&
+ !/^[0-9a-f]{32,}$/i.test(p) &&
+ !/^\d+$/.test(p) &&
+ p.length < 40
+ );
+
+ const verb =
+ {
+ POST: 'create',
+ PUT: 'update',
+ PATCH: 'update',
+ DELETE: 'delete',
+ }[method.toUpperCase()] ?? method.toLowerCase();
+
+ const tail = parts[parts.length - 1];
+ // A route whose last segment is already a verb (`ban`, `unban`, `revoke`,
+ // `panic`) reads worse with one appended: `admin.users.ban.create`.
+ const tailIsVerb =
+ !!tail &&
+ /^(ban|unban|revoke|suspend|block|approve|reject|resolve|withdraw|panic|test|send|retry|rotate|reset|refresh|validate|cancel|fill|close|reopen|assign|pin|unpin|lock|unlock|promote|demote|clear|sweep|flush|import|export)$/.test(
+ tail
+ );
+
+ const base = parts.join('.') || 'unknown';
+ return tailIsVerb ? base : `${base}.${verb}`;
+}
+
+/** Methods that change something. A GET is not audited. */
+const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
+
+/**
+ * Is this a request the audit log is for?
+ *
+ * Staff consoles only. Member-facing mutations are not staff actions and
+ * logging them would turn a register of authority into a record of everybody's
+ * browsing — which the privacy toggles elsewhere in this codebase exist to
+ * prevent.
+ */
+export function isAuditable(method: string, path: string): boolean {
+ if (!MUTATING.has(method.toUpperCase())) return false;
+ return path.startsWith('/api/admin/') || path.startsWith('/api/mod/');
+}
+
+/**
+ * The values h3 bound to route parameters for this request, if any.
+ *
+ * Defensive about the shape: this reads `event.context` from inside a hook
+ * that runs after the response, and a missing or oddly-typed `params` must
+ * degrade to "no params" rather than throw inside the log.
+ */
+function routeParamValues(event: H3Event): string[] {
+ const params = event.context?.params;
+ if (!params || typeof params !== 'object') return [];
+ return Object.values(params as Record).filter(
+ (v): v is string => typeof v === 'string' && v.length > 0
+ );
+}
+
+export interface AuditActor {
+ id: string;
+ username: string;
+ isAdmin?: boolean;
+ isModerator?: boolean;
+ isOwner?: boolean;
+}
+
+/** `owner` outranks `admin` outranks `moderator`. */
+function roleOf(actor: AuditActor): string {
+ if (actor.isOwner) return 'owner';
+ if (actor.isAdmin) return 'admin';
+ if (actor.isModerator) return 'moderator';
+ // Reached only if a staff route ever stops being staff-gated. Recorded as
+ // what it is rather than silently promoted.
+ return 'member';
+}
+
+/**
+ * Write one row. Never throws.
+ *
+ * `statusCode` is recorded whatever it is, failures included: a run of 403s
+ * from one account is a signal, and a log that kept only the successes would
+ * hide exactly the attempts worth seeing.
+ */
+export async function writeAuditEntry(
+ event: H3Event,
+ actor: AuditActor,
+ statusCode: number
+): Promise {
+ const detail = (event.context.auditDetail ?? {}) as AuditDetail;
+ // `event.path` carries the query string; the audit row must not.
+ const path = (event.path ?? '').split('?')[0] ?? '';
+ const method = (event.method ?? 'GET').toUpperCase();
+
+ let ipHash: string | null = null;
+ try {
+ const ip = getClientIP(event);
+ ipHash = ip ? hashIP(ip) : null;
+ } catch {
+ // An unresolvable client IP is not a reason to lose the entry.
+ }
+
+ try {
+ await db.insert(schema.auditLog).values({
+ id: randomUUID(),
+ actorId: actor.id,
+ actorName: actor.username,
+ actorRole: roleOf(actor),
+ action: detail.action ?? deriveAction(method, path, routeParamValues(event)),
+ method,
+ path,
+ targetType: detail.targetType ?? null,
+ targetId: detail.targetId ?? null,
+ targetLabel: detail.targetLabel ?? null,
+ changes: detail.changes ?? null,
+ statusCode,
+ actorIpHash: ipHash,
+ });
+ } catch (err) {
+ // Loud in the operator's logs, invisible to the request. See the note at
+ // the top: a ban that went through with no row is recoverable, a ban that
+ // 500s because of its own log entry is not.
+ console.error('[Audit] write failed:', (err as Error).message);
+ }
+}
diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts
index 6d1a4f68..79283c91 100644
--- a/apps/api/utils/schemas.ts
+++ b/apps/api/utils/schemas.ts
@@ -253,6 +253,13 @@ export const adminSettingsSchema = z.object({
.min(1)
.max(3650)
.optional(),
+ /**
+ * Staff audit retention, in days. `0` is legitimate here and means "keep
+ * indefinitely" — unlike the notification periods above, which have no such
+ * reading and start at 1. An audit log an operator can only shorten is an
+ * audit log with a built-in expiry nobody chose.
+ */
+ auditRetentionDays: z.number().int().min(0).max(3650).optional(),
notificationsRetentionUnreadDays: z
.number()
.int()
diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts
index 92be873e..f22b157d 100644
--- a/apps/api/utils/settings.ts
+++ b/apps/api/utils/settings.ts
@@ -129,6 +129,16 @@ export const SETTINGS_KEYS = {
// be measured rather than assumed. Absent (or `any`) means "unknown" — an
// SVG, a format we do not walk, or an image uploaded before the
// measurement existed. See `utils/imageSniff.manifestIconSizes`.
+ /**
+ * How long staff audit entries are kept, in days. 0 = forever.
+ *
+ * Long by default (a year) because the question an audit log answers is
+ * usually asked late — after a member disputes a ban, or after a staff
+ * account turns out to have been compromised weeks ago. Operators in
+ * jurisdictions that require a shorter hold can shorten it, and the value is
+ * published on `/api/privacy` either way.
+ */
+ AUDIT_LOG_RETENTION_DAYS: 'audit_log_retention_days',
SITE_LOGO_IMAGE_SIZE: 'site_logo_image_size',
SITE_FAVICON_SIZE: 'site_favicon_size',
SITE_SUBTITLE: 'site_subtitle',
@@ -366,6 +376,18 @@ export async function getSiteLogoImage(): Promise {
return value || null;
}
+/**
+ * Days an audit entry survives. 0 means "keep indefinitely" — the sweep skips
+ * entirely rather than treating 0 as "delete everything", which is the reading
+ * that would quietly empty the register.
+ */
+export async function getAuditRetentionDays(): Promise {
+ const value = await getSetting(SETTINGS_KEYS.AUDIT_LOG_RETENTION_DAYS);
+ const parsed = value ? parseInt(value, 10) : NaN;
+ if (!Number.isFinite(parsed) || parsed < 0) return 365;
+ return parsed;
+}
+
/**
* The `sizes` string for the uploaded logo / favicon, as measured at upload.
*
diff --git a/apps/web/app/composables/useAdminNav.ts b/apps/web/app/composables/useAdminNav.ts
index 8b153653..affafcba 100644
--- a/apps/web/app/composables/useAdminNav.ts
+++ b/apps/web/app/composables/useAdminNav.ts
@@ -108,6 +108,16 @@ export function useAdminNav() {
icon: 'ph:prohibit',
description: t('admin.descriptions.bannedIps'),
},
+ {
+ // Sits with people for the same reason Banned IPs does — it is
+ // about who did what to whom. Not marked `ownerOnly`: every admin
+ // is accountable for the console, and a register only the owner can
+ // read is one nobody checks.
+ label: t('admin.nav.audit'),
+ path: '/admin/audit',
+ icon: 'ph:scroll',
+ description: t('admin.descriptions.audit'),
+ },
],
},
{
diff --git a/apps/web/app/pages/admin/audit.vue b/apps/web/app/pages/admin/audit.vue
new file mode 100644
index 00000000..ee4aa21d
--- /dev/null
+++ b/apps/web/app/pages/admin/audit.vue
@@ -0,0 +1,667 @@
+
+
diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json
index 49177905..9a7f4c79 100644
--- a/apps/web/i18n/locales/en.json
+++ b/apps/web/i18n/locales/en.json
@@ -321,7 +321,9 @@
"rotateReadKeyBody": "Anything using the old value stops working immediately. Your torrents are unaffected — this key cannot announce.",
"rotated": "Key rotated.",
"reauthRequired": "Log in again to rotate a key.",
- "legacyPasskeyNote": "Your announce passkey still works on feeds for now, so existing setups keep running. Move them to the RSS key above: a passkey given to a third party can announce on your behalf."
+ "legacyPasskeyNote": "Your announce passkey still works on feeds for now, so existing setups keep running. Move them to the RSS key above: a passkey given to a third party can announce on your behalf.",
+ "prowlarrDefinition": "Prowlarr definition (.yml)",
+ "prowlarrHint": "Drop it in Prowlarr’s Definitions/Custom folder, restart, then add this tracker and paste the key above."
},
"activity": {
"title": "Activity",
diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json
index c4991711..3dd048bd 100644
--- a/apps/web/i18n/locales/fr.json
+++ b/apps/web/i18n/locales/fr.json
@@ -321,7 +321,9 @@
"rotateReadKeyBody": "Tout ce qui utilise l'ancienne valeur cesse de fonctionner immédiatement. Vos torrents ne sont pas affectés : cette clé ne peut pas annoncer.",
"rotated": "Clé renouvelée.",
"reauthRequired": "Reconnectez-vous pour renouveler une clé.",
- "legacyPasskeyNote": "Votre passkey d'annonce fonctionne encore sur les flux pour l'instant, afin que vos configurations existantes continuent de tourner. Basculez-les sur la clé RSS ci-dessus : une passkey confiée à un tiers permet d'annoncer en votre nom."
+ "legacyPasskeyNote": "Votre passkey d'annonce fonctionne encore sur les flux pour l'instant, afin que vos configurations existantes continuent de tourner. Basculez-les sur la clé RSS ci-dessus : une passkey confiée à un tiers permet d'annoncer en votre nom.",
+ "prowlarrDefinition": "Définition Prowlarr (.yml)",
+ "prowlarrHint": "À déposer dans le dossier Definitions/Custom de Prowlarr, puis redémarrer, ajouter ce tracker et coller la clé ci-dessus."
},
"activity": {
"title": "Activité",
diff --git a/doc/integrations/torznab.md b/doc/integrations/torznab.md
index 573cc297..67ba7ca3 100644
--- a/doc/integrations/torznab.md
+++ b/doc/integrations/torznab.md
@@ -361,3 +361,54 @@ The Torznab API admin panel (`/admin/torznab`) provides comprehensive management
- Confirm the tracker URL is accessible
- Check firewall rules allow outbound HTTPS
- Try the capabilities endpoint first to test connectivity
+
+---
+
+## Prowlarr: a ready-made indexer definition
+
+`GET /api/torznab/cardigann.yml` returns a Cardigann definition **for this
+instance**, with its name, its URL and its own categories already filled in.
+
+```bash
+curl -O https://tracker.example/api/torznab/cardigann.yml
+# then drop it in Prowlarr's Definitions/Custom folder:
+# Docker /config/Definitions/Custom/
+# Linux/macOS ~/.config/Prowlarr/Definitions/Custom/
+# Windows C:\ProgramData\Prowlarr\Definitions\Custom\
+```
+
+Restart Prowlarr, add the tracker by name, and paste your **RSS key** (see
+[Keys](../guide/api-keys.md) — not the announce passkey).
+
+Members can download it straight from their profile page, beside the key
+itself.
+
+### Why it is generated rather than shipped in the repo
+
+The categories. They are operator-configured — names, slugs and Newznab
+mappings all live in the database — so a static file could describe every
+instance's categories except the one the member is actually joining.
+
+### Two things operators should know
+
+**Reloading is unreliable offline.** Prowlarr's `IndexerDefinitionUpdate`
+command downloads the official definition pack *first* and only clears its cache
+afterwards, inside the same `try`. On a host with no egress to
+`indexers.prowlarr.com` the exception is swallowed and the cache is never
+cleared — **restart Prowlarr instead**. There is also a five-minute rolling
+cache on the request generator.
+
+**Renaming the instance means renaming the file.** Prowlarr keys a custom
+definition on its *filename*, and refuses to load one whose filename **or
+`name`** collides with a built-in — the built-in wins and yours is dropped with
+nothing but a line in the log. The generated file is named after your site, so
+if you rename the site, re-download it and remove the old file.
+
+### What the definition maps
+
+Every `` this feed emits, including `infohash`, both volume
+factors and — where the site imposes them — `minimumratio` and
+`minimumseedtime`. Note that the selectors address attributes rather than
+elements (`[name=seeders]` + `attribute: value`): a namespace-prefixed element
+cannot be cleanly addressed in CSS, which is why every Cardigann definition that
+consumes a Torznab feed is written this way.
From 19a664d6cce576a13f435b00d0c182e12287cb61 Mon Sep 17 00:00:00 2001
From: dim145
Date: Tue, 1 Sep 2026 10:30:19 +0200
Subject: [PATCH 15/96] fix(api): a bad filter on the audit log was a 500
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`/api/admin/audit` called `querySchema.parse()` on the query string
directly, so a malformed `page` or an unknown `status` came back as an
unhandled ZodError — a 500, and a line in the error log, for a request
that was simply wrong.
`validateQuery` is the wrapper the rest of the API uses; it turns the same
failure into a 400 with the offending field named. Nothing else changes.
Found while checking the routes added on this branch for the same
mistake — they had it too, and were fixed before they were committed.
Co-Authored-By: Claude Opus 5
---
apps/api/routes/api/admin/audit/index.get.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/api/routes/api/admin/audit/index.get.ts b/apps/api/routes/api/admin/audit/index.get.ts
index e1ae0094..49d7e886 100644
--- a/apps/api/routes/api/admin/audit/index.get.ts
+++ b/apps/api/routes/api/admin/audit/index.get.ts
@@ -29,6 +29,7 @@ import { db, schema } from '@trackarr/db';
import { requireAdminSession } from '~~/utils/adminAuth';
import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
import { escapeLike } from '~~/utils/sql';
+import { validateQuery } from '~~/utils/schemas';
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
@@ -55,7 +56,7 @@ export default defineEventHandler(async (event) => {
await requireAdminSession(event);
await rateLimit(event, RATE_LIMITS.public);
- const params = querySchema.parse(getQuery(event));
+ const params = validateQuery(event, querySchema);
const conditions: SQL[] = [];
if (params.actorId) {
From 98e3be224600b54527fc47367c31d508e35f28b0 Mon Sep 17 00:00:00 2001
From: dim145
Date: Tue, 1 Sep 2026 10:30:19 +0200
Subject: [PATCH 16/96] docs(roadmap): eight lines that are no longer wishes
Co-Authored-By: Claude Opus 5
---
doc/guide/roadmap.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/doc/guide/roadmap.md b/doc/guide/roadmap.md
index 3726c132..028c2faa 100644
--- a/doc/guide/roadmap.md
+++ b/doc/guide/roadmap.md
@@ -4,6 +4,17 @@ Trackarr is actively developed with a focus on performance, security, and usabil
## Released
+### v0.36.x — Per-torrent buffs, torrent lifecycle, split keys, saved searches
+
+- [x] **Per-torrent bonus buffs** — Multipliers and pinning on a single release rather than on the whole site. Freeleech one rare torrent to revive it, double the upload on an internal release, pin something to the top of listings. The rule where a buff meets a running site-wide event is "the member gets the better of the two, axis by axis", never the product — which would have a freeleech silently quadrupling an upload bonus nobody granted. A lapsed buff is neutralised in the announce query itself, so it ends the moment its timestamp passes with no sweep to schedule, and the values ride on the row the announce already had to read, so a buffed torrent costs nothing extra. Pinned releases are held out of the listing flow and returned as a capped block rather than folded into the `ORDER BY`, which would have stopped every single-column index from serving the sort. Two gates: any moderator may pin, only an admin may touch the multipliers. See [Per-torrent buffs](./torrent-buffs.md).
+- [x] **Torrent lifecycle** — Three things a catalogue needs once it is a few years old. **Superseding**: a release can be marked as replaced by a better one, with the older one staying online, keeping its swarm and its hit-and-run obligations — people are seeding it, and retiring it under them would be a tidy-up that behaves like a hit and run. Guarded against self-reference, cycles, and pointers into pages a member may not be allowed to read. **Reseed requests**: a torrent at zero seeders was a silent dead end, and the site already knew exactly who could fix it; past snatchers now get asked, once per torrent per day site-wide. **Unregistered infohashes**: one request tells a client which of its torrents this tracker still serves, which is also the entry point automated cross-seeding needed. See [Torrent lifecycle](./torrent-lifecycle.md).
+- [x] **Separate RSS and API keys** — One secret used to authenticate the announce, the feeds and the API alike, so a member who pasted their feed URL into a third-party service handed over the credential that announces on their behalf — and the only remedy broke every torrent in their client. Members now hold three keys, each revocable on its own, minted on first use rather than at registration. The announce passkey still opens the read surfaces while `legacy_passkey_read_access` allows it, because every feed already configured anywhere carries it; an operator closes that door once their members have moved. Panic mode encrypts all three, Caddy strips all three from its access log, and the read surfaces now share one resolver — the RSS gate used to accept a key the Torznab gate would reject, and vice versa. See [Keys](./api-keys.md).
+- [x] **Saved searches** — A stored filter that notifies its owner when a matching upload is accepted: the server-side half of what members do with autobrr, for the member with no seedbox and no bot. The filter's vocabulary is the catalogue's own, so it is created from a "save this search" button rather than retyped into a second form. The stored query drops the prefix marker the live search appends — settled intent should not fire "The Crown" on everything that merely starts the same way — and the evaluation is inverted, one query asking which filters match this torrent rather than replaying each filter's search. It will not push adult content to somebody who turned it off, and it never names an anonymous uploader. See [Saved searches](./saved-searches.md).
+- [x] **Login history** — Every attempt to open a session, successful or refused, with the method and a hashed address. Members read their own; staff read any member's from their profile, where the question of account sharing actually comes up. Failures matter most: there is no per-account lockout on this site, so an attempt spread across addresses meets nothing at the time and this is the only trace it leaves. The daily salt means addresses compare within a day and not across weeks, and both views say so rather than letting a reader draw a conclusion from noise. See [Security](./security.md).
+- [x] **Invite tree** — The genealogy has always been in `invitations`; both pages that read it only ever rendered one generation. `/admin/invite-tree` walks both directions — who vouched for a member, and who they let in — which is the standard procedure after a ban for cheating. Erased accounts render as tombstones: erasure keeps every edge intact and only takes the name, which is exactly the property a genealogy needs. Bounded at ten generations and four hundred members, and honest when it truncates. See [Invitations](./invitations.md).
+- [x] **Prowlarr indexer definition** — `GET /api/torznab/cardigann.yml` generates a Cardigann definition for the instance, with its name, URL and own categories filled in — a static file in the repo could describe every instance's categories except the one the member is joining. Downloadable from the member's own profile beside the key it needs. See [Torznab](../integrations/torznab.md).
+- [x] **BEP 21 partial seeds** — A client holding every piece it asked for but not the whole torrent reports `left=0` with `event=paused`; qBittorrent does it whenever somebody downloads part of a multi-file torrent. The tracker never rejected it, as some do, but counted the peer as a seed — inflating the seeder count and making a torrent look healthier than it was. Such a peer now stays in the swarm, counts as a leecher, and banks no seed time towards a hit-and-run requirement it cannot meet. Non-compact responses stay unsupported, and the guide now says so instead of leaving it to be discovered. See [UDP tracker](./udp-tracker.md).
+
### v0.35.x — Staff audit log, BEP 52 announce, data export, installable app
- [x] **Staff audit log** — Every mutating request to `/api/admin/**` and `/api/mod/**` now leaves one row: actor and role as they were at the time, a stable action key, target, what changed, and the HTTP status — refusals included, since a run of 403s from one account is the pattern such a register exists to surface. Written by a pair of Nitro hooks rather than by each route, so coverage is structural: a staff route added tomorrow is audited before anybody writes a line for it. Append-only (no edit path, no per-row delete); rows leave only through a retention sweep whose period is published on the public `/privacy` page. Read by admins only — moderators fill the table and cannot read it. Request bodies are never captured; the settings route records which settings were touched, never their values. See [Staff audit log](./audit-log.md).
From 0c47ec693532c25631ea1e6883ebc0bf1af70a92 Mon Sep 17 00:00:00 2001
From: dim145
Date: Tue, 1 Sep 2026 12:07:27 +0200
Subject: [PATCH 17/96] fix(torznab): a block a member could lift by rotating
their own key
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An administrator blocks a member's Torznab access. The member opens their
own settings page, resets their passkey, and the feed answers them again.
Not evaded — erased. The entry is stored under `sha256(passkey)`
truncated, so changing the passkey moves the index out from under it, and
`/api/admin/torznab/blacklist` then lists nothing at all. Measured on a
throwaway stack: refused before the rotation, served after, and the
operator's own list of blocked members empty.
Three routes write `users.passkey`, and looking at the other two was more
useful than fixing the one:
`admin/torznab/users/:id/reset` had the identical hole, which is worse
than it sounds — reset sits beside block on the same page, so the button
an operator presses to deal with a leaked key silently undid the one they
pressed to punish. Lifting a block is its own route, deliberately.
`auth/passkey` did carry the block, from `user.passkey` — the session's
copy. A session opened before a rotation elsewhere holds a dead value, and
the carry would then write the entry under a hash nobody presents. That
failure looks exactly like success and frees the member. It reads the row
now.
The carry-over lives in `utils/torznabStats` rather than in each route,
because the requirement is invisible from inside a rotation handler: the
block is in Redis, under a key the route never mentions. `carryTorznabBlock`
before the row changes, `retireTorznabPasskey` after — in that order no
instant has a live passkey that is unblocked, which the other order cannot
say. The entry is copied verbatim, so the console keeps the original reason
and date; a carried block claiming to have been applied just now would
erase the only record of when it really was.
It is also the one function in that module that does NOT swallow a Redis
failure. The rest fail open on purpose: enforcement that cannot reach Redis
lets a poll through and bites again when Redis returns. A carry-over that
failed open would lose the block permanently, so the rotation is refused
instead — 503, and the passkey unchanged. Verified with Redis stopped.
Panic mode is excluded, and not as an exception: it encrypts and decrypts
the same credential for every account, so the index leaves and comes back
with it, and writing a block under the ciphertext's hash would be wrong.
The test is structural, over the route sources, because that is the shape
of the bug: nothing in a handler that mints a passkey hints that something
in Redis depends on it. It fails on the fourth rotation route, the one
nobody has written yet. Mutation-tested, and the first version of it was
worthless — an unused import satisfies a name match, and `indexOf` answers
-1, which is less than every offset in the file, so the ordering assertion
passed loudest on a route that did neither.
Co-Authored-By: Claude Opus 5
---
.../admin/torznab/users/[id]/reset.post.ts | 14 ++-
apps/api/routes/api/auth/passkey.post.ts | 47 ++++----
apps/api/routes/api/me/passkey/reset.post.ts | 19 +++-
apps/api/test/torznabRotation.test.ts | 101 ++++++++++++++++++
apps/api/utils/torznabStats.ts | 63 +++++++++++
doc/integrations/torznab.md | 12 +++
6 files changed, 231 insertions(+), 25 deletions(-)
create mode 100644 apps/api/test/torznabRotation.test.ts
diff --git a/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts b/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts
index 4e98780c..fb75c852 100644
--- a/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts
+++ b/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts
@@ -7,7 +7,10 @@ import { requireAdminSession } from '~~/utils/adminAuth';
import { db, schema } from '@trackarr/db';
import { eq } from 'drizzle-orm';
import { generatePasskey } from '~~/utils/auth';
-import { clearTorznabUserStats } from '~~/utils/torznabStats';
+import {
+ carryTorznabBlock,
+ retireTorznabPasskey,
+} from '~~/utils/torznabStats';
export default defineEventHandler(async (event) => {
await requireAdminSession(event);
@@ -40,14 +43,19 @@ export default defineEventHandler(async (event) => {
// Generate new passkey
const newPasskey = generatePasskey();
+ // Resetting a leaked key is not lifting a restriction, and the two live on
+ // the same page: without this, the reset button beside the block button
+ // silently undid it. Unblocking is its own route, deliberately.
+ await carryTorznabBlock(oldPasskey, newPasskey);
+
// Update user
await db
.update(schema.users)
.set({ passkey: newPasskey })
.where(eq(schema.users.id, userId));
- // Clear old stats
- await clearTorznabUserStats(oldPasskey);
+ // Block entry and counters for a value that is nobody's any more.
+ await retireTorznabPasskey(oldPasskey);
return {
success: true,
diff --git a/apps/api/routes/api/auth/passkey.post.ts b/apps/api/routes/api/auth/passkey.post.ts
index 76119607..91b26dfb 100644
--- a/apps/api/routes/api/auth/passkey.post.ts
+++ b/apps/api/routes/api/auth/passkey.post.ts
@@ -23,6 +23,10 @@ import { eq } from 'drizzle-orm';
import { db, schema } from '@trackarr/db';
import { generateToken } from '~~/utils/server';
import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import {
+ carryTorznabBlock,
+ retireTorznabPasskey,
+} from '~~/utils/torznabStats';
import { z } from 'zod';
const bodySchema = z.object({
@@ -44,6 +48,29 @@ export default defineEventHandler(async (event) => {
// and what BitTorrent clients pass through ?passkey=.
const fresh = generateToken(16);
+ // The row rather than the session. The session's copy of the passkey is
+ // whatever was current when it was opened, so a member who rotated from
+ // another device would have the block carried from a value that is already
+ // dead — the entry would be written under a hash nobody presents, which
+ // looks exactly like a successful carry-over and frees the member.
+ const [current] = await db
+ .select({ passkey: schema.users.passkey })
+ .from(schema.users)
+ .where(eq(schema.users.id, user.id))
+ .limit(1);
+
+ if (!current) {
+ throw createError({
+ statusCode: 404,
+ message: 'User not found',
+ });
+ }
+
+ // Before the update, and refusing rather than failing open: the block is
+ // keyed by passkey hash, so a rotation that dropped it would let a blocked
+ // member self-lift an administrator's restriction by minting a new passkey.
+ await carryTorznabBlock(current.passkey, fresh);
+
const [updated] = await db
.update(schema.users)
.set({ passkey: fresh })
@@ -57,24 +84,8 @@ export default defineEventHandler(async (event) => {
});
}
- // Carry any Torznab access block across the rotation. The block is
- // keyed by passkey hash, so without this a blocked user could
- // self-lift the admin's restriction simply by minting a new
- // passkey (finding: Torznab block evaded by rotation). Re-apply it
- // to the new key and drop the now-dead old entry + stats.
- try {
- const wasBlocked = await isTorznabUserBlocked(user.passkey);
- if (wasBlocked.blocked) {
- await blockTorznabUser(
- updated.passkey,
- wasBlocked.reason ?? 'carried over on passkey rotation'
- );
- await unblockTorznabUser(user.passkey);
- }
- await clearTorznabUserStats(user.passkey);
- } catch (err) {
- console.warn('[passkey rotate] torznab block migration failed:', err);
- }
+ // The old value is nobody's now — block entry and counters both go.
+ await retireTorznabPasskey(current.passkey);
// Refresh the session in place so the next reveal/copy on the page
// returns the new value rather than the stale one we cached at login.
diff --git a/apps/api/routes/api/me/passkey/reset.post.ts b/apps/api/routes/api/me/passkey/reset.post.ts
index 7f15d748..c8ecc186 100644
--- a/apps/api/routes/api/me/passkey/reset.post.ts
+++ b/apps/api/routes/api/me/passkey/reset.post.ts
@@ -24,7 +24,10 @@ import { db, schema } from '@trackarr/db';
import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth';
import { generatePasskey } from '~~/utils/auth';
import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
-import { clearTorznabUserStats } from '~~/utils/torznabStats';
+import {
+ carryTorznabBlock,
+ retireTorznabPasskey,
+} from '~~/utils/torznabStats';
import { notify } from '~~/utils/notify';
export default defineEventHandler(async (event) => {
@@ -45,14 +48,22 @@ export default defineEventHandler(async (event) => {
const oldPasskey = row.passkey;
const newPasskey = generatePasskey();
+ // Before the row changes. A Torznab access block is keyed by a hash of the
+ // passkey, so a rotation that did not carry it over would hand a blocked
+ // member the lift for free — this route is self-service, needs no
+ // administrator, and would have been the whole restriction's back door.
+ // Refuses the rotation if it cannot be sure, rather than freeing the member.
+ await carryTorznabBlock(oldPasskey, newPasskey);
+
await db
.update(schema.users)
.set({ passkey: newPasskey })
.where(eq(schema.users.id, user.id));
- // The per-passkey Torznab counters are keyed on the old value; leaving them
- // behind would both leak the rotation and strand the rows.
- await clearTorznabUserStats(oldPasskey);
+ // The old value belongs to nobody now: its block entry and its per-passkey
+ // counters both index a hash no account matches any more, and leaving the
+ // counters behind would also leak the rotation.
+ await retireTorznabPasskey(oldPasskey);
// The session cookie carries the passkey, so it now holds a dead one. Write
// the new value back rather than force a re-login.
diff --git a/apps/api/test/torznabRotation.test.ts b/apps/api/test/torznabRotation.test.ts
new file mode 100644
index 00000000..d3570b53
--- /dev/null
+++ b/apps/api/test/torznabRotation.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect } from 'vitest';
+import { readdir, readFile } from 'fs/promises';
+import { join } from 'path';
+
+/**
+ * A Torznab access block is stored under a hash of the member's passkey, so
+ * every route that rotates that passkey has to move the block onto the
+ * replacement. One of them did not, and the effect was that a blocked member
+ * could lift an administrator's restriction from their own settings page.
+ *
+ * Nothing about a rotation route makes that requirement visible while writing
+ * it — the block lives in Redis, under a key the route never mentions — which
+ * is exactly why this is a structural test over the source rather than a unit
+ * test of one function. It fails on the fourth rotation path, the one nobody
+ * has written yet.
+ */
+
+const ROUTES = join(import.meta.dirname, '..', 'routes');
+
+async function* walk(dir: string): AsyncGenerator {
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) yield* walk(full);
+ else if (entry.name.endsWith('.ts')) yield full;
+ }
+}
+
+/**
+ * Every route file that gives `users.passkey` a different value — matched on
+ * the drizzle write, `.set({ passkey: … })`, whatever the variable holding it
+ * is called.
+ *
+ * Panic mode is the one exclusion, and it is not an exception to the rule: it
+ * encrypts the stored passkey of every account and decrypts it back, so the
+ * member's credential never changes. The block index moves out from under the
+ * entry while the site is sealed — where no feed works for anybody — and comes
+ * back with it. Writing a block under the ciphertext's hash would be actively
+ * wrong.
+ *
+ * The predicate is deliberately the wide one: anything that writes the column
+ * by some route this test has never seen lands in the list and has to say what
+ * it does about the block.
+ */
+async function rotationRoutes(): Promise<{ path: string; source: string }[]> {
+ const found: { path: string; source: string }[] = [];
+ for await (const path of walk(ROUTES)) {
+ const source = await readFile(path, 'utf8');
+ if (!/\.set\(\{[^}]*\bpasskey:/s.test(source)) continue;
+ if (/encryptField|decryptField/.test(source)) continue;
+ found.push({ path: path.slice(ROUTES.length + 1), source });
+ }
+ return found;
+}
+
+describe('passkey rotation carries the Torznab block', () => {
+ it('finds the rotation routes at all', async () => {
+ const routes = await rotationRoutes();
+ // If this drops to zero the test has stopped testing anything — a renamed
+ // column or a switch away from `.set({ passkey })` would make every
+ // assertion below vacuously true.
+ expect(routes.map((r) => r.path).sort()).toEqual([
+ 'api/admin/torznab/users/[id]/reset.post.ts',
+ 'api/auth/passkey.post.ts',
+ 'api/me/passkey/reset.post.ts',
+ ]);
+ });
+
+ it('every rotation route carries the block onto the new passkey', async () => {
+ const missing = (await rotationRoutes())
+ // The open paren matters: an unused import satisfies a plain name
+ // match, which is precisely the state a half-applied fix leaves behind.
+ .filter((r) => !r.source.includes('carryTorznabBlock('))
+ .map((r) => r.path);
+ expect(missing).toEqual([]);
+ });
+
+ it('and retires the old one', async () => {
+ const missing = (await rotationRoutes())
+ .filter((r) => !r.source.includes('retireTorznabPasskey('))
+ .map((r) => r.path);
+ expect(missing).toEqual([]);
+ });
+
+ it('carries before it writes, and retires after', async () => {
+ // Order is the whole guarantee: carry first and there is no instant in
+ // which a live passkey is unblocked. A route that retired the old entry
+ // before the row changed would open exactly the window this closes.
+ for (const { path, source } of await rotationRoutes()) {
+ const carry = source.indexOf('carryTorznabBlock(');
+ const write = source.search(/\.set\(\{[^}]*\bpasskey:/s);
+ const retire = source.indexOf('retireTorznabPasskey(');
+ // Assert they are there before comparing positions: `indexOf` answers
+ // -1 for absent, and -1 is less than every offset in the file, so the
+ // ordering check would pass loudest on the route that does neither.
+ expect(carry, `${path}: calls carryTorznabBlock`).toBeGreaterThan(-1);
+ expect(retire, `${path}: calls retireTorznabPasskey`).toBeGreaterThan(-1);
+ expect(carry, `${path}: carry before the write`).toBeLessThan(write);
+ expect(retire, `${path}: retire after the write`).toBeGreaterThan(write);
+ }
+ });
+});
diff --git a/apps/api/utils/torznabStats.ts b/apps/api/utils/torznabStats.ts
index aeabb33c..34891a01 100644
--- a/apps/api/utils/torznabStats.ts
+++ b/apps/api/utils/torznabStats.ts
@@ -475,6 +475,69 @@ export async function unblockTorznabUser(passkey: string): Promise {
}
}
+/**
+ * Move an access block onto the passkey that replaces it.
+ *
+ * The block is indexed by `passkeyId(passkey)`, so a rotation that merely
+ * dropped the old entry would BE the lift: a blocked member mints a new
+ * passkey and the administrator's restriction is gone, with nothing anywhere
+ * to say it ever applied. Three routes rotate `users.passkey` today, and the
+ * carry-over lives here rather than in each of them so a fourth cannot
+ * reintroduce that quietly.
+ *
+ * Call it BEFORE the row is updated, and `retireTorznabPasskey` after. That
+ * order leaves no instant in which a live passkey is unblocked: the
+ * replacement inherits the block before anything can poll with it, and the old
+ * entry survives right up to the moment it stops being the member's key.
+ *
+ * The entry is copied verbatim — same reason, same `blockedAt` — because the
+ * console sorts and displays both, and a carried block that claimed to have
+ * been applied just now would erase the only record of when it really was.
+ *
+ * Unlike every other function in this module, this one does NOT swallow a
+ * Redis failure. The others fail open on purpose: enforcement that cannot
+ * reach Redis lets a poll through, and the block bites again when Redis comes
+ * back. A carry-over that failed open would lose the block *permanently*, so
+ * the rotation is refused instead and nothing changes.
+ *
+ * Returns whether a block was carried.
+ */
+export async function carryTorznabBlock(
+ oldPasskey: string,
+ newPasskey: string
+): Promise {
+ try {
+ const entry = await redis.hget(KEYS.BLOCKED, passkeyId(oldPasskey));
+ if (!entry) return false;
+ await redis.hset(KEYS.BLOCKED, passkeyId(newPasskey), entry);
+ return true;
+ } catch (error) {
+ console.error(
+ '[Torznab Stats] Could not carry the block over a rotation:',
+ error
+ );
+ throw createError({
+ statusCode: 503,
+ message:
+ 'Passkey rotation is temporarily unavailable. Your passkey has not been changed — try again shortly.',
+ });
+ }
+}
+
+/**
+ * Forget a passkey that is nobody's any more: its block entry and its
+ * per-passkey counters.
+ *
+ * Both index a hash of a value that has just stopped existing, so leaving them
+ * behind stops nothing and leaves the console listing a block against an id no
+ * account matches. Best-effort by the same reasoning that makes the carry-over
+ * strict: what is left behind here is litter, not a hole.
+ */
+export async function retireTorznabPasskey(passkey: string): Promise {
+ await unblockTorznabUser(passkey);
+ await clearTorznabUserStats(passkey);
+}
+
export async function isTorznabUserBlocked(
passkey: string
): Promise<{ blocked: boolean; reason?: string }> {
diff --git a/doc/integrations/torznab.md b/doc/integrations/torznab.md
index 67ba7ca3..e38da265 100644
--- a/doc/integrations/torznab.md
+++ b/doc/integrations/torznab.md
@@ -330,6 +330,18 @@ The Torznab API admin panel (`/admin/torznab`) provides comprehensive management
- Block users from API access
- View per-user request logs
+A block is recorded against the member, not against the key they happen to be
+holding. It is stored under a hash of their announce passkey, and every route
+that rotates that passkey — the member's own settings page included — moves the
+block onto the replacement, keeping the original reason and date. So resetting a
+leaked passkey does not quietly lift a restriction, and a blocked member cannot
+lift it themselves by minting a new key. Unblocking is its own action, under
+**Blacklist Management**.
+
+If Redis is unreachable, a rotation is refused rather than performed without the
+block following it: the member is told to try again, and their passkey is
+unchanged.
+
### Request Logs
- Real-time log of recent API requests
From 6915bdb16acf2ab33eee0204d05a68f67c262337 Mon Sep 17 00:00:00 2001
From: dim145
Date: Tue, 1 Sep 2026 15:27:20 +0200
Subject: [PATCH 18/96] fix(themes): the contrast gate was not looking at the
field it types into
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The gate itself already existed, and this is the part worth writing down: a
theme editor that measures contrast, warns rather than refuses, and reasons
about WCAG 2.x versus APCA shipped three days before I went looking for it.
What was wrong was its coverage.
Ten pairs were declared. `.input` sets `background-color: rgb(var(--bg-elevated))`
with `color: rgb(var(--fg-default))` and a `--fg-faint` placeholder, and neither
of those surfaces was among them — so the text a member types, on every form on
the site, was measured against nothing. `--bg-inset` was in the same position:
36 components paint on it, and in the LIGHT theme it is the darkest surface
rather than a middle one, which makes it the worst case there rather than a case
already bracketed.
Adding the pairs made the two SHIPPED themes fail, which is the argument for
them:
dark fg-subtle 4.00:1 on a field, 4.23:1 on a card, 4.40:1 on a panel
light fg-subtle 4.35:1 on an inset panel
light fg-faint 4.35:1 on an inset panel
`--fg-subtle` had been set to 121 to clear 4.55:1 on `--bg-base`, and 121 is
below AA on every other surface it is painted on. The same file already records
this lesson twice — `--fg-faint` shipped at 1.84:1, light's `--fg-subtle` at
2.08:1 — and both times the fix was to measure the pair somebody had declared.
The third time, the pair was the one nobody had.
So: 127 in dark, 111 in light, both a few points and imperceptible, both
verified against all twenty pairs. Changed in `main.css` AND in the copy inside
`packages/shared`, which an existing test compares — a duplicate that can drift
is a duplicate that will.
The status colours are now checked as TEXT, which they are: `text-error` appears
in 18 components, `text-warning` in 9, `text-accent` in 9. They pass in both
built-ins; the pairs exist so an operator's theme cannot quietly fail them.
`--info` is deliberately absent — it has no text use at all, and a pair nothing
renders is a pair that will be wrong without anyone noticing.
And the editor now shows the whole measurement rather than only the failures.
4.6:1 and 12:1 both look like silence when a gate speaks up only when it is
unhappy, and one of them breaks on the next nudge. Twenty rows, each with its
ratio, its requirement, and a sample drawn in the two colours being measured;
the count in the header (`20/20`) is the summary. The pair labels are translated
through a key derived from the two token names, because the English in the
schema is a code comment that happened to be rendered — the warning line above
still interpolates it, which is how a French console came to read "muted labels
on cards".
Co-Authored-By: Claude Opus 5
---
apps/web/app/assets/css/main.css | 22 +++-
apps/web/app/components/admin/ThemeEditor.vue | 101 +++++++++++++++++-
apps/web/i18n/locales/en.json | 27 ++++-
apps/web/i18n/locales/fr.json | 27 ++++-
apps/web/test/themeTokens.test.ts | 40 +++++++
doc/guide/themes.md | 35 ++++--
packages/shared/src/theme.ts | 96 ++++++++++++++---
7 files changed, 320 insertions(+), 28 deletions(-)
diff --git a/apps/web/app/assets/css/main.css b/apps/web/app/assets/css/main.css
index dabea2d0..74560721 100644
--- a/apps/web/app/assets/css/main.css
+++ b/apps/web/app/assets/css/main.css
@@ -35,7 +35,14 @@
and is not perceptible next to the old value. Same class of defect as the
`--fg-faint` note below, found by the contrast gate the theme system needs
anyway. */
- --fg-subtle: 121 121 121;
+ /* 127, up from 121. The note above measured this token against `--bg-base`
+ only, which is the surface it fails on LAST: 121 gave 4.55:1 there and
+ 4.23:1 on `--bg-surface` — every muted label inside a card — and 4.40:1 on
+ `--bg-inset`. 127 clears 4.5:1 on all three. Found by extending the
+ contrast gate to the surfaces it was not checking, which is the third time
+ this file records the same lesson: the pair nobody declared is the pair
+ that ships broken. */
+ --fg-subtle: 127 127 127;
/* Bumped from 74/74/74 (~1.84:1 on bg-base — WCAG fail) up to
130/130/130 (~5.2:1 on bg-base) so the dozen-or-so mono micro
labels using --fg-faint as a text colour stay legible. */
@@ -242,8 +249,8 @@
--fg-default: 10 10 10;
--fg-strong: 0 0 0;
--fg-muted: 85 85 85;
- --fg-subtle: 115 115 115;
- /* 115, down from 176 — the biggest visible change in this file, and a bug
+ --fg-subtle: 111 111 111;
+ /* 111, down from 176 — the biggest visible change in this file, and a bug
fix rather than a preference. 176 on `--bg-base` is **2.08:1**, which is
unreadable, and it is the same defect the dark theme already had: the note
above records `--fg-faint` being raised from 1.84:1 to 5.2:1 there for
@@ -253,8 +260,13 @@
Light-mode micro labels and placeholders are therefore noticeably darker
than before. That is the point: at 2.08:1 they were decorative, not
- legible. */
- --fg-faint: 115 115 115;
+ legible.
+
+ 111, down from 115, for the same reason `--fg-subtle` moved with it: in the
+ light theme `--bg-inset` (245) is the DARKEST surface rather than a middle
+ one, so it is the worst case here and it was not measured either. 115 gave
+ 4.35:1 on it; 111 gives 4.55:1. */
+ --fg-faint: 111 111 111;
--line-default: 229 229 229;
--line-strong: 208 208 208;
diff --git a/apps/web/app/components/admin/ThemeEditor.vue b/apps/web/app/components/admin/ThemeEditor.vue
index 5261e9e5..d5c117ed 100644
--- a/apps/web/app/components/admin/ThemeEditor.vue
+++ b/apps/web/app/components/admin/ThemeEditor.vue
@@ -225,7 +225,9 @@
-
+
+
+
+
+
+
diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json
index 6ba13b71..4902d0f3 100644
--- a/apps/web/i18n/locales/en.json
+++ b/apps/web/i18n/locales/en.json
@@ -48,6 +48,7 @@
"federated": "Federated",
"federatedIdentity": "Federated identity",
"forum": "Forum",
+ "stats": "Stats",
"admin": "Admin",
"mod": "Mod",
"uploaded": "Uploaded",
@@ -5379,5 +5380,84 @@
"body": "Search the catalogue, then use “Save this search” to be told when something new matches it.",
"cta": "Browse the catalogue"
}
+ },
+ "stats": {
+ "eyebrow": "The site",
+ "title": "State of the tracker",
+ "lede": "What the site is made of, how it got here, and what a year of it looked like. Everything on this page is aggregate — no member’s volume appears anywhere.",
+ "uncategorised": "Uncategorised",
+ "foot": "Counters are snapshotted hourly. Years run from January to January, in UTC.",
+ "now": {
+ "title": "Right now",
+ "torrents": "Releases",
+ "members": "Members",
+ "seeders": "Seeders",
+ "leechers": "{n} leeching",
+ "snatches": "Completed",
+ "traffic": "Traffic"
+ },
+ "growth": {
+ "title": "How it got here",
+ "window": "{days} days",
+ "windowAria": "Length of history to show",
+ "torrents": "Releases",
+ "torrentsUnit": "releases",
+ "members": "Members",
+ "membersUnit": "members",
+ "traffic": "Traffic per day",
+ "none": "No snapshots yet — the collector writes one an hour, so this fills in from the next one."
+ },
+ "series": {
+ "tooShort": "Not enough history to draw yet.",
+ "table": "Show the numbers",
+ "when": "When",
+ "summaryBars": "{label}: {total} in total, highest {peak} on {when}",
+ "summaryTrend": "{label}: {from} to {to}"
+ },
+ "categories": {
+ "title": "What is in the catalogue",
+ "note": "Live, accepted releases only. Bars are relative to the largest category.",
+ "none": "Nothing catalogued yet."
+ },
+ "top": {
+ "snatched": "Most snatched",
+ "swarms": "Biggest swarms",
+ "none": "Nothing to rank yet."
+ },
+ "uploaders": {
+ "title": "Who is uploading",
+ "note": "Ranked by number of live releases, never by volume — there is no setting on this site by which you could decline to appear in a volume board, so there is no volume board. Members who upload anonymously are not listed.",
+ "count": "{n} releases",
+ "none": "Nobody has uploaded yet."
+ },
+ "year": {
+ "title": "{year} in review",
+ "pickAria": "Choose a year",
+ "added": "Releases added",
+ "joined": "Members joined",
+ "snatches": "Snatches",
+ "traffic": "Traffic",
+ "trafficNote": "at least — the counter drops when an account is erased",
+ "byMonth": "Releases per month",
+ "topCategories": "Busiest categories",
+ "topReleases": "Most snatched of the year",
+ "busiest": "Busiest day: {day}, {bytes} moved.",
+ "nothing": "Nothing this year.",
+ "none": "No data for this year."
+ },
+ "mine": {
+ "title": "Your {year}",
+ "uploads": "Uploads",
+ "snatches": "Snatches",
+ "up": "Uploaded",
+ "down": "Downloaded",
+ "seedTime": "Seeded",
+ "bonus": "Bonus earned",
+ "hours": "{n} h",
+ "days": "{n} d",
+ "best": "Your most snatched release:",
+ "bestSnatches": "{n} snatches",
+ "favourite": "Mostly {category} — {n} of your uploads."
+ }
}
}
diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json
index 820bb034..81d30b0a 100644
--- a/apps/web/i18n/locales/fr.json
+++ b/apps/web/i18n/locales/fr.json
@@ -48,6 +48,7 @@
"federated": "Fédéré",
"federatedIdentity": "Identité fédérée",
"forum": "Forum",
+ "stats": "Statistiques",
"admin": "Admin",
"mod": "Modération",
"uploaded": "Envoyé",
@@ -5379,5 +5380,84 @@
"body": "Cherchez dans le catalogue, puis utilisez « Enregistrer cette recherche » pour être prévenu·e quand quelque chose de nouveau y correspond.",
"cta": "Parcourir le catalogue"
}
+ },
+ "stats": {
+ "eyebrow": "Le site",
+ "title": "État du tracker",
+ "lede": "De quoi le site est fait, comment il en est arrivé là, et à quoi a ressemblé une année. Tout est agrégé : le volume d’aucun membre n’apparaît ici.",
+ "uncategorised": "Sans catégorie",
+ "foot": "Les compteurs sont relevés toutes les heures. Les années vont de janvier à janvier, en UTC.",
+ "now": {
+ "title": "En ce moment",
+ "torrents": "Releases",
+ "members": "Membres",
+ "seeders": "Sources",
+ "leechers": "{n} en téléchargement",
+ "snatches": "Téléchargements terminés",
+ "traffic": "Trafic"
+ },
+ "growth": {
+ "title": "Comment on en est arrivé là",
+ "window": "{days} jours",
+ "windowAria": "Durée d’historique à afficher",
+ "torrents": "Releases",
+ "torrentsUnit": "releases",
+ "members": "Membres",
+ "membersUnit": "membres",
+ "traffic": "Trafic par jour",
+ "none": "Aucun relevé pour l’instant — le collecteur en écrit un par heure, la courbe se remplira au prochain."
+ },
+ "series": {
+ "tooShort": "Pas encore assez d’historique pour tracer.",
+ "table": "Afficher les chiffres",
+ "when": "Quand",
+ "summaryBars": "{label} : {total} au total, maximum {peak} le {when}",
+ "summaryTrend": "{label} : de {from} à {to}"
+ },
+ "categories": {
+ "title": "Ce que contient le catalogue",
+ "note": "Uniquement les releases actives et acceptées. Les barres sont relatives à la plus grosse catégorie.",
+ "none": "Rien au catalogue pour l’instant."
+ },
+ "top": {
+ "snatched": "Les plus téléchargées",
+ "swarms": "Les plus gros essaims",
+ "none": "Rien à classer pour l’instant."
+ },
+ "uploaders": {
+ "title": "Qui envoie",
+ "note": "Classement par nombre de releases en ligne, jamais par volume : aucun réglage de ce site ne permet de refuser d’apparaître dans un classement de volume, donc il n’y en a pas. Les membres qui envoient anonymement ne sont pas listés.",
+ "count": "{n} releases",
+ "none": "Personne n’a encore envoyé de release."
+ },
+ "year": {
+ "title": "Bilan {year}",
+ "pickAria": "Choisir une année",
+ "added": "Releases ajoutées",
+ "joined": "Membres arrivés",
+ "snatches": "Téléchargements",
+ "traffic": "Trafic",
+ "trafficNote": "au moins — le compteur baisse quand un compte est effacé",
+ "byMonth": "Releases par mois",
+ "topCategories": "Catégories les plus actives",
+ "topReleases": "Les plus téléchargées de l’année",
+ "busiest": "Journée la plus chargée : {day}, {bytes} échangés.",
+ "nothing": "Rien cette année.",
+ "none": "Aucune donnée pour cette année."
+ },
+ "mine": {
+ "title": "Votre {year}",
+ "uploads": "Envois",
+ "snatches": "Téléchargements",
+ "up": "Envoyé",
+ "down": "Téléchargé",
+ "seedTime": "Partagé",
+ "bonus": "Bonus gagnés",
+ "hours": "{n} h",
+ "days": "{n} j",
+ "best": "Votre release la plus téléchargée :",
+ "bestSnatches": "{n} téléchargements",
+ "favourite": "Surtout {category} — {n} de vos envois."
+ }
}
}
diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts
index ade8fc17..95e7ece1 100644
--- a/doc/.vitepress/config.ts
+++ b/doc/.vitepress/config.ts
@@ -117,6 +117,7 @@ export default defineConfig({
{ text: 'Cross-seed', link: '/guide/cross-seed' },
{ text: 'Install as an App', link: '/guide/install-as-app' },
{ text: 'Saved Searches', link: '/guide/saved-searches' },
+ { text: 'Site Statistics', link: '/guide/stats' },
{ text: 'Federation', link: '/guide/federation' },
],
},
diff --git a/doc/guide/stats.md b/doc/guide/stats.md
new file mode 100644
index 00000000..1d70231f
--- /dev/null
+++ b/doc/guide/stats.md
@@ -0,0 +1,85 @@
+# Site statistics
+
+**/stats** is the site looking at itself: what the catalogue is made of, how the
+site got here, what everybody is grabbing, and what a year of it was. Available
+to every member.
+
+Before it existed, all of this was in the database and visible only to
+administrators — a member saw four counters on the homepage and nothing else.
+
+## What is on it
+
+- **Right now** — releases and the bytes they add up to, members, seeders and
+ leechers, completed downloads, total traffic.
+- **How it got here** — releases, members and traffic per day over 30, 90 or 365
+ days.
+- **What is in the catalogue** — releases and bytes per category.
+- **Most snatched** and **biggest swarms** — ten releases each.
+- **Who is uploading** — the ten members with the most live releases.
+- **The year in review** — one year at a time, with a monthly breakdown, the
+ busiest categories and releases, and the busiest single day.
+- **Your year** — your own uploads, snatches, bytes, seed time and bonus.
+
+## What is deliberately not on it
+
+**No per-member volume.** No ratio board, no "top uploaders by terabyte". Those
+are the numbers a tracker leaderboard is traditionally built on, and there is no
+setting on this site by which a member could decline to appear in one — so
+publishing it would publish, for every member, a figure they never agreed to
+publish. The uploader board ranks by **number of live releases**, which is
+already visible on each member's profile, so counting them discloses nothing new.
+
+**No member who uploads anonymously.** `Settings → Privacy → anonymous uploads`
+conceals a name on every surface that attributes a release. A board naming them
+would be the one surface that undoes it, so they are left out of the board and
+still counted in the totals.
+
+**No adult release** for a member who has not opted in, in any list that names a
+release.
+
+## Two figures that are not the same
+
+`Releases added` and its byte figure are properties of the catalogue: the size of
+what was catalogued in the year. They are exact.
+
+`Traffic` is how much actually moved, taken as the difference between the first
+and last hourly snapshot inside the window. It is **a floor, not a total**: the
+counter behind it is `SUM(users.uploaded)`, which drops when an account is erased
+or a moderator resets a cheater's stats. The page says so under the figure.
+
+For the same reason, a per-day figure is clamped at zero rather than shown
+negative — a chart reporting "-4.2 TB on Tuesday" would be a number a reader
+would try to explain.
+
+## Where the numbers come from
+
+| Section | Source |
+| --- | --- |
+| Right now | `torrents`, `torrent_stats`, `users`, latest `site_stats` |
+| History | `site_stats`, one snapshot an hour |
+| Categories | `torrents` joined `categories` |
+| Rankings | `torrent_stats.completed` / `.seeders` |
+| Snatches in a year | `hnr_tracking`, which is the only dated per-download record |
+| Your year | your own rows in the four tables above, plus `bonus_grants` |
+
+A day with no snapshot is absent from the charts rather than drawn as zero: an
+instance that was down for six hours must not draw a cliff on a counter that
+never moved. Under each chart, **Show the numbers** opens the same values as a
+table — the picture is not the data.
+
+## Years, and time zones
+
+A year runs from 1 January to 1 January **in UTC**. The members of one tracker
+are spread across every time zone, so a year anchored on the server's own offset
+would be an arbitrary choice presented as a fact.
+
+The year selector only offers years the instance has snapshots for. A review of a
+year the site did not exist for would be an empty page, which reads as broken
+rather than as empty.
+
+## Caching
+
+The site view is cached for a minute, per window and per adult-visibility. A past
+year is cached for a day, since it cannot change; the current year for a minute.
+Your own year is not cached at all — a member who has just uploaded something
+should see it.
From 5d4ac27d69773faee9544b15af46339e7def2cfa Mon Sep 17 00:00:00 2001
From: dim145
Date: Tue, 1 Sep 2026 15:28:46 +0200
Subject: [PATCH 20/96] feat(irc): one line in a channel, and the parser for it
in the same breath
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
autobrr and autodl-irssi are how releases are actually raced, and both speak
IRC. The mechanism is deliberately archaic — a bot says one line per accepted
upload, a client matches it against filters and grabs — and that is exactly why
it is universal. An RSS feed is polled once a minute; a channel message arrives
when the release does.
`grep -ril '\birc\b' apps/ doc/` returned nothing before this.
## The decision the roadmap left open
It worried about freezing a public format. The answer is not to freeze it: the
announce line is a template in settings, and **the regular expression handed to
members is derived from that template**.
Every tracker that ships a hand-written definition beside a configurable format
eventually ships two things that disagree, and the failure is silent — the
channel keeps announcing, the definition keeps not matching, and members
conclude the tracker is broken. `announcePattern(template)` makes that
impossible by construction, and it settles the versioning question too: the
template lives in the database, so a new default in a release never changes what
a running instance emits.
`/api/irc/autobrr.yml` therefore generates the whole definition, the way the
Cardigann one does and one step further: that one is per-instance because the
categories are, this one because the FORMAT is. The file carries a `tests` block
— a rendered sample and the values it should yield — so it arrives with a proof
that it parses this instance's own format.
The test in this commit is the round trip, run against the real shipped pattern
converted to JavaScript syntax rather than rewritten in it: render a line, parse
it back, check every capture. It covers a reordered template, a repeated token
(Go's regexp rejects a duplicate group name, so the second use is a
back-reference), an operator's typo, and the values that break naive patterns.
## Two omissions, both on purpose
The upload multiplier is printed for people and mapped to nothing, because
autobrr has no field for it. The seeding requirement is not in the line at all:
`minimumRatio` and `minimumSeedTime` are NOT in autobrr's `MapVars` — read from
its source, after its documentation suggested otherwise — and it reads both from
the Torznab feed this site already serves. A field no tool can consume is noise
in a format that has to stay parseable for years.
No key travels in the line. Everybody in the channel sees every line, so a
personalised download URL would hand every member the credentials of one; the
client appends its own read key, which is what the generated `downloadurl`
template is for.
## What the bot will never do
It never reads a command from the channel. The only inputs it acts on are PING
and the numerics that tell it whether it is connected — a bot taking orders from
a channel would be a remote control for the tracker, gated on IRC's idea of
identity, and IRC does not have one. It never joins a second channel and never
speaks to a user.
Values are sanitised before they reach a line, and that is the injection
boundary of the feature rather than a nicety: IRC frames commands with CRLF, so
a release name carrying `\r\n` does not corrupt the line, it ENDS it, and the
rest becomes a command the bot appears to have sent. Release names are
member-supplied.
## One bot, however many API instances
A Redis lease, renewed every fifteen seconds, elects one connection. Three
instances would otherwise mean three bots and every release announced three
times — to autobrr, which would grab it three times.
Announcing is never on an upload's path: `announceRelease` resolves what it
needs, hands a line to a paced, bounded queue, and returns. A channel that is
down cannot slow a moderator's queue. Past the cap the OLDEST lines drop, because
on an announce channel a stale release is worth less than a fresh one.
Adult releases are off by default, and that is a judgement rather than a
convenience: a channel is one stream with no per-member preferences in it.
Members can turn adult content off on the site and nobody can turn it off in a
channel, so the operator decides once, and the safe direction is the one that
does not put titles in front of people who turned them off. An anonymous
uploader is never named, by the same rule the catalogue and the feeds use.
Verified against a real ircd: the bot joins, an accepted release is announced
with the right size and buff, an approved ADULT release produces nothing, the
anonymous member's release says `anonymous`, and the regex from the file the
server handed a member parses the lines a witness in the channel actually heard.
Two instances announced once; the survivor of a `kill -9` on the leader took the
channel over and announced again.
Co-Authored-By: Claude Opus 5
---
README.md | 1 +
apps/api/plugins/irc-announce.ts | 52 ++
apps/api/routes/api/admin/irc/index.get.ts | 40 ++
apps/api/routes/api/admin/irc/index.put.ts | 128 +++++
apps/api/routes/api/admin/irc/test.post.ts | 39 ++
apps/api/routes/api/irc/autobrr.yml.get.ts | 59 ++
apps/api/routes/api/torrents/index.post.ts | 15 +
apps/api/test/ircAnnounce.test.ts | 327 +++++++++++
apps/api/utils/irc/announcer.ts | 378 +++++++++++++
apps/api/utils/irc/autobrr.ts | 187 +++++++
apps/api/utils/irc/client.ts | 392 +++++++++++++
apps/api/utils/irc/format.ts | 321 +++++++++++
apps/api/utils/irc/settings.ts | 150 +++++
apps/api/utils/torrentModeration.ts | 18 +
apps/web/app/composables/useAdminNav.ts | 6 +
apps/web/app/pages/admin/irc.vue | 614 +++++++++++++++++++++
apps/web/i18n/locales/en.json | 63 +++
apps/web/i18n/locales/fr.json | 63 +++
doc/.vitepress/config.ts | 5 +-
doc/integrations/irc.md | 169 ++++++
20 files changed, 3026 insertions(+), 1 deletion(-)
create mode 100644 apps/api/plugins/irc-announce.ts
create mode 100644 apps/api/routes/api/admin/irc/index.get.ts
create mode 100644 apps/api/routes/api/admin/irc/index.put.ts
create mode 100644 apps/api/routes/api/admin/irc/test.post.ts
create mode 100644 apps/api/routes/api/irc/autobrr.yml.get.ts
create mode 100644 apps/api/test/ircAnnounce.test.ts
create mode 100644 apps/api/utils/irc/announcer.ts
create mode 100644 apps/api/utils/irc/autobrr.ts
create mode 100644 apps/api/utils/irc/client.ts
create mode 100644 apps/api/utils/irc/format.ts
create mode 100644 apps/api/utils/irc/settings.ts
create mode 100644 apps/web/app/pages/admin/irc.vue
create mode 100644 doc/integrations/irc.md
diff --git a/README.md b/README.md
index cfe6ba22..253bf679 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre
- **Release sheet builder** — a four-step wizard at `/torrents/fiche` turns a video file into a BBCode sheet, an NFO and a normalised release name, then hands all three to the upload form. MediaInfo runs **in the browser** through WebAssembly and reads only the chunks it asks for, so a 40 GB remux is analysed without ever being uploaded. Every dropdown keeps an "Other…" entry, and bitrate/size unit selectors change the frame of reference without touching the value.
- **Operator console** — `/admin` covers users, categories, roles, invites, branding, panic, tags, Torznab, reports, HnR.
- **Torrent lifecycle** — a release can be marked superseded by a better one (the older stays online and keeps its swarm), a dead swarm can ask its past snatchers for a reseed, and one request tells a client which of its torrents this tracker still serves. See [Torrent lifecycle](doc/guide/torrent-lifecycle.md).
+- **IRC announce channel** — one line in a channel per accepted upload, the mechanism autobrr and autodl-irssi race on, with the autobrr indexer definition **generated from the announce template in force** so the format and the parser cannot disagree. Off by default; one bot however many API instances. See [IRC announce](doc/integrations/irc.md).
- **Site statistics** — `/stats` is the site looking at itself: what the catalogue holds, how it grew, what is being grabbed, and a year in review — plus your own year. No per-member volume anywhere, because no setting lets a member opt out of one. See [Site statistics](doc/guide/stats.md).
- **Saved searches** — a stored filter notifies its owner when a matching upload is accepted; the server-side half of autobrr, for members with no seedbox. See [Saved searches](doc/guide/saved-searches.md).
- **Invite tree** — who vouched for a member and who they let in, ten generations either way. Erased accounts keep their edges and lose their name.
diff --git a/apps/api/plugins/irc-announce.ts b/apps/api/plugins/irc-announce.ts
new file mode 100644
index 00000000..69d73424
--- /dev/null
+++ b/apps/api/plugins/irc-announce.ts
@@ -0,0 +1,52 @@
+/**
+ * Keeps the announce bot in the state the settings describe.
+ *
+ * A timer rather than a one-shot connect, because three separate things need
+ * the same tick: taking the lease (or renewing it), noticing a configuration
+ * change another instance saved, and retrying after a failure. The client
+ * itself has no reconnect loop for exactly this reason — one place decides when
+ * to try again, and its interval is the backoff.
+ *
+ * The interval is the lease renewal, not a poll of the settings: at 15 s a
+ * 45-second lease survives two missed ticks, which is what makes a garbage
+ * collection pause or a slow query a non-event rather than a handover.
+ *
+ * Nothing here throws. A tick that cannot read Redis or the settings leaves the
+ * bot as it was and tries again — an announce channel is a convenience, and it
+ * must not be able to take an API instance down with it.
+ */
+import { LEASE_RENEW_MS, reconcile, shutdownAnnouncer } from '~~/utils/irc/announcer';
+import { getIrcEnabled } from '~~/utils/irc/settings';
+
+export default defineNitroPlugin(async (nitro) => {
+ // The first look is deliberately quiet: the overwhelmingly common case is an
+ // instance with no IRC configured at all, and it should say nothing.
+ try {
+ if (await getIrcEnabled()) {
+ console.log(
+ `[IRC] Announce enabled — reconciling every ${LEASE_RENEW_MS / 1000}s`
+ );
+ }
+ } catch {
+ // Settings unreadable at boot: the tick will say so if it persists.
+ }
+
+ const tick = async () => {
+ try {
+ await reconcile();
+ } catch (err) {
+ console.warn('[IRC] reconcile failed:', (err as Error).message);
+ }
+ };
+
+ void tick();
+ const timer = setInterval(tick, LEASE_RENEW_MS);
+ timer.unref?.();
+
+ // Release the lease on the way out rather than letting it expire: a rolling
+ // restart otherwise leaves the channel unattended for up to a full TTL.
+ nitro.hooks.hook('close', async () => {
+ clearInterval(timer);
+ await shutdownAnnouncer();
+ });
+});
diff --git a/apps/api/routes/api/admin/irc/index.get.ts b/apps/api/routes/api/admin/irc/index.get.ts
new file mode 100644
index 00000000..eef209b5
--- /dev/null
+++ b/apps/api/routes/api/admin/irc/index.get.ts
@@ -0,0 +1,40 @@
+/**
+ * GET /api/admin/irc
+ *
+ * The announce bot's configuration and what it is currently doing.
+ *
+ * The secrets come back blank with a `has…` flag beside each, the same contract
+ * the notification channels use: an admin needs to know a password is set
+ * without the console being a place to read it back.
+ *
+ * `status.leader` is worth surfacing rather than hiding. In a multi-instance
+ * deployment exactly one API process holds the connection, so an operator
+ * looking at a console served by another one would otherwise see `idle` and
+ * conclude the bot is down.
+ */
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { ircStatus } from '~~/utils/irc/announcer';
+import { ANNOUNCE_TOKENS, DEFAULT_ANNOUNCE_TEMPLATE, announcePattern } from '~~/utils/irc/format';
+import { getIrcConfig, getIrcEnabled, redactIrcConfig } from '~~/utils/irc/settings';
+
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+
+ const [enabled, config] = await Promise.all([getIrcEnabled(), getIrcConfig()]);
+
+ return {
+ enabled,
+ config: redactIrcConfig(config),
+ status: ircStatus(),
+ /** So the form can explain the template rather than link to a doc page. */
+ tokens: Object.entries(ANNOUNCE_TOKENS).map(([name, def]) => ({
+ name,
+ variable: def.variable,
+ describes: def.describes,
+ })),
+ defaultTemplate: DEFAULT_ANNOUNCE_TEMPLATE,
+ /** The regex members' clients will use — shown so an operator editing the
+ * template can see what it does to the definition they are handing out. */
+ pattern: announcePattern(config.template).pattern,
+ };
+});
diff --git a/apps/api/routes/api/admin/irc/index.put.ts b/apps/api/routes/api/admin/irc/index.put.ts
new file mode 100644
index 00000000..6d6a49ab
--- /dev/null
+++ b/apps/api/routes/api/admin/irc/index.put.ts
@@ -0,0 +1,128 @@
+/**
+ * PUT /api/admin/irc
+ *
+ * Save the announce configuration, then reconcile the connection so the change
+ * is visible before the admin's page has finished reloading — rather than at
+ * the next plugin tick, which would make a correct setting look broken for
+ * thirty seconds.
+ *
+ * ## A blank secret means "keep the stored one"
+ *
+ * The GET blanks the three credentials, so a form that round-tripped what it
+ * received would erase them on every save. Blank therefore means unchanged, and
+ * clearing one is an explicit `null` — the same contract the notification
+ * channels use, and the reason it is a contract at all is that the alternative
+ * silently disconnects a working bot the first time an admin edits the channel
+ * name.
+ *
+ * ## The template is validated by being used
+ *
+ * An operator can write anything in it. What is checked is the thing that
+ * matters: that the pattern derived from it reads back the line rendered from
+ * it. A template that fails that would produce a definition every member's
+ * autobrr silently ignores, so it is refused here with the sample line in the
+ * message.
+ */
+import { z } from 'zod/v4';
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import { validateBody } from '~~/utils/schemas';
+import { reconcile } from '~~/utils/irc/announcer';
+import { SAMPLE_FIELDS } from '~~/utils/irc/autobrr';
+import {
+ announcePattern,
+ renderAnnounce,
+ templateTokens,
+ toJsRegExp,
+} from '~~/utils/irc/format';
+import {
+ getIrcConfig,
+ redactIrcConfig,
+ setIrcConfig,
+ setIrcEnabled,
+ type IrcAnnounceConfig,
+} from '~~/utils/irc/settings';
+
+/** `null` clears a secret, an absent/empty string keeps it. */
+const secret = z.union([z.string(), z.null()]).optional();
+
+const bodySchema = z.object({
+ enabled: z.boolean(),
+ host: z.string().trim().max(253),
+ port: z.coerce.number().int().min(1).max(65535),
+ tls: z.boolean(),
+ nick: z
+ .string()
+ .trim()
+ .max(30)
+ // RFC 2812's nick grammar, minus the leading-digit case no network accepts.
+ // Pipes and brackets are in it, which matters here: `name|autodl` is the
+ // convention announce channels require.
+ .regex(/^[A-Za-z\[\]\\`_^{|}][A-Za-z0-9\[\]\\`_^{|}-]*$/, 'Not a valid IRC nick'),
+ realname: z.string().trim().max(60),
+ serverPassword: secret,
+ saslUser: z.string().trim().max(60),
+ saslPassword: secret,
+ perform: z.array(z.string().trim().max(400)).max(8),
+ channel: z.string().trim().max(60).regex(/^[#&][^\s,]+$/, 'Not a valid channel'),
+ channelKey: secret,
+ template: z.string().trim().min(1).max(500),
+ siteUrl: z.string().trim().max(300),
+ announceAdult: z.boolean(),
+});
+
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ await rateLimit(event, RATE_LIMITS.mutation);
+
+ const body = await validateBody(event, bodySchema);
+ const stored = await getIrcConfig();
+
+ // Round-trip the template before anything is written: a stored template that
+ // its own regex cannot read is a broken definition handed to every member.
+ const sample = renderAnnounce(body.template, SAMPLE_FIELDS);
+ const { pattern } = announcePattern(body.template);
+ const match = toJsRegExp(pattern).exec(sample);
+ if (!match) {
+ throw createError({
+ statusCode: 400,
+ message: `That template does not parse back. Rendered: ${sample}`,
+ });
+ }
+ // A template that mentions no fields would announce a constant line, which
+ // parses perfectly and tells a client nothing.
+ if (!templateTokens(body.template).includes('name')) {
+ throw createError({
+ statusCode: 400,
+ message: 'The template has to include {name} — a client cannot filter on a line with no release in it.',
+ });
+ }
+
+ const keep = (next: string | null | undefined, current: string): string =>
+ next === null ? '' : next ? next : current;
+
+ const config: IrcAnnounceConfig = {
+ host: body.host,
+ port: body.port,
+ tls: body.tls,
+ nick: body.nick,
+ realname: body.realname,
+ serverPassword: keep(body.serverPassword, stored.serverPassword),
+ saslUser: body.saslUser,
+ saslPassword: keep(body.saslPassword, stored.saslPassword),
+ perform: body.perform.filter((line) => line.length > 0),
+ channel: body.channel,
+ channelKey: keep(body.channelKey, stored.channelKey),
+ template: body.template,
+ siteUrl: body.siteUrl.replace(/\/+$/, ''),
+ announceAdult: body.announceAdult,
+ };
+
+ await setIrcConfig(config);
+ await setIrcEnabled(body.enabled);
+ // Fire-and-forget: a server that will not answer must not hang the save that
+ // an operator needs to correct it.
+ void reconcile();
+
+ return { config: redactIrcConfig(config), enabled: body.enabled, sample };
+});
diff --git a/apps/api/routes/api/admin/irc/test.post.ts b/apps/api/routes/api/admin/irc/test.post.ts
new file mode 100644
index 00000000..999e0691
--- /dev/null
+++ b/apps/api/routes/api/admin/irc/test.post.ts
@@ -0,0 +1,39 @@
+/**
+ * POST /api/admin/irc/test
+ *
+ * Say one line in the channel, so an operator can see the bot works without
+ * waiting for somebody to upload something.
+ *
+ * The line is fixed text plus the admin's name. It is not operator-supplied,
+ * which is the point: a route that let staff put arbitrary text in a channel
+ * would be a broadcast surface with an audit entry and no rate limit worth the
+ * name. Announcing is what the bot is for.
+ *
+ * Refuses rather than queues when the bot is not connected. Queuing would
+ * return "sent" for a line that leaves at the next reconnection, minutes later,
+ * to an operator who is trying to find out whether the connection works.
+ */
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import { ircStatus, saySomething } from '~~/utils/irc/announcer';
+
+export default defineEventHandler(async (event) => {
+ const { user } = await requireAdminSession(event);
+ await rateLimit(event, RATE_LIMITS.mutation);
+
+ const status = ircStatus();
+ if (status.state !== 'ready') {
+ throw createError({
+ statusCode: 409,
+ message: status.leader
+ ? `The bot is not in the channel (${status.state}${status.lastError ? `: ${status.lastError}` : ''}).`
+ : 'Another instance holds the connection; ask it, or check the status here in a moment.',
+ });
+ }
+
+ const line = `Test line from ${user.username} — the announce bot is connected.`;
+ if (!saySomething(line)) {
+ throw createError({ statusCode: 409, message: 'The bot is not connected here.' });
+ }
+ return { sent: true, line };
+});
diff --git a/apps/api/routes/api/irc/autobrr.yml.get.ts b/apps/api/routes/api/irc/autobrr.yml.get.ts
new file mode 100644
index 00000000..51bfad7e
--- /dev/null
+++ b/apps/api/routes/api/irc/autobrr.yml.get.ts
@@ -0,0 +1,59 @@
+/**
+ * GET /api/irc/autobrr.yml
+ *
+ * The autobrr indexer definition for this instance, generated from the announce
+ * template in force. See `utils/irc/autobrr.ts` for why it is generated rather
+ * than shipped.
+ *
+ * Members only, like the Prowlarr definition: the categories are not secret but
+ * the file names the instance, its address and its IRC network, and an
+ * invite-only tracker publishes none of those.
+ *
+ * 404 when announcing is off — an empty definition would be worse than no
+ * definition, because a member would configure it and then wait for lines that
+ * are never coming.
+ */
+import { requireAuthSession } from '~~/utils/adminAuth';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import { getSiteName } from '~~/utils/server';
+import { autobrrDefinition } from '~~/utils/irc/autobrr';
+import { getIrcConfig, getIrcEnabled, ircConfigReady } from '~~/utils/irc/settings';
+
+export default defineEventHandler(async (event) => {
+ await requireAuthSession(event);
+ await rateLimit(event, RATE_LIMITS.public);
+
+ const [enabled, config] = await Promise.all([getIrcEnabled(), getIrcConfig()]);
+ if (!enabled || !ircConfigReady(config)) {
+ throw createError({
+ statusCode: 404,
+ message: 'This instance does not announce to an IRC channel.',
+ });
+ }
+
+ const siteName = await getSiteName();
+ const yaml = autobrrDefinition({
+ siteName,
+ // The host the member reached, for the same reason the Cardigann route uses
+ // it: an env var set when the container was built is not where the member is.
+ baseUrl: getRequestURL(event).origin,
+ irc: {
+ host: config.host,
+ port: config.port,
+ tls: config.tls,
+ channel: config.channel,
+ announcer: config.nick,
+ keyed: !!config.channelKey,
+ invited: config.perform.length > 0,
+ },
+ template: config.template,
+ });
+
+ setHeader(event, 'Content-Type', 'application/yaml; charset=utf-8');
+ setHeader(
+ event,
+ 'Content-Disposition',
+ `attachment; filename="${siteName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.yaml"`
+ );
+ return yaml;
+});
diff --git a/apps/api/routes/api/torrents/index.post.ts b/apps/api/routes/api/torrents/index.post.ts
index 42cbb3d0..4e71060f 100644
--- a/apps/api/routes/api/torrents/index.post.ts
+++ b/apps/api/routes/api/torrents/index.post.ts
@@ -14,6 +14,7 @@ import { getUploadRules, evaluateUpload } from '~~/utils/uploadRules';
import { notifyMany, listStaffRecipients } from '~~/utils/notify';
import { fanoutFollowedUserUpload } from '~~/utils/followerFanout';
import { fanoutSavedSearchMatches } from '~~/utils/savedSearchFanout';
+import { announceRelease } from '~~/utils/irc/announcer';
/**
@@ -552,6 +553,20 @@ export default defineEventHandler(async (event) => {
tvdbId,
uploaderId: user.id,
});
+ // And the IRC channel. A fresh upload carries no per-torrent buff of its
+ // own — those are a moderation action — so the three multiplier fields are
+ // null and the announcer folds in whatever site-wide event is running.
+ void announceRelease({
+ id,
+ infoHash,
+ name,
+ size: totalSize,
+ categoryId: categoryId || null,
+ uploaderId: user.id,
+ downloadMultiplier: null,
+ uploadMultiplier: null,
+ multipliersUntil: null,
+ });
}
return {
diff --git a/apps/api/test/ircAnnounce.test.ts b/apps/api/test/ircAnnounce.test.ts
new file mode 100644
index 00000000..62a0d3bc
--- /dev/null
+++ b/apps/api/test/ircAnnounce.test.ts
@@ -0,0 +1,327 @@
+import { describe, it, expect } from 'vitest';
+import {
+ ANNOUNCE_TOKENS,
+ DEFAULT_ANNOUNCE_TEMPLATE,
+ announcePattern,
+ freeleechPercent,
+ humanSize,
+ renderAnnounce,
+ sanitiseValue,
+ templateTokens,
+ toJsRegExp,
+ type AnnounceFields,
+} from '../utils/irc/format';
+import { SAMPLE_FIELDS, autobrrDefinition, slugifyId } from '../utils/irc/autobrr';
+
+/**
+ * The announce format, tested the only way that means anything: by parsing what
+ * it emits with the pattern we hand to members.
+ *
+ * A tracker's announce format is a public contract with software nobody here
+ * controls. The failure mode is not an exception — it is a channel that keeps
+ * talking, an autobrr that keeps not matching, and members who conclude the
+ * tracker is broken. So the round trip is the test, and it runs against the
+ * REAL pattern from `announcePattern`, converted to JavaScript syntax rather
+ * than rewritten in it.
+ */
+
+const parse = (template: string, line: string) =>
+ toJsRegExp(announcePattern(template).pattern).exec(line)?.groups;
+
+describe('the default format round-trips', () => {
+ it('parses every field back out of a rendered line', () => {
+ const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, SAMPLE_FIELDS);
+ const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line);
+ expect(groups).toBeDefined();
+ expect(groups).toMatchObject({
+ category: 'Movies',
+ name: 'Example.Release.2026.1080p.BluRay.x264-GROUP',
+ size: '14.62 GiB',
+ freeleechPercent: '100%',
+ uploadFactor: '2',
+ tags: '1080p, bluray, x264',
+ uploader: 'example',
+ infoHash: '0123456789abcdef0123456789abcdef01234567',
+ });
+ });
+
+ it('survives the values that break naive patterns', () => {
+ const awkward: AnnounceFields = {
+ ...SAMPLE_FIELDS,
+ // Brackets, colons, a dash run, unicode, and a name long enough to worry
+ // about — all of which appear in real release names.
+ name: 'Some.Show.S01E01.[HDR10+].Ünïcødé.-.MULTi.VFF.2160p.x265-Grp',
+ category: 'TV/UHD',
+ tags: 'hdr10+, x265, multi',
+ uploader: 'user|autodl',
+ };
+ const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, awkward);
+ const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line);
+ expect(groups?.name).toBe(awkward.name);
+ expect(groups?.category).toBe('TV/UHD');
+ expect(groups?.uploader).toBe('user|autodl');
+ expect(groups?.tags).toBe('hdr10+, x265, multi');
+ });
+
+ it('parses a release with nothing on it', () => {
+ // The empty case is a real one — an uncategorised, untagged, anonymous
+ // upload with no buff — and it is where an optional group would go wrong.
+ const bare: AnnounceFields = {
+ ...SAMPLE_FIELDS,
+ category: 'uncategorised',
+ freeleechPercent: '0%',
+ uploadFactor: '1',
+ tags: '-',
+ uploader: 'anonymous',
+ };
+ const groups = parse(
+ DEFAULT_ANNOUNCE_TEMPLATE,
+ renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, bare)
+ );
+ expect(groups).toMatchObject({
+ category: 'uncategorised',
+ tags: '-',
+ uploader: 'anonymous',
+ freeleechPercent: '0%',
+ });
+ });
+});
+
+describe('an operator can change the format', () => {
+ it('derives a pattern that reads a reordered template', () => {
+ const custom = '[{category}] {name} | {size} | {tags} | {url}';
+ const groups = parse(custom, renderAnnounce(custom, SAMPLE_FIELDS));
+ expect(groups?.name).toBe(SAMPLE_FIELDS.name);
+ expect(groups?.size).toBe('14.62 GiB');
+ expect(groups?.tags).toBe('1080p, bluray, x264');
+ });
+
+ it('handles a template that repeats a token', () => {
+ // Go's regexp rejects a duplicate group name, so the second use has to be a
+ // back-reference — and an operator naming the hash twice is reasonable.
+ const custom = 'NEW {name} :: {infoHash} :: {url} :: {infoHash}';
+ const { pattern } = announcePattern(custom);
+ expect(pattern).toContain('(?P');
+ expect(pattern).toContain('(?P=infoHash)');
+ const groups = parse(custom, renderAnnounce(custom, SAMPLE_FIELDS));
+ expect(groups?.infoHash).toBe(SAMPLE_FIELDS.infoHash);
+ });
+
+ it('treats an unknown token as literal text, in both directions', () => {
+ // A typo has to render and parse consistently, or the line stops matching
+ // for a reason nobody can see.
+ const custom = 'NEW {nmae} {name} :: {url}';
+ const line = renderAnnounce(custom, SAMPLE_FIELDS);
+ expect(line).toContain('{nmae}');
+ expect(parse(custom, line)?.name).toBe(SAMPLE_FIELDS.name);
+ });
+
+ it('escapes regex metacharacters in the literal parts', () => {
+ const custom = 'NEW (release) [{category}] {name} $$ {url}';
+ expect(parse(custom, renderAnnounce(custom, SAMPLE_FIELDS))?.category).toBe(
+ 'Movies'
+ );
+ });
+});
+
+describe('sanitising, which is the injection boundary', () => {
+ it('strips the frame delimiters out of a release name', () => {
+ // A name carrying CRLF would not corrupt the line — it would END it, and
+ // the rest would be a command the bot appears to have sent.
+ const evil = 'Nice.Release\r\nPRIVMSG #ops :give me ops\r\n';
+ const cleaned = sanitiseValue(evil);
+ expect(cleaned).not.toMatch(/[\r\n]/);
+ expect(cleaned).toBe('Nice.Release PRIVMSG #ops :give me ops');
+ });
+
+ it('renders an injected newline into one harmless line', () => {
+ const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, {
+ ...SAMPLE_FIELDS,
+ name: 'X\r\nQUIT',
+ });
+ expect(line.split('\n')).toHaveLength(1);
+ expect(line).not.toContain('\r');
+ });
+
+ it('drops the colour codes and control bytes', () => {
+ // U+0003 is mIRC colour, U+0002 bold, U+000F reset. Clients render them,
+ // a parser does not, and neither belongs in a machine-readable line.
+ expect(sanitiseValue('\u000304red \u0002text\u000f')).toBe('04red text');
+ });
+
+ it('cannot invent a field by carrying the separator', () => {
+ const groups = parse(
+ DEFAULT_ANNOUNCE_TEMPLATE,
+ renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, {
+ ...SAMPLE_FIELDS,
+ name: 'Release :: 999 GiB :: FL 100%',
+ })
+ );
+ // The name keeps its text with the separator neutralised, and the real size
+ // field is still the real one.
+ expect(groups?.size).toBe('14.62 GiB');
+ expect(groups?.name).toContain('Release - 999 GiB - FL 100%');
+ });
+
+ it('never renders an empty field', () => {
+ // An empty value would collapse two separators into one and shift every
+ // field after it.
+ expect(sanitiseValue(' ')).toBe('-');
+ const groups = parse(
+ DEFAULT_ANNOUNCE_TEMPLATE,
+ renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { ...SAMPLE_FIELDS, tags: '' })
+ );
+ expect(groups?.tags).toBe('-');
+ });
+
+ it('truncates by bytes and stays on a character boundary', () => {
+ const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, {
+ ...SAMPLE_FIELDS,
+ name: 'é'.repeat(500),
+ });
+ expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(403);
+ expect(line).not.toContain('�');
+ });
+});
+
+describe('the figures in the line', () => {
+ it('reads a download multiplier as a freeleech percentage', () => {
+ expect(freeleechPercent(1)).toBe('0%');
+ expect(freeleechPercent(0)).toBe('100%');
+ expect(freeleechPercent(0.5)).toBe('50%');
+ // Out-of-range values are clamped rather than printed: the token's pattern
+ // accepts three digits, and `-40%` would not parse at all.
+ expect(freeleechPercent(-1)).toBe('100%');
+ expect(freeleechPercent(9)).toBe('0%');
+ });
+
+ it('formats sizes the way a client shows them', () => {
+ expect(humanSize(0)).toBe('0 B');
+ expect(humanSize(1023)).toBe('1023 B');
+ expect(humanSize(1024)).toBe('1.00 KiB');
+ expect(humanSize(15_700_000_000)).toBe('14.62 GiB');
+ });
+
+ it('formats every size the size pattern accepts', () => {
+ const pattern = new RegExp(`^${ANNOUNCE_TOKENS.size!.pattern}$`);
+ for (const bytes of [0, 1, 999, 1024, 1_048_576, 15_700_000_000, 2 ** 50]) {
+ expect(pattern.test(humanSize(bytes))).toBe(true);
+ }
+ });
+});
+
+describe('the generated autobrr definition', () => {
+ const definition = autobrrDefinition({
+ siteName: 'Example Tracker',
+ baseUrl: 'https://tracker.example.com',
+ irc: {
+ host: 'irc.example.com',
+ port: 6697,
+ tls: true,
+ channel: '#announce',
+ announcer: 'trackarr',
+ keyed: true,
+ invited: true,
+ },
+ template: DEFAULT_ANNOUNCE_TEMPLATE,
+ });
+
+ it('carries the pattern that reads this instance format', () => {
+ const { pattern } = announcePattern(DEFAULT_ANNOUNCE_TEMPLATE);
+ // Single-quoted YAML, so the only escaping is a doubled quote — and the
+ // pattern has none. This is the assertion that the file we hand out is the
+ // pattern we tested above, rather than something adjacent to it.
+ expect(definition).toContain(`pattern: '${pattern}'`);
+ });
+
+ it('carries a self-test line that its own pattern parses', () => {
+ const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, SAMPLE_FIELDS);
+ expect(definition).toContain(line);
+ expect(parse(DEFAULT_ANNOUNCE_TEMPLATE, line)).toBeDefined();
+ });
+
+ it('expects each mapped variable, and nothing unmapped', () => {
+ for (const token of templateTokens(DEFAULT_ANNOUNCE_TEMPLATE)) {
+ const def = ANNOUNCE_TOKENS[token]!;
+ if (def.variable) expect(definition).toContain(`${token}: `);
+ }
+ // `url` and `uploadFactor` are printed for people and mapped to nothing;
+ // claiming them in `expect` would put autobrr's own test suite at odds with
+ // its behaviour.
+ expect(definition).not.toContain('url: "https://tracker.example.com/torrents/');
+ });
+
+ it('asks for the API key and never for the passkey', () => {
+ expect(definition).toContain('name: apikey');
+ expect(definition).toContain('{{ .apikey }}');
+ // The help text names the passkey to warn a member off it, so what must be
+ // absent is the SUBSTITUTION — no URL in this file may carry the credential
+ // that can announce on somebody's behalf.
+ expect(definition).not.toContain('{{ .passkey }}');
+ expect(definition).not.toMatch(/passkey=/);
+ });
+
+ it('points the download URL at the endpoint that takes a read key', () => {
+ expect(definition).toContain(
+ 'downloadurl: "/api/torznab/download?id={{ .torrentId }}&apikey={{ .apikey }}"'
+ );
+ expect(definition).toContain('infourl: "/torrents/{{ .torrentId }}"');
+ });
+
+ it('names the network and the announcer autobrr has to match', () => {
+ expect(definition).toContain('server: "irc.example.com"');
+ expect(definition).toContain('port: 6697');
+ expect(definition).toContain('tls: true');
+ expect(definition).toContain('- "trackarr"');
+ expect(definition).toContain('name: "#announce"');
+ });
+
+ it('offers the invite field only when the channel is not open', () => {
+ const open = autobrrDefinition({
+ siteName: 'Example Tracker',
+ baseUrl: 'https://tracker.example.com',
+ irc: {
+ host: 'irc.example.com',
+ port: 6667,
+ tls: false,
+ channel: '#announce',
+ announcer: 'bot',
+ keyed: false,
+ invited: false,
+ },
+ template: DEFAULT_ANNOUNCE_TEMPLATE,
+ });
+ expect(definition).toContain('invite_command');
+ expect(open).not.toContain('invite_command');
+ });
+
+ it('derives an identifier that will not collide with a shipped one', () => {
+ expect(slugifyId('Example Tracker')).toBe('example-tracker');
+ // `Ü` and `é` decompose and lose their marks; `ø` does not — it is a letter
+ // in its own right, so it becomes a separator like any other non-ASCII
+ // character. Pinned because the alternative is meeting it in a filename.
+ expect(slugifyId('Ünïcødé Trackér!')).toBe('unic-de-tracker');
+ expect(slugifyId('///')).toBe('trackarr');
+ });
+
+ it('re-derives itself when the operator edits the template', () => {
+ const custom = 'DROP {name} [{category}] {size} {url}';
+ const other = autobrrDefinition({
+ siteName: 'Example Tracker',
+ baseUrl: 'https://tracker.example.com',
+ irc: {
+ host: 'irc.example.com',
+ port: 6697,
+ tls: true,
+ channel: '#announce',
+ announcer: 'trackarr',
+ keyed: false,
+ invited: false,
+ },
+ template: custom,
+ });
+ expect(other).toContain(`pattern: '${announcePattern(custom).pattern}'`);
+ // And the definition no longer claims fields the new template does not emit.
+ expect(other).not.toContain('freeleechPercent: ');
+ });
+});
diff --git a/apps/api/utils/irc/announcer.ts b/apps/api/utils/irc/announcer.ts
new file mode 100644
index 00000000..7b218331
--- /dev/null
+++ b/apps/api/utils/irc/announcer.ts
@@ -0,0 +1,378 @@
+/**
+ * The site's one announce bot: what holds the connection, and what decides
+ * whether a release is announced.
+ *
+ * ## One connection for the whole fleet
+ *
+ * Every other periodic job here takes a Redis lock per tick and does its sweep.
+ * A connection cannot work that way — it is held, not performed — so this takes
+ * the same lock as a LEASE: whichever instance wins connects and renews every
+ * fifteen seconds; the others do nothing. If the leader dies, the key expires
+ * and the next instance to tick takes over.
+ *
+ * The reason this matters is not efficiency. Three instances, three
+ * connections, three bots in the channel, and every release announced three
+ * times — to autobrr, which would then grab it three times.
+ *
+ * ## Announcing is not on the upload's path
+ *
+ * `announceRelease` returns immediately. It resolves what it needs from the
+ * database, hands a line to the queue and stops; a channel that is down, a
+ * server that is throttling, an operator who mistyped the host — none of it can
+ * slow down or fail an upload. The failure mode of an announce is a missing
+ * line, and that has to stay true.
+ *
+ * ## What is never announced
+ *
+ * A release that is not accepted and live; an adult release unless the operator
+ * turned that on; the name of a member who uploads anonymously. The first is
+ * what the channel is for, the second is the operator's decision, and the third
+ * is the same rule the catalogue, the feeds and the federated catalogue already
+ * apply — this is simply one more surface that must not be the exception.
+ */
+import { and, eq } from 'drizzle-orm';
+import { db, schema } from '@trackarr/db';
+import { redis } from '~~/utils/server';
+import { adultCategoryIds } from '~~/utils/adultContent';
+import { concealsUploader } from '~~/utils/uploaderVisibility';
+import { IDENTITY, volumeFactors } from '~~/utils/torrentBuffs';
+import { getActiveSnapshot } from '~~/utils/bonusEvents';
+import { getFederationConfig } from '~~/utils/federation/config';
+import { IrcClient, type IrcStatus } from './client';
+import {
+ freeleechPercent,
+ humanSize,
+ renderAnnounce,
+ type AnnounceFields,
+} from './format';
+import {
+ getIrcConfig,
+ getIrcEnabled,
+ ircConfigReady,
+ type IrcAnnounceConfig,
+} from './settings';
+
+const LEASE_KEY = 'irc_announce:leader';
+const LEASE_TTL_S = 45;
+export const LEASE_RENEW_MS = 15_000;
+
+let client: IrcClient | null = null;
+let leaseOwner: string | null = null;
+let activeSignature = '';
+let lastError: string | null = null;
+/** Kept for the admin console when this instance is not the leader. */
+let lastStatus: IrcStatus | null = null;
+
+/** Changing any of these means the connection has to be rebuilt. */
+function signatureOf(config: IrcAnnounceConfig): string {
+ return JSON.stringify([
+ config.host,
+ config.port,
+ config.tls,
+ config.nick,
+ config.serverPassword,
+ config.saslUser,
+ config.saslPassword,
+ config.perform,
+ config.channel,
+ config.channelKey,
+ ]);
+}
+
+function owner(): string {
+ return `${process.pid}:${process.env.HOSTNAME || 'local'}`;
+}
+
+/**
+ * Take or renew the lease.
+ *
+ * `SET NX` to take it; a compare-and-renew to keep it. The renew is a Lua
+ * script because "check the owner then extend" as two commands is the classic
+ * way to extend a lock another instance has already taken.
+ */
+const RENEW_SCRIPT = `
+if redis.call('get', KEYS[1]) == ARGV[1] then
+ return redis.call('expire', KEYS[1], ARGV[2])
+end
+return 0
+`;
+
+async function holdLease(): Promise {
+ const me = owner();
+ if (leaseOwner === me) {
+ const kept = await redis.eval(RENEW_SCRIPT, 1, LEASE_KEY, me, String(LEASE_TTL_S));
+ if (kept === 1) return true;
+ // Lost it — the process was paused long enough for the key to expire and
+ // somebody else took over. Drop the connection rather than run a second bot.
+ leaseOwner = null;
+ stop('lost the lease');
+ return false;
+ }
+ const taken = await redis.set(LEASE_KEY, me, 'EX', LEASE_TTL_S, 'NX');
+ if (taken !== 'OK') return false;
+ leaseOwner = me;
+ return true;
+}
+
+async function releaseLease(): Promise {
+ if (leaseOwner !== owner()) return;
+ const me = leaseOwner;
+ leaseOwner = null;
+ try {
+ // Same compare-then-act problem in the other direction: only delete a key
+ // that is still ours.
+ await redis.eval(
+ `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0`,
+ 1,
+ LEASE_KEY,
+ me
+ );
+ } catch {
+ // The lease expires by itself; a failure here costs at most one TTL of
+ // nobody announcing.
+ }
+}
+
+function stop(reason: string): void {
+ if (client) {
+ lastStatus = client.status();
+ client.close(reason);
+ client = null;
+ }
+ activeSignature = '';
+}
+
+/**
+ * Bring the connection in line with the settings. Called by the plugin on a
+ * timer, and by the admin routes after a save so a change lands immediately.
+ */
+export async function reconcile(): Promise {
+ let enabled = false;
+ let config: IrcAnnounceConfig | null = null;
+ try {
+ enabled = await getIrcEnabled();
+ if (enabled) config = await getIrcConfig();
+ } catch (err) {
+ lastError = (err as Error).message;
+ stop('configuration unreadable');
+ return;
+ }
+
+ if (!enabled || !config || !ircConfigReady(config)) {
+ if (client) stop('announcing disabled');
+ await releaseLease();
+ return;
+ }
+
+ if (!(await holdLease())) return;
+
+ const signature = signatureOf(config);
+ if (client && signature === activeSignature) {
+ const status = client.status();
+ lastStatus = status;
+ // A client that has given up is not restarted here on purpose: `error` is
+ // terminal for one socket, and the next tick builds a fresh one. That makes
+ // the retry cadence the plugin's interval — a bounded, visible backoff
+ // rather than a reconnect loop inside the client.
+ if (status.state === 'error') {
+ lastError = status.lastError;
+ stop('retrying');
+ }
+ return;
+ }
+
+ stop('reconfigured');
+ activeSignature = signature;
+ lastError = null;
+ client = new IrcClient(
+ {
+ host: config.host,
+ port: config.port,
+ tls: config.tls,
+ nick: config.nick,
+ realname: config.realname,
+ serverPassword: config.serverPassword || undefined,
+ saslUser: config.saslUser || undefined,
+ saslPassword: config.saslPassword || undefined,
+ perform: config.perform,
+ channel: config.channel,
+ channelKey: config.channelKey || undefined,
+ },
+ {
+ onState: (status) => {
+ lastStatus = status;
+ if (status.state === 'error') lastError = status.lastError;
+ if (status.state === 'ready') {
+ console.log(
+ `[IRC] Announcing in ${config!.channel} on ${config!.host} as ${status.nick}`
+ );
+ }
+ },
+ }
+ );
+ client.connect();
+}
+
+/** Everything the admin console shows about the bot. */
+export function ircStatus(): IrcStatus & { leader: boolean } {
+ const status = client?.status() ?? lastStatus;
+ return {
+ state: status?.state ?? 'idle',
+ nick: status?.nick ?? '',
+ since: status?.since ?? null,
+ lastError: status?.lastError ?? lastError,
+ queued: status?.queued ?? 0,
+ sent: status?.sent ?? 0,
+ dropped: status?.dropped ?? 0,
+ leader: leaseOwner === owner(),
+ };
+}
+
+/** Say one arbitrary line — the admin console's test button, and nothing else. */
+export function saySomething(line: string): boolean {
+ if (!client) return false;
+ client.say(line);
+ return true;
+}
+
+export async function shutdownAnnouncer(): Promise {
+ stop('shutting down');
+ await releaseLease();
+}
+
+/**
+ * Where a release page lives, as an absolute address when we have one.
+ *
+ * The bot has no request to take an origin from, so: what the operator typed,
+ * then the federation identity's public URL for the instances that already
+ * declared one, then nothing — and "nothing" yields a path rather than a
+ * guessed hostname. A path is honest and still useful; a wrong hostname sends
+ * every member of the channel somewhere else.
+ */
+async function siteBase(config: IrcAnnounceConfig): Promise {
+ if (config.siteUrl) return config.siteUrl.replace(/\/+$/, '');
+ try {
+ const federation = await getFederationConfig();
+ if (federation?.publicUrl) return federation.publicUrl.replace(/\/+$/, '');
+ } catch {
+ // Federation is optional and may not be configured at all.
+ }
+ return '';
+}
+
+export interface AnnounceCandidate {
+ id: string;
+ infoHash: string;
+ name: string;
+ size: number;
+ categoryId: string | null;
+ uploaderId: string | null;
+ downloadMultiplier: number | null;
+ uploadMultiplier: number | null;
+ multipliersUntil: Date | null;
+}
+
+/**
+ * Announce one accepted release. Fire-and-forget by contract.
+ *
+ * The caller has the torrent it just accepted, so nothing here re-reads it. The
+ * three extra lookups are the ones the caller cannot know: the category name,
+ * the tags, and whether the uploader is anonymous.
+ */
+export async function announceRelease(
+ torrent: AnnounceCandidate
+): Promise {
+ if (!client) return;
+
+ let config: IrcAnnounceConfig;
+ try {
+ if (!(await getIrcEnabled())) return;
+ config = await getIrcConfig();
+ } catch {
+ return;
+ }
+
+ // The adult gate, before anything else is spent on the release.
+ if (!config.announceAdult && torrent.categoryId) {
+ const adult = await adultCategoryIds();
+ if (adult.includes(torrent.categoryId)) return;
+ }
+
+ const [category] = torrent.categoryId
+ ? await db
+ .select({ name: schema.categories.name })
+ .from(schema.categories)
+ .where(eq(schema.categories.id, torrent.categoryId))
+ .limit(1)
+ : [];
+
+ const tagRows = await db
+ .select({ name: schema.tags.name })
+ .from(schema.torrentTags)
+ .innerJoin(schema.tags, eq(schema.tags.id, schema.torrentTags.tagId))
+ .where(eq(schema.torrentTags.torrentId, torrent.id));
+
+ let uploader = 'anonymous';
+ if (torrent.uploaderId) {
+ const [row] = await db
+ .select({
+ username: schema.users.username,
+ anonymousUploads: schema.users.anonymousUploads,
+ })
+ .from(schema.users)
+ .where(
+ and(
+ eq(schema.users.id, torrent.uploaderId),
+ eq(schema.users.isBanned, false)
+ )
+ )
+ .limit(1);
+ // `concealsUploader` rather than a fresh comparison: one definition of who
+ // may be named, shared with the detail page, the feeds and federation.
+ if (row && !concealsUploader(row.anonymousUploads)) uploader = row.username;
+ }
+
+ // The same computation the Torznab feed publishes, from the same function:
+ // the better of the site-wide event and the torrent's own buff, with an
+ // expired `multipliers_until` already neutralised. Announcing a figure the
+ // feed contradicts would be worse than announcing none — a member racing on
+ // the channel and a member polling the feed have to see one tracker.
+ const activeEvent = await getActiveSnapshot();
+ const siteWide = activeEvent
+ ? {
+ download: activeEvent.downloadMultiplier,
+ upload: activeEvent.uploadMultiplier,
+ }
+ : IDENTITY;
+ const { downloadVolumeFactor, uploadVolumeFactor } = volumeFactors(
+ {
+ // The columns are nullable and the scale is percent: a row with nothing
+ // set means "no buff", which is 100 on both axes rather than 0. Getting
+ // this wrong would announce every release as freeleech.
+ downloadMultiplier: torrent.downloadMultiplier ?? 100,
+ uploadMultiplier: torrent.uploadMultiplier ?? 100,
+ multipliersUntil: torrent.multipliersUntil,
+ },
+ siteWide
+ );
+
+ const site = await siteBase(config);
+ const fields: AnnounceFields = {
+ name: torrent.name,
+ category: category?.name ?? 'uncategorised',
+ size: humanSize(torrent.size),
+ freeleechPercent: freeleechPercent(downloadVolumeFactor),
+ uploadFactor: String(Number(uploadVolumeFactor.toFixed(2))),
+ tags: tagRows.length ? tagRows.map((t) => t.name).join(', ') : '-',
+ uploader,
+ // No key in the URL, which is the industry convention and the only safe
+ // choice: a line in a channel is seen by everybody in it, so a personalised
+ // download link would hand every member the credentials of one. The client
+ // appends its own read key — that is what the generated definition's
+ // `downloadurl` template is for.
+ url: `${site}/torrents/${torrent.infoHash}`,
+ infoHash: torrent.infoHash,
+ };
+
+ client.say(renderAnnounce(config.template, fields));
+}
diff --git a/apps/api/utils/irc/autobrr.ts b/apps/api/utils/irc/autobrr.ts
new file mode 100644
index 00000000..f782e325
--- /dev/null
+++ b/apps/api/utils/irc/autobrr.ts
@@ -0,0 +1,187 @@
+/**
+ * The autobrr indexer definition for this instance, generated.
+ *
+ * Same argument as the Prowlarr definition next door, one step further. That
+ * one is generated because the category map is per instance; this one is
+ * generated because the **announce format is per instance** — it is a template
+ * an operator may edit — so a hand-written definition would be a guess about a
+ * string in somebody else's database.
+ *
+ * The pattern comes from `announcePattern(template)`, which is the same
+ * function the renderer's output is shaped by. There is no second description
+ * of the format anywhere: one template produces both the line and the regex
+ * that reads it, which is why the round-trip test in `test/ircAnnounce.test.ts`
+ * is able to prove they agree for any template rather than for one.
+ *
+ * ## The test line is not decoration
+ *
+ * A definition carries `tests`, and autobrr's own tooling runs them. Emitting a
+ * rendered sample and the values it should yield means the file we hand a member
+ * arrives with a proof that it parses this instance's format — and if an
+ * operator's template is unparseable by its own regex, that shows up in their
+ * autobrr rather than in a silence nobody can explain.
+ */
+import {
+ ANNOUNCE_TOKENS,
+ announcePattern,
+ renderAnnounce,
+ templateTokens,
+ type AnnounceFields,
+} from './format';
+
+/** A release that exercises every field, for the definition's self-test. */
+export const SAMPLE_FIELDS: AnnounceFields = {
+ name: 'Example.Release.2026.1080p.BluRay.x264-GROUP',
+ category: 'Movies',
+ size: '14.62 GiB',
+ freeleechPercent: '100%',
+ uploadFactor: '2',
+ tags: '1080p, bluray, x264',
+ uploader: 'example',
+ url: 'https://tracker.example.com/torrents/0123456789abcdef0123456789abcdef01234567',
+ infoHash: '0123456789abcdef0123456789abcdef01234567',
+};
+
+/** YAML single-quoted scalar — the form that needs no backslash escaping, which
+ * matters when the value IS a regular expression. */
+function sq(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`;
+}
+
+/** YAML double-quoted scalar, for values that carry no backslashes. */
+function dq(value: string): string {
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
+}
+
+export function slugifyId(name: string): string {
+ const slug = name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 40);
+ return slug || 'trackarr';
+}
+
+export interface AutobrrDefinitionInput {
+ siteName: string;
+ /** Where the member reached this instance — the definition's `urls`. */
+ baseUrl: string;
+ /** IRC network details, from the operator's config. */
+ irc: {
+ host: string;
+ port: number;
+ tls: boolean;
+ channel: string;
+ /** The bot's nick, which autobrr matches announcements against. */
+ announcer: string;
+ /** Whether a channel key is in use — the member has to be told to ask. */
+ keyed: boolean;
+ /** True when the operator configured perform lines, which usually means an
+ * invite request the member's own bot will have to make too. */
+ invited: boolean;
+ };
+ template: string;
+}
+
+export function autobrrDefinition(input: AutobrrDefinitionInput): string {
+ const id = slugifyId(input.siteName);
+ const { pattern } = announcePattern(input.template);
+ const tokens = templateTokens(input.template);
+
+ // What the sample line should yield: every mapped token the template uses.
+ // Built from the same table the pattern is, so a token added to the format
+ // appears in the expectations without anybody remembering to add it.
+ const expectations = tokens
+ .map((token) => {
+ const def = ANNOUNCE_TOKENS[token]!;
+ if (!def.variable) return null;
+ const value = SAMPLE_FIELDS[token as keyof AnnounceFields];
+ return ` ${token}: ${dq(String(value))}`;
+ })
+ .filter(Boolean)
+ .join('\n');
+
+ const sampleLine = renderAnnounce(input.template, SAMPLE_FIELDS);
+
+ return `---
+# Generated by ${input.siteName} — do not hand-edit.
+#
+# This definition is produced from the announce template in force on the
+# instance, so it always matches what the channel is actually saying. If the
+# operator changes the format, download it again.
+#
+# Drop this file in autobrr's custom definitions directory, restart autobrr,
+# then add the indexer and paste your API key from ${input.baseUrl}/me.
+version: 2
+name: ${dq(input.siteName)}
+identifier: ${id}
+description: ${dq(`${input.siteName} is a private tracker`)}
+language: en-us
+urls:
+ - ${dq(`${input.baseUrl}/`)}
+privacy: private
+protocol: torrent
+supports:
+ - irc
+ - rss
+
+settings:
+ - name: apikey
+ type: secret
+ required: true
+ label: API key
+ help: ${dq('Settings → Keys → API key. Not your announce passkey: that one can announce on your behalf.')}
+
+irc:
+ network: ${dq(input.siteName)}
+ server: ${dq(input.irc.host)}
+ port: ${input.irc.port}
+ tls: ${input.irc.tls}
+ settings:
+ - name: nick
+ type: text
+ required: true
+ label: Nick
+ help: ${dq('Your IRC nick. Some networks want a bot suffix, e.g. yourname|autodl.')}
+ - name: auth.account
+ type: text
+ required: false
+ label: NickServ account
+ help: ${dq('Only if the network requires you to be identified.')}
+ - name: auth.password
+ type: secret
+ required: false
+ label: NickServ password
+ help: ${dq('Only if the network requires you to be identified.')}
+${
+ input.irc.invited || input.irc.keyed
+ ? ` - name: invite_command
+ type: secret
+ required: false
+ label: Invite command
+ help: ${dq('The channel is not open to everyone. Ask the operator what to send, and to whom.')}
+`
+ : ''
+}
+ channels:
+ - name: ${dq(input.irc.channel)}
+ announcers:
+ - ${dq(input.irc.announcer)}
+ parse:
+ type: single
+ lines:
+ - pattern: ${sq(pattern)}
+ tests:
+ - line: ${sq(sampleLine)}
+ expect:
+${expectations}
+ match:
+ infourl: ${dq('/torrents/{{ .torrentId }}')}
+ # No key travels in the announce line — everyone in the channel sees
+ # it. Your own key is appended here, by autobrr, from the setting
+ # above.
+ downloadurl: ${dq('/api/torznab/download?id={{ .torrentId }}&apikey={{ .apikey }}')}
+`;
+}
diff --git a/apps/api/utils/irc/client.ts b/apps/api/utils/irc/client.ts
new file mode 100644
index 00000000..7095660f
--- /dev/null
+++ b/apps/api/utils/irc/client.ts
@@ -0,0 +1,392 @@
+/**
+ * A minimal IRC client — enough to say one line in one channel, forever.
+ *
+ * ## Why not a library
+ *
+ * Because the job is registration, PING, JOIN and PRIVMSG, and every library
+ * that does those also does DCC, CTCP, channel modes and user tracking. The
+ * whole protocol surface here is under two hundred lines, and the alternative
+ * is a dependency on the network boundary of a private tracker — a place where
+ * "what does this parse and what does it do with it" should be readable in one
+ * sitting.
+ *
+ * ## What it deliberately does not do
+ *
+ * It never reads a command FROM the channel. Nothing an operator or a member
+ * says to the bot makes it do anything: the only inputs are PING (answered) and
+ * the numerics it needs to know it is connected. A bot that took commands from a
+ * channel would be a remote control for the tracker, gated on IRC's idea of
+ * identity — and IRC does not have one.
+ *
+ * It also never joins more than one channel, and never speaks to a user. An
+ * announce bot with a private-message surface is an invitation to social
+ * engineering with no upside.
+ *
+ * ## Flood
+ *
+ * Servers kill clients that talk too fast, and the penalty is a disconnect
+ * mid-burst — which on a tracker means the ten releases a moderator just
+ * accepted are the ten nobody hears about. So writes go through a queue with a
+ * minimum interval, and the queue is bounded: past its cap the OLDEST lines are
+ * dropped, because on an announce channel a stale release is worth less than a
+ * fresh one.
+ */
+import net from 'node:net';
+import tls from 'node:tls';
+
+export interface IrcConfig {
+ host: string;
+ port: number;
+ tls: boolean;
+ /** Sent as-is before registration when set (server password, not NickServ). */
+ serverPassword?: string;
+ nick: string;
+ /** Some networks require a suffix on a bot's nick to let it into #announce. */
+ realname?: string;
+ /** SASL PLAIN. Preferred over NickServ when the network offers it. */
+ saslUser?: string;
+ saslPassword?: string;
+ /** Raw lines sent once, after registration, before JOIN. NickServ identify,
+ * an invite request to a channel bot, whatever the network needs. */
+ perform?: string[];
+ channel: string;
+ channelKey?: string;
+}
+
+export type IrcState =
+ | 'idle'
+ | 'connecting'
+ | 'registering'
+ | 'joining'
+ | 'ready'
+ | 'error';
+
+export interface IrcStatus {
+ state: IrcState;
+ /** The nick actually in use — a collision may have changed it. */
+ nick: string;
+ since: number | null;
+ lastError: string | null;
+ queued: number;
+ sent: number;
+ dropped: number;
+}
+
+const WRITE_INTERVAL_MS = 1_500;
+const QUEUE_CAP = 200;
+const CONNECT_TIMEOUT_MS = 20_000;
+/** A registration that never completes is indistinguishable from a hung socket
+ * at the protocol level, so it gets its own deadline. */
+const REGISTER_TIMEOUT_MS = 45_000;
+
+export interface IrcClientEvents {
+ onState?: (status: IrcStatus) => void;
+ onLog?: (line: string) => void;
+}
+
+export class IrcClient {
+ private socket: net.Socket | tls.TLSSocket | null = null;
+ private buffer = '';
+ private state: IrcState = 'idle';
+ private nick: string;
+ private since: number | null = null;
+ private lastError: string | null = null;
+ private queue: string[] = [];
+ private sentCount = 0;
+ private droppedCount = 0;
+ private timer: NodeJS.Timeout | null = null;
+ private connectTimer: NodeJS.Timeout | null = null;
+ private nickAttempt = 0;
+ private closed = false;
+
+ constructor(
+ private readonly config: IrcConfig,
+ private readonly events: IrcClientEvents = {}
+ ) {
+ this.nick = config.nick;
+ }
+
+ status(): IrcStatus {
+ return {
+ state: this.state,
+ nick: this.nick,
+ since: this.since,
+ lastError: this.lastError,
+ queued: this.queue.length,
+ sent: this.sentCount,
+ dropped: this.droppedCount,
+ };
+ }
+
+ /** Queue a message for the channel. Never throws, never blocks a caller. */
+ say(message: string): void {
+ if (this.closed) return;
+ if (this.queue.length >= QUEUE_CAP) {
+ // Oldest first: on an announce channel the fresh release is the one worth
+ // saying, and a queue that drops the NEW line would hide exactly what the
+ // members are waiting for.
+ this.queue.shift();
+ this.droppedCount++;
+ }
+ this.queue.push(message);
+ this.pump();
+ }
+
+ connect(): void {
+ if (this.socket || this.closed) return;
+ this.setState('connecting');
+ this.buffer = '';
+ this.nickAttempt = 0;
+ this.nick = this.config.nick;
+
+ const onReady = () => {
+ this.clearConnectTimer();
+ this.setState('registering');
+ this.armRegisterDeadline();
+ // SASL has to be negotiated before registration completes, so the CAP
+ // request goes first or not at all.
+ if (this.config.saslUser && this.config.saslPassword) {
+ this.raw('CAP REQ :sasl');
+ }
+ if (this.config.serverPassword) this.raw(`PASS ${this.config.serverPassword}`);
+ this.raw(`NICK ${this.nick}`);
+ this.raw(
+ `USER ${this.nick} 0 * :${this.config.realname || 'Trackarr announce'}`
+ );
+ };
+
+ try {
+ if (this.config.tls) {
+ const socket = tls.connect(
+ {
+ host: this.config.host,
+ port: this.config.port,
+ servername: this.config.host,
+ },
+ onReady
+ );
+ this.socket = socket;
+ } else {
+ const socket = net.connect(
+ { host: this.config.host, port: this.config.port },
+ onReady
+ );
+ this.socket = socket;
+ }
+ } catch (err) {
+ this.fail((err as Error).message);
+ return;
+ }
+
+ this.connectTimer = setTimeout(() => {
+ this.fail(`no connection within ${CONNECT_TIMEOUT_MS / 1000}s`);
+ }, CONNECT_TIMEOUT_MS);
+ this.connectTimer.unref?.();
+
+ this.socket.setEncoding('utf8');
+ this.socket.on('data', (chunk: string) => this.onData(chunk));
+ this.socket.on('error', (err: Error) => this.fail(err.message));
+ this.socket.on('close', () => {
+ if (!this.closed && this.state !== 'error') this.fail('connection closed');
+ });
+ }
+
+ /** Close for good. A client that has been shut down never reconnects. */
+ close(reason = 'shutting down'): void {
+ this.closed = true;
+ this.stopTimers();
+ if (this.socket) {
+ try {
+ this.raw(`QUIT :${reason}`);
+ this.socket.end();
+ } catch {
+ // The socket was already gone; nothing to say about it.
+ }
+ this.socket.destroy();
+ this.socket = null;
+ }
+ this.setState('idle');
+ }
+
+ // ── protocol ──────────────────────────────────────────────────────────────
+
+ private onData(chunk: string): void {
+ this.buffer += chunk;
+ // IRC frames on CRLF, but plenty of servers and bouncers send a bare LF.
+ const lines = this.buffer.split(/\r?\n/);
+ this.buffer = lines.pop() ?? '';
+ for (const line of lines) {
+ if (line) this.handleLine(line);
+ }
+ // A peer that never sends a newline would otherwise grow this without
+ // bound. 8 KiB is sixteen times the longest legal frame.
+ if (this.buffer.length > 8192) this.buffer = '';
+ }
+
+ private handleLine(line: string): void {
+ this.events.onLog?.(line);
+
+ if (line.startsWith('PING ')) {
+ this.raw(`PONG ${line.slice(5)}`);
+ return;
+ }
+
+ // :prefix COMMAND params… — the prefix is not needed for anything here.
+ const parts = line.startsWith(':') ? line.slice(1).split(' ').slice(1) : line.split(' ');
+ const command = parts[0]?.toUpperCase();
+
+ switch (command) {
+ case 'AUTHENTICATE': {
+ if (parts[1] === '+' && this.config.saslUser && this.config.saslPassword) {
+ const payload = Buffer.from(
+ `${this.config.saslUser}\0${this.config.saslUser}\0${this.config.saslPassword}`,
+ 'utf8'
+ ).toString('base64');
+ this.raw(`AUTHENTICATE ${payload}`);
+ }
+ return;
+ }
+ case 'CAP': {
+ // ACK on the sasl cap is the go-ahead; anything else means the server
+ // will not do SASL, and registration continues without it.
+ if (parts[2]?.toUpperCase() === 'ACK') this.raw('AUTHENTICATE PLAIN');
+ else if (parts[2]?.toUpperCase() === 'NAK') this.raw('CAP END');
+ return;
+ }
+ case '903': // SASL succeeded
+ this.raw('CAP END');
+ return;
+ case '904': // SASL failed
+ case '905':
+ case '906':
+ // Not fatal on its own: a network may still let an unauthenticated bot
+ // into a keyed channel, and failing here would hide that. The error is
+ // recorded so the operator sees why the channel refused them.
+ this.lastError = 'SASL authentication refused';
+ this.raw('CAP END');
+ return;
+ case '001': {
+ // Registered. Perform lines first — an invite request has to land
+ // before the JOIN it enables.
+ for (const raw of this.config.perform ?? []) {
+ if (raw.trim()) this.raw(raw.trim());
+ }
+ this.setState('joining');
+ this.raw(
+ this.config.channelKey
+ ? `JOIN ${this.config.channel} ${this.config.channelKey}`
+ : `JOIN ${this.config.channel}`
+ );
+ return;
+ }
+ case '366': {
+ // End of NAMES for the channel we asked for: we are in.
+ if (parts[2]?.toLowerCase() === this.config.channel.toLowerCase()) {
+ this.since = Date.now();
+ this.setState('ready');
+ this.pump();
+ }
+ return;
+ }
+ case '433':
+ case '436': {
+ // Nick taken. Bots reconnect faster than servers time out ghosts, so a
+ // collision with our own previous session is the common case rather
+ // than the interesting one.
+ this.nickAttempt++;
+ if (this.nickAttempt > 3) {
+ this.fail('nick unavailable after three attempts');
+ return;
+ }
+ this.nick = `${this.config.nick}${this.nickAttempt}`;
+ this.raw(`NICK ${this.nick}`);
+ return;
+ }
+ case '473': // +i, invite only
+ case '475': // wrong key
+ case '474': // banned
+ case '471': // full
+ this.fail(`channel refused the bot (${command})`);
+ return;
+ case 'KILL':
+ this.fail('killed by the server');
+ return;
+ case 'ERROR':
+ this.fail(line.slice(0, 200));
+ return;
+ default:
+ return;
+ }
+ }
+
+ private raw(line: string): void {
+ if (!this.socket) return;
+ // One frame, one line — a caller that managed to smuggle a newline in here
+ // would be writing a second command.
+ const safe = line.replace(/[\r\n]/g, ' ');
+ try {
+ this.socket.write(`${safe}\r\n`);
+ } catch (err) {
+ this.fail((err as Error).message);
+ }
+ }
+
+ private pump(): void {
+ if (this.timer || this.state !== 'ready' || this.queue.length === 0) return;
+ const send = () => {
+ this.timer = null;
+ if (this.state !== 'ready') return;
+ const next = this.queue.shift();
+ if (next === undefined) return;
+ this.raw(`PRIVMSG ${this.config.channel} :${next}`);
+ this.sentCount++;
+ if (this.queue.length > 0) {
+ this.timer = setTimeout(send, WRITE_INTERVAL_MS);
+ this.timer.unref?.();
+ }
+ };
+ send();
+ }
+
+ private armRegisterDeadline(): void {
+ const deadline = setTimeout(() => {
+ if (this.state === 'registering' || this.state === 'joining') {
+ this.fail('registration did not complete');
+ }
+ }, REGISTER_TIMEOUT_MS);
+ deadline.unref?.();
+ }
+
+ private clearConnectTimer(): void {
+ if (this.connectTimer) {
+ clearTimeout(this.connectTimer);
+ this.connectTimer = null;
+ }
+ }
+
+ private stopTimers(): void {
+ this.clearConnectTimer();
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+
+ private fail(message: string): void {
+ this.lastError = message;
+ this.stopTimers();
+ this.since = null;
+ if (this.socket) {
+ this.socket.removeAllListeners();
+ this.socket.destroy();
+ this.socket = null;
+ }
+ this.setState('error');
+ }
+
+ private setState(state: IrcState): void {
+ if (this.state === state) return;
+ this.state = state;
+ this.events.onState?.(this.status());
+ }
+}
diff --git a/apps/api/utils/irc/format.ts b/apps/api/utils/irc/format.ts
new file mode 100644
index 00000000..83792110
--- /dev/null
+++ b/apps/api/utils/irc/format.ts
@@ -0,0 +1,321 @@
+/**
+ * The announce line, and the regular expression that reads it back.
+ *
+ * ## Why IRC at all, in 2026
+ *
+ * Because autobrr and autodl-irssi speak it, and between them they are how
+ * releases are actually raced. The mechanism is deliberately archaic — a bot
+ * says one line per accepted upload, a client matches it against filters and
+ * grabs — and that is exactly why it is universal. An RSS feed is polled; a
+ * channel message arrives.
+ *
+ * ## The one design decision worth reading
+ *
+ * The line is a template the operator can change, and the parsing regex is
+ * DERIVED FROM THAT TEMPLATE rather than written next to it.
+ *
+ * Every tracker that ships a hand-written definition alongside a configurable
+ * format eventually ships two things that disagree, and the failure is silent:
+ * the channel keeps announcing, the definition keeps not matching, and members
+ * conclude the tracker is broken. Deriving one from the other makes that
+ * impossible by construction — `/api/irc/autobrr.yml` regenerates from the
+ * template in force, so an operator who reorders the fields gets a definition
+ * that reads the new order.
+ *
+ * It also settles the versioning question the roadmap worried about. The format
+ * is not frozen because it does not need to be: the template is stored in
+ * settings, so changing the DEFAULT here never changes what a running instance
+ * emits, and whatever it emits is what the generated definition parses.
+ *
+ * ## Every field is always present
+ *
+ * No optional segments: a torrent with no tags says `-`, an anonymous upload
+ * says `anonymous`, a release with no freeleech says `FL 0%`. A fixed shape
+ * costs a few characters and buys a regex with no optional groups — and an
+ * optional group is how a parser silently attributes one field's value to
+ * another when the middle one is missing.
+ *
+ * ## Names that mean something to autobrr
+ *
+ * The capture names are taken from autobrr's own `MapVars` — `releaseName`,
+ * `category`, `torrentSize`, `freeleechPercent`, `tags`, `uploader`,
+ * `torrentId` — so the values land in the fields its filters read. Two
+ * omissions are deliberate:
+ *
+ * - The upload multiplier is printed for people and mapped to nothing, because
+ * autobrr has no field for it. Inventing a variable name would produce a
+ * definition that looks richer and behaves identically.
+ * - The seeding requirement is not in the line. autobrr reads `minimumratio`
+ * and `minimumseedtime` from the Torznab feed, which this site now serves,
+ * and there is no IRC variable for either. A field no tool can consume is
+ * noise in a format that has to stay parseable for years.
+ */
+
+/** The format the derived regex and the docs both describe. Bump on a change. */
+export const ANNOUNCE_FORMAT_VERSION = 1;
+
+/**
+ * What a token may hold, and what autobrr calls it.
+ *
+ * `pattern` is lazy on purpose. The literals between tokens anchor the match,
+ * and a greedy group would eat the next separator whenever a value happened to
+ * contain one — which is precisely the case `sanitiseValue` cannot fully rule
+ * out for a release name.
+ */
+export interface AnnounceToken {
+ /** autobrr's variable name, or null when nothing consumes it. */
+ readonly variable: string | null;
+ /** Regex body, without the named-group wrapper. */
+ readonly pattern: string;
+ /** One line, for the operator staring at the template field. */
+ readonly describes: string;
+}
+
+export const ANNOUNCE_TOKENS: Readonly> = {
+ name: {
+ variable: 'releaseName',
+ pattern: '.+?',
+ describes: 'the release name',
+ },
+ category: {
+ variable: 'category',
+ pattern: '.+?',
+ describes: 'the category, or `uncategorised`',
+ },
+ size: {
+ variable: 'torrentSize',
+ // A number and a unit. autobrr parses the unit itself, so the group has to
+ // keep them together.
+ pattern: '\\d+(?:\\.\\d+)?\\s*[KMGTP]?i?B',
+ describes: 'the total size, e.g. `14.62 GiB`',
+ },
+ freeleechPercent: {
+ variable: 'freeleechPercent',
+ // 0% for a normal torrent, 100% for freeleech, and the values in between
+ // that per-torrent download multipliers make possible.
+ pattern: '\\d{1,3}%',
+ describes: 'how much of the download is free, e.g. `100%`',
+ },
+ uploadFactor: {
+ // Printed for people. autobrr has no field for it — see the note above.
+ variable: null,
+ pattern: '\\d+(?:\\.\\d+)?',
+ describes: 'the upload multiplier, e.g. `2`',
+ },
+ tags: {
+ variable: 'tags',
+ pattern: '[^:]*?',
+ describes: 'comma-separated tags, or `-`',
+ },
+ uploader: {
+ variable: 'uploader',
+ pattern: '\\S+',
+ describes: 'the uploader, or `anonymous`',
+ },
+ url: {
+ // Not `baseUrl`: this is the whole page address, and autobrr's `baseUrl`
+ // means the site root that its own templates prepend.
+ variable: null,
+ pattern: '\\S+',
+ describes: 'the link to the release page',
+ },
+ infoHash: {
+ variable: 'torrentId',
+ // `torrentId` rather than `torrentHash` because the URL templates in the
+ // generated definition interpolate `{{ .torrentId }}`, and on this site the
+ // id in a download URL IS the v1 infohash.
+ pattern: '[a-f0-9]{40}',
+ describes: 'the v1 infohash — what the download URL is keyed on',
+ },
+} as const;
+
+export type AnnounceTokenName = keyof typeof ANNOUNCE_TOKENS;
+
+/**
+ * The default line.
+ *
+ * Shaped after the definitions autobrr already ships — a literal lead-in, then
+ * ` :: ` separated fields — so somebody who has written an indexer definition
+ * before recognises it, and `announcers` filtering on the first word works the
+ * way it does elsewhere.
+ */
+export const DEFAULT_ANNOUNCE_TEMPLATE =
+ 'NEW [{category}] {name} :: {size} :: FL {freeleechPercent} :: UL x{uploadFactor} :: {tags} :: by {uploader} :: {url} :: {infoHash}';
+
+export interface AnnounceFields {
+ name: string;
+ category: string;
+ size: string;
+ freeleechPercent: string;
+ uploadFactor: string;
+ tags: string;
+ uploader: string;
+ url: string;
+ infoHash: string;
+}
+
+/** How long a rendered line may be before it is cut. */
+const MAX_LINE_BYTES = 400;
+
+/**
+ * Make a value safe to put in an IRC message, and safe to parse back out.
+ *
+ * The first half is not cosmetic. IRC frames commands with CRLF, so a value
+ * carrying `\r\n` does not corrupt the line — it ENDS it, and everything after
+ * becomes a command the bot appears to have sent. A release name is
+ * member-supplied text, which makes this the injection boundary of the whole
+ * feature.
+ *
+ * The second half keeps the format readable: the separator is stripped from
+ * values so a name containing ` :: ` cannot invent a field, and control
+ * characters (including the colour codes IRC clients interpret) are dropped
+ * rather than escaped, because nothing downstream has a use for them.
+ */
+export function sanitiseValue(raw: string): string {
+ return (
+ raw
+ // CR, LF, NUL and the rest of C0, plus DEL: the frame delimiters and the
+ // colour codes. Replaced with a space rather than removed, so two words
+ // separated by one do not silently become a single word.
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
+ .replace(/\s*::\s*/g, ' - ')
+ .replace(/\s+/g, ' ')
+ .trim() || '-'
+ );
+}
+
+/** Bytes, the way IRC counts them. */
+function byteLength(s: string): number {
+ return Buffer.byteLength(s, 'utf8');
+}
+
+/**
+ * Render one line.
+ *
+ * Truncation is by bytes and not by characters, because the 512-byte frame is a
+ * byte limit and a release name is UTF-8. Cutting mid-sequence would emit a
+ * replacement character to every client in the channel, so the cut walks back
+ * to a whole character.
+ */
+export function renderAnnounce(
+ template: string,
+ fields: AnnounceFields
+): string {
+ const line = template.replace(/\{(\w+)\}/g, (whole, token: string) => {
+ if (!(token in ANNOUNCE_TOKENS)) return whole;
+ const value = fields[token as keyof AnnounceFields];
+ return sanitiseValue(value == null ? '' : String(value));
+ });
+
+ if (byteLength(line) <= MAX_LINE_BYTES) return line;
+ let cut = line;
+ while (byteLength(cut) > MAX_LINE_BYTES - 1 && cut.length > 0) {
+ cut = cut.slice(0, -1);
+ }
+ return `${cut}…`;
+}
+
+/** Which tokens a template uses, in order, ignoring anything unknown. */
+export function templateTokens(template: string): AnnounceTokenName[] {
+ const out: AnnounceTokenName[] = [];
+ for (const m of template.matchAll(/\{(\w+)\}/g)) {
+ const token = m[1]!;
+ if (token in ANNOUNCE_TOKENS) out.push(token as AnnounceTokenName);
+ }
+ return out;
+}
+
+/** Regex metacharacters in the template's literal text. */
+function escapeLiteral(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export interface AnnouncePattern {
+ /** RE2-compatible pattern with named groups — Go's regexp, which is what
+ * autobrr compiles it with. */
+ pattern: string;
+ /** The autobrr variables it captures, in order. */
+ variables: string[];
+}
+
+/**
+ * Turn a template into the pattern that reads its output.
+ *
+ * A token used twice gets a named group once and a back-reference after, since
+ * Go's regexp rejects a repeated group name — and a template that mentions the
+ * infohash in both the URL and a field of its own is a reasonable thing for an
+ * operator to write.
+ */
+export function announcePattern(template: string): AnnouncePattern {
+ let pattern = '';
+ const named = new Set();
+ const variables: string[] = [];
+ let last = 0;
+
+ for (const m of template.matchAll(/\{(\w+)\}/g)) {
+ const token = m[1]!;
+ const start = m.index!;
+ pattern += escapeLiteral(template.slice(last, start));
+ last = start + m[0].length;
+
+ const def = ANNOUNCE_TOKENS[token];
+ if (!def) {
+ // An unknown token is emitted literally by the renderer, so it is matched
+ // literally here. Anything else would make the pattern disagree with the
+ // line for the one input an operator typo produces.
+ pattern += escapeLiteral(m[0]);
+ continue;
+ }
+ if (named.has(token)) {
+ pattern += `(?P=${token})`;
+ continue;
+ }
+ named.add(token);
+ pattern += `(?P<${token}>${def.pattern})`;
+ if (def.variable) variables.push(def.variable);
+ }
+ pattern += escapeLiteral(template.slice(last));
+ return { pattern: `^${pattern}$`, variables };
+}
+
+/**
+ * The same pattern as a JavaScript RegExp.
+ *
+ * Go writes named groups `(?P…)` and JavaScript writes `(?…)`; the two
+ * differ in that one character and in nothing else that this pattern uses.
+ * Converting rather than generating twice is what lets the test parse the real
+ * shipped pattern instead of a lookalike.
+ */
+export function toJsRegExp(pattern: string): RegExp {
+ return new RegExp(
+ pattern.replace(/\(\?P')
+ );
+}
+
+/**
+ * The freeleech figure, from a download multiplier.
+ *
+ * `0` means the download is free, so it reads as 100%. Rounded to whole
+ * percent: the token's pattern accepts three digits and no decimal point, and a
+ * multiplier is a slider an operator set rather than a measurement.
+ */
+export function freeleechPercent(downloadMultiplier: number): string {
+ const clamped = Math.min(1, Math.max(0, downloadMultiplier));
+ return `${Math.round((1 - clamped) * 100)}%`;
+}
+
+/** `14.62 GiB` — binary units, the ones a torrent client shows. */
+export function humanSize(bytes: number): string {
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
+ let value = Math.max(0, bytes);
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ // Whole bytes have no decimals; everything else gets two, which is what the
+ // size pattern accepts and what every client renders.
+ return unit === 0
+ ? `${Math.round(value)} ${units[unit]}`
+ : `${value.toFixed(2)} ${units[unit]}`;
+}
diff --git a/apps/api/utils/irc/settings.ts b/apps/api/utils/irc/settings.ts
new file mode 100644
index 00000000..1d808207
--- /dev/null
+++ b/apps/api/utils/irc/settings.ts
@@ -0,0 +1,150 @@
+/**
+ * The operator's IRC configuration, and why it is one encrypted blob.
+ *
+ * Three of these fields are credentials — the server password, the SASL
+ * password, the channel key — and `settings` is a plaintext table. The
+ * notification channels solved the same problem already: one JSON value,
+ * AES-GCM at rest, key derived from the session secret or from a dedicated
+ * `CHANNEL_ENCRYPTION_KEY`. Reusing that is both less code and one fewer
+ * decision about where secrets live.
+ *
+ * The trade is that the whole config is read and written as a unit, which suits
+ * a connection you have to restart to reconfigure anyway.
+ */
+import { getSetting, setSetting } from '~~/utils/server';
+import { decryptJson, encryptJson } from '~~/utils/channelSecrets';
+import { DEFAULT_ANNOUNCE_TEMPLATE } from './format';
+
+export const IRC_SETTINGS = {
+ /** Encrypted JSON: everything below except `enabled`. */
+ CONFIG: 'irc_announce_config',
+ /** Plain, because the plugin reads it on every tick and it is not a secret. */
+ ENABLED: 'irc_announce_enabled',
+} as const;
+
+export interface IrcAnnounceConfig {
+ host: string;
+ port: number;
+ tls: boolean;
+ nick: string;
+ realname: string;
+ serverPassword: string;
+ saslUser: string;
+ saslPassword: string;
+ /** One raw IRC line per entry, sent after registration and before JOIN. */
+ perform: string[];
+ channel: string;
+ channelKey: string;
+ template: string;
+ /**
+ * The public address the announce line links to, e.g. `https://tracker.example.com`.
+ *
+ * The bot has no request to derive an origin from — unlike every other place
+ * in this codebase that builds an absolute URL — so it has to be told.
+ * Falling back to the federation identity's `public_url` covers the instances
+ * that already declared one; with neither, the line carries a path and says
+ * so in the admin console rather than inventing a hostname.
+ */
+ siteUrl: string;
+ /**
+ * Whether the adult tree is announced.
+ *
+ * Off by default, and this is the one default here that is a judgement rather
+ * than a convenience. A channel is a single stream with no per-member
+ * preferences in it: everyone who joins sees every line. The site lets a
+ * member decide whether adult releases exist for them, and an announce
+ * channel cannot honour that decision — so the operator makes it once, for
+ * the channel, and the safe direction is the one that does not put titles
+ * nobody asked for in front of people who turned them off.
+ */
+ announceAdult: boolean;
+}
+
+export const IRC_DEFAULTS: IrcAnnounceConfig = {
+ host: '',
+ port: 6697,
+ tls: true,
+ nick: 'trackarr',
+ realname: 'Trackarr announce',
+ serverPassword: '',
+ saslUser: '',
+ saslPassword: '',
+ perform: [],
+ channel: '#announce',
+ channelKey: '',
+ template: DEFAULT_ANNOUNCE_TEMPLATE,
+ siteUrl: '',
+ announceAdult: false,
+};
+
+export async function getIrcEnabled(): Promise {
+ // Off unless asked for: a tracker that started announcing to a channel
+ // because it was upgraded would be announcing without anyone deciding to.
+ return (await getSetting(IRC_SETTINGS.ENABLED)) === 'true';
+}
+
+export async function setIrcEnabled(enabled: boolean): Promise {
+ await setSetting(IRC_SETTINGS.ENABLED, enabled ? 'true' : 'false');
+}
+
+export async function getIrcConfig(): Promise {
+ const raw = await getSetting(IRC_SETTINGS.CONFIG);
+ if (!raw) return { ...IRC_DEFAULTS };
+ try {
+ const stored = decryptJson>(raw);
+ if (!stored) return { ...IRC_DEFAULTS };
+ return { ...IRC_DEFAULTS, ...stored, perform: stored.perform ?? [] };
+ } catch (err) {
+ // A blob that will not decrypt means the key changed — an operator rotating
+ // `CHANNEL_ENCRYPTION_KEY`, or a restore from a backup taken under another
+ // one. Returning defaults would silently disconnect the bot and lose the
+ // settings on the next save, so this is loud and the announcer stays down.
+ console.error(
+ '[IRC] Could not decrypt the announce config; leaving the bot off:',
+ (err as Error).message
+ );
+ throw err;
+ }
+}
+
+export async function setIrcConfig(
+ config: IrcAnnounceConfig
+): Promise {
+ await setSetting(IRC_SETTINGS.CONFIG, encryptJson(config));
+}
+
+/** What the admin console may see: the same config with the secrets blanked. */
+export function redactIrcConfig(
+ config: IrcAnnounceConfig
+): IrcAnnounceConfig & {
+ hasServerPassword: boolean;
+ hasSaslPassword: boolean;
+ hasChannelKey: boolean;
+} {
+ return {
+ ...config,
+ serverPassword: '',
+ saslPassword: '',
+ channelKey: '',
+ hasServerPassword: !!config.serverPassword,
+ hasSaslPassword: !!config.saslPassword,
+ hasChannelKey: !!config.channelKey,
+ };
+}
+
+/**
+ * Whether a config is complete enough to try.
+ *
+ * Deliberately not a Zod schema on the whole shape: the admin form sends
+ * partials (a secret left blank means "keep the stored one"), and the useful
+ * question at connect time is narrower than "is this valid".
+ */
+export function ircConfigReady(config: IrcAnnounceConfig): boolean {
+ return (
+ !!config.host &&
+ config.port > 0 &&
+ config.port <= 65535 &&
+ !!config.nick &&
+ /^[#&]/.test(config.channel)
+ );
+}
diff --git a/apps/api/utils/torrentModeration.ts b/apps/api/utils/torrentModeration.ts
index efc5b20d..c258c661 100644
--- a/apps/api/utils/torrentModeration.ts
+++ b/apps/api/utils/torrentModeration.ts
@@ -20,6 +20,7 @@ import { db, schema } from '@trackarr/db';
import { and, eq, inArray } from 'drizzle-orm';
import { fanoutFollowedUserUpload } from './followerFanout';
import { fanoutSavedSearchMatches } from './savedSearchFanout';
+import { announceRelease } from './irc/announcer';
import { randomUUID } from 'node:crypto';
import { redis } from '../redis/client';
@@ -229,6 +230,23 @@ export async function transitionStatus(opts: {
tvdbId: updated.tvdbId,
uploaderId: updated.uploaderId,
});
+
+ // The IRC announce channel, on the same edge and for the same reason: this
+ // is the moment a release becomes something a member could grab. Racing is
+ // the whole point of the channel, so the line goes out here rather than on
+ // a sweep — and fire-and-forget, because a channel that is down must not
+ // hold up a moderator's queue.
+ void announceRelease({
+ id: updated.id,
+ infoHash: updated.infoHash,
+ name: updated.name,
+ size: Number(updated.size),
+ categoryId: updated.categoryId,
+ uploaderId: updated.uploaderId,
+ downloadMultiplier: updated.downloadMultiplier,
+ uploadMultiplier: updated.uploadMultiplier,
+ multipliersUntil: updated.multipliersUntil,
+ });
}
// Federation needs nothing here. A row that leaves the accepted state stops
diff --git a/apps/web/app/composables/useAdminNav.ts b/apps/web/app/composables/useAdminNav.ts
index b7ac93d6..3dd5c07c 100644
--- a/apps/web/app/composables/useAdminNav.ts
+++ b/apps/web/app/composables/useAdminNav.ts
@@ -211,6 +211,12 @@ export function useAdminNav() {
icon: 'ph:plug',
description: t('admin.descriptions.torznab'),
},
+ {
+ label: t('admin.nav.irc'),
+ path: '/admin/irc',
+ icon: 'ph:broadcast',
+ description: t('admin.descriptions.irc'),
+ },
{
label: t('admin.nav.notifications'),
path: '/admin/notifications',
diff --git a/apps/web/app/pages/admin/irc.vue b/apps/web/app/pages/admin/irc.vue
new file mode 100644
index 00000000..5693f187
--- /dev/null
+++ b/apps/web/app/pages/admin/irc.vue
@@ -0,0 +1,614 @@
+
+
+
+
+
{{ $t('admin.irc.eyebrow') }}
+
{{ $t('admin.irc.title') }}
+
{{ $t('admin.irc.lede') }}
+
+
+
+
+
+
+
{{ $t(`admin.irc.states.${state}`) }}
+
{{ status.nick }}
+
{{ status.lastError }}
+
+
+
+
+
+
+
+ {{ $t('admin.irc.notLeader') }}
+
+
+
+
+
+
{{ $t('admin.irc.sections.traffic') }}
+
+
{{ $t('admin.irc.stats.sent') }}
{{ status.sent }}
+
{{ $t('admin.irc.stats.queued') }}
{{ status.queued }}
+
{{ $t('admin.irc.stats.dropped') }}
{{ status.dropped }}
+
+
{{ $t('admin.irc.stats.since') }}
+
{{ status.since ? new Date(status.since).toLocaleString() : '—' }}