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 <b>'); }); }); + +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(`<torznab:attr name="infohash" value="${'a'.repeat(40)}"/>`); + expect(xml).toContain('<torznab:attr name="minimumratio" value="0.8"/>'); + expect(xml).toContain('<torznab:attr name="minimumseedtime" value="172800"/>'); + }); + + 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('<torznab:attr name="downloadvolumefactor" value="0"/>'); + expect(xml).toContain('<torznab:attr name="uploadvolumefactor" value="2"/>'); + }); +}); 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: <guid>abc123...</guid> <pubDate>Wed, 08 Jan 2025 10:00:00 +0000</pubDate> <size>4500000000</size> + <torznab:attr name="category" value="2000"/> + <torznab:attr name="size" value="4500000000"/> <torznab:attr name="seeders" value="25"/> <torznab:attr name="peers" value="5"/> + <torznab:attr name="grabs" value="112"/> + <torznab:attr name="downloadvolumefactor" value="0"/> + <torznab:attr name="uploadvolumefactor" value="1"/> + <torznab:attr name="infohash" value="abc123..."/> + <torznab:attr name="minimumratio" value="0.6"/> + <torznab:attr name="minimumseedtime" value="86400"/> + <torznab:attr name="imdbid" value="tt1234567"/> <enclosure url="https://..." type="application/x-bittorrent"/> </item> </channel> </rss> ``` +### 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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<string, unknown>)) + ); + }); + + 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: `i<digits>e`. + 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: `<length>:<bytes>`. 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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 `<link rel="manifest">` 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(… / <alpha>)`. 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<string, string> = { + 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('<html>'), ['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('<svg width="10"></svg>'))).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<string | null> { 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<string> { + const value = await getSetting(SETTINGS_KEYS.SITE_LOGO_IMAGE_SIZE); + return value || 'any'; +} + +export async function getSiteFaviconSizes(): Promise<string> { + const value = await getSetting(SETTINGS_KEYS.SITE_FAVICON_SIZE); + return value || 'any'; +} + export async function getSiteFavicon(): Promise<string | null> { 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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<T> { + total: number; + returned: number; + /** True when `total > returned` — the list is a prefix, not the record. */ + truncated: boolean; + items: T[]; +} + +function capped<T>(items: T[], total: number): Capped<T> { + 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<number> { + 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 @@ </div> </section> + <!-- Your data — the access / portability half of the rights the danger + zone below covers the erasure half of. Deliberately NOT inside the + danger zone: downloading a copy of your own record is not a + destructive act and should not be dressed as one. --> + <section id="data" class="form-section"> + <header class="section-head"> + <span class="section-number">08</span> + <h2 class="section-title">{{ $t('settings.sections.data') }}</h2> + <span class="section-rule" /> + </header> + + <div class="section-body"> + <p class="templates-blurb">{{ $t('settings.data.blurb') }}</p> + <p v-if="exportError" class="danger-error"> + <Icon name="ph:warning-circle-fill" /> {{ exportError }} + </p> + <button + type="button" + class="btn btn-secondary btn-sm templates-link" + :disabled="exporting" + @click="downloadExport" + > + <Icon + :name="exporting ? 'ph:circle-notch' : 'ph:download-simple'" + :class="{ 'animate-spin': exporting }" + /> + {{ exporting ? $t('settings.data.preparing') : $t('settings.data.download') }} + </button> + <p class="templates-blurb">{{ $t('settings.data.excludedNote') }}</p> + </div> + </section> + <!-- Danger zone — irreversible self-service account erasure. Kept last and visually apart so it is never a mis-click away from a save. --> <section id="danger" class="form-section"> @@ -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<SectionKey>('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<Blob>('/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-<username>-<date>.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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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<number>`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<EraseResult> { ) ); + // 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<string, unknown>; +} + +/** + * 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<string, unknown>).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<void> { + 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<string | null> { 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<number> { + 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 @@ +<template> + <div class="audit"> + <!-- ── Head ────────────────────────────────────────────── --> + <header class="au-head"> + <div> + <p class="au-eyebrow">{{ $t('admin.audit.eyebrow') }}</p> + <h2 class="au-title"> + {{ $t('admin.audit.title') }} + <span class="au-title-accent tabular-nums">{{ data?.total ?? 0 }}</span> + </h2> + <p class="au-lede">{{ $t('admin.audit.lede') }}</p> + </div> + <button + type="button" + class="tool-btn" + :disabled="loading" + :title="$t('admin.audit.refresh')" + @click="refreshData()" + > + <Icon name="ph:arrows-clockwise-bold" :class="{ 'animate-spin': loading }" /> + </button> + </header> + + <!-- ── Filters ─────────────────────────────────────────── --> + <section class="au-filters" :aria-label="$t('admin.audit.filtersLabel')"> + <label class="au-field au-field--grow"> + <span class="field-label">{{ $t('admin.audit.search') }}</span> + <input + v-model="searchInput" + type="search" + class="input" + autocomplete="off" + spellcheck="false" + :placeholder="$t('admin.audit.searchPlaceholder')" + /> + </label> + + <label class="au-field"> + <span class="field-label">{{ $t('admin.audit.action') }}</span> + <select v-model="action" class="input"> + <option value="">{{ $t('admin.audit.anyAction') }}</option> + <option v-for="a in actions" :key="a.action" :value="a.action"> + {{ a.action }} ({{ a.count }}) + </option> + </select> + </label> + + <label class="au-field"> + <span class="field-label">{{ $t('admin.audit.from') }}</span> + <input v-model="from" type="date" class="input" /> + </label> + + <label class="au-field"> + <span class="field-label">{{ $t('admin.audit.to') }}</span> + <input v-model="to" type="date" class="input" /> + </label> + + <label class="au-toggle"> + <input v-model="failuresOnly" type="checkbox" /> + <span>{{ $t('admin.audit.failuresOnly') }}</span> + </label> + + <button + v-if="anyFilter" + type="button" + class="tool-btn tool-btn--text" + @click="clearFilters" + > + {{ $t('admin.audit.clear') }} + </button> + </section> + + <!-- ── The register ────────────────────────────────────── --> + <section class="au-table-wrap" :aria-label="$t('admin.audit.tableLabel')"> + <table class="au-table"> + <thead> + <tr> + <th scope="col">{{ $t('admin.audit.col.when') }}</th> + <th scope="col">{{ $t('admin.audit.col.actor') }}</th> + <th scope="col">{{ $t('admin.audit.col.action') }}</th> + <th scope="col">{{ $t('admin.audit.col.target') }}</th> + <th scope="col" class="au-num">{{ $t('admin.audit.col.status') }}</th> + <th scope="col"><span class="sr-only">{{ $t('admin.audit.col.detail') }}</span></th> + </tr> + </thead> + <tbody> + <tr v-if="!loading && !rows.length"> + <td colspan="6" class="au-empty">{{ $t('admin.audit.empty') }}</td> + </tr> + <template v-for="row in rows" :key="row.id"> + <tr :class="{ 'au-row--failed': !ok(row.statusCode) }"> + <td class="au-when"> + <time :datetime="row.createdAt">{{ stamp(row.createdAt) }}</time> + </td> + <td class="au-actor"> + <span class="au-actor-name">{{ row.actorName }}</span> + <span class="au-role" :class="`au-role--${row.actorRole}`"> + {{ row.actorRole }} + </span> + </td> + <td><code class="au-action">{{ row.action }}</code></td> + <td class="au-target"> + <template v-if="row.targetLabel || row.targetId"> + <span v-if="row.targetType" class="au-target-type">{{ row.targetType }}</span> + <span class="au-target-label">{{ row.targetLabel || row.targetId }}</span> + </template> + <span v-else class="au-dash">—</span> + </td> + <td class="au-num"> + <span class="au-status" :class="ok(row.statusCode) ? 'au-status--ok' : 'au-status--bad'"> + {{ row.statusCode }} + </span> + </td> + <td class="au-num"> + <button + v-if="hasDetail(row)" + type="button" + class="tool-btn tool-btn--sm" + :aria-expanded="expanded === row.id" + :aria-label="$t('admin.audit.col.detail')" + @click="expanded = expanded === row.id ? null : row.id" + > + <Icon + name="ph:caret-down-bold" + :class="{ 'au-caret--open': expanded === row.id }" + /> + </button> + </td> + </tr> + <tr v-if="expanded === row.id" class="au-detail-row"> + <td colspan="6"> + <dl class="au-detail"> + <div> + <dt>{{ $t('admin.audit.detail.request') }}</dt> + <dd><code>{{ row.method }} {{ row.path }}</code></dd> + </div> + <div v-if="row.actorIpHash"> + <dt>{{ $t('admin.audit.detail.ipHash') }}</dt> + <dd> + <code>{{ row.actorIpHash }}</code> + <span class="au-note">{{ $t('admin.audit.detail.ipNote') }}</span> + </dd> + </div> + <div v-if="row.changes"> + <dt>{{ $t('admin.audit.detail.changes') }}</dt> + <dd><pre class="au-json">{{ pretty(row.changes) }}</pre></dd> + </div> + </dl> + </td> + </tr> + </template> + </tbody> + </table> + </section> + + <!-- ── Pager ───────────────────────────────────────────── --> + <nav v-if="totalPages > 1" class="au-pager" :aria-label="$t('admin.audit.pagerLabel')"> + <button type="button" class="tool-btn" :disabled="page <= 1" @click="page--"> + <Icon name="ph:caret-left-bold" /> + </button> + <span class="au-pager-label tabular-nums"> + {{ $t('admin.audit.pageOf', { page, pages: totalPages }) }} + </span> + <button + type="button" + class="tool-btn" + :disabled="page >= totalPages" + @click="page++" + > + <Icon name="ph:caret-right-bold" /> + </button> + </nav> + + <!-- ── Retention ───────────────────────────────────────── --> + <section class="au-retention" :aria-label="$t('admin.audit.retention.label')"> + <div> + <h3 class="au-retention-title">{{ $t('admin.audit.retention.title') }}</h3> + <p class="au-retention-text">{{ $t('admin.audit.retention.text') }}</p> + </div> + <form class="au-retention-form" @submit.prevent="saveRetention"> + <label class="au-field"> + <span class="field-label">{{ $t('admin.audit.retention.days') }}</span> + <input + v-model.number="retentionDays" + type="number" + min="0" + max="3650" + class="input au-retention-input" + /> + </label> + <button type="submit" class="btn btn-secondary btn-sm" :disabled="savingRetention"> + <Icon + :name="savingRetention ? 'ph:circle-notch' : 'ph:check-bold'" + :class="{ 'animate-spin': savingRetention }" + /> + {{ $t('admin.audit.retention.save') }} + </button> + </form> + <p v-if="retentionDays === 0" class="au-retention-note"> + {{ $t('admin.audit.retention.forever') }} + </p> + </section> + + <p class="au-foot">{{ $t('admin.audit.foot') }}</p> + </div> +</template> + +<script setup lang="ts"> +/** + * The staff register. + * + * Admin-only at three layers, and they are not redundant: the page middleware, + * the nav entry, and `requireAdminSession` on the API. Moderators fill this + * table and cannot read it — see the route for why. + * + * A read-only surface, deliberately. There is no delete button and no "mark + * reviewed": rows leave only through the retention sweep, and a register the + * registered can amend is not a register. + */ +definePageMeta({ middleware: 'admin' }); + +interface AuditRow { + id: string; + actorId: string | null; + actorName: string; + actorRole: string; + action: string; + method: string; + path: string; + targetType: string | null; + targetId: string | null; + targetLabel: string | null; + changes: Record<string, unknown> | null; + statusCode: number; + actorIpHash: string | null; + createdAt: string; +} + +interface AuditPayload { + items: AuditRow[]; + total: number; + page: number; + pageSize: number; +} + +const { t } = useI18n(); + +const searchInput = ref(''); +const debouncedSearch = ref(''); +const action = ref(''); +const from = ref(''); +const to = ref(''); +const failuresOnly = ref(false); +const page = ref(1); +const pageSize = ref(50); +const expanded = ref<string | null>(null); + +// Same 250 ms debounce as the other admin listings — one query per pause, not +// per keystroke. +let searchTimer: ReturnType<typeof setTimeout> | null = null; +watch(searchInput, (val) => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + debouncedSearch.value = val.trim(); + page.value = 1; + }, 250); +}); + +// Any filter change puts you back on page 1: staying on page 7 of a narrower +// result set shows an empty table and reads as "nothing found". +watch([action, from, to, failuresOnly], () => { + page.value = 1; +}); + +const apiQuery = computed(() => { + const q: Record<string, string | number> = { + page: page.value, + pageSize: pageSize.value, + }; + if (debouncedSearch.value) q.q = debouncedSearch.value; + if (action.value) q.action = action.value; + if (from.value) q.from = from.value; + // The date input gives a day; the filter is inclusive, so the end of it. + if (to.value) q.to = `${to.value}T23:59:59.999Z`; + if (failuresOnly.value) q.failuresOnly = 'true'; + return q; +}); + +const { + data, + pending: loading, + refresh: refreshData, +} = await useFetch<AuditPayload>('/api/admin/audit', { + query: apiQuery, + watch: [apiQuery], + default: () => null as unknown as AuditPayload, +}); + +// Read from the table, not from a hard-coded list — an un-enriched route's +// action key is derived from its path, so only the data knows what exists. +const { data: actionsData } = await useFetch<{ + items: Array<{ action: string; count: number }>; +}>('/api/admin/audit/actions', { default: () => ({ items: [] }) }); + +const actions = computed(() => actionsData.value?.items ?? []); +const rows = computed(() => data.value?.items ?? []); +const totalPages = computed(() => + Math.max(1, Math.ceil((data.value?.total ?? 0) / pageSize.value)) +); +const anyFilter = computed( + () => + !!debouncedSearch.value || + !!action.value || + !!from.value || + !!to.value || + failuresOnly.value +); + +function clearFilters() { + searchInput.value = ''; + debouncedSearch.value = ''; + action.value = ''; + from.value = ''; + to.value = ''; + failuresOnly.value = false; + page.value = 1; +} + +// ── Retention ───────────────────────────────────────────────── +// The control lives here rather than on the settings page: this is where an +// admin asks the question, and a setting whose only way in is a SQL prompt is +// a setting only its author can change. +const notifications = useNotificationStore(); +const { data: settings } = await useFetch<{ auditRetentionDays: number }>( + '/api/admin/settings', + { default: () => ({ auditRetentionDays: 365 }) } +); +const retentionDays = ref(settings.value?.auditRetentionDays ?? 365); +watch( + () => settings.value?.auditRetentionDays, + (v) => { + if (typeof v === 'number') retentionDays.value = v; + } +); +const savingRetention = ref(false); + +async function saveRetention() { + savingRetention.value = true; + try { + await $fetch('/api/admin/settings', { + method: 'PUT', + body: { auditRetentionDays: retentionDays.value }, + }); + notifications.success(t('admin.audit.retention.saved')); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + notifications.error(e?.data?.message || e?.message || t('admin.audit.retention.failed')); + } finally { + savingRetention.value = false; + } +} + +const ok = (status: number) => status >= 200 && status < 300; +const hasDetail = (row: AuditRow) => !!row.changes || !!row.actorIpHash || !!row.path; + +/** + * Pinned to `en-GB` for the same reason the account page pins its locale: a + * server-rendered timestamp formatted with the OS locale and a client one + * formatted with the browser's produce different strings and a hydration + * mismatch. A register is also the one place where an unambiguous, sortable + * stamp beats a friendly one. + */ +function stamp(iso: string): string { + return new Date(iso).toLocaleString('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: 'UTC', + }); +} + +function pretty(changes: Record<string, unknown>): string { + return JSON.stringify(changes, null, 2); +} + +useHead({ title: () => t('admin.audit.title') }); +</script> + +<style scoped> +/* + * A ledger, not a dashboard. Hairline rules, tabular figures, one accent — + * the page is read while something has gone wrong, and every flourish is a + * thing between the reader and the row they are looking for. + * + * Colour lives on utility classes and on the few semantic classes below; + * this sheet owns geometry. + */ +.audit { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.au-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.au-eyebrow { + margin: 0 0 0.25rem; + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.au-title { + margin: 0; + display: flex; + align-items: baseline; + gap: 0.5rem; + font-size: 1.35rem; +} +.au-title-accent { + font-size: 0.95rem; + color: rgb(var(--accent)); +} +.au-lede { + margin: 0.4rem 0 0; + max-width: 46rem; + font-size: 0.82rem; + line-height: 1.6; + color: rgb(var(--fg-subtle)); +} + +/* ── Filters ─────────────────────────────────────────────────── */ +.au-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem; + padding: 0.9rem; + border: 1px solid rgb(var(--line-default)); + border-radius: 0.5rem; + background: rgb(var(--bg-surface)); +} +.au-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 9rem; +} +.au-field--grow { + flex: 1 1 14rem; +} +.au-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + padding-bottom: 0.5rem; +} + +/* ── Table ───────────────────────────────────────────────────── */ +/* Its own scroll container: the action and path columns are long, and the + page body must never scroll sideways. */ +.au-table-wrap { + overflow-x: auto; + border: 1px solid rgb(var(--line-default)); + border-radius: 0.5rem; +} +.au-table { + width: 100%; + min-width: 46rem; + border-collapse: collapse; + font-size: 0.82rem; +} +.au-table th { + text-align: left; + padding: 0.6rem 0.75rem; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); + border-bottom: 1px solid rgb(var(--line-default)); + white-space: nowrap; +} +.au-table td { + padding: 0.55rem 0.75rem; + border-bottom: 1px solid rgb(var(--line-default)); + vertical-align: top; +} +.au-table tbody tr:last-child td { + border-bottom: 0; +} +.au-num { + text-align: right; + white-space: nowrap; +} +.au-when { + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: rgb(var(--fg-subtle)); +} +.au-actor { + display: flex; + flex-direction: column; + gap: 0.15rem; +} +.au-actor-name { + font-weight: 600; +} +.au-role { + font-size: 0.65rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.au-role--owner, +.au-role--admin { + color: rgb(var(--accent)); +} +.au-action { + font-size: 0.78rem; + word-break: break-word; +} +.au-target { + display: flex; + flex-direction: column; + gap: 0.15rem; + max-width: 18rem; +} +.au-target-type { + font-size: 0.65rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.au-target-label { + word-break: break-word; +} +.au-dash, +.au-empty { + color: rgb(var(--fg-subtle)); +} +.au-empty { + padding: 2rem 0.75rem; + text-align: center; +} +.au-status { + font-variant-numeric: tabular-nums; +} +.au-status--bad { + color: rgb(var(--danger)); +} +/* A left rule rather than a tinted row: a failed request is worth spotting + while scanning, not worth repainting the line. */ +.au-row--failed td:first-child { + box-shadow: inset 2px 0 0 rgb(var(--danger)); +} +.au-caret--open { + transform: rotate(180deg); +} + +/* ── Detail ──────────────────────────────────────────────────── */ +.au-detail-row td { + background: rgb(var(--bg-surface)); +} +.au-detail { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin: 0; +} +.au-detail dt { + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); + margin-bottom: 0.2rem; +} +.au-detail dd { + margin: 0; +} +.au-note { + margin-left: 0.5rem; + font-size: 0.72rem; + color: rgb(var(--fg-subtle)); +} +.au-json { + margin: 0; + padding: 0.6rem 0.75rem; + overflow-x: auto; + font-size: 0.76rem; + line-height: 1.5; + border: 1px solid rgb(var(--line-default)); + border-radius: 0.35rem; + background: rgb(var(--bg-inset)); +} + +/* ── Pager + foot ────────────────────────────────────────────── */ +.au-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} +.au-pager-label { + font-size: 0.8rem; + color: rgb(var(--fg-subtle)); +} +/* ── Retention ───────────────────────────────────────────────── */ +.au-retention { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem; + border: 1px solid rgb(var(--line-default)); + border-radius: 0.5rem; + background: rgb(var(--bg-surface)); +} +.au-retention-title { + margin: 0 0 0.25rem; + font-size: 0.9rem; +} +.au-retention-text, +.au-retention-note { + margin: 0; + max-width: 38rem; + font-size: 0.78rem; + line-height: 1.6; + color: rgb(var(--fg-subtle)); +} +.au-retention-form { + display: flex; + align-items: flex-end; + gap: 0.6rem; +} +.au-retention-input { + width: 7rem; +} + +.au-foot { + margin: 0; + padding-top: 0.75rem; + border-top: 1px solid rgb(var(--line-default)); + font-size: 0.75rem; + line-height: 1.6; + color: rgb(var(--fg-subtle)); +} + +@media (max-width: 40rem) { + .au-head { + flex-direction: column; + } + .au-field, + .au-field--grow { + flex: 1 1 100%; + min-width: 0; + } +} +</style> diff --git a/apps/web/app/pages/privacy.vue b/apps/web/app/pages/privacy.vue index 4b1ac31c..bd1c2a10 100644 --- a/apps/web/app/pages/privacy.vue +++ b/apps/web/app/pages/privacy.vue @@ -84,6 +84,10 @@ interface PrivacyFacts { notificationsReadDays: number; notificationsUnreadDays: number; }; + /** The staff audit log. `0` = kept indefinitely. */ + staffAudit: { + retentionDays: number; + }; } const { t } = useI18n(); @@ -142,6 +146,23 @@ const entries = computed<Entry[]>(() => { }), }); + // The staff register. Listed with the durations rather than among the notes + // below because it IS a duration, and because a member who was banned or had + // an upload rejected is the target of one of its rows — the period is theirs + // to know, not only the operator's. + { + const d = f.staffAudit.retentionDays; + rows.push({ + key: 'staffAudit', + days: d, + forever: d <= 0, + title: t('privacy.staffAudit.title'), + text: d > 0 + ? t('privacy.staffAudit.timed', { n: d }) + : t('privacy.staffAudit.forever'), + }); + } + return rows; }); diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 5564bfbf..a62c269f 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -1621,7 +1621,8 @@ }, "filterPlaceholder": "Filter sections…", "noMatch": "No section matches.", - "closeMenu": "Close the admin menu" + "closeMenu": "Close the admin menu", + "audit": "Audit log" }, "descriptions": { "dashboard": "Tracker configuration and node management", @@ -1643,7 +1644,8 @@ "settings": "System-wide registration and tracker settings", "federation": "Link this tracker with trusted Trackarr instances", "federationTaxonomy": "Map partners' category slugs onto your own categories", - "listingTemplates": "Curate the listing layouts every member can pick in the upload wizard." + "listingTemplates": "Curate the listing layouts every member can pick in the upload wizard.", + "audit": "Every change made from the staff console — who, what, when." }, "taxonomy": { "title": "Federated taxonomy", @@ -3698,6 +3700,50 @@ "clear": "Clear", "footerHint": "Or type a Phosphor name.", "toggleAria": "Open the icon picker" + }, + "audit": { + "eyebrow": "Accountability", + "title": "Audit log", + "lede": "Every mutating request to the admin and moderation consoles, with the account that made it. Append-only: entries cannot be edited or deleted, and leave only when the retention period expires. Readable by admins; moderator actions appear here too.", + "refresh": "Reload", + "filtersLabel": "Filters", + "tableLabel": "Audit entries", + "pagerLabel": "Pagination", + "search": "Search", + "searchPlaceholder": "Staff or target name…", + "action": "Action", + "anyAction": "Any action", + "from": "From", + "to": "To", + "failuresOnly": "Refused only", + "clear": "Clear filters", + "empty": "No entry matches these filters.", + "pageOf": "Page {page} of {pages}", + "foot": "Times are UTC. A refused request is recorded too — a run of them from one account is the pattern this register exists to show. Post-panic recovery is not logged: after panic mode there is no session, so there is no account to name.", + "col": { + "when": "When (UTC)", + "actor": "Staff", + "action": "Action", + "target": "Target", + "status": "Status", + "detail": "Detail" + }, + "detail": { + "request": "Request", + "changes": "Changes", + "ipHash": "Address", + "ipNote": "Hashed with a salt that rotates daily — comparable only within the same day." + }, + "retention": { + "label": "Retention", + "title": "How long entries are kept", + "text": "Days before an entry is swept. 0 keeps them indefinitely. Published on the public privacy page, so members can read the period that applies to decisions taken about them.", + "days": "Days", + "save": "Save", + "saved": "Retention updated.", + "failed": "Could not save the retention period.", + "forever": "Entries are currently kept indefinitely." + } } }, "mod": { @@ -5112,7 +5158,12 @@ "title": "What staff can read", "text": "A moderator opens one private message, and only one that has an open report against it. The endpoint answers nothing for any other message, so it cannot be used to walk a conversation, and it answers nothing at all for an encrypted one. Every one of those reads is recorded — who, when, and whether anything was legible — and the record is visible to all staff, not only to whoever ordered it." }, - "foot": "This page lists retention. It is not a substitute for whatever notice the operator of this instance is required to publish where it is hosted." + "foot": "This page lists retention. It is not a substitute for whatever notice the operator of this instance is required to publish where it is hosted.", + "staffAudit": { + "title": "Staff actions", + "timed": "Every change made from the moderation or admin console — a ban, a role, a setting, a rejected upload — is recorded with who made it and when, and kept for {n} days. Readable by admins only. The entry cannot be edited or removed by the staffer who caused it.", + "forever": "Every change made from the moderation or admin console — a ban, a role, a setting, a rejected upload — is recorded with who made it and when, and kept indefinitely. Readable by admins only. The entry cannot be edited or removed by the staffer who caused it." + } }, "tickets": { "title": "Tickets", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 71ebeeb5..b85b2315 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -1621,7 +1621,8 @@ }, "filterPlaceholder": "Filtrer les sections…", "noMatch": "Aucune section ne correspond.", - "closeMenu": "Fermer le menu d'administration" + "closeMenu": "Fermer le menu d'administration", + "audit": "Journal d'audit" }, "descriptions": { "dashboard": "Configuration du tracker et gestion des nœuds", @@ -1643,7 +1644,8 @@ "settings": "Paramètres globaux d'inscription et de tracker", "federation": "Relier ce tracker à des instances Trackarr de confiance", "federationTaxonomy": "Faire correspondre les slugs de catégorie des partenaires à vos propres catégories", - "listingTemplates": "Choisir les mises en page de fiche proposées à tous les membres dans l'assistant d'upload." + "listingTemplates": "Choisir les mises en page de fiche proposées à tous les membres dans l'assistant d'upload.", + "audit": "Chaque changement fait depuis la console du personnel — qui, quoi, quand." }, "taxonomy": { "title": "Taxonomie fédérée", @@ -3698,6 +3700,50 @@ "clear": "Effacer", "footerHint": "Ou tapez un nom Phosphor.", "toggleAria": "Ouvrir le sélecteur d'icônes" + }, + "audit": { + "eyebrow": "Redevabilité", + "title": "Journal d'audit", + "lede": "Chaque requête modifiante vers les consoles d'administration et de modération, avec le compte qui l'a faite. En ajout seul : une entrée ne peut être ni modifiée ni supprimée, et ne part qu'à l'expiration du délai de conservation. Lisible par les administrateurs ; les actions des modérateurs y figurent aussi.", + "refresh": "Recharger", + "filtersLabel": "Filtres", + "tableLabel": "Entrées d'audit", + "pagerLabel": "Pagination", + "search": "Rechercher", + "searchPlaceholder": "Nom du personnel ou de la cible…", + "action": "Action", + "anyAction": "N'importe quelle action", + "from": "Du", + "to": "Au", + "failuresOnly": "Refusées seulement", + "clear": "Effacer les filtres", + "empty": "Aucune entrée ne correspond à ces filtres.", + "pageOf": "Page {page} sur {pages}", + "foot": "Les heures sont en UTC. Une requête refusée est enregistrée aussi — une série de refus depuis un même compte est précisément le motif que ce registre existe pour montrer. La restauration après un panic n'est pas journalisée : après le panic mode il n'y a plus de session, donc aucun compte à nommer.", + "col": { + "when": "Quand (UTC)", + "actor": "Personnel", + "action": "Action", + "target": "Cible", + "status": "Statut", + "detail": "Détail" + }, + "detail": { + "request": "Requête", + "changes": "Changements", + "ipHash": "Adresse", + "ipNote": "Hachée avec un sel qui tourne chaque jour — comparable seulement au sein d'une même journée." + }, + "retention": { + "label": "Conservation", + "title": "Durée de conservation des entrées", + "text": "Nombre de jours avant balayage d'une entrée. 0 les conserve sans limite. Publié sur la page de confidentialité publique, pour que les membres puissent lire le délai qui s'applique aux décisions les concernant.", + "days": "Jours", + "save": "Enregistrer", + "saved": "Conservation mise à jour.", + "failed": "La durée de conservation n'a pas pu être enregistrée.", + "forever": "Les entrées sont actuellement conservées sans limite de durée." + } } }, "mod": { @@ -5112,7 +5158,12 @@ "title": "Ce que le staff peut lire", "text": "Un modérateur ouvre un message privé, et seulement un qui fait l'objet d'un signalement ouvert. La route ne répond rien pour un autre message — elle ne permet donc pas de parcourir une conversation — et rien du tout pour un message chiffré. Chacune de ces lectures est enregistrée — qui, quand, et si quelque chose était lisible — et le registre est visible de tout le staff, pas seulement de celui qui l'a demandé." }, - "foot": "Cette page liste des durées de conservation. Elle ne remplace pas la notice que l'exploitant de cette instance doit publier là où elle est hébergée." + "foot": "Cette page liste des durées de conservation. Elle ne remplace pas la notice que l'exploitant de cette instance doit publier là où elle est hébergée.", + "staffAudit": { + "title": "Actions du personnel", + "timed": "Chaque changement fait depuis la console de modération ou d'administration — un bannissement, un rôle, un réglage, une mise en ligne refusée — est enregistré avec son auteur et sa date, et conservé {n} jours. Lisible par les administrateurs uniquement. L'entrée ne peut être ni modifiée ni supprimée par le membre du personnel qui l'a provoquée.", + "forever": "Chaque changement fait depuis la console de modération ou d'administration — un bannissement, un rôle, un réglage, une mise en ligne refusée — est enregistré avec son auteur et sa date, et conservé sans limite de durée. Lisible par les administrateurs uniquement. L'entrée ne peut être ni modifiée ni supprimée par le membre du personnel qui l'a provoquée." + } }, "tickets": { "title": "Tickets", diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index c9c30785..0423abe9 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: 'Staff Audit Log', link: '/guide/audit-log' }, { text: 'Your Data (GDPR)', link: '/guide/your-data' }, ], }, diff --git a/doc/guide/audit-log.md b/doc/guide/audit-log.md new file mode 100644 index 00000000..40e07d98 --- /dev/null +++ b/doc/guide/audit-log.md @@ -0,0 +1,115 @@ +# Staff audit log + +Every change made from the moderation or admin console is recorded: who made +it, what it was, what it targeted, and whether it succeeded. Admins read it at +**`/admin/audit`**. + +## Why it exists + +This instance already kept a shelf of thematic ledgers, each good inside its +own lane — an append-only record of freeleech-pool contributions, a ledger of +every bonus grant, a discussion thread per moderated upload, a tombstone for a +withdrawn report, a log on the one route that reads private mail. None of them +answered the question asked across the whole site: *who banned this member, and +when?* + +That gap sat oddly beside everything else here. Members' IPs are hashed under a +daily-rotating salt, passwords never leave the browser, the database can be +encrypted in an emergency, and an account can be erased on request — and yet +nothing could say which moderator took which decision. Protection ran one way +only. It also meant a compromised staff account left no trace. + +## What is recorded + +One row per **mutating** request (`POST`, `PUT`, `PATCH`, `DELETE`) to +`/api/admin/**` or `/api/mod/**`, by an authenticated staff member. + +| Field | Meaning | +| --- | --- | +| Actor | Username and role **as they were at the time** — a later promotion or rename does not rewrite history | +| Action | A stable dotted key: `user.ban`, `settings.update`, `admin.federation.peers.delete` | +| Request | Method and path, query string stripped | +| Target | Type, id and a readable label, when the route names one | +| Changes | What moved, e.g. `{"isBanned":{"from":false,"to":true}}` | +| Status | The HTTP status — **failures included** | +| Address | The actor's IP, hashed like every other IP here | + +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. + +### Refused requests are the interesting ones + +A `403` from an account that should not have reached that console, repeated, is +the pattern this table exists to surface. So a refusal is stored exactly like a +success, with its status code, and the listing has a **Refused only** filter. + +### Coverage is structural + +The row is written by a pair of Nitro hooks, not by each route. A staff route +added tomorrow is audited before anybody writes a line for it — which is the +difference between coverage and a convention people remember for a while. A +route that knows more than its URL does can sharpen its own entry (the ban +route names the member and the reason; the settings route names the fields that +changed); one that says nothing still appears, under an action key derived from +its path and its route parameters. + +## What it deliberately does not record + +- **Request bodies.** They carry passwords, panic passwords, channel tokens and + 2FA secrets. A route wanting a diff passes the specific fields it means. The + settings route records which settings were touched, never their values. +- **Post-panic recovery.** After panic mode there is no session, so there is no + account to name. `POST /api/admin/panic/restore` is rate-limited and + globally capped instead; see [Panic Mode](./panic-mode.md). +- **Anything a moderator merely looked at.** See above. + +## Append-only + +Nothing in the application updates or deletes a row. There is no edit control, +no per-row delete, and no "mark as reviewed" — a register whose entries can be +amended by the people it registers is not a register. + +The single exception is retention, which removes whole rows by age. + +## Retention + +`audit_log_retention_days`, default **365**, `0` = keep indefinitely. A daily +sweep deletes rows past the threshold, and the value is published on the public +`/privacy` page alongside every other retention period this instance applies. + +A year rather than the 90 days notifications get: 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. + +## Who can read it + +**Admins only**, enforced on the page and again on the API. Moderators fill the +table and cannot read it. That is deliberate: 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 to it. The instance owner is an admin, so they +are covered. + +## Erasure + +A staff member who erases their account keeps their entries, with the pointer to +their user row cleared and the **name left in place**. 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 decisions by +closing their account. It is kept on the same basis as the invitation tree and +the reports that erasure already keeps: a record of an obligation between the +tracker and other members. + +Where the erased account was the **target** rather than the actor, both the +pointer and the label go — being banned is not an act they took, it is a thing +recorded about them. + +## The address is only comparable within a day + +`actorIpHash` goes through the same daily-rotating-salt hash as everywhere else; +no raw IP is persisted anywhere in this system and an audit log is a poor place +to make the first exception. Two rows can therefore be compared for "same +address" only if they fall on the same day. That is enough for *this admin's +session came from somewhere else than the rest of today's actions* and not +enough for a long-range history — the same trade the rest of the codebase makes. diff --git a/packages/db/src/migrations/0059_staff_audit_log.sql b/packages/db/src/migrations/0059_staff_audit_log.sql new file mode 100644 index 00000000..c9f2dcd0 --- /dev/null +++ b/packages/db/src/migrations/0059_staff_audit_log.sql @@ -0,0 +1,22 @@ +CREATE TABLE "audit_log" ( + "id" text PRIMARY KEY NOT NULL, + "actor_id" text, + "actor_name" text NOT NULL, + "actor_role" text NOT NULL, + "action" text NOT NULL, + "method" text NOT NULL, + "path" text NOT NULL, + "target_type" text, + "target_id" text, + "target_label" text, + "changes" jsonb, + "status_code" integer NOT NULL, + "actor_ip_hash" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_log_created_idx" ON "audit_log" USING btree ("created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "audit_log_actor_idx" ON "audit_log" USING btree ("actor_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "audit_log_target_idx" ON "audit_log" USING btree ("target_type","target_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action","created_at" DESC NULLS LAST); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0059_snapshot.json b/packages/db/src/migrations/meta/0059_snapshot.json new file mode 100644 index 00000000..ed9bf72f --- /dev/null +++ b/packages/db/src/migrations/meta/0059_snapshot.json @@ -0,0 +1,9740 @@ +{ + "id": "1f993e3b-ce7d-40ce-a1e2-36d11d122ea8", + "prevId": "e152909d-3510-445e-a554-924f81f5b41e", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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 d8480d24..5bc6146a 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -414,6 +414,13 @@ "when": 1788198389400, "tag": "0058_bep52_announce", "breakpoints": true + }, + { + "idx": 59, + "version": "7", + "when": 1788199247442, + "tag": "0059_staff_audit_log", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index afa2fdc0..ab918c30 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2447,6 +2447,118 @@ export const anticheatFlags = pgTable( export type AnticheatFlag = typeof anticheatFlags.$inferSelect; export type NewAnticheatFlag = typeof anticheatFlags.$inferInsert; +// ============================================================================ +// Staff audit log — who did what, across the whole console +// ============================================================================ +// +// Every mutating request to `/api/admin/**` and `/api/mod/**` lands here, one +// row per request, written after the response by a Nitro hook rather than by +// each route. See `apps/api/utils/audit.ts` and `apps/api/plugins/audit-log.ts`. +// +// ## Why the table exists +// +// The instance already had a shelf of thematic ledgers, each good inside its +// own lane: `freeleech_pool_contributions` is append-only, `bonus_grants` +// records every credit, `torrent_moderation_messages` holds the discussion on +// an upload, a withdrawn report leaves a tombstone, and the one route that +// reads private mail logs itself. What none of them answered is the question +// asked across the site: who banned this member, who changed that setting, who +// touched federation, who fired panic mode — and when. +// +// A system that goes to this much trouble to protect members from the site +// (hashed IPs on a rotating salt, zero-knowledge auth, panic encryption, GDPR +// erasure) and cannot say which moderator did what has the asymmetry the wrong +// way round. It is also what makes a compromised staff account invisible. +// +// ## Append-only, and what that means here +// +// Nothing in the application updates or deletes a row except the retention +// sweep. There is no edit path and no "mark as reviewed": a register whose +// entries can be amended by the people it registers is not a register. The +// retention sweep is the single exception, it deletes whole rows by age, and +// the period it uses is published on `/api/privacy` like every other one. +// +// ## Denormalised actor, on purpose +// +// `actorId` is a nullable FK so an erased account does not take its history +// with it; `actorName` and `actorRole` are copies taken at write time. Three +// reasons, and the first is the one that matters: the point of the row is what +// was true WHEN the action happened. A moderator later promoted to admin, or +// renamed, or erased, must not retroactively change the record. The other two +// are that a listing then needs no join, and that GDPR erasure can scrub the +// name in place (see `utils/account/eraseAccount`) without deleting the entry. +// +// ## The IP is hashed, and only comparable within a day +// +// `actorIpHash` goes through the same daily-rotating-salt hash as everywhere +// else — no raw IP is persisted anywhere in this system and an audit log is a +// poor place to make the first exception. The consequence is stated rather +// than hidden: two rows can be compared for "same IP" only if they fall on the +// same day. That is enough for "this admin's session came from somewhere else +// than the rest of today's actions" and not enough for a long-range history, +// which is the trade the rest of the codebase already makes. +export const auditLog = pgTable( + 'audit_log', + { + id: text('id').primaryKey(), + // Null once the actor's account has been erased. The name survives in + // `actorName`, scrubbed to the tombstone by the erasure. + actorId: text('actor_id').references(() => users.id, { + onDelete: 'set null', + }), + actorName: text('actor_name').notNull(), + /** `owner` | `admin` | `moderator`, as it was at the time. */ + actorRole: text('actor_role').notNull(), + /** + * A stable, dotted key naming the operation — `user.ban`, + * `settings.update`, `federation.peer.revoke`. + * + * Set by the route when it calls `auditDetail`; otherwise derived from the + * method and path so an un-enriched route still produces something a + * person can read and a filter can group on. Deriving rather than leaving + * it null is what makes coverage a property of the middleware instead of a + * property of somebody remembering. + */ + action: text('action').notNull(), + method: text('method').notNull(), + /** Request path, query string stripped — it can carry personal data. */ + path: text('path').notNull(), + /** `user` | `torrent` | `setting` | `peer` | … — free-form by design. */ + targetType: text('target_type'), + targetId: text('target_id'), + /** Human-readable target, denormalised for the same reason the actor is. */ + targetLabel: text('target_label'), + /** + * What changed, as `{ field: { from, to } }`, or any shape a route finds + * clearer. Only ever what the route chose to record: request bodies are + * NEVER captured wholesale, because they carry passwords, panic passwords + * and channel tokens. + */ + changes: jsonb('changes'), + statusCode: integer('status_code').notNull(), + actorIpHash: text('actor_ip_hash'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + // The default listing: newest first, whole table. + index('audit_log_created_idx').on(table.createdAt.desc()), + // "Everything this staffer did" — the first question asked of an audit log + // when an account is suspected. + index('audit_log_actor_idx').on(table.actorId, table.createdAt.desc()), + // "Everything done to this member / torrent" — the second question. + index('audit_log_target_idx').on( + table.targetType, + table.targetId, + table.createdAt.desc() + ), + // Filtering the listing by operation. + index('audit_log_action_idx').on(table.action, table.createdAt.desc()), + ] +); + +export type AuditLogEntry = typeof auditLog.$inferSelect; +export type NewAuditLogEntry = typeof auditLog.$inferInsert; + // ============================================================================ // Federation (inter-instance, opt-in, owner-controlled — Phase 0 socle) // ============================================================================ From 8cfd07173616acbddeac3039bac8938a0aadb54c Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Mon, 31 Aug 2026 23:37:57 +0200 Subject: [PATCH 06/96] docs(roadmap): the five that shipped, and two that shipped a while ago MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.35.x entry, plus a correction that had been sitting there for two releases: "Private Messages" and "Theme System" were still listed under **Later** while both were live — messaging landed in 0.34.0 and the theme system before it. A reader coming to the project from this page would under-read it by two whole features, which is the opposite of what a roadmap is for. Both now have entries under Released, where the code is. "Collages / Collections" was the other stale line, and it was stale by being two things. A **group** is releases of the same work, keyed on an external metadata id — already in progress, and the cross-seed content signatures already answer "these are the same work". A **collection** is an editorial grouping somebody curates. One is identity, the other is taste, and a single bullet covering both is why the line survived three releases without anybody being able to say whether it was done. Only the second is still open, and it now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- doc/guide/roadmap.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/doc/guide/roadmap.md b/doc/guide/roadmap.md index 14ff7a30..3726c132 100644 --- a/doc/guide/roadmap.md +++ b/doc/guide/roadmap.md @@ -4,6 +4,23 @@ Trackarr is actively developed with a focus on performance, security, and usabil ## Released +### 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). +- [x] **BEP 52 announce** — A v2 or hybrid torrent announces under a second infohash (SHA-256 truncated to 20 bytes), and the tracker only ever looked up `info_hash`. So a hybrid torrent errored on every second announce and its swarm was split, with v1-only and v2-capable peers keyed apart in Redis and unable to see each other. The announce path now tries v1 first (unchanged, one unique-index hit) and falls back to a partial expression index over `left(info_hash_v2, 40)`, returning the canonical hash as the swarm key — which is what merges the two halves. `/scrape` does the same over both transports, bounded to 8 lookups per request. `info_hash_v2` itself was being hashed over a re-encoding of the decoded info dict, which is the same value for a canonical torrent and a different one for anything else; it is now hashed over the file's original bytes, located by a bencode range scanner, and back-filled over the existing catalogue. See [BitTorrent v2](./bittorrent-v2.md). +- [x] **Torznab: seeding obligations and infohash** — The feed now emits `minimumratio`, `minimumseedtime` and `infohash`. The first two are the numbers the tracker already enforces — a minimum ratio at announce time, a required seed time as a hit-and-run sanction — and until now nobody was told them in advance; sent through the channel Sonarr and Radarr already read, they turn hit-and-run from a trap into a contract. Both are omitted rather than sent as `0` when the site imposes neither. The volume factors, hard-coded to `1` since the feed shipped, now reflect the running bonus event: the feed was telling *Arr clients "normal rates" during a site-wide freeleech. Mirrored (federated) items carry the hash but no obligations — those belong to the instance that holds the release. See [Torznab](../integrations/torznab.md). +- [x] **Data export (GDPR Art. 15 / 20)** — Erasure (Art. 17) has been here since the account page; the access and portability half had not, even though the erasure had already settled what counts as a member's data and a dozen `/api/me/*` routes were reading most of it a page at a time. One request now returns the record as a record: one JSON document, behind the same fresh-auth step-up erasure uses, with every collection capped and declaring its own true total. Credentials, private-message bodies, other members' identities and anti-cheat findings are excluded, and the file lists every omission with its reason. See [Your data](./your-data.md). +- [x] **Installable app (PWA)** — A manifest served by the API, so the app's name, colours and icon follow the instance's branding instead of being baked into the bundle; plus the service worker (which has been the Web Push receiver all along) registered on page load rather than only when a member enables notifications, since a browser decides installability at load time. Icon `sizes` is measured from the uploaded image's own header rather than asserted — Chrome installs on the strength of that claim, and a fabricated `512x512` buys an install prompt and a blurry icon. No offline mode: every page here is a live view of a swarm, and a cache-first worker would serve yesterday's ratio with no way for the reader to tell. See [Install as an app](./install-as-app.md). + +### v0.34.x — Private messaging + +- [x] **Private messages** — Direct conversations with optional end-to-end encryption, reactions, replies, edits, read receipts, archiving and search; a staff half with broadcasts and pinned messages; blocking that covers every route into a conversation; retention periods published on `/privacy`. Ships disabled. See [Messaging](./messaging.md). +- [x] **Support tickets** — A way to reach the staff that blocking cannot close, with categories, assignment and closure reasons. + +### v0.33.x — Configurable themes + +- [x] **Theme system** — Operator-authored themes beyond the built-in dark/light pair: a token set per theme, a `System` mode mapping two of them onto the OS preference, uploaded fonts, and a per-response stylesheet at `/api/theme.css` that is render-blocking in both the SSR and static shapes so the first paint is already the right one. See [Themes](./themes.md). + ### v0.26.x — Report tombstones, federation health, generated OpenAPI - [x] **Withdrawn-report tombstones** — Withdrawing a pending report used to hard-delete the row, which meant someone could file and pull reports in series and leave no trace. The row now survives as `withdrawn` and leaves the reporter's own list entirely — from their side nothing changed — while moderation gains a Withdrawn filter and, beside each reporter, how many reports they have withdrawn overall, shown from two upward. See [Reports](./reports.md). @@ -89,9 +106,7 @@ Trackarr is actively developed with a focus on performance, security, and usabil ## Later - [ ] **Plugin Architecture** — Admin-activatable modules -- [ ] **Private Messages** — User-to-user inbox system -- [ ] **Collages / Collections** — Group torrents by theme -- [ ] **Theme System** — Custom theme support beyond the built-in dark/light pair +- [ ] **Collections** — Member-curated groupings by theme, with suggestions and a bulk `.torrent` download. Distinct from the persisted **groups** above, which are releases of the same work keyed on a metadata id: one is editorial, the other is identity, and conflating them is what made this line ambiguous for three releases. - [ ] **OpenAPI response schemas** — The generated spec covers paths, methods, auth and shared request bodies. Response shapes, and the bodies of the ~70 routes that declare their schema inside the handler closure, need a convention change before static analysis can reach them honestly. - [ ] **API docs viewer** — The spec is served raw; a bundled viewer (Swagger UI / Scalar) would need packaging, since the CSP rules out a CDN. - [ ] **E2E Tests** — Complete functional test suite. Priority within this bucket: the money paths (bonus accrual, bounty escrow, freeleech pool), which are the least covered relative to what they move. From 067f2bee056ee5b3a13b34d082d14bb0b07d8e7d Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 09:58:37 +0200 Subject: [PATCH 07/96] feat(bonus): a freeleech you can put on one torrent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multiplier system was site-wide and only site-wide. An operator could declare a freeleech for everybody or for nobody — there was no way to revive one rare release, no way to double the upload on an internal, no way to put anything at the top of a listing. It is the most visible thing missing from the operator's side, and the pipe for it already existed: Torznab has been emitting `downloadvolumefactor` per item since the feed shipped, and those numbers could only ever describe the whole page. Four columns on `torrents`: two multipliers, an expiry, a pinned flag. Basis points ×100, the same units `bonus_events` uses, because one unit system beats two. Two multipliers plus one expiry rather than a family of booleans — one pair of columns expresses freeleech, silverleech, double-upload and every combination, where three booleans can contradict each other and need a precedence rule nobody remembers. The decision worth arguing about is how a torrent buff meets a running site-wide event, and the answer is NOT the product. Multiplying a site-wide freeleech (download 0) by a per-torrent double-upload (upload 200) gives 0 and 400 — the freeleech silently doubling an upload bonus nobody granted. `bonus.Best` takes the better of each axis independently: lower download wins, higher upload wins. That keeps every buff meaning what its operator set it to, and buys a property worth having — a buff can only ever help a member relative to the site-wide state, so granting a freeleech never requires checking what else is running first. The rule is asserted against the same table of cases on both sides of the wire. Expiry is neutralised in the announce query itself: CASE WHEN multipliers_until IS NULL OR multipliers_until > now() THEN download_multiplier ELSE 100 END So a buff ends the moment its timestamp passes, whether or not any job noticed, and the hot path carries no clock logic. There is nothing to schedule and nothing to forget to run. It costs nothing on the announce. The values ride on the row step 3 had to read anyway, so a buffed torrent and an unbuffed one are the same number of queries. `ResolveAnnouncedTorrent` grew a return type rather than a fourth out-parameter, which is also what stopped the scrape path from silently keeping the old shape. Pinning does NOT go in the ORDER BY, which is the obvious implementation and the wrong one: `is_sticky DESC` in front of the sort key stops every existing single-column index from serving it, so a catalogue that sorted by date off an index starts doing a full sort on every page. Pinned rows come back as a separate capped block on page 1, under the same filters as the flow, and are held OUT of the flow on every page — so a release appears exactly once and the page count describes what can actually be scrolled through. Five of them, because a first screen that is all pins is a page with no listing on it. Two gates, and the split is not bureaucracy. Pinning moves a release up a page; setting its download multiplier to 0 mints upload credit out of nothing. Any moderator may do the first, only an admin the second — the same line the ban route already draws. Both land in the audit log with before-and-after values, which is what makes an economic power auditable rather than merely restricted. Deliberately no notification to the uploader. It would need a 51st type across six files, it would fire on every adjustment including the ones that take a buff away, and the badge on the page already says what is true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + .../api/mod/torrents/[hash]/buffs.put.ts | 197 + apps/api/routes/api/torrents/index.get.ts | 68 +- apps/api/routes/api/torznab/api/index.get.ts | 23 +- apps/api/test/torrentBuffs.test.ts | 140 + apps/api/utils/torrentBuffs.ts | 98 + apps/tracker/db/queries/torrents.sql | 17 +- apps/tracker/db/schema.sql | 7 + apps/tracker/internal/bonus/bonus.go | 30 + apps/tracker/internal/bonus/bonus_test.go | 70 + apps/tracker/internal/db/cache.go | 41 +- apps/tracker/internal/queries/querier.go | 7 +- apps/tracker/internal/queries/torrents.sql.go | 44 +- apps/tracker/internal/server/handler.go | 42 +- apps/web/app/pages/torrents/[hash]/index.vue | 289 + apps/web/app/pages/torrents/index.vue | 57 + apps/web/i18n/locales/en.json | 27 +- apps/web/i18n/locales/fr.json | 27 +- doc/.vitepress/config.ts | 1 + doc/guide/torrent-buffs.md | 90 + .../db/src/migrations/0060_torrent_buffs.sql | 4 + .../db/src/migrations/meta/0060_snapshot.json | 9767 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 32 + 24 files changed, 11032 insertions(+), 54 deletions(-) create mode 100644 apps/api/routes/api/mod/torrents/[hash]/buffs.put.ts create mode 100644 apps/api/test/torrentBuffs.test.ts create mode 100644 apps/api/utils/torrentBuffs.ts create mode 100644 doc/guide/torrent-buffs.md create mode 100644 packages/db/src/migrations/0060_torrent_buffs.sql create mode 100644 packages/db/src/migrations/meta/0060_snapshot.json diff --git a/README.md b/README.md index 2cb2ead3..cddb2dc7 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre - **Seed-bonus points** — customisable per-minute rules (time, torrent age, rarity); tiered curves with live preview; ledger-backed. - **Bonus shop** — operator-curated catalogue with built-in `upload_credit` and `invite` effects. - **Bonus events** — time-bounded Freeleech / Silverleech / custom multipliers, applied on the announce hot path. +- **Per-torrent buffs** — freeleech, silverleech or double-upload on one release, plus pinning. Where a buff meets a site-wide event the member gets the better of the two on each axis, never the product; a lapsed buff is neutralised in the announce query itself, so nothing has to sweep. See [Per-torrent buffs](doc/guide/torrent-buffs.md). - **Panic Mode** — instant AES-256-GCM encryption of torrent data + user fields; recovery requires the original Panic Password. See [Panic Mode](doc/guide/panic-mode.md). - **Distributed rate limiting** — Redis-backed sliding windows; progressive penalties; auto IP bans. - **Optional static deployment** — distroless nginx serves a CSR bundle in **~28 MB** (see below). diff --git a/apps/api/routes/api/mod/torrents/[hash]/buffs.put.ts b/apps/api/routes/api/mod/torrents/[hash]/buffs.put.ts new file mode 100644 index 00000000..f9e430e8 --- /dev/null +++ b/apps/api/routes/api/mod/torrents/[hash]/buffs.put.ts @@ -0,0 +1,197 @@ +/** + * PUT /api/mod/torrents/:hash/buffs + * + * Per-torrent bonus multipliers and the pinned flag — the buffs an operator + * applies to one release rather than to the whole site. + * + * ## Two powers, two gates + * + * `requireModeratorSession` covers the route, and the multipliers then require + * `isAdmin` on top. That split is not bureaucracy: pinning a release moves it + * up a page and changes nothing about what it costs, while setting its download + * multiplier to 0 mints upload credit out of nothing. One is editorial, the + * other is economic, and the existing ban route already draws a line in the + * same place (a moderator bans, an admin bans a moderator). + * + * Whichever gate applies, the request lands in the staff audit log — it is a + * mutating call under `/api/mod/`, so the hook records it without this route + * asking. `auditDetail` below only sharpens what it says. + * + * ## Units + * + * Basis points ×100, the same convention `bonus_events` uses: `0` freeleech, + * `50` silverleech, `100` normal, `200` double upload. Bounds come from + * `utils/bonusEvents` rather than being restated, so a torrent buff can never + * exceed what a site-wide event may do. + * + * ## `until` + * + * `null` means "until an operator changes it". A timestamp means the buff + * lapses on its own — and it lapses in SQL, on the announce path's own read, + * so there is no sweep to schedule and nothing to forget. Setting a time in + * the past is refused rather than silently accepted as "already over": it + * reads as a mistake, and the way to end a buff is to reset it. + * + * ## No notification + * + * A freeleech on somebody's upload is good news, and it is deliberately not + * announced to them. It would need a 51st notification type across six files, + * it would fire on every adjustment including the ones that take a buff away, + * and the badge on the torrent page already says what is true. The audit log + * says who did it. + */ +import { eq } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { auditDetail } from '~~/utils/audit'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; +import { + DOWNLOAD_MULTIPLIER_MAX, + DOWNLOAD_MULTIPLIER_MIN, + UPLOAD_MULTIPLIER_MAX, + UPLOAD_MULTIPLIER_MIN, +} from '~~/utils/bonusEvents'; + +const bodySchema = z.object({ + downloadMultiplier: z + .number() + .int() + .min(DOWNLOAD_MULTIPLIER_MIN) + .max(DOWNLOAD_MULTIPLIER_MAX) + .optional(), + uploadMultiplier: z + .number() + .int() + .min(UPLOAD_MULTIPLIER_MIN) + .max(UPLOAD_MULTIPLIER_MAX) + .optional(), + /** ISO timestamp, or null for "no end date". */ + until: z.union([z.iso.datetime(), z.null()]).optional(), + isSticky: z.boolean().optional(), +}); + +export default defineEventHandler(async (event) => { + const { user: actor } = await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + const infoHash = hash.toLowerCase(); + + const body = await validateBody(event, bodySchema); + + const touchesEconomy = + body.downloadMultiplier !== undefined || + body.uploadMultiplier !== undefined || + body.until !== undefined; + + if (touchesEconomy && !actor.isAdmin) { + throw createError({ + statusCode: 403, + message: 'Only an admin can change a torrent’s bonus multipliers.', + }); + } + + const existing = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, infoHash), + columns: { + id: true, + name: true, + downloadMultiplier: true, + uploadMultiplier: true, + multipliersUntil: true, + isSticky: true, + }, + }); + if (!existing) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + // A past timestamp is a mistake, not a way to end a buff. Reset the + // multipliers to 100 for that. + if (typeof body.until === 'string' && new Date(body.until) <= new Date()) { + throw createError({ + statusCode: 400, + message: 'The end date must be in the future. Reset the multipliers to end a buff now.', + }); + } + + const next = { + downloadMultiplier: body.downloadMultiplier ?? existing.downloadMultiplier, + uploadMultiplier: body.uploadMultiplier ?? existing.uploadMultiplier, + multipliersUntil: + body.until === undefined + ? existing.multipliersUntil + : body.until === null + ? null + : new Date(body.until), + isSticky: body.isSticky ?? existing.isSticky, + }; + + // An end date on a torrent carrying no buff describes nothing, and it would + // sit in the row waiting to "expire" values that are already neutral. Drop it + // rather than store a fact about nothing. + if ( + next.downloadMultiplier === 100 && + next.uploadMultiplier === 100 && + next.multipliersUntil !== null + ) { + next.multipliersUntil = null; + } + + auditDetail(event, { + action: 'torrent.buffs', + targetType: 'torrent', + targetId: existing.id, + targetLabel: existing.name, + changes: { + ...(next.downloadMultiplier !== existing.downloadMultiplier + ? { + downloadMultiplier: { + from: existing.downloadMultiplier, + to: next.downloadMultiplier, + }, + } + : {}), + ...(next.uploadMultiplier !== existing.uploadMultiplier + ? { + uploadMultiplier: { + from: existing.uploadMultiplier, + to: next.uploadMultiplier, + }, + } + : {}), + ...(next.multipliersUntil?.getTime() !== existing.multipliersUntil?.getTime() + ? { + until: { + from: existing.multipliersUntil?.toISOString() ?? null, + to: next.multipliersUntil?.toISOString() ?? null, + }, + } + : {}), + ...(next.isSticky !== existing.isSticky + ? { isSticky: { from: existing.isSticky, to: next.isSticky } } + : {}), + }, + }); + + await db + .update(schema.torrents) + .set(next) + .where(eq(schema.torrents.id, existing.id)); + + // The announce path reads the row directly and the SQL neutralises a lapsed + // buff, so there is no cache to bust here — a change takes effect on the + // next announce, not on the next cache TTL. + return { + success: true, + downloadMultiplier: next.downloadMultiplier, + uploadMultiplier: next.uploadMultiplier, + until: next.multipliersUntil?.toISOString() ?? null, + isSticky: next.isSticky, + }; +}); diff --git a/apps/api/routes/api/torrents/index.get.ts b/apps/api/routes/api/torrents/index.get.ts index 9404e4e6..336ba4af 100644 --- a/apps/api/routes/api/torrents/index.get.ts +++ b/apps/api/routes/api/torrents/index.get.ts @@ -18,6 +18,16 @@ import { } from '~~/utils/search'; import { adultCategoryIds } from '~~/utils/adultContent'; +/** + * How many pinned releases one listing may carry. + * + * Small on purpose. A pin is an editorial act — "read this one" — and the + * moment a first screen is all pins the listing has stopped being a listing. + * An operator who wants ten things at the top wants a homepage block, not a + * catalogue. + */ +const MAX_PINNED = 5; + export default defineEventHandler(async (event) => { // Require authentication const { user } = await requireUserSession(event); @@ -216,8 +226,22 @@ export default defineEventHandler(async (event) => { // The search predicate is kept apart from the filters, so the fuzzy fallback // replays the same query replacing only that. + /** + * Pinned releases are lifted out of the flow entirely, on every page. + * + * Not folded into the ORDER BY, which is the obvious implementation and the + * wrong one: putting `is_sticky DESC` in front of the sort key stops every + * existing single-column index from serving it, so a catalogue that sorted by + * date off an index starts doing a full sort on every page. A separate, + * capped query costs one extra round trip on page 1 and nothing after. + * + * Excluded on every page rather than only on page 1 so a torrent appears + * exactly once in a listing, and so `total` and the page count agree with + * what the reader can actually scroll through. + */ + const notPinned = eq(schema.torrents.isSticky, false); const compose = (search: SQL | null) => { - const all = search ? [...conditions, search] : conditions; + const all = search ? [...conditions, search, notPinned] : [...conditions, notPinned]; return all.length > 0 ? and(...all) : undefined; }; const countRows = async (where: SQL | undefined) => { @@ -246,6 +270,29 @@ export default defineEventHandler(async (event) => { // tiebreaker. const orderByClause = buildTorrentOrderBy(query.sortBy, query.order); + /** + * The pinned block, page 1 only, and under the SAME filters as the flow — + * a release pinned site-wide has no business appearing in a search for + * something else, and a member filtering by category is asking a question + * that a pin does not override. + * + * Capped hard: pinning is an editorial act and a page whose first screen is + * all pins is a page with no listing on it. + */ + const pinnedRows = + query.page === 1 + ? await db.query.torrents.findMany({ + where: and( + ...(searchCondition ? [...conditions, searchCondition] : conditions), + eq(schema.torrents.isSticky, true), + ), + columns: { torrentData: false }, + with: { category: true, torrentTags: { with: { tag: true } } }, + orderBy: orderByClause, + limit: MAX_PINNED, + }) + : []; + const torrents = await db.query.torrents.findMany({ where: whereClause, // Negative projection: select every column EXCEPT the raw .torrent @@ -267,10 +314,15 @@ export default defineEventHandler(async (event) => { // `total` was already computed above: it is what gates the fuzzy fallback. + // Pinned rows and flow rows are enriched as one list — one Redis round of + // stats, one favourites query — then split back apart at the end. Doing it + // twice would double both for a block that is usually empty. + const allRows = [...pinnedRows, ...torrents]; + // Enrich with live stats from Redis. Tolerate partial failure: a Redis hiccup // for one torrent should not fail the whole listing — fall back to zeroes. const settled = await Promise.allSettled( - torrents.map((t) => getStats(t.infoHash)) + allRows.map((t) => getStats(t.infoHash)) ); // Bulk-lookup the viewer's favorited torrent_ids among the page @@ -279,7 +331,7 @@ export default defineEventHandler(async (event) => { // toggle's filled/outline state authoritative without a // per-row round-trip. let favoritedSet = new Set<string>(); - if (torrents.length > 0) { + if (allRows.length > 0) { const rows = await db .select({ torrentId: schema.torrentFavorites.torrentId }) .from(schema.torrentFavorites) @@ -288,14 +340,14 @@ export default defineEventHandler(async (event) => { eq(schema.torrentFavorites.userId, user.id), inArray( schema.torrentFavorites.torrentId, - torrents.map((t) => t.id), + allRows.map((t) => t.id), ), ), ); favoritedSet = new Set(rows.map((r) => r.torrentId)); } - const enriched = torrents.map((torrent, i) => { + const enriched = allRows.map((torrent, i) => { const r = settled[i]; const stats = r.status === 'fulfilled' @@ -316,10 +368,14 @@ export default defineEventHandler(async (event) => { }); return { - data: enriched, + // Split back apart in the order they went in. + pinned: enriched.slice(0, pinnedRows.length), + data: enriched.slice(pinnedRows.length), pagination: { page: query.page, limit: query.limit, + // Pinned rows are outside this count, which is what keeps the page + // count honest about the flow the reader is paging through. total, pages: Math.ceil(total / query.limit), }, diff --git a/apps/api/routes/api/torznab/api/index.get.ts b/apps/api/routes/api/torznab/api/index.get.ts index b7031384..6746e717 100644 --- a/apps/api/routes/api/torznab/api/index.get.ts +++ b/apps/api/routes/api/torznab/api/index.get.ts @@ -52,6 +52,7 @@ import { isHnrEnabled, } from '~~/utils/settings'; import { getActiveSnapshot } from '~~/utils/bonusEvents'; +import { IDENTITY, volumeFactors } from '~~/utils/torrentBuffs'; import { escapeLike } from '~~/utils/sql'; import { adultCategoryIds } from '~~/utils/adultContent'; @@ -409,14 +410,17 @@ async function performSearch( 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; + // The site-wide half of the volume factors. The per-torrent half is on each + // row and is folded in inside the map below, so two releases in one response + // can legitimately carry different factors — which is the whole point of a + // per-torrent buff, and was impossible while these were one pair of numbers + // for the page. + const siteWide = activeEvent + ? { + download: activeEvent.downloadMultiplier, + upload: activeEvent.uploadMultiplier, + } + : IDENTITY; // Fetch torrents const torrents = await db.query.torrents.findMany({ @@ -449,8 +453,7 @@ async function performSearch( leechers: stats.leechers, grabs: stats.completed, downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.passkey}`, - downloadVolumeFactor, - uploadVolumeFactor, + ...volumeFactors(torrent, siteWide), infoHash: torrent.infoHash, minimumRatio: minRatio, minimumSeedTime, diff --git a/apps/api/test/torrentBuffs.test.ts b/apps/api/test/torrentBuffs.test.ts new file mode 100644 index 00000000..57d12404 --- /dev/null +++ b/apps/api/test/torrentBuffs.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { + best, + buffLabel, + IDENTITY, + torrentMultipliers, + volumeFactors, +} from '../utils/torrentBuffs'; + +// The API's half of a rule the Go tracker also implements +// (`apps/tracker/internal/bonus.Best`). These cases mirror `TestBest` there on +// purpose: two implementations of one rule drift, and the table is what catches +// it when only one side is changed. + +const NEUTRAL = { downloadMultiplier: 100, uploadMultiplier: 100, multipliersUntil: null }; + +describe('torrentMultipliers', () => { + it('reads the buff off the row', () => { + expect( + torrentMultipliers({ ...NEUTRAL, downloadMultiplier: 0 }) + ).toEqual({ download: 0, upload: 100 }); + }); + + it('neutralises a lapsed buff', () => { + const past = new Date('2020-01-01T00:00:00Z'); + expect( + torrentMultipliers({ + downloadMultiplier: 0, + uploadMultiplier: 200, + multipliersUntil: past, + }) + ).toEqual(IDENTITY); + }); + + it('honours a buff that has not lapsed yet', () => { + const future = new Date(Date.now() + 60_000); + expect( + torrentMultipliers({ + downloadMultiplier: 0, + uploadMultiplier: 100, + multipliersUntil: future, + }) + ).toEqual({ download: 0, upload: 100 }); + }); + + it('treats a null end date as no end date', () => { + expect( + torrentMultipliers({ + downloadMultiplier: 50, + uploadMultiplier: 100, + multipliersUntil: null, + }) + ).toEqual({ download: 50, upload: 100 }); + }); +}); + +describe('best', () => { + it('is the identity when nothing is running and nothing is buffed', () => { + expect(best(IDENTITY, IDENTITY)).toEqual(IDENTITY); + }); + + it('takes the lower download and the higher upload', () => { + // The case the product gets wrong: multiplying a site freeleech by a + // per-torrent double upload would give upload 400 — credit nobody granted. + expect( + best({ download: 0, upload: 100 }, { download: 100, upload: 200 }) + ).toEqual({ download: 0, upload: 200 }); + }); + + it('cannot be made worse by the stingier side', () => { + expect( + best({ download: 0, upload: 200 }, { download: 100, upload: 100 }) + ).toEqual({ download: 0, upload: 200 }); + }); + + it('is commutative', () => { + const a = { download: 50, upload: 150 }; + const b = { download: 0, upload: 100 }; + expect(best(a, b)).toEqual(best(b, a)); + }); +}); + +describe('volumeFactors', () => { + it('converts basis points to the plain factors Torznab wants', () => { + expect( + volumeFactors( + { downloadMultiplier: 0, uploadMultiplier: 200, multipliersUntil: null }, + IDENTITY + ) + ).toEqual({ downloadVolumeFactor: 0, uploadVolumeFactor: 2 }); + }); + + it('lets two torrents in one response differ', () => { + // Which is the whole point: these used to be one pair of numbers for the + // entire page. + const buffed = volumeFactors( + { downloadMultiplier: 0, uploadMultiplier: 100, multipliersUntil: null }, + IDENTITY + ); + const plain = volumeFactors(NEUTRAL, IDENTITY); + expect(buffed.downloadVolumeFactor).toBe(0); + expect(plain.downloadVolumeFactor).toBe(1); + }); + + it('still reflects a site-wide event on an unbuffed torrent', () => { + expect( + volumeFactors(NEUTRAL, { download: 0, upload: 200 }) + ).toEqual({ downloadVolumeFactor: 0, uploadVolumeFactor: 2 }); + }); +}); + +describe('buffLabel', () => { + it('names the two presets and the double upload', () => { + expect(buffLabel({ ...NEUTRAL, downloadMultiplier: 0 })).toBe('freeleech'); + expect(buffLabel({ ...NEUTRAL, downloadMultiplier: 50 })).toBe('silverleech'); + expect(buffLabel({ ...NEUTRAL, uploadMultiplier: 200 })).toBe('double-upload'); + }); + + it('falls back to `custom` for anything else', () => { + expect( + buffLabel({ downloadMultiplier: 25, uploadMultiplier: 150, multipliersUntil: null }) + ).toBe('custom'); + }); + + it('is null when the torrent carries no buff of its own', () => { + // Deliberately blind to site-wide events: a badge on one torrent among a + // hundred has to mean THIS one. + expect(buffLabel(NEUTRAL)).toBeNull(); + }); + + it('is null once the buff has lapsed', () => { + expect( + buffLabel({ + downloadMultiplier: 0, + uploadMultiplier: 100, + multipliersUntil: new Date('2020-01-01T00:00:00Z'), + }) + ).toBeNull(); + }); +}); diff --git a/apps/api/utils/torrentBuffs.ts b/apps/api/utils/torrentBuffs.ts new file mode 100644 index 00000000..3016b91f --- /dev/null +++ b/apps/api/utils/torrentBuffs.ts @@ -0,0 +1,98 @@ +/** + * What a torrent actually costs right now — the site-wide bonus event and the + * torrent's own buff, resolved into one pair of factors. + * + * The rule is the same one the Go tracker applies on the announce path + * (`internal/bonus.Best`): the member gets the better of the two on each axis, + * never the product. Two implementations of one rule is a drift risk, and the + * mitigation is that this is the ONLY place the API expresses it — Torznab, the + * torrent detail route and the listing all call in here rather than each doing + * their own `Math.min`. + * + * If you change the rule, change `apps/tracker/internal/bonus/bonus.go` in the + * same commit. The tests on both sides assert the same table of cases. + */ + +/** Basis points ×100, as stored. */ +export interface Multipliers { + download: number; + upload: number; +} + +export const IDENTITY: Multipliers = { download: 100, upload: 100 }; + +/** The columns this needs from a torrent row. */ +export interface BuffableTorrent { + downloadMultiplier: number; + uploadMultiplier: number; + multipliersUntil: Date | null; +} + +/** + * The torrent's own buff, neutralised when it has lapsed. + * + * The announce path gets this from SQL; here it is done in TypeScript because + * the rows are already in memory and re-reading them to let Postgres do the + * comparison would be a query for an `if`. + */ +export function torrentMultipliers( + torrent: BuffableTorrent, + now: Date = new Date() +): Multipliers { + const lapsed = + torrent.multipliersUntil !== null && torrent.multipliersUntil <= now; + if (lapsed) return IDENTITY; + return { + download: torrent.downloadMultiplier, + upload: torrent.uploadMultiplier, + }; +} + +/** + * The better of two multiplier sets, axis by axis. + * + * Download: lower wins (0 is freeleech). Upload: higher wins (200 is double + * credit). Neither side can make the other worse, so an operator granting a + * buff never has to check what else is running first. + */ +export function best(a: Multipliers, b: Multipliers): Multipliers { + return { + download: Math.min(a.download, b.download), + upload: Math.max(a.upload, b.upload), + }; +} + +/** + * The pair a consumer should be told about, as plain factors rather than basis + * points — which is what Torznab's `downloadvolumefactor` / + * `uploadvolumefactor` want. + */ +export function volumeFactors( + torrent: BuffableTorrent, + siteWide: Multipliers, + now: Date = new Date() +): { downloadVolumeFactor: number; uploadVolumeFactor: number } { + const m = best(siteWide, torrentMultipliers(torrent, now)); + return { + downloadVolumeFactor: m.download / 100, + uploadVolumeFactor: m.upload / 100, + }; +} + +/** + * A short label for the buff a torrent carries on its own, or null when it + * carries none. Site-wide events are announced elsewhere and are not this + * function's business — a member seeing "Freeleech" on one torrent among a + * hundred should be able to trust that it means *this* one. + */ +export function buffLabel( + torrent: BuffableTorrent, + now: Date = new Date() +): 'freeleech' | 'silverleech' | 'double-upload' | 'custom' | null { + const m = torrentMultipliers(torrent, now); + if (m.download === 100 && m.upload === 100) return null; + if (m.download === 0 && m.upload === 100) return 'freeleech'; + if (m.download === 50 && m.upload === 100) return 'silverleech'; + if (m.download === 100 && m.upload === 200) return 'double-upload'; + return 'custom'; +} diff --git a/apps/tracker/db/queries/torrents.sql b/apps/tracker/db/queries/torrents.sql index 162633d4..841e6813 100644 --- a/apps/tracker/db/queries/torrents.sql +++ b/apps/tracker/db/queries/torrents.sql @@ -1,7 +1,16 @@ -- name: FindActiveTorrentByInfoHash :one -- Returns the active torrent matching the given hex info_hash, or no rows -- if either it doesn't exist or it's been deactivated. -SELECT id +-- +-- The two multipliers come back with it, already neutralised when the buff has +-- lapsed. Doing that here rather than in Go is what keeps the announce path +-- free of clock logic AND free of a sweep that has to run on time: a buff +-- expires the moment its timestamp passes, whether or not anything noticed. +SELECT id, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier FROM torrents WHERE info_hash = $1 AND is_active = true @@ -27,7 +36,11 @@ SELECT id -- 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 +SELECT id, info_hash, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier FROM torrents WHERE left(info_hash_v2, 40) = sqlc.arg(announced_hash)::text AND is_active = true diff --git a/apps/tracker/db/schema.sql b/apps/tracker/db/schema.sql index f1511413..19525c5b 100644 --- a/apps/tracker/db/schema.sql +++ b/apps/tracker/db/schema.sql @@ -41,6 +41,13 @@ CREATE TABLE IF NOT EXISTS torrents ( uploader_id text, category_id text, is_active boolean NOT NULL DEFAULT true, + -- Per-torrent bonus multipliers, basis points x100 (0 = freeleech, + -- 100 = normal, 200 = double). NULL `multipliers_until` means the buff has + -- no end date; a past one means it has lapsed, and the announce query + -- neutralises it in SQL so the hot path carries no clock logic. + download_multiplier integer NOT NULL DEFAULT 100, + upload_multiplier integer NOT NULL DEFAULT 100, + multipliers_until timestamp, is_approved boolean NOT NULL DEFAULT false, created_at timestamp NOT NULL DEFAULT NOW() ); diff --git a/apps/tracker/internal/bonus/bonus.go b/apps/tracker/internal/bonus/bonus.go index accc5b51..3cf272ed 100644 --- a/apps/tracker/internal/bonus/bonus.go +++ b/apps/tracker/internal/bonus/bonus.go @@ -51,6 +51,36 @@ type Multipliers struct { // against it returns the input deltas unchanged. var Identity = Multipliers{Download: 100, Upload: 100} +// Best returns the multipliers most favourable to the member, axis by axis. +// +// This is how a per-torrent buff combines with a running site-wide event, and +// the rule is "the member gets the better of the two" rather than the product. +// The product is what a reader first expects and it is wrong in practice: a +// site-wide freeleech (download 0) multiplied by a torrent-level double-upload +// (upload 200) would give 0 and 400 — the freeleech silently doubling an upload +// bonus nobody granted. Taking the better of each axis independently keeps each +// buff meaning what its operator set it to. +// +// - download: LOWER wins. 0 is freeleech and 100 is normal, so less is +// better for whoever is leeching. +// - upload: HIGHER wins. 200 is double credit, so more is better for whoever +// is seeding. +// +// Neither side can make the other worse: buffing a torrent can only ever help a +// member relative to the site-wide state, and vice versa. That is a property +// worth keeping — an operator granting a freeleech should never have to check +// what else is running first. +func Best(a, b Multipliers) Multipliers { + out := a + if b.Download < out.Download { + out.Download = b.Download + } + if b.Upload > out.Upload { + out.Upload = b.Upload + } + return out +} + // Apply scales a pair of (deltaUp, deltaDown) byte counts by the // upload / download multipliers. Integer-only — `delta * mul / 100` // never introduces floating-point drift, and any rounding goes diff --git a/apps/tracker/internal/bonus/bonus_test.go b/apps/tracker/internal/bonus/bonus_test.go index a9061b27..af6bf9d8 100644 --- a/apps/tracker/internal/bonus/bonus_test.go +++ b/apps/tracker/internal/bonus/bonus_test.go @@ -229,3 +229,73 @@ func TestGet_RedisError_FailsOpen(t *testing.T) { t.Fatalf("got %+v, want Identity (Redis down → fail-open)", got) } } + +// Best is how a per-torrent buff meets a site-wide event. The rule is "the +// member gets the better of the two, axis by axis" — not the product, which is +// what a reader first expects and which silently invents credit nobody granted. +func TestBest(t *testing.T) { + cases := []struct { + name string + event Multipliers + torrent Multipliers + wantDown int + wantUp int + }{ + { + name: "nothing running, no buff", + event: Identity, + torrent: Identity, + wantDown: 100, wantUp: 100, + }, + { + name: "per-torrent freeleech while the site is normal", + event: Identity, + torrent: Multipliers{Download: 0, Upload: 100}, + wantDown: 0, wantUp: 100, + }, + { + name: "site freeleech reaches a torrent with no buff", + event: Multipliers{Download: 0, Upload: 100}, + torrent: Identity, + wantDown: 0, wantUp: 100, + }, + { + // The case that makes the product wrong: multiplying would give + // download 0 and upload 400, i.e. the freeleech doubling an upload + // bonus the operator never granted. + name: "site freeleech plus per-torrent double upload", + event: Multipliers{Download: 0, Upload: 100}, + torrent: Multipliers{Download: 100, Upload: 200}, + wantDown: 0, wantUp: 200, + }, + { + name: "the more generous download wins, either side", + event: Multipliers{Download: 50, Upload: 100}, + torrent: Multipliers{Download: 0, Upload: 100}, + wantDown: 0, wantUp: 100, + }, + { + // A buff can never make a member worse off than the site-wide + // state, whichever way round the two are. + name: "a stingier torrent buff cannot undo a site freeleech", + event: Multipliers{Download: 0, Upload: 200}, + torrent: Multipliers{Download: 100, Upload: 100}, + wantDown: 0, wantUp: 200, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Best(tc.event, tc.torrent) + if got.Download != tc.wantDown || got.Upload != tc.wantUp { + t.Fatalf("Best(%+v, %+v) = %+v, want {Download:%d Upload:%d}", + tc.event, tc.torrent, got, tc.wantDown, tc.wantUp) + } + // Commutative on each axis: which argument is "the event" and which + // is "the torrent" must not change the answer. + if swapped := Best(tc.torrent, tc.event); swapped != got { + t.Fatalf("Best is not commutative: %+v vs %+v", got, swapped) + } + }) + } +} diff --git a/apps/tracker/internal/db/cache.go b/apps/tracker/internal/db/cache.go index 760272b0..d6c2ee8f 100644 --- a/apps/tracker/internal/db/cache.go +++ b/apps/tracker/internal/db/cache.go @@ -23,6 +23,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" + "github.com/florianjs/trackarr/apps/tracker/internal/bonus" "github.com/florianjs/trackarr/apps/tracker/internal/queries" ) @@ -213,29 +214,53 @@ func (d *DB) IsIpBanned(ctx context.Context, ip string) (bool, error) { // 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. +// The per-torrent buffs ride along on the same row, so they cost nothing: the +// lookup had to happen anyway, and the SQL has already neutralised a lapsed +// buff. Callers combine them with the site-wide event via `bonus.Best`. +type ResolvedTorrent struct { + ID string + // SwarmKey is the CANONICAL v1 info_hash, whichever form was announced. + SwarmKey string + Multipliers bonus.Multipliers +} + func (d *DB) ResolveAnnouncedTorrent( ctx context.Context, announcedHex string, -) (torrentID string, swarmKey string, err error) { - id, err := d.Q.FindActiveTorrentByInfoHash(ctx, announcedHex) +) (ResolvedTorrent, error) { + row, err := d.Q.FindActiveTorrentByInfoHash(ctx, announcedHex) if err == nil { - return id, announcedHex, nil + return ResolvedTorrent{ + ID: row.ID, + SwarmKey: announcedHex, + Multipliers: bonus.Multipliers{ + Download: int(row.DownloadMultiplier), + Upload: int(row.UploadMultiplier), + }, + }, nil } if !errors.Is(err, pgx.ErrNoRows) { - return "", "", err + return ResolvedTorrent{}, err } - row, v2Err := d.Q.FindActiveTorrentByInfoHashV2Short(ctx, announcedHex) + v2, 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 ResolvedTorrent{}, err } - return "", "", v2Err + return ResolvedTorrent{}, v2Err } - return row.ID, row.InfoHash, nil + return ResolvedTorrent{ + ID: v2.ID, + SwarmKey: v2.InfoHash, + Multipliers: bonus.Multipliers{ + Download: int(v2.DownloadMultiplier), + Upload: int(v2.UploadMultiplier), + }, + }, nil } // InvalidateCache drops every cached setting. Used in tests. diff --git a/apps/tracker/internal/queries/querier.go b/apps/tracker/internal/queries/querier.go index 84809f95..e7b8688b 100644 --- a/apps/tracker/internal/queries/querier.go +++ b/apps/tracker/internal/queries/querier.go @@ -27,7 +27,12 @@ type Querier interface { CreateHnrEntry(ctx context.Context, arg CreateHnrEntryParams) error // 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 two multipliers come back with it, already neutralised when the buff has + // lapsed. Doing that here rather than in Go is what keeps the announce path + // free of clock logic AND free of a sweep that has to run on time: a buff + // expires the moment its timestamp passes, whether or not anything noticed. + FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (FindActiveTorrentByInfoHashRow, error) // The BEP 52 second swarm. // // A v2 or hybrid torrent has two infohashes and a client that supports v2 diff --git a/apps/tracker/internal/queries/torrents.sql.go b/apps/tracker/internal/queries/torrents.sql.go index 6d1eb5ca..5aaee440 100644 --- a/apps/tracker/internal/queries/torrents.sql.go +++ b/apps/tracker/internal/queries/torrents.sql.go @@ -10,24 +10,43 @@ import ( ) const findActiveTorrentByInfoHash = `-- name: FindActiveTorrentByInfoHash :one -SELECT id +SELECT id, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier FROM torrents WHERE info_hash = $1 AND is_active = true LIMIT 1 ` +type FindActiveTorrentByInfoHashRow struct { + ID string + DownloadMultiplier int32 + UploadMultiplier int32 +} + // Returns the active torrent matching the given hex info_hash, or no rows // if either it doesn't exist or it's been deactivated. -func (q *Queries) FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (string, error) { +// +// The two multipliers come back with it, already neutralised when the buff has +// lapsed. Doing that here rather than in Go is what keeps the announce path +// free of clock logic AND free of a sweep that has to run on time: a buff +// expires the moment its timestamp passes, whether or not anything noticed. +func (q *Queries) FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (FindActiveTorrentByInfoHashRow, error) { row := q.db.QueryRow(ctx, findActiveTorrentByInfoHash, infoHash) - var id string - err := row.Scan(&id) - return id, err + var i FindActiveTorrentByInfoHashRow + err := row.Scan(&i.ID, &i.DownloadMultiplier, &i.UploadMultiplier) + return i, err } const findActiveTorrentByInfoHashV2Short = `-- name: FindActiveTorrentByInfoHashV2Short :one -SELECT id, info_hash +SELECT id, info_hash, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier FROM torrents WHERE left(info_hash_v2, 40) = $1::text AND is_active = true @@ -35,8 +54,10 @@ SELECT id, info_hash ` type FindActiveTorrentByInfoHashV2ShortRow struct { - ID string - InfoHash string + ID string + InfoHash string + DownloadMultiplier int32 + UploadMultiplier int32 } // The BEP 52 second swarm. @@ -61,6 +82,11 @@ type FindActiveTorrentByInfoHashV2ShortRow struct { 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) + err := row.Scan( + &i.ID, + &i.InfoHash, + &i.DownloadMultiplier, + &i.UploadMultiplier, + ) return i, err } diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go index 3fc96def..58a02071 100644 --- a/apps/tracker/internal/server/handler.go +++ b/apps/tracker/internal/server/handler.go @@ -322,7 +322,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // 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) + resolved, err := s.db.ResolveAnnouncedTorrent(ctx, infoHashHex) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return AnnounceOutcome{Failure: "Torrent not found or inactive"} @@ -330,13 +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 { + torrentID := resolved.ID + if resolved.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 + "announced", infoHashHex, "swarm", resolved.SwarmKey) + infoHashHex = resolved.SwarmKey } // 4. Dedup window — skip if same {hash,peer,event} fired within 2 seconds @@ -555,14 +556,23 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli } } - // 5b. Apply the active bonus event multipliers (Freeleech / - // Silverleech / custom) before persisting. The resolver reads - // from a 30 s in-memory cache backed by Redis, so this is a - // near-zero-cost call when no event is active. With identity - // (1x/1x) the deltas are unchanged. The cap above guarantees - // the multiplication can never overflow int64 - // (1 TiB × 1000 / 100 = 10 TiB ≪ 9.2 EiB). - mults := s.bonus.Get(ctx) + // 5b. Apply the bonus multipliers before persisting. + // + // Two sources now. The site-wide event (Freeleech / Silverleech / custom) + // comes from a 30 s in-memory cache backed by Redis, so it is a near-zero + // cost call when nothing is running. The per-torrent buff arrived on the + // row we already had to read in step 3, so it costs nothing at all — and + // the SQL has already neutralised it if it lapsed, which is why there is no + // clock here. + // + // `Best` gives the member the better of the two on each axis rather than + // the product; see the note on it for why the product is the wrong answer. + // With no event and no buff both are identity and the deltas are unchanged. + // + // The 1 TiB cap above still guarantees the multiplication cannot overflow + // int64 (1 TiB × 1000 / 100 = 10 TiB ≪ 9.2 EiB), and `Best` cannot raise a + // multiplier above the larger of its two inputs, so it does not widen that. + mults := bonus.Best(s.bonus.Get(ctx), resolved.Multipliers) deltaUp, deltaDown = mults.Apply(deltaUp, deltaDown) // 6. Persist user stats deltas (best-effort: log but don't reject). @@ -934,14 +944,14 @@ func (s *Server) ScrapeStats( } *resolveBudget-- - _, swarmKey, err := s.db.ResolveAnnouncedTorrent(ctx, announcedHex) - if err != nil || swarmKey == announcedHex { + resolved, err := s.db.ResolveAnnouncedTorrent(ctx, announcedHex) + if err != nil || resolved.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) + seeders, leechers, _ = s.peers.Counts(ctx, resolved.SwarmKey) + completed, _ = s.peers.CompletedCount(ctx, resolved.SwarmKey) return seeders, leechers, completed } diff --git a/apps/web/app/pages/torrents/[hash]/index.vue b/apps/web/app/pages/torrents/[hash]/index.vue index f2c122d0..2a1665bb 100644 --- a/apps/web/app/pages/torrents/[hash]/index.vue +++ b/apps/web/app/pages/torrents/[hash]/index.vue @@ -150,6 +150,19 @@ :status="torrent.moderationStatus" size="sm" /> + <!-- The buff this release carries ON ITS OWN. Deliberately blind to a + site-wide event: a badge on one torrent among a hundred has to + mean this one, or it means nothing. --> + <template v-if="buff"> + <span class="hero-eyebrow-sep">·</span> + <span class="buff-badge" :class="`buff-badge--${buff.kind}`"> + <Icon :name="buff.icon" /> + {{ $t(`torrent.buff.${buff.kind}`) }} + <span v-if="buff.until" class="buff-until" :title="formatDate(buff.until)"> + {{ $t('torrent.buff.until', { when: formatAge(buff.until) }) }} + </span> + </span> + </template> <span class="hero-eyebrow-spacer" aria-hidden="true" /> <!-- Favorite star — sits to the left of the report button so the "save" action is the warmer affordance and the @@ -591,6 +604,76 @@ </div> </section> + <!-- § STAFF TOOLS — two blocks that share one thing: they are decisions + about a release rather than facts about it. Kept together so the page + has one operator area instead of three, and each block carries its own + gate. Pinning is editorial and any moderator may do it; the + multipliers mint credit and only an admin may. The form mirrors that + rather than hiding a 403 behind a button that looks available. --> + <section v-if="isStaff" class="section section--buffs"> + <header class="section-head"> + <span class="section-head-mark" aria-hidden="true">§</span> + <h2 class="section-head-title">{{ $t('torrents.detail.staffTools') }}</h2> + <span class="section-head-line" aria-hidden="true" /> + </header> + + <h3 class="staff-block-title">{{ $t('torrents.detail.buffs.title') }}</h3> + <p class="buffs-lede">{{ $t('torrents.detail.buffs.lede') }}</p> + + <div class="buffs-grid"> + <label class="buffs-field"> + <span class="field-label">{{ $t('torrents.detail.buffs.download') }}</span> + <select v-model.number="buffForm.downloadMultiplier" class="input" :disabled="!user?.isAdmin"> + <option :value="0">0× — {{ $t('torrent.buff.freeleech') }}</option> + <option :value="50">0.5× — {{ $t('torrent.buff.silverleech') }}</option> + <option :value="100">1× — {{ $t('torrents.detail.buffs.normal') }}</option> + </select> + </label> + + <label class="buffs-field"> + <span class="field-label">{{ $t('torrents.detail.buffs.upload') }}</span> + <select v-model.number="buffForm.uploadMultiplier" class="input" :disabled="!user?.isAdmin"> + <option :value="100">1× — {{ $t('torrents.detail.buffs.normal') }}</option> + <option :value="150">1.5×</option> + <option :value="200">2× — {{ $t('torrent.buff.doubleUpload') }}</option> + <option :value="300">3×</option> + </select> + </label> + + <label class="buffs-field"> + <span class="field-label">{{ $t('torrents.detail.buffs.until') }}</span> + <input + v-model="buffForm.until" + type="datetime-local" + class="input" + :disabled="!user?.isAdmin" + /> + <span class="buffs-hint">{{ $t('torrents.detail.buffs.untilHint') }}</span> + </label> + + <label class="buffs-toggle"> + <input v-model="buffForm.isSticky" type="checkbox" /> + <span>{{ $t('torrents.detail.buffs.pin') }}</span> + </label> + </div> + + <div class="buffs-actions"> + <button type="button" class="btn btn-secondary" :disabled="buffBusy" @click="saveBuffs"> + <Icon :name="buffBusy ? 'ph:circle-notch' : 'ph:check-bold'" :class="{ 'animate-spin': buffBusy }" /> + {{ $t('torrents.detail.buffs.save') }} + </button> + <button + v-if="buff" + type="button" + class="btn-ghost" + :disabled="buffBusy || !user?.isAdmin" + @click="clearBuffs" + > + {{ $t('torrents.detail.buffs.clear') }} + </button> + </div> + </section> + <!-- § SWARM — admin-only peer list. Operational data, kept as a table because that's the format that scans best. --> <section v-if="user?.isAdmin" class="section section--swarm"> @@ -736,6 +819,19 @@ interface TorrentDetail { // header badge + the inline panel can render without an extra // round-trip. moderationStatus?: 'pending' | 'accepted' | 'changes_requested' | 'rejected'; + /** + * Per-torrent bonus buffs. Basis points ×100 as stored — `0` freeleech, + * `100` normal, `200` double upload — with `multipliersUntil` null when the + * buff has no end date. + * + * A lapsed buff is NOT neutralised in the payload: the row is sent as it is + * and the badge decides, so a staffer editing it sees the values that are + * actually stored rather than a helpful lie. + */ + downloadMultiplier?: number; + uploadMultiplier?: number; + multipliersUntil?: string | null; + isSticky?: boolean; stats: { seeders: number; leechers: number; @@ -1067,6 +1163,112 @@ async function toggleFedSwarm() { const notifications = useNotificationStore(); const confirm = useConfirm(); +/** + * The buff this release carries on its own, or null. + * + * Mirrors `apps/api/utils/torrentBuffs.buffLabel` — same four names, same + * "lapsed reads as none" rule. Duplicated here rather than sent pre-computed + * because the staff panel below edits the raw numbers, and a payload carrying + * both the numbers and a label derived from them is a payload with two answers + * to one question. + */ +const buff = computed(() => { + const t0 = torrent.value; + if (!t0 || t0.downloadMultiplier === undefined || t0.uploadMultiplier === undefined) { + return null; + } + const lapsed = !!t0.multipliersUntil && new Date(t0.multipliersUntil) <= new Date(); + if (lapsed) return null; + const dl = t0.downloadMultiplier; + const ul = t0.uploadMultiplier; + if (dl === 100 && ul === 100) return null; + + const kind = + dl === 0 && ul === 100 + ? 'freeleech' + : dl === 50 && ul === 100 + ? 'silverleech' + : dl === 100 && ul === 200 + ? 'doubleUpload' + : 'custom'; + const icon = + kind === 'freeleech' + ? 'ph:gift-fill' + : kind === 'silverleech' + ? 'ph:gift' + : kind === 'doubleUpload' + ? 'ph:arrow-fat-lines-up-fill' + : 'ph:sparkle-fill'; + return { kind, icon, until: t0.multipliersUntil ?? null, dl, ul }; +}); + +const isStaff = computed( + () => !!user.value && (user.value.isAdmin || user.value.isModerator) +); + +/** + * The staff form's own state, seeded from the row and NOT bound to it. + * + * A `datetime-local` input wants `YYYY-MM-DDTHH:mm` in local time, while the + * row carries an ISO instant — so the two cannot share a ref without one of + * them being wrong. Seeding once and converting on save keeps the conversion + * in one place. + */ +const buffForm = reactive({ + downloadMultiplier: 100, + uploadMultiplier: 100, + until: '', + isSticky: false, +}); +const buffBusy = ref(false); + +watch( + torrent, + (t0) => { + if (!t0) return; + buffForm.downloadMultiplier = t0.downloadMultiplier ?? 100; + buffForm.uploadMultiplier = t0.uploadMultiplier ?? 100; + buffForm.isSticky = t0.isSticky ?? false; + // Trim the seconds and the zone: the input accepts neither. + buffForm.until = t0.multipliersUntil + ? new Date(t0.multipliersUntil).toISOString().slice(0, 16) + : ''; + }, + { immediate: true } +); + +async function putBuffs(body: Record<string, unknown>) { + buffBusy.value = true; + try { + await $fetch(`/api/mod/torrents/${hash}/buffs`, { method: 'PUT', body }); + await refresh(); + notifications.success(t('torrents.detail.buffs.saved')); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + notifications.error(e?.data?.message || e?.message || t('torrents.detail.buffs.failed')); + } finally { + buffBusy.value = false; + } +} + +function saveBuffs() { + // A moderator may only send the pin — the multipliers would be refused, and + // sending them anyway would turn every pin into a 403. + if (!user.value?.isAdmin) { + return putBuffs({ isSticky: buffForm.isSticky }); + } + return putBuffs({ + downloadMultiplier: buffForm.downloadMultiplier, + uploadMultiplier: buffForm.uploadMultiplier, + until: buffForm.until ? new Date(buffForm.until).toISOString() : null, + isSticky: buffForm.isSticky, + }); +} + +function clearBuffs() { + return putBuffs({ downloadMultiplier: 100, uploadMultiplier: 100, until: null }); +} + // Compute permissions const canEdit = computed(() => { if (!loggedIn.value || !user.value) return false; @@ -1883,10 +2085,12 @@ async function confirmDelete() { .section--nfo { --section-tint: var(--warning); } .section--cross { --section-tint: var(--release-purple); } .section--swarm { --section-tint: var(--danger); } +.section--buffs { --section-tint: var(--accent-warm); } .section--activity .section-head-line, .section--nfo .section-head-line, .section--cross .section-head-line, .section--swarm .section-head-line, +.section--buffs .section-head-line, .section--note .section-head-line { background: linear-gradient( 90deg, @@ -2908,4 +3112,89 @@ async function confirmDelete() { letter-spacing: calc(0.04em * var(--tracking-scale)); color: rgb(var(--fg-default)); } +/* ── Buff badge (hero eyebrow) ──────────────────────────────────── + Reads as a label, not a button: it states a fact about the release and + nothing about it is clickable. */ +.buff-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.1rem 0.45rem; + border: 1px solid rgb(var(--buff-tint) / 0.4); + border-radius: 0.25rem; + background: rgb(var(--buff-tint) / 0.12); + color: rgb(var(--buff-tint)); + font-size: 0.66rem; + letter-spacing: 0.07em; + text-transform: uppercase; + white-space: nowrap; +} +.buff-badge--freeleech { --buff-tint: var(--online); } +.buff-badge--silverleech { --buff-tint: var(--info); } +.buff-badge--doubleUpload { --buff-tint: var(--accent-warm); } +.buff-badge--custom { --buff-tint: var(--accent); } +.buff-until { + opacity: 0.75; + text-transform: none; + letter-spacing: 0; +} + +/* ── Staff tools ──────────────────────────────────────────────────── */ +.staff-block-title { + margin: 0 0 0.4rem; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(var(--fg-default)); +} +.staff-block-title--spaced { + margin-top: 2rem; + padding-top: 1.25rem; + border-top: 1px solid rgb(var(--line-default)); +} + +/* ── Staff buff panel ─────────────────────────────────────────────── */ +.buffs-lede { + margin: 0 0 1rem; + max-width: 62ch; + font-size: 0.8125rem; + line-height: 1.55; + color: rgb(var(--fg-muted)); +} +.buffs-grid { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.85rem; +} +.buffs-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 11rem; +} +.buffs-hint { + font-size: 0.7rem; + color: rgb(var(--fg-subtle)); +} +.buffs-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + padding-bottom: 0.55rem; + font-size: 0.8125rem; +} +.buffs-actions { + display: flex; + align-items: center; + gap: 0.6rem; + margin-top: 1rem; +} + +@media (max-width: 40rem) { + .buffs-field { + flex: 1 1 100%; + min-width: 0; + } +} </style> diff --git a/apps/web/app/pages/torrents/index.vue b/apps/web/app/pages/torrents/index.vue index dd3b56b3..4e79e94e 100644 --- a/apps/web/app/pages/torrents/index.vue +++ b/apps/web/app/pages/torrents/index.vue @@ -229,6 +229,25 @@ /> </div> + <!-- ── Pinned ──────────────────────────────────────────────── + Above the results and visually apart, because a pin answers a + different question than the listing does: not "what matches" but + "read this one". Rendered with the same table so the columns line up + with the flow underneath — a pin changes the position of a release, + never how it is read. --> + <section v-if="pinnedTorrents.length > 0" class="pinned-block"> + <header class="pinned-head"> + <Icon name="ph:push-pin-fill" class="pinned-icon" /> + <h2 class="pinned-title">{{ $t('search.pinned.title') }}</h2> + <span class="pinned-rule" /> + </header> + <div class="card overflow-hidden"> + <div class="overflow-x-auto"> + <TorrentTable :torrents="pinnedTorrents" :compact="true" /> + </div> + </div> + </section> + <!-- ── Results body ──────────────────────────────────────── --> <section v-if="hasActiveQuery"> <div v-if="isLoading" class="results-loading"> @@ -504,6 +523,13 @@ const { pending, refresh: refreshTorrents, } = await useFetch<{ + /** + * Editorially pinned releases, page 1 only, under the same filters as the + * flow. They are held OUT of `data` on every page, so a release appears + * exactly once in a listing and the page count describes what can actually + * be scrolled through. + */ + pinned: TorrentWithStats[]; data: TorrentWithStats[]; pagination: { page: number; @@ -660,6 +686,7 @@ const { data: trendingData } = await useFetch<{ data: TorrentWithStats[] }>( ); const torrents = computed(() => torrentsData.value?.data ?? []); +const pinnedTorrents = computed(() => torrentsData.value?.pinned ?? []); const trendingTorrents = computed(() => trendingData.value?.data ?? []); // ── View-aware result state ────────────────────────────────── @@ -1264,6 +1291,36 @@ useHead({ } /* ─── Loading / empty ───────────────────────────────────── */ +/* ── Pinned block ──────────────────────────────────────────────── + Set apart from the flow by a rule and an icon rather than by a tint: the + rows inside are ordinary rows and should read as such. */ +.pinned-block { + margin-bottom: 1.75rem; +} +.pinned-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.6rem; +} +.pinned-icon { + width: 1rem; + height: 1rem; + color: rgb(var(--accent)); +} +.pinned-title { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.pinned-rule { + flex: 1; + height: 1px; + background: rgb(var(--line-default)); +} + .results-loading, .results-empty { display: flex; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index a62c269f..62b77caa 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -817,7 +817,22 @@ "footer": "Document withheld at viewer request" }, "uploaderAnonymous": "Anonymous", - "uploaderAnonymousTooltip": "This member chose not to have their name attached to their releases." + "uploaderAnonymousTooltip": "This member chose not to have their name attached to their releases.", + "buffs": { + "title": "Bonus buffs", + "lede": "Multipliers apply to this release alone. Where a site-wide event is also running, a member gets whichever is better on each side — a buff can never make a torrent cost more. Pinning is editorial: it lifts the release to the top of listings and changes nothing about what it costs.", + "download": "Download", + "upload": "Upload", + "normal": "Normal", + "until": "Ends", + "untilHint": "Leave empty for no end date.", + "pin": "Pin to the top of listings", + "save": "Save", + "clear": "Remove the buff", + "saved": "Buffs updated.", + "failed": "Could not update the buffs." + }, + "staffTools": "Staff tools" } }, "search": { @@ -877,6 +892,9 @@ "sourcesAll": "Everywhere", "sourcesLocal": "Here only", "sourcesHint": "Include releases held by partner instances" + }, + "pinned": { + "title": "Pinned" } }, "users": { @@ -1263,6 +1281,13 @@ "changesRequestedTitle": "A moderator asked for changes. Edit the torrent to resubmit it.", "rejectedTitle": "Rejected. Hidden from public listings; only a moderator can move it elsewhere." } + }, + "buff": { + "freeleech": "Freeleech", + "silverleech": "Silverleech", + "doubleUpload": "Double upload", + "custom": "Boosted", + "until": "until {when}" } }, "bonus": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index b85b2315..7c04c8b6 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -817,7 +817,22 @@ "footer": "Document retenu à la demande du visiteur" }, "uploaderAnonymous": "Anonyme", - "uploaderAnonymousTooltip": "Ce membre a choisi de ne pas associer son nom à ses publications." + "uploaderAnonymousTooltip": "Ce membre a choisi de ne pas associer son nom à ses publications.", + "buffs": { + "title": "Bonus de la release", + "lede": "Les multiplicateurs ne concernent que cette release. Si un événement global tourne en même temps, le membre reçoit le plus avantageux de chaque côté — un bonus ne peut jamais rendre un torrent plus cher. L'épinglage est éditorial : il remonte la release en tête des listings et ne change rien à son coût.", + "download": "Téléchargement", + "upload": "Envoi", + "normal": "Normal", + "until": "Fin", + "untilHint": "Laisser vide pour aucune date de fin.", + "pin": "Épingler en tête des listings", + "save": "Enregistrer", + "clear": "Retirer le bonus", + "saved": "Bonus mis à jour.", + "failed": "Les bonus n'ont pas pu être mis à jour." + }, + "staffTools": "Outils du personnel" } }, "search": { @@ -877,6 +892,9 @@ "sourcesAll": "Partout", "sourcesLocal": "Ici seulement", "sourcesHint": "Inclure les releases des instances partenaires" + }, + "pinned": { + "title": "Épinglés" } }, "users": { @@ -1263,6 +1281,13 @@ "changesRequestedTitle": "Un modérateur a demandé des modifications. Éditez le torrent pour le resoumettre.", "rejectedTitle": "Rejeté. Masqué des listes publiques ; seul un modérateur peut le déplacer ailleurs." } + }, + "buff": { + "freeleech": "Freeleech", + "silverleech": "Silverleech", + "doubleUpload": "Upload doublé", + "custom": "Bonifié", + "until": "jusqu'à {when}" } }, "bonus": { diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 0423abe9..c22743b4 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -94,6 +94,7 @@ export default defineConfig({ { text: 'Forum', link: '/guide/forum' }, { text: 'Hit-and-Run', link: '/guide/hit-and-run' }, { text: 'Bonus Events', link: '/guide/bonus-events' }, + { text: 'Per-torrent Buffs', link: '/guide/torrent-buffs' }, { text: 'Seed Bonus Economy', link: '/guide/seed-bonus' }, { text: 'Freeleech Pool', link: '/guide/freeleech-pool' }, { text: 'UDP Tracker (BEP 15)', link: '/guide/udp-tracker' }, diff --git a/doc/guide/torrent-buffs.md b/doc/guide/torrent-buffs.md new file mode 100644 index 00000000..293487e8 --- /dev/null +++ b/doc/guide/torrent-buffs.md @@ -0,0 +1,90 @@ +# Per-torrent bonus buffs + +Multipliers and pinning applied to **one release** rather than to the whole +site. Staff set them from the torrent's own page, under **Staff tools**. + +## What they are + +| Field | Meaning | +| --- | --- | +| Download multiplier | `0` freeleech, `50` silverleech, `100` normal | +| Upload multiplier | `100` normal, `200` double credit, up to `1000` | +| Ends | When the buff lapses. Empty means "until somebody changes it" | +| Pinned | Lifts the release to the top of listings. Costs nothing, changes nothing about the economy | + +Basis points ×100 — the same units `bonus_events` uses, so the project has one +unit system rather than two. + +## How a buff meets a site-wide event + +**The member gets the better of the two, on each axis independently.** Never the +product. + +| Site-wide | This torrent | Member pays / earns | +| --- | --- | --- | +| nothing | freeleech | download 0, upload ×1 | +| freeleech | nothing | download 0, upload ×1 | +| freeleech | double upload | download 0, upload **×2** | +| freeleech ×2 upload | nothing | download 0, upload ×2 | + +The product is what a reader first expects, and it is wrong in practice: a +site-wide freeleech multiplied by a torrent-level double-upload would give +upload ×4 — credit nobody granted. Taking the better of each axis keeps every +buff meaning what its operator set it to, and guarantees a useful property: a +buff can only ever *help* a member relative to the site-wide state. An operator +granting a freeleech never has to check what else is running first. + +The rule lives in two places by necessity — `apps/tracker/internal/bonus.Best` +for the announce path and `apps/api/utils/torrentBuffs` for the feed — and both +sides assert the same table of cases in their tests. + +## Two powers, two gates + +- **Pinning** is any moderator's call. It moves a release up a page. +- **The multipliers** require an **admin**. They mint upload credit out of + nothing, which is an economic act rather than an editorial one. + +Both land in the [staff audit log](./audit-log.md) with the before and after +values, since they are mutating calls under `/api/mod/`. + +## Expiry needs no sweep + +A lapsed buff is neutralised **in the announce query itself**: + +```sql +CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END +``` + +So a buff ends the moment its timestamp passes, whether or not any job noticed, +and the hot path carries no clock logic. Setting an end date in the past is +refused rather than silently accepted — the way to end a buff now is to reset +the multipliers to 100. + +The values ride along on the row the announce path already had to read, so a +buffed torrent costs exactly as much to announce as an unbuffed one. + +## Where a member sees it + +- A badge on the torrent page — **Freeleech**, **Silverleech**, **Double + upload** or **Boosted** — with its end date when it has one. Deliberately + blind to site-wide events: a badge on one torrent among a hundred has to mean + *that* one. +- The Torznab feed's `downloadvolumefactor` / `uploadvolumefactor`, per item. + Two releases in one response can now legitimately differ, which is the whole + point and was impossible while those were one pair of numbers for the page. + +## Pinning and the listing + +Pinned releases are lifted **out of the flow on every page** and returned as a +separate block on page one, under the same filters as the listing. + +Not folded into the `ORDER BY`, which is the obvious implementation and the +wrong one: `is_sticky DESC` in front of the sort key stops every existing +single-column index from serving it, so a catalogue that sorted off an index +starts doing a full sort on every page. Held out on every page rather than only +on page one so a release appears exactly once and the page count describes what +can actually be scrolled through. + +Capped at five. A first screen that is all pins is a page with no listing on it; +an operator who wants ten things at the top wants a homepage block. diff --git a/packages/db/src/migrations/0060_torrent_buffs.sql b/packages/db/src/migrations/0060_torrent_buffs.sql new file mode 100644 index 00000000..5d9abdf1 --- /dev/null +++ b/packages/db/src/migrations/0060_torrent_buffs.sql @@ -0,0 +1,4 @@ +ALTER TABLE "torrents" ADD COLUMN "download_multiplier" integer DEFAULT 100 NOT NULL;--> statement-breakpoint +ALTER TABLE "torrents" ADD COLUMN "upload_multiplier" integer DEFAULT 100 NOT NULL;--> statement-breakpoint +ALTER TABLE "torrents" ADD COLUMN "multipliers_until" timestamp;--> statement-breakpoint +ALTER TABLE "torrents" ADD COLUMN "is_sticky" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0060_snapshot.json b/packages/db/src/migrations/meta/0060_snapshot.json new file mode 100644 index 00000000..95327ba6 --- /dev/null +++ b/packages/db/src/migrations/meta/0060_snapshot.json @@ -0,0 +1,9767 @@ +{ + "id": "f29e5feb-73bb-4c6e-b61e-0eaa1f2dd0d2", + "prevId": "1f993e3b-ce7d-40ce-a1e2-36d11d122ea8", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 5bc6146a..64710d9c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1788199247442, "tag": "0059_staff_audit_log", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1788215642688, + "tag": "0060_torrent_buffs", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ab918c30..af2d6946 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1025,6 +1025,38 @@ export const torrents = pgTable( infoHashV2: text('info_hash_v2'), contentRootV2: text('content_root_v2'), isActive: boolean('is_active').default(true).notNull(), + /** + * Per-torrent bonus multipliers — the "buffs" an operator applies to one + * release rather than to the whole site. + * + * Basis points ×100, the SAME convention `bonus_events` uses, so the + * project has one unit system rather than two: `0` = freeleech, `50` = + * silverleech, `100` = normal, `200` = double upload. + * + * Two multipliers plus an expiry rather than a `freeleech_until` boolean + * family: one pair of columns expresses freeleech, silverleech, + * double-upload and every combination, where three booleans can contradict + * each other and need a precedence rule nobody remembers. + * + * `multipliersUntil` NULL means "until an operator changes it". A past + * timestamp means the buff has lapsed — the announce path neutralises it in + * SQL rather than in Go, so there is no clock logic on the hot path and no + * sweep to forget to run. + * + * How these combine with a running site-wide event is in + * `apps/tracker/internal/bonus`: the member gets the better of the two on + * each axis, never the product. + */ + downloadMultiplier: integer('download_multiplier').default(100).notNull(), + uploadMultiplier: integer('upload_multiplier').default(100).notNull(), + multipliersUntil: timestamp('multipliers_until'), + /** + * Pinned to the top of listings. Editorial, not economic — it moves a + * release up the page and changes nothing about what it costs to grab. + * That is why it is a moderator's call while the multipliers above are an + * admin's. + */ + isSticky: boolean('is_sticky').default(false).notNull(), // Per-torrent opt-in for swarm federation (Phase 4). When true AND the // owner has a swarm-scoped peer link, this torrent's peers may be shared // with / mixed from partner instances. Off by default — it re-opens the From 0df67d7c429b8e765624cf314c87249f4a873181 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 09:59:16 +0200 Subject: [PATCH 08/96] feat(tracker): a partial seed is not a seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BEP 21 has been in the specs since 2008 and this tracker treated its signal as an unknown token. A client that holds every piece it asked for — but not the whole torrent, because the member deselected files — sends `left=0` with `event=paused`. That is not an exotic case: qBittorrent does it whenever somebody downloads part of a multi-file torrent, and there is an eight-year-old issue on their tracker about the sites that answer such an announce with a failure. We never failed it, which is already better than several trackers in the wild. What we did was worse in a quieter way: `event=paused` fell into the `default` arm, became `EventNone`, and `IsSeeder()` reads `left == 0` — so the peer was counted as a **seed**. Every torrent whose only remaining peers were partial seeds looked healthier than it was, and the rarity input the bonus rules read was wrong in the same direction. So `paused` becomes a first-class event and `IsSeeder()` gains a second condition. The peer stays in the swarm — it has real pieces and other peers should be given its address — and counts as a leecher, so the seeder count means what a member reads it to mean. It also stops banking seed time, and that is the one judgement call here. Hit-and-run asks a member to seed what they took; somebody holding a deselected subset cannot satisfy that however long they stay connected, so crediting them towards a requirement they cannot meet would be generous in a way that makes the requirement meaningless. They keep serving what they have. HTTP only, and the enum says so: BEP 15 numbers its events 0..3 and has no code for this, so a UDP announce can never carry it. One existing test used `paused` as its example of an unknown token — which is exactly the bug, written down as an assertion. It now uses a token no BEP defines, and the behaviour it was actually testing (record it, treat it as a periodic announce) is unchanged. And the non-compact response, while the guide was open: `compact=0` is ignored and always has been. That is not going to change — every client written this century requests compact and most cannot parse anything else — but "why does compact=0 do nothing" is a reasonable question to ask once, so it is now answered in writing rather than in a source comment nobody reading the docs will find. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + apps/tracker/internal/announce/announce.go | 29 +++++++- .../internal/announce/announce_test.go | 67 ++++++++++++++++++- apps/tracker/internal/server/handler.go | 7 ++ doc/guide/udp-tracker.md | 37 ++++++++++ 5 files changed, 136 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cddb2dc7..01f98317 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre - **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). +- **BEP 21 partial seeds** — a client holding only the files it asked for announces `event=paused`; it stays in the swarm, counts as a leecher rather than a seed, and banks no seed time towards a requirement it cannot meet. - **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/tracker/internal/announce/announce.go b/apps/tracker/internal/announce/announce.go index 708803e9..7f52aee0 100644 --- a/apps/tracker/internal/announce/announce.go +++ b/apps/tracker/internal/announce/announce.go @@ -22,6 +22,15 @@ const ( EventStarted EventStopped EventCompleted + // EventPaused is BEP 21's partial-seed signal. A client that holds every + // piece it asked for — but not the whole torrent, because the member + // deselected files — reports `left=0` and `event=paused`. It is still + // worth connecting to: it has real pieces to serve. It is simply not a + // seed, and BEP 21 asks the tracker not to report it as one. + // + // HTTP only. BEP 15 numbers its events 0..3 and has no code for this, so a + // UDP announce can never carry it. + EventPaused ) // String returns a stable lowercase event name for logging/storage. @@ -33,6 +42,8 @@ func (e Event) String() string { return "stopped" case EventCompleted: return "completed" + case EventPaused: + return "paused" default: return "update" } @@ -127,6 +138,15 @@ func Parse(q url.Values) (*Request, error) { r.Event = EventStopped case "completed": r.Event = EventCompleted + case "paused": + // BEP 21. Recognised rather than swallowed, which is what lets the + // peer be classified correctly below — and it is a real client + // behaviour, not an exotic one: qBittorrent sends it whenever a member + // downloads only some files of a multi-file torrent. Trackers that + // reject the value outright break those clients; we never did, but we + // did count the peer as a seed, which inflated the swarm's seeder + // count and made the torrent look healthier than it was. + r.Event = EventPaused default: // Per BEP 3, unknown events are equivalent to a periodic // announce (no event). We still want operator visibility so @@ -158,8 +178,13 @@ func Parse(q url.Values) (*Request, error) { return r, nil } -// IsSeeder reports whether the announce describes a seeding peer (left == 0). -func (r *Request) IsSeeder() bool { return r.Left == 0 } +// IsSeeder reports whether the announce describes a seeding peer. +// +// `left == 0` is necessary and no longer sufficient: a BEP 21 partial seed has +// nothing left to fetch of what it asked for, yet does not hold the torrent. It +// stays in the swarm — it has pieces others want — and counts as a leecher, so +// the seeder count means what a member reads it to mean. +func (r *Request) IsSeeder() bool { return r.Left == 0 && r.Event != EventPaused } func parseInt64(s string) (int64, bool) { if s == "" { diff --git a/apps/tracker/internal/announce/announce_test.go b/apps/tracker/internal/announce/announce_test.go index 36ad26e3..7568ede6 100644 --- a/apps/tracker/internal/announce/announce_test.go +++ b/apps/tracker/internal/announce/announce_test.go @@ -138,6 +138,7 @@ func TestEvent_String(t *testing.T) { EventStarted: "started", EventStopped: "stopped", EventCompleted: "completed", + EventPaused: "paused", EventNone: "update", } for ev, want := range cases { @@ -149,7 +150,9 @@ func TestEvent_String(t *testing.T) { func TestParse_UnknownEvent_RecordedOnRequest(t *testing.T) { q := baseValid() - q.Set("event", "paused") + // Not `paused` — that used to be this test's example of an unknown token + // and is a recognised BEP 21 event since. Any token no BEP defines does. + q.Set("event", "hibernating") r, err := Parse(q) if err != nil { t.Fatal(err) @@ -157,8 +160,8 @@ func TestParse_UnknownEvent_RecordedOnRequest(t *testing.T) { if r.Event != EventNone { t.Errorf("Event: got %v, want EventNone for unknown token", r.Event) } - if r.UnknownEventRaw != "paused" { - t.Errorf("UnknownEventRaw: got %q, want %q", r.UnknownEventRaw, "paused") + if r.UnknownEventRaw != "hibernating" { + t.Errorf("UnknownEventRaw: got %q, want %q", r.UnknownEventRaw, "hibernating") } } @@ -277,3 +280,61 @@ func TestRequest_IsSeederTrueWhenLeftZero(t *testing.T) { t.Error("IsSeeder() should be true when left=0") } } + +// BEP 21 — a partial seed says `left=0` and `event=paused`. It holds every +// piece it asked for and not the whole torrent, so it belongs in the swarm and +// does not belong in the seeder count. +func TestParsePausedIsNotASeeder(t *testing.T) { + q := baseValid() + q.Set("left", "0") + q.Set("event", "paused") + + r, err := Parse(q) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if r.Event != EventPaused { + t.Errorf("Event = %v, want EventPaused", r.Event) + } + if r.Event.String() != "paused" { + t.Errorf("Event.String() = %q, want %q", r.Event.String(), "paused") + } + if r.IsSeeder() { + t.Error("a paused peer with left=0 must not count as a seeder") + } + // It is a recognised event, so it must not also be reported as an unknown + // one — that would put it in the operator's "misbehaving client" log. + if r.UnknownEventRaw != "" { + t.Errorf("UnknownEventRaw = %q, want empty", r.UnknownEventRaw) + } +} + +// The ordinary case must be untouched: left=0 with no event is still a seed. +func TestParseLeftZeroWithoutPausedIsStillASeeder(t *testing.T) { + q := baseValid() + q.Set("left", "0") + + r, err := Parse(q) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if !r.IsSeeder() { + t.Error("left=0 with no event must still be a seeder") + } +} + +// A paused peer that still has data left is a leecher either way — the event +// must not be able to promote anything. +func TestParsePausedWithBytesLeftIsALeecher(t *testing.T) { + q := baseValid() + q.Set("left", "1024") + q.Set("event", "paused") + + r, err := Parse(q) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if r.IsSeeder() { + t.Error("a paused peer with bytes left must not count as a seeder") + } +} diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go index 58a02071..9abc7e0f 100644 --- a/apps/tracker/internal/server/handler.go +++ b/apps/tracker/internal/server/handler.go @@ -708,6 +708,13 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // concurrent announces with distinct events (started/completed/update, // all left=0) can't each book the same `elapsed` — an N× over-credit // the per-event dedup at step 4 doesn't stop (finding M7). + // + // A BEP 21 partial seed is excluded by `IsSeeder()` and that is the + // intended reading: hit-and-run asks a member to seed what they took, and + // somebody holding a deselected subset cannot satisfy it however long they + // stay connected. They keep serving the pieces they do have — they are + // still in the swarm — they simply do not bank seed time towards a + // requirement they cannot meet. if req.IsSeeder() && prev != nil { elapsed := (time.Now().UnixMilli() - prev.UpdatedAt) / 1000 seedKey := infoHashHex + ":" + peerHex + ":seedtime" diff --git a/doc/guide/udp-tracker.md b/doc/guide/udp-tracker.md index 33a5be33..19730de1 100644 --- a/doc/guide/udp-tracker.md +++ b/doc/guide/udp-tracker.md @@ -227,3 +227,40 @@ The package ships **17 unit tests** covering the parser shapes, BEP 41 fragmentation, connection-id validity windows, the IPv4-in-16 stability trick (so a peer that announces from `1.2.3.4` and again from `::ffff:1.2.3.4` keeps the same id), and the anti-spoofing guard. + +--- + +## Partial seeds (BEP 21) + +A client that holds every piece it asked for — but not the whole torrent, +because the member deselected files — reports `left=0` and `event=paused`. + +That is a real client behaviour and not an exotic one: qBittorrent sends it +whenever somebody downloads only part of a multi-file torrent. Some trackers +reject the value outright and break those clients. This one never did, but it +did count the peer as a **seed**, which inflated the swarm's seeder count and +made a torrent look healthier than it was. + +A paused peer now: + +- **stays in the swarm** — it has real pieces to serve, and other peers should + be given its address; +- **counts as a leecher**, so the seeder count means what a member reads it to + mean; +- **does not bank seed time.** Hit-and-run asks a member to seed what they took, + and somebody holding a deselected subset cannot satisfy that however long they + stay connected. + +HTTP only. BEP 15 numbers its events 0 to 3 and has no code for this, so a UDP +announce can never carry it. + +## Non-compact responses are not supported + +`compact=0` is ignored: the tracker always answers with the compact peer format +(BEP 23 for IPv4, BEP 7's `peers6` for IPv6). + +This is deliberate and is not going to change. Every client written this century +requests compact and most cannot parse anything else; the non-compact peer-dict +format costs several times the bytes for the same information. It is written +down here because "why does `compact=0` do nothing" is a reasonable question to +ask once. From 939111d391d8e6c6d807c98c3ff315698419ebdf Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 10:12:57 +0200 Subject: [PATCH 09/96] feat(torrents): three things a catalogue needs when it gets old MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A few years in, a catalogue accumulates problems the moderation pipeline has no vocabulary for. It could say a release was rejected. It could not say a release was fine and is simply no longer the one to take, could not do anything with a swarm that had gone to zero, and could not tell a member which of the torrents in their client this site still carries. ## Superseding `superseded_by_id`, and the important part is what it does NOT do: the older release stays listed, stays downloadable, keeps its swarm, and its snatchers keep their hit-and-run obligations. People are seeding it, and pulling it out from under them would turn a tidy-up into a hit and run of the operator's own making. What changes is that both pages say so — a banner on the older one pointing at the replacement, a list on the newer one of what it replaced. Informational rather than a warning, because the file is still perfectly good; colouring it red would say otherwise. Four guards, and each is a specific failure rather than defensive padding. Self-reference would render a page pointing at itself and make the chain walk non-terminating; a cycle is the same thing the long way round; a target that is not accepted-and-active sends members to a page they may not be allowed to read; and a target that is itself superseded makes the pointer a dead end the member has to walk by hand. The walk is bounded at 32 hops anyway, because a cycle that arrived through a restore or a hand-edited row must not hang a request. `ON DELETE set null` rather than cascade: deleting the newer release must not delete the older one it replaced. ## Reseed requests A torrent at zero seeders was a silent dead end — the page showed a zero and nothing followed from it. Yet the site has always known exactly who could fix it: `hnr_tracking` holds one row per (member, torrent) forever, written both by the tracker on first completion and by the API the moment somebody clicks download, unconditionally. So the zero leads somewhere now. One request per torrent per day SITE-WIDE, not per member: the people who need protecting are the recipients, and ten members each asking once is ten notifications for one problem. `SET NX` on a Redis key with a TTL — no column, no sweep, and it expires by itself. Capped at 200 recipients, most recent snatchers first, because they are the likeliest to still hold the files. Superseded releases refuse: asking members to resurrect one works against a decision staff already took. Erased and banned accounts are skipped. Members who hide their download history are NOT — that preference governs who can enumerate their snatch list, and one notification about one torrent enumerates nothing. It does tell them the site remembers, which the guide says out loud. ## Unregistered infohashes The question a client cannot answer on its own: an announce failing because the tracker is down looks identical to one failing because the release was deleted, so the usual answer is to leave dead entries in place forever. One request over up to 256 hashes sorts them into `active`, `superseded` (with the replacement's hash), `pending` and `unregistered`. Rejected and inactive rows deliberately answer `unregistered`. Both the detail endpoint and the duplicate preflight are careful not to confirm that a rejected hash exists — it would make either an oracle for enumerating what moderation turned down — and an endpoint taking 256 hashes at a time is the last place to open that door. It is also the entry point automated cross-seeding needed: a script that knows which of its local torrents this site does not have is a script that knows what to upload. ## One extraction `withConcurrency` moved out of `followerFanout` into `utils/fanout`. The reseed ping is its second caller, and the reason it exists is worth having in front of whoever adds a third: each task is a database insert plus a Redis publish plus possibly an outbound HTTP request, so an unbounded `Promise.all` over recipients is a denial of service you write yourself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + .../mod/torrents/[hash]/supersede.delete.ts | 50 + .../api/mod/torrents/[hash]/supersede.put.ts | 169 + .../torrents/[hash]/reseed-request.post.ts | 137 + .../api/torrents/[hash]/supersessions.get.ts | 90 + .../routes/api/torrents/unregistered.post.ts | 116 + apps/api/utils/fanout.ts | 46 + apps/api/utils/followerFanout.ts | 39 +- apps/api/utils/notify.ts | 7 + apps/api/utils/notifyRenderer.ts | 8 + .../settings/NotificationsSection.vue | 10 + apps/web/app/pages/notifications.vue | 1 + apps/web/app/pages/torrents/[hash]/index.vue | 311 + apps/web/i18n/locales/en.json | 32 +- apps/web/i18n/locales/fr.json | 32 +- doc/.vitepress/config.ts | 1 + doc/guide/torrent-lifecycle.md | 99 + .../src/migrations/0061_torrent_supersede.sql | 5 + .../db/src/migrations/meta/0061_snapshot.json | 9814 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 33 + 21 files changed, 10968 insertions(+), 40 deletions(-) create mode 100644 apps/api/routes/api/mod/torrents/[hash]/supersede.delete.ts create mode 100644 apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts create mode 100644 apps/api/routes/api/torrents/[hash]/reseed-request.post.ts create mode 100644 apps/api/routes/api/torrents/[hash]/supersessions.get.ts create mode 100644 apps/api/routes/api/torrents/unregistered.post.ts create mode 100644 apps/api/utils/fanout.ts create mode 100644 doc/guide/torrent-lifecycle.md create mode 100644 packages/db/src/migrations/0061_torrent_supersede.sql create mode 100644 packages/db/src/migrations/meta/0061_snapshot.json diff --git a/README.md b/README.md index 01f98317..2233a6a3 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. +- **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). - **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/routes/api/mod/torrents/[hash]/supersede.delete.ts b/apps/api/routes/api/mod/torrents/[hash]/supersede.delete.ts new file mode 100644 index 00000000..6ace6e00 --- /dev/null +++ b/apps/api/routes/api/mod/torrents/[hash]/supersede.delete.ts @@ -0,0 +1,50 @@ +/** + * DELETE /api/mod/torrents/:hash/supersede + * + * Lift a supersede marking — the release is current again, or the pointer was + * wrong. Sibling of `supersede.put.ts`, which carries the reasoning and the + * guards; this end needs neither, because clearing a pointer cannot create a + * loop or send anybody to a page they cannot read. + * + * Unconditional: clearing an already-clear marking succeeds and says so. A 404 + * for "it was not superseded anyway" would make the UI carry a state check for + * an operation whose outcome is the same either way. + */ +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { auditDetail } from '~~/utils/audit'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; + +export default defineEventHandler(async (event) => { + await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + + const source = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, hash.toLowerCase()), + columns: { id: true, name: true, supersededById: true }, + }); + if (!source) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + auditDetail(event, { + action: 'torrent.supersede.clear', + targetType: 'torrent', + targetId: source.id, + targetLabel: source.name, + changes: { supersededBy: { from: source.supersededById, to: null } }, + }); + + await db + .update(schema.torrents) + .set({ supersededById: null, supersededAt: null, supersedeReason: null }) + .where(eq(schema.torrents.id, source.id)); + + return { success: true, supersededBy: null }; +}); diff --git a/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts b/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts new file mode 100644 index 00000000..5bc5a9a7 --- /dev/null +++ b/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts @@ -0,0 +1,169 @@ +/** + * PUT /api/mod/torrents/:hash/supersede { supersededById, reason? } + * + * Mark this release as replaced by a better one — "trumping", in the vocabulary + * of the trackers that have had it for twenty years. + * + * ## What it does not do + * + * It does not retire the older release. It stays listed, stays downloadable, + * keeps its swarm, and its snatchers keep their hit-and-run obligations. People + * are seeding it; pulling it out from under them would turn a tidy-up into a + * hit-and-run of the operator's own making. + * + * What changes is that both pages say so. A member choosing between two copies + * of the same work is told which one the staff consider current, and the older + * page points at the newer. That is the whole feature — a catalogue with no way + * to express "this replaces that" ages by accumulating duplicates of uneven + * quality with no hierarchy between them. + * + * ## The guards, and why each one exists + * + * - **Not itself.** A row superseded by itself would render a page pointing + * at itself and, worse, would make the chain walk below never terminate. + * - **No cycle.** A supersedes B supersedes A is the same non-termination + * reached the long way round. Walked forward from the target, bounded. + * - **The target must be `accepted`.** Pointing members at something pending + * or rejected sends them to a page they may not be allowed to read. + * - **The target must not itself be superseded.** Otherwise the pointer sends + * a member to a dead end, and they have to walk the chain by hand. Staff are + * told to point at the head of the chain instead. + * + * Lifting the marking again is the sibling `supersede.delete.ts`. + * + * Both verbs land in the staff audit log — they are mutating calls under + * `/api/mod/`, so the hook records them whether or not this route says anything. + */ +import { and, eq, isNull } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { auditDetail } from '~~/utils/audit'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; + +const bodySchema = z.object({ + /** The replacement's infohash — what a moderator has in front of them. */ + supersededById: z.string().regex(/^[a-fA-F0-9]{40}$/), + reason: z.string().trim().max(500).optional(), +}); + +/** How far a supersede chain may be walked before we call it a cycle. */ +const MAX_CHAIN = 32; + +export default defineEventHandler(async (event) => { + await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + const infoHash = hash.toLowerCase(); + + const source = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, infoHash), + columns: { id: true, name: true, supersededById: true }, + }); + if (!source) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + const body = await validateBody(event, bodySchema); + const targetHash = body.supersededById.toLowerCase(); + + if (targetHash === infoHash) { + throw createError({ + statusCode: 400, + message: 'A release cannot supersede itself.', + }); + } + + const target = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, targetHash), + columns: { + id: true, + name: true, + moderationStatus: true, + isActive: true, + supersededById: true, + }, + }); + if (!target) { + throw createError({ + statusCode: 404, + message: 'The replacement torrent was not found.', + }); + } + if (target.moderationStatus !== 'accepted' || !target.isActive) { + throw createError({ + statusCode: 400, + message: + 'The replacement must be an accepted, active release — otherwise the pointer sends members to a page they may not be able to read.', + }); + } + if (target.supersededById) { + throw createError({ + statusCode: 400, + message: + 'That release is itself superseded. Point at the head of the chain instead.', + }); + } + + // Walk forward from the target: if the chain reaches back to the source, the + // pointer would close a loop. Bounded by MAX_CHAIN so a pre-existing cycle in + // the data — which this route refuses to create, but a restore or a manual + // edit could — cannot hang the request. + let cursor: string | null = target.id; + for (let i = 0; cursor && i < MAX_CHAIN; i++) { + if (cursor === source.id) { + throw createError({ + statusCode: 400, + message: 'That would create a supersede loop.', + }); + } + const next: { supersededById: string | null } | undefined = + await db.query.torrents.findFirst({ + where: eq(schema.torrents.id, cursor), + columns: { supersededById: true }, + }); + cursor = next?.supersededById ?? null; + } + + auditDetail(event, { + action: 'torrent.supersede', + targetType: 'torrent', + targetId: source.id, + targetLabel: source.name, + changes: { + supersededBy: { from: source.supersededById, to: target.id }, + supersededByName: target.name, + ...(body.reason ? { reason: body.reason } : {}), + }, + }); + + await db + .update(schema.torrents) + .set({ + supersededById: target.id, + supersededAt: new Date(), + supersedeReason: body.reason ?? null, + }) + // Guarded on the value we read, so two moderators racing on the same row + // cannot interleave into a state neither of them chose. `isNull` rather + // than `eq(col, null)`: the latter compiles to `= NULL`, which is never + // true, and the guard would silently match nothing. + .where( + and( + eq(schema.torrents.id, source.id), + source.supersededById === null + ? isNull(schema.torrents.supersededById) + : eq(schema.torrents.supersededById, source.supersededById) + ) + ); + + return { + success: true, + supersededBy: { infoHash: targetHash, name: target.name }, + }; +}); diff --git a/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts b/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts new file mode 100644 index 00000000..43ebf8d7 --- /dev/null +++ b/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts @@ -0,0 +1,137 @@ +/** + * POST /api/torrents/:hash/reseed-request + * + * Ask the people who once downloaded this release to put it back online. + * + * A torrent at zero seeders is, today, a silent dead end: the page shows a + * zero and nothing follows from it. Yet the site knows exactly who could fix + * it — `hnr_tracking` holds one row per (member, torrent) forever, written both + * by the tracker on first completion and by the API the moment somebody clicks + * download. Turning that into a notification is a handful of lines against a + * measurable effect on catalogue health, which is why this is worth having and + * a "dead torrents" report is not. + * + * ## Guards, in the order they matter + * + * - **Zero seeders, checked live.** A request against a healthy swarm is + * noise sent to strangers. The count comes from Redis, the same source the + * page the member is looking at used. + * - **One request per torrent per day, site-wide.** Not per member: the + * recipients are what needs protecting, and ten members each asking once is + * ten notifications for one problem. The lock is a Redis key with a TTL — + * no column, no sweep, and it expires by itself. + * - **A cap on recipients.** An ancient release with 20 000 snatchers would + * otherwise be a mass-mail button available to every member. + * + * ## Who is NOT notified + * + * The requester (they know), erased accounts (a tombstone has no inbox), and + * banned accounts. `hideDownloadHistory` members ARE notified: the preference + * governs who can enumerate their snatch list, and a notification about one + * torrent they downloaded does not enumerate anything — but it does tell them + * the site remembers, which is why the guide says so plainly. + */ +import { and, eq, isNull, ne } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { getStats, redis } from '~~/utils/server'; +import { notify } from '~~/utils/notify'; +import { FANOUT_CONCURRENCY, withConcurrency } from '~~/utils/fanout'; + +/** One request per torrent per day. */ +const COOLDOWN_S = 24 * 60 * 60; +const cooldownKey = (torrentId: string) => `reseed:asked:${torrentId}`; + +/** + * How many past snatchers one request may reach. + * + * The people most likely to still hold the files are the most recent ones, so + * the cap takes the newest rows rather than an arbitrary slice. + */ +const MAX_RECIPIENTS = 200; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + const infoHash = hash.toLowerCase(); + + const torrent = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, infoHash), + columns: { + id: true, + name: true, + isActive: true, + moderationStatus: true, + supersededById: true, + }, + }); + if (!torrent || !torrent.isActive || torrent.moderationStatus !== 'accepted') { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + // A superseded release is meant to fade. Asking members to resurrect one + // works against the decision a moderator already took. + if (torrent.supersededById) { + throw createError({ + statusCode: 400, + message: + 'This release has been superseded. Ask for the replacement to be seeded instead.', + }); + } + + const stats = await getStats(infoHash); + if (stats.seeders > 0) { + throw createError({ + statusCode: 400, + message: 'This torrent still has seeders.', + }); + } + + // Claim the day's slot before doing any work. `SET NX` is atomic, so two + // members pressing the button at the same instant produce one notification + // round, not two. + const claimed = await redis.set(cooldownKey(torrent.id), '1', 'EX', COOLDOWN_S, 'NX'); + if (claimed !== 'OK') { + throw createError({ + statusCode: 429, + message: 'A reseed has already been requested for this torrent today.', + }); + } + + const snatchers = await db + .select({ userId: schema.hnrTracking.userId }) + .from(schema.hnrTracking) + .innerJoin(schema.users, eq(schema.users.id, schema.hnrTracking.userId)) + .where( + and( + eq(schema.hnrTracking.torrentId, torrent.id), + ne(schema.hnrTracking.userId, user.id), + // A tombstone has no inbox, and a banned member cannot act on it. + isNull(schema.users.deletedAt), + eq(schema.users.isBanned, false) + ) + ) + .orderBy(schema.hnrTracking.downloadedAt) + .limit(MAX_RECIPIENTS); + + const recipients = snatchers.map((r) => r.userId); + + // Fire-and-forget, after the response: the member pressed a button and does + // not need to wait on 200 notification inserts to learn that it worked. + void withConcurrency(recipients, FANOUT_CONCURRENCY, async (userId) => { + await notify( + userId, + 'reseed_requested', + { torrentName: torrent.name, requesterUsername: user.username }, + `/torrents/${infoHash}` + ); + }); + + return { success: true, notified: recipients.length }; +}); diff --git a/apps/api/routes/api/torrents/[hash]/supersessions.get.ts b/apps/api/routes/api/torrents/[hash]/supersessions.get.ts new file mode 100644 index 00000000..a0bda29e --- /dev/null +++ b/apps/api/routes/api/torrents/[hash]/supersessions.get.ts @@ -0,0 +1,90 @@ +/** + * GET /api/torrents/:hash/supersessions + * + * Both directions of the trump pointer for one release: what replaced it, and + * what it replaced. + * + * Its own endpoint rather than fields on the detail payload, for the same + * reason the cross-seed siblings have one: the detail page fetches it + * separately and non-blocking, so a torrent with a long supersede chain never + * delays the page it belongs to. A missing or failing endpoint degrades to a + * hidden section. + * + * Visibility follows the catalogue's: a member sees `accepted` rows only, staff + * see everything. Without that, the pointer would be a way to learn that a + * pending or rejected release exists — the same oracle the duplicate preflight + * is careful not to be. + */ +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; + +/** A release can replace several older ones; the list is capped all the same. */ +const MAX_SUPERSEDES = 25; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + + const isStaff = !!user.isAdmin || !!user.isModerator; + const visible = (alias: typeof schema.torrents) => + isStaff ? undefined : eq(alias.moderationStatus, 'accepted'); + + const source = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, hash.toLowerCase()), + columns: { + id: true, + supersededById: true, + supersededAt: true, + supersedeReason: true, + }, + }); + if (!source) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + // Forward: the release that replaced this one. + const supersededBy = source.supersededById + ? ((await db.query.torrents.findFirst({ + where: and( + eq(schema.torrents.id, source.supersededById), + visible(schema.torrents) + ), + columns: { infoHash: true, name: true, size: true, createdAt: true }, + })) ?? null) + : null; + + // Reverse: the releases this one replaced. Served by + // `torrents_superseded_by_idx`. + const supersedes = await db.query.torrents.findMany({ + where: and( + eq(schema.torrents.supersededById, source.id), + visible(schema.torrents) + ), + columns: { + infoHash: true, + name: true, + size: true, + supersededAt: true, + supersedeReason: true, + }, + limit: MAX_SUPERSEDES, + }); + + return { + supersededBy: supersededBy + ? { + ...supersededBy, + at: source.supersededAt, + reason: source.supersedeReason, + } + : null, + supersedes, + }; +}); diff --git a/apps/api/routes/api/torrents/unregistered.post.ts b/apps/api/routes/api/torrents/unregistered.post.ts new file mode 100644 index 00000000..5a5e50c0 --- /dev/null +++ b/apps/api/routes/api/torrents/unregistered.post.ts @@ -0,0 +1,116 @@ +/** + * POST /api/torrents/unregistered { infoHashes: string[] } + * + * Given a list of infohashes from a client, say which of them this tracker + * still serves — and for the ones it does not, why. + * + * The question a member's torrent client cannot answer on its own. A client + * holding four hundred torrents across six trackers has no way to tell an + * announce failing because the tracker is down from one failing because the + * release was deleted, so the usual answer is to leave dead entries in place + * forever. This is one request that sorts them. + * + * It is also the natural entry point for automated cross-seeding: a script + * that knows which of its local torrents this site does NOT have is a script + * that knows what to upload. + * + * ## Verdicts + * + * | verdict | meaning | + * | --- | --- | + * | `active` | served; announces should work | + * | `superseded` | served, but a better release replaced it — the replacement's hash comes with it | + * | `pending` | uploaded here and not through moderation yet | + * | `unregistered` | this tracker has no such torrent | + * + * `rejected` and inactive rows deliberately answer `unregistered`. The detail + * endpoint and the duplicate preflight both refuse to confirm that a rejected + * hash exists — it would turn either into an oracle for enumerating what + * moderation turned down — and an endpoint that takes 256 hashes at a time is + * the last place to open that door. + * + * ## Bounded + * + * 256 hashes per request. A client with more asks twice; the cap is what keeps + * one request from becoming a table scan with a list of arguments. + */ +import { inArray } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; + +const MAX_HASHES = 256; + +const bodySchema = z.object({ + infoHashes: z + .array(z.string().regex(/^[a-fA-F0-9]{40}$/)) + .min(1) + .max(MAX_HASHES), +}); + +export default defineEventHandler(async (event) => { + // A member's own read of the catalogue, so a session. A script wanting to + // call this needs a cookie for now; per-user read keys come later on this + // branch, and this is the first route that will want one. + await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const body = await validateBody(event, bodySchema); + // De-duplicated and lowercased once, so a caller sending the same hash twice + // does not pay for it twice. + const wanted = [...new Set(body.infoHashes.map((h) => h.toLowerCase()))]; + + const rows = await db.query.torrents.findMany({ + where: inArray(schema.torrents.infoHash, wanted), + columns: { + id: true, + infoHash: true, + isActive: true, + moderationStatus: true, + supersededById: true, + }, + }); + + // The replacements' hashes, in one extra query rather than one per row. + const replacementIds = [ + ...new Set(rows.map((r) => r.supersededById).filter((v): v is string => !!v)), + ]; + const replacements = replacementIds.length + ? await db.query.torrents.findMany({ + where: inArray(schema.torrents.id, replacementIds), + columns: { id: true, infoHash: true, name: true }, + }) + : []; + const replacementById = new Map(replacements.map((r) => [r.id, r])); + + const byHash = new Map(rows.map((r) => [r.infoHash, r])); + + const results = wanted.map((infoHash) => { + const row = byHash.get(infoHash); + if (!row || !row.isActive || row.moderationStatus === 'rejected') { + return { infoHash, verdict: 'unregistered' as const }; + } + if (row.moderationStatus !== 'accepted') { + return { infoHash, verdict: 'pending' as const }; + } + if (row.supersededById) { + const rep = replacementById.get(row.supersededById); + return { + infoHash, + verdict: 'superseded' as const, + // Absent when the replacement is not itself visible — the verdict is + // still true and still useful without it. + supersededBy: rep ? { infoHash: rep.infoHash, name: rep.name } : null, + }; + } + return { infoHash, verdict: 'active' as const }; + }); + + return { + checked: results.length, + limit: MAX_HASHES, + results, + }; +}); diff --git a/apps/api/utils/fanout.ts b/apps/api/utils/fanout.ts new file mode 100644 index 00000000..6b807de1 --- /dev/null +++ b/apps/api/utils/fanout.ts @@ -0,0 +1,46 @@ +/** + * A bounded worker pool for notification fan-outs. + * + * Lived in `followerFanout.ts` until the reseed request became its second + * caller. The reasoning is unchanged and worth keeping in front of whoever + * adds a third: an unbounded `Promise.all` over recipients is a denial of + * service you write yourself. Each task is a database insert plus a Redis + * publish plus — for anyone who configured one — an outbound HTTP request to + * Telegram, Discord or a webhook. A release followed by two thousand members + * would open two thousand of those at once, against a connection pool sized + * for tens. + * + * Failures are swallowed per item on purpose. A fan-out is best-effort, and one + * recipient whose webhook is refusing connections must not cost the other + * nineteen hundred their notification. + */ +/** + * 20 is high enough that a few hundred recipients finish in roughly one notify + * round-trip's worth of wall time, and low enough that an uploader with 50 000 + * followers cannot open 50 000 concurrent connections to Postgres and Redis at + * upload time. + */ +export const FANOUT_CONCURRENCY = 20; + +export async function withConcurrency<T>( + items: T[], + concurrency: number, + fn: (item: T) => Promise<void> +): Promise<void> { + if (items.length === 0) return; + const queue = items.slice(); + const workers = Array(Math.min(concurrency, items.length)) + .fill(0) + .map(async () => { + while (queue.length > 0) { + const item = queue.shift(); + if (item === undefined) return; + try { + await fn(item); + } catch { + // best-effort: don't let one bad recipient sink the rest + } + } + }); + await Promise.all(workers); +} diff --git a/apps/api/utils/followerFanout.ts b/apps/api/utils/followerFanout.ts index 0fcfded8..8f9d90ce 100644 --- a/apps/api/utils/followerFanout.ts +++ b/apps/api/utils/followerFanout.ts @@ -21,6 +21,9 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { notify } from './notify'; +// The pool and its size live in `utils/fanout` — they gained a second caller +// (the reseed request) and a shared cap is the point of having one. +import { FANOUT_CONCURRENCY, withConcurrency } from './fanout'; interface FanoutInput { uploaderId: string; @@ -30,42 +33,6 @@ interface FanoutInput { torrentName: string; } -/** Pool size for per-follower notify dispatch. 20 is high - * enough that a few hundred followers finish in roughly one - * notify round-trip's worth of wall time, low enough that an - * uploader with 50k+ followers can't open 50k+ concurrent - * connections to Postgres and Redis at upload time. */ -const FANOUT_CONCURRENCY = 20; - -/** - * Worker-pool concurrency limiter. Spins up `concurrency` - * workers that pop items from a shared queue. Each task's - * failure is swallowed — the fan-out is best-effort and a - * single recipient's notify glitch must not skip the rest. - */ -async function withConcurrency<T>( - items: T[], - concurrency: number, - fn: (item: T) => Promise<void>, -): Promise<void> { - if (items.length === 0) return; - const queue = items.slice(); - const workers = Array(Math.min(concurrency, items.length)) - .fill(0) - .map(async () => { - while (queue.length > 0) { - const item = queue.shift(); - if (item === undefined) return; - try { - await fn(item); - } catch { - // best-effort: don't let one bad recipient sink the rest - } - } - }); - await Promise.all(workers); -} - export async function fanoutFollowedUserUpload( input: FanoutInput, ): Promise<void> { diff --git a/apps/api/utils/notify.ts b/apps/api/utils/notify.ts index a0e08cc8..18619872 100644 --- a/apps/api/utils/notify.ts +++ b/apps/api/utils/notify.ts @@ -44,6 +44,13 @@ export type NotificationType = | 'upload_reset' | 'moderation_message_received' | 'torrent_deleted_by_staff' + // ── Catalogue health ─────────────────────────────────────── + /** + * Somebody asked for a dead torrent to be seeded again, and you are on the + * list because you downloaded it once. Not a demand — the member has no way + * to know whether you still hold the files. + */ + | 'reseed_requested' // ── P1 — Hit & Run ───────────────────────────────────────── | 'hnr_violation_marked' | 'hnr_cleared' diff --git a/apps/api/utils/notifyRenderer.ts b/apps/api/utils/notifyRenderer.ts index 38cde4a1..cdf0e0c1 100644 --- a/apps/api/utils/notifyRenderer.ts +++ b/apps/api/utils/notifyRenderer.ts @@ -48,6 +48,10 @@ const EN: Dict = { title: 'Torrent removed by staff', desc: '{actorUsername} deleted “{torrentName}”.', }, + reseed_requested: { + title: 'Reseed requested', + desc: '“{torrentName}” has no seeders. You downloaded it once — can you seed it again?', + }, hnr_violation_marked: { title: 'Hit & Run flagged', desc: '“{torrentName}” crossed the grace period without enough seeding.', @@ -257,6 +261,10 @@ const FR: Dict = { title: 'Torrent supprimé par un staff', desc: '{actorUsername} a supprimé « {torrentName} ».', }, + reseed_requested: { + title: 'Remise en partage demandée', + desc: '« {torrentName} » n’a plus aucune source. Vous l’avez téléchargé : pouvez-vous le repartager ?', + }, hnr_violation_marked: { title: 'Hit & Run signalé', desc: '« {torrentName} » a dépassé la période de grâce sans seed suffisant.', diff --git a/apps/web/app/components/settings/NotificationsSection.vue b/apps/web/app/components/settings/NotificationsSection.vue index cd6c3e0c..723a243a 100644 --- a/apps/web/app/components/settings/NotificationsSection.vue +++ b/apps/web/app/components/settings/NotificationsSection.vue @@ -585,6 +585,7 @@ const notifTypes = [ 'upload_reset', 'moderation_message_received', 'torrent_deleted_by_staff', + 'reseed_requested', 'hnr_violation_marked', 'hnr_cleared', 'hnr_exempted', @@ -648,6 +649,15 @@ const categories: EventCategory[] = [ color: '#ff6b6b', types: ['hnr_violation_marked', 'hnr_cleared', 'hnr_exempted'], }, + { + // Grouped with moderation rather than with hit-and-run: a reseed request + // is somebody asking for help, not the site telling you off, and the two + // land very differently in an inbox. + key: 'catalogue', + icon: 'ph:hand-heart-bold', + color: '#4ade80', + types: ['reseed_requested'], + }, { key: 'account', icon: 'ph:user-circle-bold', diff --git a/apps/web/app/pages/notifications.vue b/apps/web/app/pages/notifications.vue index a238b6fb..9f6da835 100644 --- a/apps/web/app/pages/notifications.vue +++ b/apps/web/app/pages/notifications.vue @@ -192,6 +192,7 @@ const TYPE_DEFAULTS: Record< upload_reset: { icon: 'ph:arrow-counter-clockwise-bold', tone: 'info' }, moderation_message_received: { icon: 'ph:chat-circle-text-bold', tone: 'info' }, torrent_deleted_by_staff: { icon: 'ph:trash-bold', tone: 'danger' }, + reseed_requested: { icon: 'ph:hand-heart-bold', tone: 'social' }, hnr_violation_marked: { icon: 'ph:lightning-bold', tone: 'danger' }, hnr_cleared: { icon: 'ph:check-circle-bold', tone: 'gain' }, hnr_exempted: { icon: 'ph:shield-check-bold', tone: 'gain' }, diff --git a/apps/web/app/pages/torrents/[hash]/index.vue b/apps/web/app/pages/torrents/[hash]/index.vue index 2a1665bb..7c52ab86 100644 --- a/apps/web/app/pages/torrents/[hash]/index.vue +++ b/apps/web/app/pages/torrents/[hash]/index.vue @@ -466,6 +466,29 @@ </div> </li> </ul> + + <!-- A dead swarm used to be a zero and nothing else. The site knows who + downloaded this once, so the zero can lead somewhere. Hidden for a + superseded release: asking members to resurrect one works against a + decision staff already took. --> + <div v-if="canAskReseed" class="reseed-ask"> + <p class="reseed-hint"> + <Icon name="ph:hand-heart-bold" class="reseed-icon" /> + {{ $t('torrents.detail.reseed.hint') }} + </p> + <button + type="button" + class="btn btn-secondary btn-sm" + :disabled="reseedBusy" + @click="askReseed" + > + <Icon + :name="reseedBusy ? 'ph:circle-notch' : 'ph:megaphone-bold'" + :class="{ 'animate-spin': reseedBusy }" + /> + {{ reseedBusy ? $t('torrents.detail.reseed.asking') : $t('torrents.detail.reseed.ask') }} + </button> + </div> </section> <!-- § NOTE — uploader's description, when present. Treated as an @@ -526,6 +549,69 @@ </ul> </section> + <!-- § SUPERSEDED — this release was replaced, or replaces others. Sits + with the cross-seed sections because it answers the same kind of + question (what else is this?), and reuses their markup so a reader + scanning the page meets one list format, not three. --> + <section + v-if="supersessions?.supersededBy || supersessions?.supersedes.length" + class="section section--cross" + > + <header class="section-head"> + <span class="section-head-mark" aria-hidden="true">§</span> + <h2 class="section-head-title">{{ $t('torrents.detail.supersede.title') }}</h2> + <span class="section-head-line" aria-hidden="true" /> + </header> + + <!-- Replaced BY something: the important direction, and a banner rather + than a list item — a member who landed here should be told before + they press download, not after they scroll. --> + <div v-if="supersessions.supersededBy" class="supersede-banner"> + <Icon name="ph:arrow-bend-right-up-bold" class="supersede-banner-icon" /> + <div class="supersede-banner-body"> + <p class="supersede-banner-lead"> + {{ $t('torrents.detail.supersede.replacedBy') }} + </p> + <NuxtLink + :to="`/torrents/${supersessions.supersededBy.infoHash}`" + class="supersede-banner-link" + > + {{ supersessions.supersededBy.name }} + </NuxtLink> + <p v-if="supersessions.supersededBy.reason" class="supersede-banner-reason"> + {{ supersessions.supersededBy.reason }} + </p> + <p class="supersede-banner-note"> + {{ $t('torrents.detail.supersede.stillSeedable') }} + </p> + </div> + </div> + + <!-- Replaces others: informational, so it reads as the ordinary list. --> + <template v-if="supersessions.supersedes.length"> + <p class="cross-note"> + <Icon name="ph:info-bold" class="cross-note-icon" /> + {{ $t('torrents.detail.supersede.replaces') }} + </p> + <ul class="cross-list"> + <li v-for="old0 in supersessions.supersedes" :key="old0.infoHash" class="cross-item"> + <NuxtLink :to="`/torrents/${old0.infoHash}`" class="cross-link"> + <Icon name="ph:arrow-bend-left-down-bold" class="cross-icon" /> + <span class="cross-name">{{ old0.name }}</span> + <span class="cross-meta"> + <span class="cross-meta-size">{{ formatSize(old0.size) }}</span> + <template v-if="old0.supersedeReason"> + <span class="cross-meta-sep">·</span> + <span class="cross-meta-cat">{{ old0.supersedeReason }}</span> + </template> + </span> + <Icon name="ph:arrow-right-bold" class="cross-arrow" /> + </NuxtLink> + </li> + </ul> + </template> + </section> + <!-- § FEDERATED CROSS-SEED — same content on a partner (M2). If you also have an account there, you can cross-seed it from the bytes you already hold, fetched with your own partner passkey. --> @@ -672,6 +758,53 @@ {{ $t('torrents.detail.buffs.clear') }} </button> </div> + + <!-- Trumping. Takes the replacement's infohash because that is what a + moderator has in front of them — they are looking at the newer + release's page in the other tab. --> + <h3 class="staff-block-title staff-block-title--spaced"> + {{ $t('torrents.detail.supersede.staffTitle') }} + </h3> + <p class="buffs-lede">{{ $t('torrents.detail.supersede.staffLede') }}</p> + + <div v-if="supersessions?.supersededBy" class="buffs-actions"> + <p class="buffs-lede" style="margin: 0"> + {{ $t('torrents.detail.supersede.currently', { name: supersessions.supersededBy.name }) }} + </p> + <button type="button" class="btn-ghost" :disabled="supersedeBusy" @click="clearSupersede"> + {{ $t('torrents.detail.supersede.clear') }} + </button> + </div> + + <div v-else class="buffs-grid"> + <label class="buffs-field" style="flex: 1 1 22rem"> + <span class="field-label">{{ $t('torrents.detail.supersede.hashLabel') }}</span> + <input + v-model="supersedeForm.hash" + type="text" + class="input" + autocomplete="off" + spellcheck="false" + placeholder="40 hex" + /> + </label> + <label class="buffs-field" style="flex: 1 1 16rem"> + <span class="field-label">{{ $t('torrents.detail.supersede.reasonLabel') }}</span> + <input v-model="supersedeForm.reason" type="text" class="input" maxlength="500" /> + </label> + <button + type="button" + class="btn btn-secondary" + :disabled="supersedeBusy || supersedeForm.hash.length !== 40" + @click="saveSupersede" + > + <Icon + :name="supersedeBusy ? 'ph:circle-notch' : 'ph:arrow-bend-right-up-bold'" + :class="{ 'animate-spin': supersedeBusy }" + /> + {{ $t('torrents.detail.supersede.mark') }} + </button> + </div> </section> <!-- § SWARM — admin-only peer list. Operational data, kept as a @@ -927,6 +1060,30 @@ const federationEnabled = computed(() => Boolean(branding.value?.federationEnabled), ); +/** + * Both directions of the trump pointer. A fourth side fetch beside the + * cross-seed ones, non-blocking for the same reason: the page must render + * whether or not this endpoint answers, and a hidden section is the right + * degradation. + */ +const { data: supersessions, refresh: refreshSupersessions } = await useFetch<{ + supersededBy: { + infoHash: string; + name: string; + size: number; + at: string | null; + reason: string | null; + } | null; + supersedes: Array<{ + infoHash: string; + name: string; + size: number; + supersedeReason: string | null; + }>; +}>(`/api/torrents/${hash}/supersessions`, { + default: () => ({ supersededBy: null, supersedes: [] }), +}); + const { data: federatedCrossSeeds } = await useFetch<{ items: FederatedCrossSeedItem[]; total: number; @@ -1269,6 +1426,82 @@ function clearBuffs() { return putBuffs({ downloadMultiplier: 100, uploadMultiplier: 100, until: null }); } +/** + * Offered only when the swarm is genuinely dead and the release is still the + * current one. The server checks both again — this is about not showing a + * button that will be refused, not about trusting the client. + */ +const reseedBusy = ref(false); +const canAskReseed = computed( + () => + loggedIn.value && + !!torrent.value && + !torrent.value.gatedAdult && + torrent.value.stats?.seeders === 0 && + !supersessions.value?.supersededBy +); + +async function askReseed() { + reseedBusy.value = true; + try { + const res = await $fetch<{ notified: number }>( + `/api/torrents/${hash}/reseed-request`, + { method: 'POST' } + ); + notifications.success(t('torrents.detail.reseed.done', { count: res.notified })); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + notifications.error( + e?.data?.message || e?.message || t('torrents.detail.reseed.failed') + ); + } finally { + reseedBusy.value = false; + } +} + +const supersedeForm = reactive({ hash: '', reason: '' }); +const supersedeBusy = ref(false); + +async function saveSupersede() { + supersedeBusy.value = true; + try { + await $fetch(`/api/mod/torrents/${hash}/supersede`, { + method: 'PUT', + body: { + supersededById: supersedeForm.hash.trim().toLowerCase(), + reason: supersedeForm.reason.trim() || undefined, + }, + }); + supersedeForm.hash = ''; + supersedeForm.reason = ''; + await refreshSupersessions(); + notifications.success(t('torrents.detail.supersede.saved')); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + notifications.error( + e?.data?.message || e?.message || t('torrents.detail.supersede.failed') + ); + } finally { + supersedeBusy.value = false; + } +} + +async function clearSupersede() { + supersedeBusy.value = true; + try { + await $fetch(`/api/mod/torrents/${hash}/supersede`, { method: 'DELETE' }); + await refreshSupersessions(); + notifications.success(t('torrents.detail.supersede.cleared')); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + notifications.error( + e?.data?.message || e?.message || t('torrents.detail.supersede.failed') + ); + } finally { + supersedeBusy.value = false; + } +} + // Compute permissions const canEdit = computed(() => { if (!loggedIn.value || !user.value) return false; @@ -3112,6 +3345,84 @@ async function confirmDelete() { letter-spacing: calc(0.04em * var(--tracking-scale)); color: rgb(var(--fg-default)); } + +/* ── Reseed ask ─────────────────────────────────────────────────── + Sits under the stat row it belongs to, separated by a rule rather than a + panel: it is a follow-up to the zero above it, not a section of its own. */ +.reseed-ask { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 0.9rem; + border-top: 1px solid rgb(var(--line-default)); +} +.reseed-hint { + display: flex; + align-items: center; + gap: 0.45rem; + margin: 0; + max-width: 60ch; + font-size: 0.8125rem; + line-height: 1.5; + color: rgb(var(--fg-muted)); +} +.reseed-icon { + flex-shrink: 0; + width: 1rem; + height: 1rem; + color: rgb(var(--accent)); +} + +/* ── Superseded banner ──────────────────────────────────────────── + A banner rather than a list row for the "replaced by" direction: a member + who landed on an outdated release should be told before they press + download. The tone is informational, not a warning — the file is still + perfectly seedable, and colouring it red would say otherwise. */ +.supersede-banner { + display: flex; + gap: 0.75rem; + padding: 0.85rem 1rem; + margin-bottom: 1rem; + border: 1px solid rgb(var(--info) / 0.35); + border-radius: 0.5rem; + background: rgb(var(--info) / 0.08); +} +.supersede-banner-icon { + flex-shrink: 0; + width: 1.15rem; + height: 1.15rem; + margin-top: 0.1rem; + color: rgb(var(--info)); +} +.supersede-banner-body { + min-width: 0; +} +.supersede-banner-lead { + margin: 0 0 0.2rem; + font-size: 0.72rem; + letter-spacing: 0.09em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.supersede-banner-link { + font-weight: 600; + overflow-wrap: anywhere; +} +.supersede-banner-reason, +.supersede-banner-note { + margin: 0.35rem 0 0; + font-size: 0.8125rem; + line-height: 1.5; + color: rgb(var(--fg-muted)); +} +.supersede-banner-note { + color: rgb(var(--fg-subtle)); + font-size: 0.75rem; +} + /* ── Buff badge (hero eyebrow) ──────────────────────────────────── Reads as a label, not a button: it states a fact about the release and nothing about it is clickable. */ diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 62b77caa..0ad96aa4 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -464,7 +464,8 @@ "social": "Social", "requests": "Requests", "invites": "Invitations", - "moderator": "Moderation tasks" + "moderator": "Moderation tasks", + "catalogue": "Catalogue" } }, "identity": { @@ -832,7 +833,30 @@ "saved": "Buffs updated.", "failed": "Could not update the buffs." }, - "staffTools": "Staff tools" + "staffTools": "Staff tools", + "supersede": { + "title": "Superseded", + "replacedBy": "Replaced by", + "replaces": "This release replaces the following, which are kept online for whoever is still seeding them.", + "stillSeedable": "This release is still downloadable and its swarm is untouched — the newer one is simply the one staff consider current.", + "staffTitle": "Supersede", + "staffLede": "Point this release at the one that replaces it. The older release stays online and keeps its swarm — what changes is that both pages say which one is current.", + "hashLabel": "Replacement infohash", + "reasonLabel": "Reason (optional)", + "mark": "Mark as superseded", + "currently": "Currently superseded by “{name}”.", + "clear": "Lift it", + "saved": "Marked as superseded.", + "cleared": "Supersede marking lifted.", + "failed": "Could not update the supersede marking." + }, + "reseed": { + "ask": "Ask for a reseed", + "asking": "Asking…", + "hint": "Nobody is seeding this. Members who downloaded it before will be asked to put it back online.", + "done": "Asked {count} past downloaders to seed it again.", + "failed": "Could not send the request." + } } }, "search": { @@ -4578,6 +4602,10 @@ "ticket_idle_warning": { "title": "Your ticket will close", "desc": "“{subject}” — answer within {days} days to keep it open." + }, + "reseed_requested": { + "title": "Reseed requested", + "desc": "“{torrentName}” has no seeders. You downloaded it once — can you seed it again?" } } }, diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 7c04c8b6..893e5850 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -464,7 +464,8 @@ "social": "Social", "requests": "Demandes", "invites": "Invitations", - "moderator": "Tâches de modération" + "moderator": "Tâches de modération", + "catalogue": "Catalogue" } }, "identity": { @@ -832,7 +833,30 @@ "saved": "Bonus mis à jour.", "failed": "Les bonus n'ont pas pu être mis à jour." }, - "staffTools": "Outils du personnel" + "staffTools": "Outils du personnel", + "supersede": { + "title": "Remplacée", + "replacedBy": "Remplacée par", + "replaces": "Cette release en remplace les suivantes, laissées en ligne pour celles et ceux qui les partagent encore.", + "stillSeedable": "Cette release reste téléchargeable et son essaim est intact — la plus récente est simplement celle que l'équipe considère comme actuelle.", + "staffTitle": "Remplacement", + "staffLede": "Désignez la release qui remplace celle-ci. L'ancienne reste en ligne et garde son essaim — ce qui change, c'est que les deux pages disent laquelle est actuelle.", + "hashLabel": "Infohash du remplaçant", + "reasonLabel": "Raison (facultatif)", + "mark": "Marquer comme remplacée", + "currently": "Actuellement remplacée par « {name} ».", + "clear": "Retirer", + "saved": "Marquée comme remplacée.", + "cleared": "Marquage retiré.", + "failed": "Le marquage n'a pas pu être mis à jour." + }, + "reseed": { + "ask": "Demander une remise en partage", + "asking": "Envoi…", + "hint": "Plus personne ne partage. Les membres qui l'ont téléchargé seront invités à le remettre en ligne.", + "done": "{count} anciens téléchargeurs ont été sollicités.", + "failed": "La demande n'a pas pu être envoyée." + } } }, "search": { @@ -4578,6 +4602,10 @@ "ticket_idle_warning": { "title": "Ton ticket va se clôturer", "desc": "« {subject} » — réponds sous {days} jours pour le garder ouvert." + }, + "reseed_requested": { + "title": "Remise en partage demandée", + "desc": "« {torrentName} » n’a plus aucune source. Vous l’avez téléchargé : pouvez-vous le repartager ?" } } }, diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index c22743b4..9afa1558 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -88,6 +88,7 @@ export default defineConfig({ { text: 'Roles & Permissions', link: '/guide/roles-and-permissions' }, { text: 'Invitations', link: '/guide/invitations' }, { text: 'Moderation', link: '/guide/moderation' }, + { text: 'Torrent Lifecycle', link: '/guide/torrent-lifecycle' }, { text: 'Reports', link: '/guide/reports' }, { text: 'Anti-cheat', link: '/guide/anti-cheat' }, { text: 'Upload Rules', link: '/guide/upload-rules' }, diff --git a/doc/guide/torrent-lifecycle.md b/doc/guide/torrent-lifecycle.md new file mode 100644 index 00000000..6f690d19 --- /dev/null +++ b/doc/guide/torrent-lifecycle.md @@ -0,0 +1,99 @@ +# Torrent lifecycle + +Three things a catalogue needs to be able to say once it is a few years old, +and could not: *that one replaces this*, *nobody is seeding this*, and *we no +longer carry that*. + +## Superseding (trumping) + +A better rip of the same work appears. Moderation could already say a release +was rejected; it could not say a release was fine and is simply no longer the +one to take. Without that, a catalogue ages by accumulating duplicates of +uneven quality with no hierarchy between them. + +Staff mark it from the older release's page: **Staff tools → Supersede**, by +pasting the replacement's infohash. + +### The older release stays online + +It keeps its listing, its download, its swarm, and its snatchers keep their +hit-and-run obligations. People are seeding it; pulling it out from under them +would turn a tidy-up into a hit-and-run of the operator's own making. + +What changes is that both pages say so. The older one carries a banner pointing +at the replacement — informational, not a warning, because the file is still +perfectly good — and the newer one lists what it replaced. + +### The guards + +| Refused | Because | +| --- | --- | +| A release superseding itself | The page would point at itself, and the chain walk would not terminate | +| A cycle (A→B→A) | The same non-termination reached the long way round | +| A target that is not `accepted` and active | The pointer would send members to a page they may not be allowed to read | +| A target that is itself superseded | The pointer would be a dead end; point at the head of the chain instead | + +The chain walk is bounded at 32 hops, so a cycle that arrived some other way — +a restore, a hand-edited row — cannot hang the request. + +## Reseed requests + +A torrent at zero seeders used to be a silent dead end: the page showed a zero +and nothing followed from it. The site knows exactly who could fix it — +`hnr_tracking` holds one row per (member, torrent) forever, written both by the +tracker on first completion and by the API the moment somebody clicks download. + +Any member can ask, from the torrent page, when the swarm is genuinely empty. +Past downloaders get a notification. + +| Guard | Value | +| --- | --- | +| Seeders must be | 0, checked live against Redis | +| One request per torrent per | 24 hours, **site-wide** — not per member | +| Recipients capped at | 200, most recent snatchers first | +| Superseded releases | refused — asking members to resurrect one works against a decision staff already took | + +The cooldown is site-wide rather than per member on purpose: the people who +need protecting are the recipients, and ten members each asking once is ten +notifications for one problem. It is a Redis key with a TTL — no column, no +sweep, and it expires by itself. + +Erased and banned accounts are skipped. Members who hide their download history +**are** notified: that preference governs who can enumerate their snatch list, +and a notification about one torrent enumerates nothing — though it does tell +them the site remembers, which is worth knowing. + +## Unregistered infohashes + +`POST /api/torrents/unregistered` takes up to 256 infohashes and says which +this tracker still serves. + +The question a torrent client cannot answer on its own: an announce failing +because the tracker is down looks exactly like one failing because the release +was deleted, so the usual answer is to leave dead entries in place forever. + +```bash +curl -X POST "https://tracker.example/api/torrents/unregistered?apikey=$API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"infoHashes":["aaaa…","bbbb…"]}' +``` + +| verdict | meaning | +| --- | --- | +| `active` | served; announces should work | +| `superseded` | served, but a better release replaced it — the replacement's hash comes with it | +| `pending` | uploaded here, not through moderation yet | +| `unregistered` | this tracker has no such torrent | + +Rejected and inactive rows deliberately answer `unregistered`. The detail +endpoint and the duplicate preflight both refuse to confirm that a rejected +hash exists — it would turn either into an oracle for enumerating what +moderation turned down — and an endpoint taking 256 hashes at a time is the +last place to open that door. + +Authenticate with the **API key** from your profile (see +[Keys](./api-keys.md)), not the announce passkey. + +This is also the natural entry point for automated cross-seeding: a script that +knows which of its local torrents this site does *not* have is a script that +knows what to upload. diff --git a/packages/db/src/migrations/0061_torrent_supersede.sql b/packages/db/src/migrations/0061_torrent_supersede.sql new file mode 100644 index 00000000..bde44cc1 --- /dev/null +++ b/packages/db/src/migrations/0061_torrent_supersede.sql @@ -0,0 +1,5 @@ +ALTER TABLE "torrents" ADD COLUMN "superseded_by_id" text;--> statement-breakpoint +ALTER TABLE "torrents" ADD COLUMN "superseded_at" timestamp;--> statement-breakpoint +ALTER TABLE "torrents" ADD COLUMN "supersede_reason" text;--> statement-breakpoint +ALTER TABLE "torrents" ADD CONSTRAINT "torrents_superseded_by_id_torrents_id_fk" FOREIGN KEY ("superseded_by_id") REFERENCES "public"."torrents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "torrents_superseded_by_idx" ON "torrents" USING btree ("superseded_by_id") WHERE "torrents"."superseded_by_id" IS NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0061_snapshot.json b/packages/db/src/migrations/meta/0061_snapshot.json new file mode 100644 index 00000000..5ffb0f67 --- /dev/null +++ b/packages/db/src/migrations/meta/0061_snapshot.json @@ -0,0 +1,9814 @@ +{ + "id": "0fea4f16-7058-4d16-857e-b51d76657eee", + "prevId": "f29e5feb-73bb-4c6e-b61e-0eaa1f2dd0d2", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 64710d9c..6b487c64 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -428,6 +428,13 @@ "when": 1788215642688, "tag": "0060_torrent_buffs", "breakpoints": true + }, + { + "idx": 61, + "version": "7", + "when": 1788216646668, + "tag": "0061_torrent_supersede", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index af2d6946..8b39f6c0 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1057,6 +1057,33 @@ export const torrents = pgTable( * admin's. */ isSticky: boolean('is_sticky').default(false).notNull(), + /** + * Trumping — this release has been superseded by a better one. + * + * A catalogue with no way to say "that one replaces this one" ages by + * accumulating duplicates of uneven quality with no hierarchy between + * them. The moderation pipeline could already say a release was rejected; + * it could not say a release was fine and is simply no longer the one to + * take. + * + * The pointer is advisory, and that is the whole design: a superseded + * torrent stays listed, stays downloadable, and keeps its swarm. People + * are seeding it, and retiring it under them would turn a tidy-up into a + * hit-and-run. What changes is that both pages say so, and a member + * choosing between two copies is told which one the staff consider + * current. + * + * `ON DELETE set null` rather than cascade: deleting the newer release + * must not delete the older one it replaced. The older row simply stops + * being superseded — which is true, at that point. + */ + supersededById: text('superseded_by_id').references( + (): AnyPgColumn => torrents.id, + { onDelete: 'set null' } + ), + supersededAt: timestamp('superseded_at'), + /** Free text, shown to members: "better source", "wrong audio track". */ + supersedeReason: text('supersede_reason'), // Per-torrent opt-in for swarm federation (Phase 4). When true AND the // owner has a swarm-scoped peer link, this torrent's peers may be shared // with / mixed from partner instances. Off by default — it re-opens the @@ -1125,6 +1152,12 @@ export const torrents = pgTable( index('torrents_tvdb_idx').on(table.tvdbId), index('torrents_igdb_idx').on(table.igdbId), index('torrents_content_signature_idx').on(table.contentSignature), + // The reverse of the supersede pointer: "what did this release replace". + // The forward direction is a primary-key lookup and needs nothing. + // Partial, because the column is null for all but a handful of rows. + index('torrents_superseded_by_idx') + .on(table.supersededById) + .where(sql`${table.supersededById} IS NOT NULL`), // The cross-tracker content key drives cross-seed / fill matching joins. index('torrents_content_root_v2_idx').on(table.contentRootV2), /** From 3bcf60c4f03f9630010180c4f0da2b92e32fc451 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 10:14:39 +0200 Subject: [PATCH 10/96] feat(me): three keys, because one was doing three jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member pastes their RSS URL into a service that indexes releases for them. They have just handed over the credential that announces on their behalf — because it is the same string. And the only way to take it back was to rotate the passkey, which breaks every torrent in their client at the same moment. So nobody did, and the key stayed out there. `rss_key` and `api_key` join `passkey`. The passkey remains the announce credential and only that; it is still the write key the tracker uses to book bytes, and none of that moved. What changed is that the two things a member hands to software are no longer the thing that speaks for them, and each can be revoked for its own reason without touching a single torrent. Nullable, minted on first read rather than at registration. A member who never wires up a feed reader should not be carrying two live secrets they have never seen, and a column that is null until somebody asks is a column an attacker cannot use. Postgres allows many NULLs under a unique index, so the constraint still holds for the ones that exist. Two things a survey of the passkey's blast radius turned up before any of this was written, both of which would have been quiet failures: **Panic mode encrypts `users.passkey` in place.** A new secret column that it did not know about would leave a "the database is encrypted" that is only mostly true — two live credentials in plaintext inside it. Both new columns are in the encrypt and restore paths. **The passkey is sealed into the session cookie**, and `GET /api/auth/passkey` reads it from there rather than from the row. A read key put in the cookie would survive its own rotation for up to seven days, which is not a revoked key. They are read from the row, on demand. The read surfaces now share one resolver. They did not before, and the difference was invisible until you hit it: the Torznab gate shape-checked the key and lowercased it, `requireSessionOrApikey` did neither, so a key stored lowercase but supplied uppercase authenticated on one surface and failed on the other. `requireReadAccess` resolves a session, then the surface's OWN key — an RSS key must not open the programmatic API, or the split has bought nothing — then the announce passkey while an operator still allows it. The old helper had exactly three callers, all migrated, so it is gone rather than left as a second way in. That last step is a migration path and is off nobody's critical path to remove: `legacy_passkey_read_access` defaults to TRUE, because every feed URL a member has configured anywhere carries the passkey and turning it off on upgrade day would break all of them at once — precisely the breakage this change exists to prevent. The Credentials card tells members to move over and says why. An operator flips it when they have. Caddy's access log strips `apikey` and `rsskey` now, alongside `passkey`. Before this, `apikey` WAS the passkey, so the existing scrub covered it by accident; generalising the parameter without generalising the scrub would have started writing live keys to disk. Erasure clears all three. Verified end to end on a throwaway stack: rotating a key makes the previous value answer 401 on the very next request, because there is no cache in front of these — which is the property that makes "revoke" mean revoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + .../routes/api/admin/panic/encrypt.post.ts | 7 + .../routes/api/admin/panic/restore.post.ts | 2 + apps/api/routes/api/me/keys/[kind].post.ts | 34 + apps/api/routes/api/me/keys/index.get.ts | 32 + .../api/routes/api/rss/category/[slug].get.ts | 4 +- apps/api/routes/api/rss/latest.get.ts | 9 +- .../routes/api/torrents/unregistered.post.ts | 9 +- apps/api/routes/api/torznab/utils/auth.ts | 61 +- apps/api/utils/account/eraseAccount.ts | 7 + apps/api/utils/account/exportAccount.ts | 2 +- apps/api/utils/account/readKeyAuth.ts | 130 + apps/api/utils/account/readKeys.ts | 170 + apps/api/utils/adminAuth.ts | 61 +- apps/api/utils/settings.ts | 21 + apps/web/app/pages/me.vue | 197 +- apps/web/i18n/locales/en.json | 11 +- apps/web/i18n/locales/fr.json | 13 +- doc/.vitepress/config.ts | 1 + doc/guide/api-keys.md | 60 + docker/caddy/Caddyfile | 19 +- .../db/src/migrations/0062_user_read_keys.sql | 4 + .../db/src/migrations/meta/0062_snapshot.json | 9840 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 25 + 25 files changed, 10635 insertions(+), 92 deletions(-) create mode 100644 apps/api/routes/api/me/keys/[kind].post.ts create mode 100644 apps/api/routes/api/me/keys/index.get.ts create mode 100644 apps/api/utils/account/readKeyAuth.ts create mode 100644 apps/api/utils/account/readKeys.ts create mode 100644 doc/guide/api-keys.md create mode 100644 packages/db/src/migrations/0062_user_read_keys.sql create mode 100644 packages/db/src/migrations/meta/0062_snapshot.json diff --git a/README.md b/README.md index 2233a6a3..2eea65aa 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). +- **Three keys, not one** — the announce passkey, an RSS/Torznab key and an API key, each revocable on its own. Handing a feed URL to a third party no longer hands over the credential that announces for you. See [Keys](doc/guide/api-keys.md). - **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/admin/panic/encrypt.post.ts b/apps/api/routes/api/admin/panic/encrypt.post.ts index b05cc8a5..f52c2e96 100644 --- a/apps/api/routes/api/admin/panic/encrypt.post.ts +++ b/apps/api/routes/api/admin/panic/encrypt.post.ts @@ -104,6 +104,13 @@ export default defineEventHandler(async (event) => { authSalt: encryptField(user.authSalt, key)!, authVerifier: encryptField(user.authVerifier, key)!, passkey: encryptField(user.passkey, key)!, + // The two read keys are credentials like the passkey is. Leaving them + // out would mean a panicked database still carrying live secrets in + // plaintext — a "the data is encrypted" that is only mostly true, which + // is the kind of gap nobody finds until it matters. `encryptField` + // passes null through, so an account that never minted one stays null. + rssKey: encryptField(user.rssKey, key) ?? undefined, + apiKey: encryptField(user.apiKey, key) ?? undefined, lastIp: encryptField(user.lastIp, key) ?? undefined, }) .where(eq(users.id, user.id)); diff --git a/apps/api/routes/api/admin/panic/restore.post.ts b/apps/api/routes/api/admin/panic/restore.post.ts index 5a359620..1f570cff 100644 --- a/apps/api/routes/api/admin/panic/restore.post.ts +++ b/apps/api/routes/api/admin/panic/restore.post.ts @@ -144,6 +144,8 @@ export default defineEventHandler(async (event) => { authSalt: decryptField(user.authSalt, key, legacyIv)!, authVerifier: decryptField(user.authVerifier, key, legacyIv)!, passkey: decryptField(user.passkey, key, legacyIv)!, + rssKey: decryptField(user.rssKey, key, legacyIv) ?? undefined, + apiKey: decryptField(user.apiKey, key, legacyIv) ?? undefined, lastIp: decryptField(user.lastIp, key, legacyIv) ?? undefined, }) .where(eq(users.id, user.id)); diff --git a/apps/api/routes/api/me/keys/[kind].post.ts b/apps/api/routes/api/me/keys/[kind].post.ts new file mode 100644 index 00000000..03cffd78 --- /dev/null +++ b/apps/api/routes/api/me/keys/[kind].post.ts @@ -0,0 +1,34 @@ +/** + * POST /api/me/keys/:kind (kind = `rss` | `api`) + * + * Rotate one read key. The old value stops working on the next request — these + * are read straight from the row, with no cache in front of them, which is the + * property that makes "revoke" mean revoke. + * + * Behind the fresh-auth step-up, like the passkey reset it sits beside: a + * borrowed session should not be able to lock the real member out of their own + * feeds by rotating underneath them. + * + * Rotating one key leaves the other two alone. That is the entire point of + * having three — a member who gave their feed URL to a service that turned out + * to be careless can undo exactly that, without touching a single torrent in + * their client. + */ +import { z } from 'zod/v4'; +import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { rotateKey } from '~~/utils/account/readKeys'; +import { validateParam } from '~~/utils/schemas'; + +const kindSchema = z.enum(['rss', 'api']); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + await requireFreshAuth(event); + + const kind = validateParam(event, 'kind', kindSchema); + const key = await rotateKey(user.id, kind); + + return { kind, key }; +}); diff --git a/apps/api/routes/api/me/keys/index.get.ts b/apps/api/routes/api/me/keys/index.get.ts new file mode 100644 index 00000000..af236a39 --- /dev/null +++ b/apps/api/routes/api/me/keys/index.get.ts @@ -0,0 +1,32 @@ +/** + * GET /api/me/keys + * + * The member's three keys, and what each one opens. + * + * The two read keys are minted on first read — a member who never opens this + * page never has one, which is one fewer live secret per account that has no + * use for it. + * + * The announce passkey is NOT minted here and not returned here: it has its own + * endpoint and its own reveal-and-rotate flow on `/me`, and duplicating it + * would give the page two places to rotate the same secret. What this returns + * about it is whether it still works on the read surfaces, so the page can say + * so. + */ +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { ensureKey } from '~~/utils/account/readKeys'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const [rssKey, apiKey, legacyPasskeyAccepted] = await Promise.all([ + ensureKey(user.id, 'rss'), + ensureKey(user.id, 'api'), + isLegacyPasskeyReadAllowed(), + ]); + + return { rssKey, apiKey, legacyPasskeyAccepted }; +}); diff --git a/apps/api/routes/api/rss/category/[slug].get.ts b/apps/api/routes/api/rss/category/[slug].get.ts index fefaf1db..e5acb5cf 100644 --- a/apps/api/routes/api/rss/category/[slug].get.ts +++ b/apps/api/routes/api/rss/category/[slug].get.ts @@ -2,7 +2,7 @@ import { db, schema } from '@trackarr/db'; import { getStats } from '~~/utils/server'; import { desc, eq, and } from 'drizzle-orm'; import { z } from 'zod'; -import { requireSessionOrApikey } from '~~/utils/adminAuth'; +import { requireReadAccess } from '~~/utils/account/readKeyAuth'; import { getTorznabIncludeFederated } from '~~/utils/torznabSettings'; import { getFederationConfig, isFederationLive } from '~~/utils/federation/config'; import { federatedFeedRows } from '~~/utils/federation/feedRows'; @@ -21,7 +21,7 @@ const querySchema = z.object({ * tracker, members-only, pending uploads filtered out. */ export default defineEventHandler(async (event) => { - const { user } = await requireSessionOrApikey(event); + const { user } = await requireReadAccess(event, 'rss'); const params = paramsSchema.parse(getRouterParams(event)); const query = querySchema.parse(getQuery(event)); diff --git a/apps/api/routes/api/rss/latest.get.ts b/apps/api/routes/api/rss/latest.get.ts index f9112702..478f34da 100644 --- a/apps/api/routes/api/rss/latest.get.ts +++ b/apps/api/routes/api/rss/latest.get.ts @@ -2,7 +2,7 @@ import { db, schema } from '@trackarr/db'; import { getStats } from '~~/utils/server'; import { desc, eq, and, or, isNull, notInArray } from 'drizzle-orm'; import { z } from 'zod'; -import { requireSessionOrApikey } from '~~/utils/adminAuth'; +import { requireReadAccess } from '~~/utils/account/readKeyAuth'; import { adultCategoryIds } from '~~/utils/adultContent'; const querySchema = z.object({ @@ -18,12 +18,13 @@ const querySchema = z.object({ * already turned down. */ export default defineEventHandler(async (event) => { - const { user } = await requireSessionOrApikey(event); + const { user } = await requireReadAccess(event, 'rss'); const query = querySchema.parse(getQuery(event)); // Build the where so feeds for users who haven't opted into XXX - // skip those entries entirely. requireSessionOrApikey already - // surfaces showAdultContent on the user record. + // skip those entries entirely. `requireReadAccess` reads + // showAdultContent from the row rather than the session cookie, so a + // member who changed the setting today does not get yesterday's feed. const conditions = [ eq(schema.torrents.isActive, true), eq(schema.torrents.moderationStatus, 'accepted'), diff --git a/apps/api/routes/api/torrents/unregistered.post.ts b/apps/api/routes/api/torrents/unregistered.post.ts index 5a5e50c0..e04d41fb 100644 --- a/apps/api/routes/api/torrents/unregistered.post.ts +++ b/apps/api/routes/api/torrents/unregistered.post.ts @@ -37,7 +37,7 @@ import { inArray } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db, schema } from '@trackarr/db'; -import { requireAuthSession } from '~~/utils/adminAuth'; +import { requireReadAccess } from '~~/utils/account/readKeyAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { validateBody } from '~~/utils/schemas'; @@ -51,10 +51,9 @@ const bodySchema = z.object({ }); export default defineEventHandler(async (event) => { - // A member's own read of the catalogue, so a session. A script wanting to - // call this needs a cookie for now; per-user read keys come later on this - // branch, and this is the first route that will want one. - await requireAuthSession(event); + // Session or key: this is meant to be called by a script as much as by a + // browser, and a script has no cookie. + await requireReadAccess(event, 'api'); await rateLimit(event, RATE_LIMITS.mutation); const body = await validateBody(event, bodySchema); diff --git a/apps/api/routes/api/torznab/utils/auth.ts b/apps/api/routes/api/torznab/utils/auth.ts index c938bf55..9576d9db 100644 --- a/apps/api/routes/api/torznab/utils/auth.ts +++ b/apps/api/routes/api/torznab/utils/auth.ts @@ -8,6 +8,7 @@ import { db, schema } from '@trackarr/db'; import { eq } from 'drizzle-orm'; import { buildErrorXml, TORZNAB_ERRORS } from './xml'; import { liftExpiredBan } from '~~/utils/banExpiry'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; export interface TorznabUser { id: string; @@ -39,34 +40,58 @@ export async function authenticateTorznab( ); } - // Validate passkey format (32 or 40 hex chars - supports legacy and new passkeys) + // Validate key format (32 or 40 hex chars — an RSS key is always 40, the + // announce passkey may be either depending on when the account was made) if (!/^[a-f0-9]{32}$/i.test(apikey) && !/^[a-f0-9]{40}$/i.test(apikey)) { throw createTorznabError( event, TORZNAB_ERRORS.INCORRECT_CREDENTIALS, - `Invalid API key format. Expected 32 or 40 hex characters (your passkey), got ${apikey.length} characters` + `Invalid API key format. Expected 32 or 40 hex characters, got ${apikey.length} characters` ); } - // Look up user by passkey. `bannedUntil` is projected here so - // `liftExpiredBan` can flip the row back to healthy when the - // timed ban has elapsed — without it we'd block users whose ban - // just expired but whom the 5-minute cron hasn't swept yet. - const users = await db - .select({ - id: schema.users.id, - username: schema.users.username, - passkey: schema.users.passkey, - isBanned: schema.users.isBanned, - bannedUntil: schema.users.bannedUntil, - isAdmin: schema.users.isAdmin, - isModerator: schema.users.isModerator, - showAdultContent: schema.users.showAdultContent, - }) + const supplied = apikey.toLowerCase(); + + /** + * The member's RSS key first, then the announce passkey while an operator + * still allows it here. + * + * Torznab is the surface members hand to Prowlarr, so it is the one most + * likely to leave the machine — which is exactly why it should not be + * carrying the credential that announces on their behalf. The passkey stays + * accepted by default because every feed already configured anywhere carries + * it; `legacy_passkey_read_access` is how an operator closes that door once + * their members have moved over. + * + * `bannedUntil` is projected so `liftExpiredBan` can flip the row back to + * healthy when a timed ban has elapsed — without it we would block users + * whose ban just expired but whom the 5-minute cron has not swept yet. + */ + const projection = { + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isBanned: schema.users.isBanned, + bannedUntil: schema.users.bannedUntil, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + showAdultContent: schema.users.showAdultContent, + }; + + let users = await db + .select(projection) .from(schema.users) - .where(eq(schema.users.passkey, apikey.toLowerCase())) + .where(eq(schema.users.rssKey, supplied)) .limit(1); + if (users.length === 0 && (await isLegacyPasskeyReadAllowed())) { + users = await db + .select(projection) + .from(schema.users) + .where(eq(schema.users.passkey, supplied)) + .limit(1); + } + const user = users[0]; if (!user) { diff --git a/apps/api/utils/account/eraseAccount.ts b/apps/api/utils/account/eraseAccount.ts index 982dc236..91d52405 100644 --- a/apps/api/utils/account/eraseAccount.ts +++ b/apps/api/utils/account/eraseAccount.ts @@ -361,6 +361,13 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { authSalt: randomBytes(24).toString('base64'), authVerifier: randomBytes(48).toString('base64'), passkey: randomUUID().replace(/-/g, ''), + // The two read keys go entirely rather than being rotated to an unusable + // value: unlike the passkey they are nullable, so "no key" is a state the + // schema already has a word for, and an erased account has nothing to + // read. Cleared by hand because the row survives an erasure — no ON + // DELETE ever fires here. + rssKey: null, + apiKey: null, totpSecret: null, totpEnabled: false, trustDevicesEnabled: false, diff --git a/apps/api/utils/account/exportAccount.ts b/apps/api/utils/account/exportAccount.ts index 8638ef1c..fb96aec2 100644 --- a/apps/api/utils/account/exportAccount.ts +++ b/apps/api/utils/account/exportAccount.ts @@ -517,7 +517,7 @@ export async function exportAccount(userId: string) { }, { what: 'Credentials and secrets', - where: 'password verifier, passkey material, trusted-device tokens, TOTP secret, notification-channel tokens and webhook URLs, the account passkey', + where: 'password verifier, passkey material, trusted-device tokens, TOTP secret, notification-channel tokens and webhook URLs, the announce passkey, the RSS and API keys', 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.', }, { diff --git a/apps/api/utils/account/readKeyAuth.ts b/apps/api/utils/account/readKeyAuth.ts new file mode 100644 index 00000000..9303ca54 --- /dev/null +++ b/apps/api/utils/account/readKeyAuth.ts @@ -0,0 +1,130 @@ +/** + * Authenticating a read surface — RSS, Torznab, the programmatic API. + * + * One resolver for all of them, because the three used to disagree in ways + * that were invisible until somebody hit one: the Torznab gate lowercased the + * key and shape-checked it, the RSS gate did neither, so the same key could + * work on one surface and fail on the other. That is fixed here by there being + * one place. + * + * ## Order + * + * 1. **The session cookie**, if there is one. A member browsing the site gets + * their own feed without a key in the URL. + * 2. **The surface's own key** — `rssKey` for feeds and Torznab, `apiKey` for + * programmatic calls. + * 3. **The announce passkey**, while `legacy_passkey_read_access` allows it. + * + * Step 3 is a migration path, not a design. The passkey was the only key these + * surfaces ever took, so it is in every feed URL a member has configured + * anywhere; removing it on the day of the split would break all of them at once + * — the exact breakage the split exists to prevent. An operator turns the + * setting off when their members have moved over, and the member-facing keys + * page says so. + */ +import type { H3Event } from 'h3'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; +import { findByReadKey, type ReadKeyHolder, type ReadKeyKind } from './readKeys'; +import { db, schema } from '@trackarr/db'; +import { eq } from 'drizzle-orm'; + +/** How the caller proved who they are — used only for logging and headers. */ +export type ReadAuthVia = 'session' | 'key' | 'legacy-passkey'; + +export interface ReadAuthResult { + user: ReadKeyHolder; + via: ReadAuthVia; +} + +/** + * The passkey lookup, kept beside the key one so both apply the same + * post-conditions (not banned, not erased) rather than each remembering. + */ +async function findByPasskey(raw: string): Promise<ReadKeyHolder | null> { + const value = raw.toLowerCase(); + // 32 or 40: the codebase has minted both lengths over time. New keys are 40; + // this gate stays permissive because it is looking at a legacy credential. + if (!/^[a-f0-9]{32}$/.test(value) && !/^[a-f0-9]{40}$/.test(value)) return null; + + const [row] = await db + .select({ + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isBanned: schema.users.isBanned, + uploaded: schema.users.uploaded, + downloaded: schema.users.downloaded, + showAdultContent: schema.users.showAdultContent, + deletedAt: schema.users.deletedAt, + }) + .from(schema.users) + .where(eq(schema.users.passkey, value)) + .limit(1); + + if (!row || row.isBanned || row.deletedAt) return null; + return row; +} + +/** + * Resolve the caller of a read surface, or throw 401. + * + * `kind` decides which dedicated key is accepted: a member's RSS key must not + * open the programmatic API and vice versa, or the split has bought nothing. + */ +export async function requireReadAccess( + event: H3Event, + kind: ReadKeyKind +): Promise<ReadAuthResult> { + // 1. A live session, if the request carries one. + try { + const session = await requireAuthSession(event); + const [row] = await db + .select({ + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isBanned: schema.users.isBanned, + uploaded: schema.users.uploaded, + downloaded: schema.users.downloaded, + showAdultContent: schema.users.showAdultContent, + deletedAt: schema.users.deletedAt, + }) + .from(schema.users) + .where(eq(schema.users.id, session.user.id)) + .limit(1); + // Read from the row rather than the cookie: the sealed session is seven + // days old at worst, and `showAdultContent` in particular decides what a + // feed contains. + if (row && !row.isBanned && !row.deletedAt) { + return { user: row, via: 'session' }; + } + } catch { + // No session — fall through to the key paths. + } + + const query = getQuery(event); + const supplied = + (typeof query.apikey === 'string' && query.apikey) || + (typeof query.passkey === 'string' && query.passkey) || + (typeof query.rsskey === 'string' && query.rsskey) || + null; + + if (supplied) { + // 2. The surface's own key. + const byKey = await findByReadKey(kind, supplied); + if (byKey) return { user: byKey, via: 'key' }; + + // 3. The announce passkey, while it is still allowed here. + if (await isLegacyPasskeyReadAllowed()) { + const byPasskey = await findByPasskey(supplied); + if (byPasskey) return { user: byPasskey, via: 'legacy-passkey' }; + } + } + + throw createError({ statusCode: 401, message: 'Authentication required' }); +} diff --git a/apps/api/utils/account/readKeys.ts b/apps/api/utils/account/readKeys.ts new file mode 100644 index 00000000..87f3a0eb --- /dev/null +++ b/apps/api/utils/account/readKeys.ts @@ -0,0 +1,170 @@ +/** + * The two read keys — minting, rotating, and resolving a caller from one. + * + * ## What each is for + * + * | key | authenticates | can announce | + * | --- | --- | --- | + * | `passkey` | the tracker's announce and scrape | **yes** | + * | `rssKey` | RSS feeds, the Torznab endpoint | no | + * | `apiKey` | programmatic calls | no | + * + * One secret used to do all three. A member who pasted their feed URL into a + * third-party service was handing over the credential that announces for them, + * and the only remedy — rotating the passkey — broke every torrent in their + * client at once. The whole point of the split is that revoking the key you + * gave away costs you nothing else. + * + * ## Minted on demand + * + * Registration does not create them. A member who never wires up a feed reader + * should not be carrying two live secrets they have never seen, and a column + * that is null until someone asks is a column an attacker cannot use. + * `ensureKey` mints on first read and is safe to call repeatedly. + * + * ## 40 hex, like `generatePasskey` + * + * The codebase already has three generators producing 32, 40 and 32 characters, + * which is why the Torznab gate accepts "32 or 40". New keys use one length so + * the next gate can be exact. + */ +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { generatePasskey } from '~~/utils/auth'; + +export type ReadKeyKind = 'rss' | 'api'; + +const COLUMN = { + rss: schema.users.rssKey, + api: schema.users.apiKey, +} as const; + +/** `^[a-f0-9]{40}$` — what `generatePasskey` produces, lowercased. */ +export const READ_KEY_PATTERN = /^[a-f0-9]{40}$/; + +export function isReadKeyShaped(value: string): boolean { + return READ_KEY_PATTERN.test(value.toLowerCase()); +} + +/** + * The member's key, minting one if they have none yet. + * + * The write is guarded on the column still being null, so two concurrent first + * reads cannot end with one of them holding a key the row no longer carries. + * The loser re-reads and returns what actually landed. + */ +export async function ensureKey( + userId: string, + kind: ReadKeyKind +): Promise<string> { + const column = COLUMN[kind]; + + const [existing] = await db + .select({ value: column }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + if (existing?.value) return existing.value; + + const minted = generatePasskey(); + const [claimed] = await db + .update(schema.users) + .set({ [kind === 'rss' ? 'rssKey' : 'apiKey']: minted }) + .where(eq(schema.users.id, userId)) + .returning({ value: column }); + + // `.returning()` gives us the row as it is now, whoever won. + return claimed?.value ?? minted; +} + +/** + * Replace a key. Whatever the member handed out stops working immediately — + * there is no cache in front of these, by design. + */ +export async function rotateKey( + userId: string, + kind: ReadKeyKind +): Promise<string> { + const minted = generatePasskey(); + await db + .update(schema.users) + .set({ [kind === 'rss' ? 'rssKey' : 'apiKey']: minted }) + .where(eq(schema.users.id, userId)); + return minted; +} + +/** Drop a key without minting a replacement. */ +export async function revokeKey( + userId: string, + kind: ReadKeyKind +): Promise<void> { + await db + .update(schema.users) + .set({ [kind === 'rss' ? 'rssKey' : 'apiKey']: null }) + .where(eq(schema.users.id, userId)); +} + +/** + * The columns every read-key holder lookup wants. Shared so the three call + * sites project the same fields — a caller resolved by RSS key and one + * resolved by passkey must be the same shape or the routes downstream start + * branching on how somebody authenticated. + */ +const HOLDER_COLUMNS = { + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isBanned: schema.users.isBanned, + uploaded: schema.users.uploaded, + downloaded: schema.users.downloaded, + showAdultContent: schema.users.showAdultContent, + deletedAt: schema.users.deletedAt, +} as const; + +/** + * Written out rather than mapped over `HOLDER_COLUMNS`: the mapped form drops + * nullability (`deletedAt` came back as `Date` instead of `Date | null`), and a + * type that quietly disagrees with the column it describes is worse than one + * that has to be kept in step by hand. + */ +export interface ReadKeyHolder { + id: string; + username: string; + passkey: string; + isAdmin: boolean; + isModerator: boolean; + isBanned: boolean; + uploaded: number; + downloaded: number; + showAdultContent: boolean; + deletedAt: Date | null; +} + +/** + * Resolve whoever holds this key, or null. + * + * Shape-checked before it touches the database: a value that cannot be a key + * is not worth a query, and refusing early keeps an arbitrary string out of a + * `WHERE`. Lowercased, because keys are stored lowercase and a member copying + * one out of a config file may well have uppercased it — the Torznab gate + * already lowercases and the RSS one did not, which meant the same key worked + * on one surface and failed on the other. + */ +export async function findByReadKey( + kind: ReadKeyKind, + raw: string +): Promise<ReadKeyHolder | null> { + const value = raw.toLowerCase(); + if (!isReadKeyShaped(value)) return null; + + const [row] = await db + .select(HOLDER_COLUMNS) + .from(schema.users) + .where(eq(COLUMN[kind], value)) + .limit(1); + + if (!row || row.isBanned || row.deletedAt) return null; + return row; +} diff --git a/apps/api/utils/adminAuth.ts b/apps/api/utils/adminAuth.ts index 2552d660..1fb2adbc 100644 --- a/apps/api/utils/adminAuth.ts +++ b/apps/api/utils/adminAuth.ts @@ -380,58 +380,13 @@ export async function requireFreshAuth(event: H3Event): Promise<void> { } /** - * Auth gate for endpoints that need to be reachable by both browser - * sessions (cookie-based) and external clients that authenticate by - * passkey/apikey (RSS readers, *Arr-style integrations). Tries the - * session cookie first, then falls back to a `?apikey=` or - * `?passkey=` query parameter. + * Read-surface authentication (RSS, Torznab, the programmatic API) lives in + * `utils/account/readKeyAuth.requireReadAccess`. * - * Returns `{ user }` shaped like a session for callers, regardless of - * which path matched. On failure, throws 401 — same shape as - * `requireUserSession` so callers don't need a separate error path. - * - * The DB-side `isBanned` check still runs: a banned user can't slip - * past via passkey just because their session was already cleared. + * It used to live here as `requireSessionOrApikey`, which accepted `?apikey=` + * or `?passkey=` against `users.passkey` with no shape check and no + * lowercasing — while the Torznab gate did both, so the same key could work on + * one surface and fail on the other. The replacement resolves a session, then + * the surface's own key, then the announce passkey while an operator still + * allows it. */ -export async function requireSessionOrApikey(event: H3Event) { - // 1. Try the regular session path (cheap, also covers banned-user - // invalidation as a side effect). - try { - return await requireAuthSession(event); - } catch { - // fall through to apikey - } - - const query = getQuery(event); - const apikey = - (typeof query.apikey === 'string' && query.apikey) || - (typeof query.passkey === 'string' && query.passkey) || - null; - if (!apikey) { - throw createError({ statusCode: 401, message: 'Authentication required' }); - } - - const [user] = await db - .select({ - id: users.id, - username: users.username, - passkey: users.passkey, - isAdmin: users.isAdmin, - isModerator: users.isModerator, - isBanned: users.isBanned, - uploaded: users.uploaded, - downloaded: users.downloaded, - // Adult content opt-in flag carried alongside the rest so - // RSS / Torznab consumers don't need to re-query for it. - showAdultContent: users.showAdultContent, - }) - .from(users) - .where(eq(users.passkey, apikey)) - .limit(1); - - if (!user || user.isBanned) { - throw createError({ statusCode: 401, message: 'Authentication required' }); - } - - return { user }; -} diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts index f22b157d..8d84a402 100644 --- a/apps/api/utils/settings.ts +++ b/apps/api/utils/settings.ts @@ -139,6 +139,18 @@ export const SETTINGS_KEYS = { * published on `/api/privacy` either way. */ AUDIT_LOG_RETENTION_DAYS: 'audit_log_retention_days', + /** + * Whether the announce passkey still authenticates the READ surfaces (RSS, + * Torznab) now that those have keys of their own. + * + * Default true, and that is a migration stance rather than a preference: the + * passkey was the only key those surfaces ever accepted, so every feed URL a + * member has configured anywhere carries it. Flipping this to false the day + * the split ships would break all of them at once, which is exactly the + * breakage the split exists to prevent. An operator turns it off once their + * members have moved over. + */ + LEGACY_PASSKEY_READ_ACCESS: 'legacy_passkey_read_access', SITE_LOGO_IMAGE_SIZE: 'site_logo_image_size', SITE_FAVICON_SIZE: 'site_favicon_size', SITE_SUBTITLE: 'site_subtitle', @@ -381,6 +393,15 @@ export async function getSiteLogoImage(): Promise<string | null> { * entirely rather than treating 0 as "delete everything", which is the reading * that would quietly empty the register. */ +/** + * True while the announce passkey may still be used on the read surfaces. + * Defaults to true — see the note on the settings key. + */ +export async function isLegacyPasskeyReadAllowed(): Promise<boolean> { + const value = await getSetting(SETTINGS_KEYS.LEGACY_PASSKEY_READ_ACCESS); + return value !== 'false'; +} + export async function getAuditRetentionDays(): Promise<number> { const value = await getSetting(SETTINGS_KEYS.AUDIT_LOG_RETENTION_DAYS); const parsed = value ? parseInt(value, 10) : NaN; diff --git a/apps/web/app/pages/me.vue b/apps/web/app/pages/me.vue index fe7f3ca5..e1e85c64 100644 --- a/apps/web/app/pages/me.vue +++ b/apps/web/app/pages/me.vue @@ -407,6 +407,57 @@ {{ $t('me.credentials.useInClient') }} </p> </article> + + <!-- The read keys. Separate from the passkey above because that is the + whole point of them: giving a feed URL to a service should not hand + over the credential that announces on your behalf, and revoking one + should not break a single torrent in your client. --> + <article v-for="k in readKeyCards" :key="k.kind" class="cred"> + <div class="cred-head"> + <span class="cred-label">{{ k.label }}</span> + <div class="cred-actions"> + <button + type="button" + class="cred-btn" + :title="k.visible ? $t('me.credentials.hide') : $t('me.credentials.show')" + @click="toggleReadKey(k.kind)" + > + <Icon :name="k.visible ? 'ph:eye-slash-bold' : 'ph:eye-bold'" /> + </button> + <button + type="button" + class="cred-btn" + :title="copied === k.kind ? $t('common.copied') : $t('common.copy')" + :disabled="!k.value" + @click="copyReadKey(k.kind)" + > + <Icon :name="copied === k.kind ? 'ph:check-bold' : 'ph:copy-simple-bold'" /> + </button> + <button + type="button" + class="cred-btn cred-btn--warn" + :title="$t('me.credentials.rotate')" + :disabled="readKeysBusy" + @click="rotateReadKey(k.kind)" + > + <Icon + :name="readKeysBusy ? 'ph:circle-notch' : 'ph:arrows-clockwise-bold'" + :class="{ 'animate-spin': readKeysBusy }" + /> + </button> + </div> + </div> + <code class="cred-value">{{ k.visible ? k.value || '…' : k.mask }}</code> + <p class="cred-note cred-note--info"> + <Icon name="ph:info-bold" /> + {{ k.note }} + </p> + </article> + + <p v-if="readKeys?.legacyPasskeyAccepted" class="cred-legacy"> + <Icon name="ph:warning-bold" /> + {{ $t('me.credentials.legacyPasskeyNote') }} + </p> </div> </section> @@ -917,7 +968,7 @@ const passkeyVisible = ref(false); const passkeyLoading = ref(false); const passkeyRotating = ref(false); const announceVisible = ref(false); -const copied = ref<'' | 'passkey' | 'announce'>(''); +const copied = ref<'' | 'passkey' | 'announce' | 'rss' | 'api'>(''); const passkeyMask = '•••• •••• •••• •••• •••• •••• •••• •••• •••• ••••'; @@ -983,6 +1034,117 @@ async function rotatePasskey() { } } +/** + * The two read keys. + * + * Fetched lazily like the passkey is, and for the same reason: a value that + * only matters when somebody asks for it should not be in the payload of every + * page load. Unlike the passkey they are NOT in the session cookie — the cookie + * is sealed for seven days and a revoked key that keeps working for a week is + * not a revoked key. + */ +const readKeys = ref<{ + rssKey: string; + apiKey: string; + legacyPasskeyAccepted: boolean; +} | null>(null); +const readKeyVisible = reactive<{ rss: boolean; api: boolean }>({ + rss: false, + api: false, +}); +const readKeysBusy = ref(false); + +async function loadReadKeys() { + if (readKeys.value) return; + try { + // Typed explicitly: left to infer, Nitro's route matcher walks every + // declared route to find the response shape and TypeScript gives up with + // "excessive stack depth" on the sibling `/api/me/keys/[kind]`. + readKeys.value = await $fetch<{ + rssKey: string; + apiKey: string; + legacyPasskeyAccepted: boolean; + }>('/api/me/keys'); + } catch { + // Same posture as the passkey card: a failure leaves the value hidden + // rather than replacing the page with an error. + } +} + +const readKeyCards = computed(() => [ + { + kind: 'rss' as const, + label: t('me.credentials.rssKey'), + note: t('me.credentials.rssKeyNote'), + value: readKeys.value?.rssKey ?? '', + visible: readKeyVisible.rss, + mask: '••••••••••••••••', + }, + { + kind: 'api' as const, + label: t('me.credentials.apiKey'), + note: t('me.credentials.apiKeyNote'), + value: readKeys.value?.apiKey ?? '', + visible: readKeyVisible.api, + mask: '••••••••••••••••', + }, +]); + +async function toggleReadKey(kind: 'rss' | 'api') { + if (!readKeyVisible[kind]) await loadReadKeys(); + readKeyVisible[kind] = !readKeyVisible[kind]; +} + +async function copyReadKey(kind: 'rss' | 'api') { + await loadReadKeys(); + const value = kind === 'rss' ? readKeys.value?.rssKey : readKeys.value?.apiKey; + if (!value) return; + await navigator.clipboard.writeText(value); + copied.value = kind; + setTimeout(() => (copied.value = ''), 1500); +} + +async function rotateReadKey(kind: 'rss' | 'api') { + const ok = await confirmDialog({ + title: t('me.credentials.rotateReadKeyTitle'), + message: t('me.credentials.rotateReadKeyBody'), + confirmText: t('me.credentials.rotate'), + destructive: true, + }); + if (!ok) return; + + readKeysBusy.value = true; + try { + // The URL is widened to `string` on purpose. Left as a template literal, + // Nitro's route-typing tries to match it against every declared route and + // TypeScript gives up with "excessive stack depth" — the inference is not + // buying anything here, since the response type is stated right above it. + const url: string = `/api/me/keys/${kind}`; + const res = await $fetch<{ kind: 'rss' | 'api'; key: string }>(url, { + method: 'POST', + }); + if (readKeys.value) { + if (res.kind === 'rss') readKeys.value.rssKey = res.key; + else readKeys.value.apiKey = res.key; + } + readKeyVisible[kind] = true; + notifications.success(t('me.credentials.rotated')); + } catch (err: unknown) { + const e = err as { + statusCode?: number; + data?: { message?: string; data?: { reauthRequired?: boolean } }; + message?: string; + }; + notifications.error( + e?.data?.data?.reauthRequired || e?.statusCode === 401 + ? t('me.credentials.reauthRequired') + : e?.data?.message || e?.message || t('me.credentials.rotateFailed') + ); + } finally { + readKeysBusy.value = false; + } +} + async function copy(kind: 'passkey' | 'announce') { if (!passkey.value) await loadPasskey(); if (!passkey.value) return; @@ -2391,6 +2553,39 @@ function formatDuration(seconds: number) { gap: 0.4rem; color: rgb(var(--fg-muted)); } +/* The rotate button on a read key. Warm rather than red: rotating one of + these is a routine, reversible thing to do — nothing breaks except what you + deliberately handed out. */ +.cred-btn--warn:not(:disabled):hover { + color: rgb(var(--warning)); + border-color: rgb(var(--warning) / 0.5); +} + +.cred-note-hint { + color: rgb(var(--fg-subtle)); +} + +.cred-legacy { + display: flex; + align-items: flex-start; + gap: 0.45rem; + margin: 0.35rem 0 0; + padding: 0.65rem 0.8rem; + border: 1px solid rgb(var(--warning) / 0.35); + border-radius: 0.4rem; + background: rgb(var(--warning) / 0.08); + font-size: 0.78rem; + line-height: 1.55; + color: rgb(var(--fg-muted)); +} +.cred-legacy svg { + flex-shrink: 0; + width: 1rem; + height: 1rem; + margin-top: 0.1rem; + color: rgb(var(--warning)); +} + .cred-note--info { color: #34d4d8; opacity: 0.85; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 0ad96aa4..1fcc5eed 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -312,7 +312,16 @@ "rotateConfirmAction": "Regenerate", "rotateSuccess": "Passkey rotated — copy the new one into your torrent client.", "rotateFailed": "Failed to regenerate passkey", - "copyFailed": "Could not copy to clipboard" + "copyFailed": "Could not copy to clipboard", + "rssKey": "RSS / Torznab key", + "rssKeyNote": "Use this in Prowlarr, Sonarr, Radarr or a feed reader. It can read the catalogue and cannot announce.", + "apiKey": "API key", + "apiKeyNote": "For scripts calling the API. It can read and cannot announce.", + "rotateReadKeyTitle": "Rotate this key?", + "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." }, "activity": { "title": "Activity", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 893e5850..41729aae 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -301,7 +301,7 @@ "title": "Identifiants tracker", "passkey": "Passkey", "announceUrl": "URL d'annonce", - "rotate": "Rotation", + "rotate": "Renouveler", "show": "Afficher", "hide": "Masquer", "warning": "Ne partagez jamais ces identifiants. Une rotation cassera tout client torrent encore configuré avec l'ancienne URL d'annonce.", @@ -312,7 +312,16 @@ "rotateConfirmAction": "Régénérer", "rotateSuccess": "Passkey régénérée — copiez la nouvelle dans votre client BitTorrent.", "rotateFailed": "Échec de la régénération de la passkey", - "copyFailed": "Impossible de copier dans le presse-papiers" + "copyFailed": "Impossible de copier dans le presse-papiers", + "rssKey": "Clé RSS / Torznab", + "rssKeyNote": "À utiliser dans Prowlarr, Sonarr, Radarr ou un lecteur de flux. Elle lit le catalogue et ne peut pas annoncer.", + "apiKey": "Clé API", + "apiKeyNote": "Pour les scripts qui appellent l'API. Elle lit et ne peut pas annoncer.", + "rotateReadKeyTitle": "Renouveler cette clé ?", + "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." }, "activity": { "title": "Activité", diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 9afa1558..06c21a0f 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -78,6 +78,7 @@ export default defineConfig({ { text: 'Panic Mode', link: '/guide/panic-mode' }, { text: 'Staff Audit Log', link: '/guide/audit-log' }, { text: 'Your Data (GDPR)', link: '/guide/your-data' }, + { text: 'Keys', link: '/guide/api-keys' }, ], }, { diff --git a/doc/guide/api-keys.md b/doc/guide/api-keys.md new file mode 100644 index 00000000..54e0ed5a --- /dev/null +++ b/doc/guide/api-keys.md @@ -0,0 +1,60 @@ +# Keys + +Every member has three, and they do different jobs: + +| Key | Opens | Can announce | +| --- | --- | --- | +| **Announce passkey** | the tracker's announce and scrape | **yes** | +| **RSS / Torznab key** | RSS feeds, the Torznab endpoint | no | +| **API key** | programmatic calls | no | + +All three are on your profile page, under **Credentials**. + +## Why three + +One secret used to do all three jobs. A member who pasted their feed URL into a +third-party service was handing over the credential that announces on their +behalf — and the only remedy, rotating the passkey, broke every torrent in their +client at the same time. + +Splitting them means each can be revoked for its own reason. Rotate the key you +gave to a service that turned out to be careless, and nothing else moves. + +## Rotating + +Each key rotates independently, behind a fresh-login step-up. The old value +stops working **on the next request** — these are read straight from the row, +with no cache in front of them, which is what makes "revoke" mean revoke. + +Rotating the announce passkey still breaks every `.torrent` in your client, as +it always did. That is why the other two exist. + +## Minted on demand + +The read keys are created the first time you open the Credentials card, not at +registration. A member who never wires up a feed reader should not be carrying +two live secrets they have never seen. + +## For operators: the legacy window + +`legacy_passkey_read_access` — **default `true`**. + +While it is on, the announce passkey still authenticates the read surfaces, as +it always did. That is a migration stance, not a preference: every feed URL your +members have configured anywhere carries the passkey, and turning this off on +the day of the upgrade would break all of them at once — the exact breakage the +split exists to prevent. + +Turn it off once your members have moved over. The Credentials card tells them +to, and says why. + +## What the split does not change + +- **`.torrent` files** carry the announce passkey and only that. The read keys + never appear in one. +- **Panic mode** encrypts all three. A panicked database carrying two secrets in + plaintext would be a "the data is encrypted" that is only mostly true. +- **Erasure** clears all three. +- **Caddy's access log** now strips `apikey` and `rsskey` alongside `passkey`. + Before the split, `apikey` *was* the passkey, so the existing scrub covered it + by accident rather than on purpose. diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index a9bb1a37..aa33f18b 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -120,11 +120,16 @@ } } - # Drop the `passkey` query param from access logs before they hit - # disk. The tracker requires it in the URL by BitTorrent protocol, - # so we can't strip it from the request itself, but we can prevent - # it from being persisted in the log file (which is otherwise a - # textbook log-leak vector for credential exfiltration). + # Drop every credential query param from access logs before they hit + # disk. The tracker requires `passkey` in the URL by BitTorrent protocol, + # and a feed reader puts `apikey` there because Torznab has no other way + # to carry one — so we can't strip them from the request itself, but we + # can prevent them from being persisted in the log file (which is + # otherwise a textbook log-leak vector for credential exfiltration). + # + # `apikey` and `rsskey` joined `passkey` when the read surfaces got keys + # of their own: before that, `apikey` WAS the passkey and this scrub + # covered it by accident rather than on purpose. log { output file /var/log/caddy/access.log { roll_size 100mb @@ -136,6 +141,8 @@ fields { uri query { delete passkey + delete apikey + delete rsskey } } } @@ -171,6 +178,8 @@ fields { uri query { delete passkey + delete apikey + delete rsskey } } } diff --git a/packages/db/src/migrations/0062_user_read_keys.sql b/packages/db/src/migrations/0062_user_read_keys.sql new file mode 100644 index 00000000..53a092ec --- /dev/null +++ b/packages/db/src/migrations/0062_user_read_keys.sql @@ -0,0 +1,4 @@ +ALTER TABLE "users" ADD COLUMN "rss_key" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "api_key" text;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_rss_key_unique" UNIQUE("rss_key");--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_api_key_unique" UNIQUE("api_key"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0062_snapshot.json b/packages/db/src/migrations/meta/0062_snapshot.json new file mode 100644 index 00000000..943b061f --- /dev/null +++ b/packages/db/src/migrations/meta/0062_snapshot.json @@ -0,0 +1,9840 @@ +{ + "id": "8db7c224-958c-4c07-905b-3d6d68bf83cc", + "prevId": "0fea4f16-7058-4d16-857e-b51d76657eee", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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 6b487c64..f1714f8d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -435,6 +435,13 @@ "when": 1788216646668, "tag": "0061_torrent_supersede", "breakpoints": true + }, + { + "idx": 62, + "version": "7", + "when": 1788217335868, + "tag": "0062_user_read_keys", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8b39f6c0..3c97e468 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -37,6 +37,31 @@ export const users = pgTable( authSalt: text('auth_salt').notNull(), // Client-generated salt (base64) authVerifier: text('auth_verifier').notNull(), // Derived verifier (base64) passkey: text('passkey').notNull().unique(), // For private tracker auth + /** + * The two read keys, separate from the announce passkey. + * + * One secret used to do all three jobs, and that had a consequence nobody + * chose: a member who pasted their RSS URL into a third-party service + * handed over the credential that announces on their behalf, and rotating + * the passkey because of it broke every torrent in their client at the + * same time. Splitting them means each can be revoked for its own reason. + * + * `rssKey` authenticates the RSS feeds and the Torznab endpoint — the + * things a member gives to Prowlarr or a feed reader. `apiKey` is for + * programmatic calls (the unregistered-hashes check today, more later). + * Neither can announce, and neither goes into a `.torrent`. + * + * Nullable, and minted on first request rather than at registration: a + * member who never wires up a feed reader should not be carrying two live + * secrets they have never seen. Postgres allows many NULLs under a unique + * index, so the constraint still holds for the ones that exist. + * + * These are NOT put in the session cookie, unlike `passkey`. The cookie is + * sealed for seven days, so a value inside it survives a rotation — which + * is precisely what "revoke this key" must not do. + */ + rssKey: text('rss_key').unique(), + apiKey: text('api_key').unique(), isAdmin: boolean('is_admin').default(false).notNull(), isModerator: boolean('is_moderator').default(false).notNull(), /** From d13d5a8ed8a6412e4311312c00aef9e57704f8d6 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 10:25:17 +0200 Subject: [PATCH 11/96] feat(me): tell me when something like this turns up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members already do this. They run autobrr against an IRC channel, or poll an RSS feed, and race the upload. Both need something running somewhere, which means the feature is available to whoever has a seedbox and to nobody else — and most members do not. A stored filter, evaluated when an upload is accepted, delivered through the notification fan-out that already exists. The filter's vocabulary is the catalogue's own and deliberately no wider: free text, one category, tags (all of which must match — the listing's AND semantics), and the three media ids. A filter the catalogue cannot express could not be created from a "save this search" button, and one that matched on something the listing cannot show would be impossible to preview. Resolution and source are tags here because they are tags there, derived from the release name at upload. Three details about the text matching, each of which would be a wrong notification rather than a wrong page: The live search appends a prefix marker to the last term, because the member is still typing and `crown` should reach `crownfall` while the query bar has focus. A saved alert is settled intent. `toExactTsQuery` drops it, and the normalised form is stored so the comparison is one SQL expression rather than a re-parse per filter. It matches the release NAME only. The catalogue can also read descriptions and NFOs, which helps a reader casting about; an alert firing on a word buried in an NFO is a notification nobody can account for. The typo-tolerant fallback is not carried over. It exists to rescue an empty results page, costs about ten times a plain match, and on a per-torrent test would produce nothing but false positives. The evaluation is inverted: one query asks Postgres which stored filters match this torrent, rather than replaying each filter's catalogue query. So a thousand members with twenty filters each is one indexed query per upload, not twenty thousand. The structured half — category, tags, ids — is plain data already in memory and is filtered there rather than turned into a join. Two hook sites, the same two the follower fan-out uses: the moderation transition, which already computes the exact `pending → accepted` edge, and the auto-accepted upload path. Placed AFTER the tags are attached in the second one — evaluating at the insert would silently miss every tag-based filter. What it will never do. It will not push adult content to somebody who turned the setting off, checked against the live preference rather than the one in force when the filter was saved — the one rule here whose absence would be actively harmful rather than merely wrong, so it is applied to the recipient and applied last. It will not name an anonymous uploader; the notification says what appeared and not who put it there. It will not tell you about your own upload. `saved_search_max_per_user` (20) bounds the whole feature, since the cost is the number of armed filters across the site. A ceiling somebody chose beats one that emerges at three in the morning, and the fan-out logs its own duration past half a second so a sweep getting slow shows up in the operator's logs rather than in complaints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + .../api/me/saved-searches/[id].delete.ts | 29 + .../routes/api/me/saved-searches/index.get.ts | 24 + .../api/me/saved-searches/index.post.ts | 97 + apps/api/routes/api/torrents/index.post.ts | 15 + apps/api/test/search.test.ts | 24 + apps/api/utils/notify.ts | 5 + apps/api/utils/notifyRenderer.ts | 8 + apps/api/utils/savedSearchFanout.ts | 158 + apps/api/utils/search.ts | 18 + apps/api/utils/settings.ts | 16 + apps/api/utils/torrentModeration.ts | 18 + apps/web/app/components/CommandPalette.vue | 1 + .../settings/NotificationsSection.vue | 3 +- apps/web/app/pages/alerts.vue | 262 + apps/web/app/pages/notifications.vue | 1 + apps/web/app/pages/torrents/index.vue | 86 + apps/web/i18n/locales/en.json | 31 +- apps/web/i18n/locales/fr.json | 31 +- doc/.vitepress/config.ts | 1 + doc/guide/saved-searches.md | 70 + .../db/src/migrations/0063_saved_searches.sql | 21 + .../db/src/migrations/meta/0063_snapshot.json | 10005 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 86 + 25 files changed, 11015 insertions(+), 3 deletions(-) create mode 100644 apps/api/routes/api/me/saved-searches/[id].delete.ts create mode 100644 apps/api/routes/api/me/saved-searches/index.get.ts create mode 100644 apps/api/routes/api/me/saved-searches/index.post.ts create mode 100644 apps/api/utils/savedSearchFanout.ts create mode 100644 apps/web/app/pages/alerts.vue create mode 100644 doc/guide/saved-searches.md create mode 100644 packages/db/src/migrations/0063_saved_searches.sql create mode 100644 packages/db/src/migrations/meta/0063_snapshot.json diff --git a/README.md b/README.md index 2eea65aa..de24d9c3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,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). +- **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). - **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/routes/api/me/saved-searches/[id].delete.ts b/apps/api/routes/api/me/saved-searches/[id].delete.ts new file mode 100644 index 00000000..1d2e84b6 --- /dev/null +++ b/apps/api/routes/api/me/saved-searches/[id].delete.ts @@ -0,0 +1,29 @@ +/** + * DELETE /api/me/saved-searches/:id — own rows only. + */ +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { uuidSchema, validateParam } from '~~/utils/schemas'; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const id = validateParam(event, 'id', uuidSchema); + + // Scoped to the owner in the WHERE rather than checked first: one statement, + // and no window between the check and the delete. + const deleted = await db + .delete(schema.savedSearches) + .where( + and(eq(schema.savedSearches.id, id), eq(schema.savedSearches.userId, user.id)) + ) + .returning({ id: schema.savedSearches.id }); + + if (deleted.length === 0) { + throw createError({ statusCode: 404, message: 'Saved search not found' }); + } + return { success: true }; +}); diff --git a/apps/api/routes/api/me/saved-searches/index.get.ts b/apps/api/routes/api/me/saved-searches/index.get.ts new file mode 100644 index 00000000..13aa30cc --- /dev/null +++ b/apps/api/routes/api/me/saved-searches/index.get.ts @@ -0,0 +1,24 @@ +/** + * GET /api/me/saved-searches — the member's stored filters. + */ +import { desc, eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { getSavedSearchMaxPerUser } from '~~/utils/settings'; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const [items, max] = await Promise.all([ + db.query.savedSearches.findMany({ + where: eq(schema.savedSearches.userId, user.id), + with: { category: { columns: { id: true, name: true, slug: true } } }, + orderBy: [desc(schema.savedSearches.createdAt)], + }), + getSavedSearchMaxPerUser(), + ]); + + return { items, max }; +}); diff --git a/apps/api/routes/api/me/saved-searches/index.post.ts b/apps/api/routes/api/me/saved-searches/index.post.ts new file mode 100644 index 00000000..ae6fcbff --- /dev/null +++ b/apps/api/routes/api/me/saved-searches/index.post.ts @@ -0,0 +1,97 @@ +/** + * POST /api/me/saved-searches + * + * Store a filter. The body is the listing's own vocabulary — the same + * parameters `/api/torrents` accepts — so the page can hand over whatever the + * member currently has on screen without translating anything. + * + * ## Two refusals worth their code + * + * **A filter with no criteria at all** would match every upload forever, which + * is not a saved search but a firehose. Refused with a message that says what + * to add rather than a generic 400. + * + * **A cap per member.** Every armed filter is evaluated against every accepted + * upload, so the cost of the feature is the number of armed filters across the + * site. A ceiling somebody chose beats one that emerges at three in the + * morning. + */ +import { and, count, eq } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; +import { toExactTsQuery } from '~~/utils/search'; +import { slugifyTag } from '~~/utils/tags'; +import { normalizeMediaId, tmdbIdBare } from '~~/utils/mediaIds'; +import { getSavedSearchMaxPerUser } from '~~/utils/settings'; + +const bodySchema = z.object({ + label: z.string().trim().min(1).max(80), + query: z.string().trim().max(255).optional(), + categoryId: z.string().max(128).optional(), + /** Tag slugs or names — `slugifyTag` resolves both, as the listing does. */ + tags: z.array(z.string().max(64)).max(10).optional(), + imdbId: z.string().max(64).optional(), + tmdbId: z.string().max(64).optional(), + tvdbId: z.string().max(64).optional(), + notify: z.boolean().optional(), +}); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const body = await validateBody(event, bodySchema); + + const tags = (body.tags ?? []).map(slugifyTag).filter(Boolean); + const query = body.query?.trim() || null; + const tsquery = query ? toExactTsQuery(query) : null; + const imdbId = body.imdbId ? normalizeMediaId('imdb', body.imdbId) : null; + const tmdbId = body.tmdbId ? tmdbIdBare(body.tmdbId) : null; + const tvdbId = body.tvdbId?.trim() || null; + const categoryId = body.categoryId?.trim() || null; + + // `query` without a usable tsquery means the member typed only punctuation — + // it looks like a criterion and matches nothing, so it does not count as one. + const hasCriteria = + !!tsquery || !!categoryId || tags.length > 0 || !!imdbId || !!tmdbId || !!tvdbId; + if (!hasCriteria) { + throw createError({ + statusCode: 400, + message: + 'A saved search needs at least one criterion — some text, a category, a tag or a media id.', + }); + } + + const max = await getSavedSearchMaxPerUser(); + const [{ value: existing } = { value: 0 }] = await db + .select({ value: count() }) + .from(schema.savedSearches) + .where(eq(schema.savedSearches.userId, user.id)); + if (existing >= max) { + throw createError({ + statusCode: 400, + message: `You can keep up to ${max} saved searches. Delete one first.`, + }); + } + + const id = randomUUID(); + await db.insert(schema.savedSearches).values({ + id, + userId: user.id, + label: body.label, + query, + tsquery, + categoryId, + tags: tags.length ? tags : null, + imdbId, + tmdbId, + tvdbId, + notify: body.notify ?? true, + }); + + return { id, success: true }; +}); diff --git a/apps/api/routes/api/torrents/index.post.ts b/apps/api/routes/api/torrents/index.post.ts index beaa3d04..42cbb3d0 100644 --- a/apps/api/routes/api/torrents/index.post.ts +++ b/apps/api/routes/api/torrents/index.post.ts @@ -13,6 +13,7 @@ import { normalizeMediaId } from '~~/utils/mediaIds'; import { getUploadRules, evaluateUpload } from '~~/utils/uploadRules'; import { notifyMany, listStaffRecipients } from '~~/utils/notify'; import { fanoutFollowedUserUpload } from '~~/utils/followerFanout'; +import { fanoutSavedSearchMatches } from '~~/utils/savedSearchFanout'; /** @@ -537,6 +538,20 @@ export default defineEventHandler(async (event) => { torrentInfoHash: infoHash, torrentName: name, }); + // Saved-search alerts, on the same edge. Placed here rather than beside + // the insert because a filter can match on tags, and the tags are attached + // a few dozen lines above this — evaluating earlier would silently miss + // every tag-based filter. + void fanoutSavedSearchMatches({ + id, + name, + infoHash, + categoryId: categoryId || null, + imdbId, + tmdbId, + tvdbId, + uploaderId: user.id, + }); } return { diff --git a/apps/api/test/search.test.ts b/apps/api/test/search.test.ts index c7e477b2..80b4e03b 100644 --- a/apps/api/test/search.test.ts +++ b/apps/api/test/search.test.ts @@ -5,6 +5,7 @@ import { fuzzyTerm, parseSearchFields, parseSearchFuzzy, + toExactTsQuery, toPrefixTsQuery, } from '../utils/search'; @@ -154,3 +155,26 @@ describe('fuzzyTerm', () => { expect(fuzzyTerm('***')).toBeNull(); }); }); + +// A saved alert is settled intent, not a query bar with focus. `crown` must +// not fire on `crownfall` — the member would have no way to see it coming, and +// it arrives as a notification rather than as a page they can re-read. +describe('toExactTsQuery', () => { + it('drops the prefix marker the live search appends', () => { + expect(toPrefixTsQuery('the crown')).toBe('the & crown:*'); + expect(toExactTsQuery('the crown')).toBe('the & crown'); + }); + + it('normalises exactly like the live search otherwise', () => { + expect(toExactTsQuery('Blade.Runner 2049!')).toBe('blade & runner & 2049'); + }); + + it('returns null when nothing usable is left', () => { + expect(toExactTsQuery(' ')).toBeNull(); + expect(toExactTsQuery('...')).toBeNull(); + }); + + it('leaves a single term intact but unprefixed', () => { + expect(toExactTsQuery('dune')).toBe('dune'); + }); +}); diff --git a/apps/api/utils/notify.ts b/apps/api/utils/notify.ts index 18619872..4ea4ac43 100644 --- a/apps/api/utils/notify.ts +++ b/apps/api/utils/notify.ts @@ -51,6 +51,11 @@ export type NotificationType = * to know whether you still hold the files. */ | 'reseed_requested' + /** + * A stored filter matched a newly accepted upload. Carries the filter's own + * label so a member running several knows which one fired. + */ + | 'saved_search_match' // ── P1 — Hit & Run ───────────────────────────────────────── | 'hnr_violation_marked' | 'hnr_cleared' diff --git a/apps/api/utils/notifyRenderer.ts b/apps/api/utils/notifyRenderer.ts index cdf0e0c1..02b4dafd 100644 --- a/apps/api/utils/notifyRenderer.ts +++ b/apps/api/utils/notifyRenderer.ts @@ -48,6 +48,10 @@ const EN: Dict = { title: 'Torrent removed by staff', desc: '{actorUsername} deleted “{torrentName}”.', }, + saved_search_match: { + title: 'A saved search matched', + desc: '“{torrentName}” matches your saved search “{searchLabel}”.', + }, reseed_requested: { title: 'Reseed requested', desc: '“{torrentName}” has no seeders. You downloaded it once — can you seed it again?', @@ -261,6 +265,10 @@ const FR: Dict = { title: 'Torrent supprimé par un staff', desc: '{actorUsername} a supprimé « {torrentName} ».', }, + saved_search_match: { + title: 'Une recherche enregistrée correspond', + desc: '« {torrentName} » correspond à votre recherche « {searchLabel} ».', + }, reseed_requested: { title: 'Remise en partage demandée', desc: '« {torrentName} » n’a plus aucune source. Vous l’avez téléchargé : pouvez-vous le repartager ?', diff --git a/apps/api/utils/savedSearchFanout.ts b/apps/api/utils/savedSearchFanout.ts new file mode 100644 index 00000000..2cc194ce --- /dev/null +++ b/apps/api/utils/savedSearchFanout.ts @@ -0,0 +1,158 @@ +/** + * Saved-search alerts: one accepted upload, every filter that wanted it. + * + * ## The direction matters + * + * The naive shape is "for each filter, run the catalogue query and see if this + * torrent comes back" — N queries per upload. This inverts it: one query asks + * Postgres which stored filters match this one torrent, then the notifications + * fan out through the same bounded pool the follower fan-out uses. + * + * The free-text half is the only part that has to be SQL, because a tsquery is + * Postgres's to evaluate. Everything else — category, tags, media ids — is + * plain data already in memory, so it is filtered in TypeScript rather than + * turned into a join. + * + * ## What a match must respect, and why each is not optional + * + * - **The adult gate.** A member who turned adult content off must not be + * pushed an adult release by a filter they wrote before that. This is the + * one rule whose absence would be actively harmful rather than merely + * wrong, so it is applied last and unconditionally. + * - **Anonymous uploads.** Same rule the follower fan-out applies: the + * uploader asked not to be named, and a notification is a place a name + * leaks. The alert says what appeared, never who put it there. + * - **The filter's owner is not the uploader.** Nobody needs telling about + * their own upload. + * + * ## Bounded, and honest about it + * + * A cap per member on how many filters may be armed keeps this O(armed + * filters) with a ceiling somebody chose, rather than one that emerges. The + * sweep logs its own duration when it exceeds a threshold, so an operator finds + * out from their logs rather than from members. + */ +import { and, eq, inArray, or, sql } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { notify } from './notify'; +import { FANOUT_CONCURRENCY, withConcurrency } from './fanout'; +import { adultCategoryIds } from './adultContent'; + +export interface SavedSearchCandidate { + id: string; + name: string; + infoHash: string; + categoryId: string | null; + imdbId: string | null; + tmdbId: string | null; + tvdbId: string | null; + uploaderId: string | null; +} + +/** Log a warning past this, so a slow sweep is visible before it is a problem. */ +const SLOW_SWEEP_MS = 500; + +export async function fanoutSavedSearchMatches( + torrent: SavedSearchCandidate +): Promise<void> { + const started = Date.now(); + + // The torrent's tag slugs, once. A filter matching on tags needs all of its + // own to be present — the listing's AND semantics. + const tagRows = await db + .select({ slug: schema.tags.slug }) + .from(schema.torrentTags) + .innerJoin(schema.tags, eq(schema.tags.id, schema.torrentTags.tagId)) + .where(eq(schema.torrentTags.torrentId, torrent.id)); + const torrentTags = new Set(tagRows.map((r) => r.slug)); + + /** + * Every armed filter whose free text matches, or which has none. + * + * The tsquery comparison runs in SQL against the torrent's own name, which + * is the field a member means when they save a search. Description and NFO + * are deliberately not consulted: the live search offers them because a + * reader is looking for something, while an alert firing on a word buried in + * an NFO is a notification nobody can account for. + */ + const candidates = await db + .select() + .from(schema.savedSearches) + .where( + and( + eq(schema.savedSearches.notify, true), + or( + sql`${schema.savedSearches.tsquery} IS NULL`, + sql`to_tsvector('simple', ${torrent.name}) @@ to_tsquery('simple', ${schema.savedSearches.tsquery})` + ) + ) + ); + + if (candidates.length === 0) return; + + // The structured half, in memory. + const matched = candidates.filter((f) => { + if (f.userId === torrent.uploaderId) return false; + if (f.categoryId && f.categoryId !== torrent.categoryId) return false; + if (f.imdbId && f.imdbId !== torrent.imdbId) return false; + if (f.tmdbId && f.tmdbId !== torrent.tmdbId) return false; + if (f.tvdbId && f.tvdbId !== torrent.tvdbId) return false; + const wanted = f.tags ?? []; + if (wanted.length && !wanted.every((t) => torrentTags.has(t))) return false; + // A filter with no free text, no category, no tag and no id would match + // every upload ever. The write path refuses to store one; this is the + // second line, in case a row predates that or arrives another way. + const empty = + !f.tsquery && !f.categoryId && !f.imdbId && !f.tmdbId && !f.tvdbId && !wanted.length; + return !empty; + }); + + if (matched.length === 0) return; + + // The adult gate, applied to the recipients rather than to the filters: it + // is a property of the member, and a member can change it after saving. + const adultIds = await adultCategoryIds(); + const isAdult = !!torrent.categoryId && adultIds.includes(torrent.categoryId); + + let recipients = matched; + if (isAdult) { + const optedIn = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where( + and( + inArray( + schema.users.id, + matched.map((f) => f.userId) + ), + eq(schema.users.showAdultContent, true) + ) + ); + const allowed = new Set(optedIn.map((u) => u.id)); + recipients = matched.filter((f) => allowed.has(f.userId)); + } + + await withConcurrency(recipients, FANOUT_CONCURRENCY, async (filter) => { + await notify( + filter.userId, + 'saved_search_match', + { searchLabel: filter.label, torrentName: torrent.name }, + `/torrents/${torrent.infoHash}` + ); + await db + .update(schema.savedSearches) + .set({ + lastMatchedAt: new Date(), + matchCount: sql`${schema.savedSearches.matchCount} + 1`, + }) + .where(eq(schema.savedSearches.id, filter.id)); + }); + + const elapsed = Date.now() - started; + if (elapsed > SLOW_SWEEP_MS) { + console.warn( + `[SavedSearch] evaluated ${candidates.length} armed filters in ${elapsed}ms ` + + `(${recipients.length} notified) — consider lowering saved_search_max_per_user` + ); + } +} diff --git a/apps/api/utils/search.ts b/apps/api/utils/search.ts index feb279f8..faf393a4 100644 --- a/apps/api/utils/search.ts +++ b/apps/api/utils/search.ts @@ -78,6 +78,24 @@ export function parseSearchFuzzy(raw: string | null | undefined): boolean { * Returns `null` when nothing usable is left, in which case the caller must * skip the filter rather than return nothing. */ +/** + * The same normalisation WITHOUT the trailing `:*`. + * + * The prefix in `toPrefixTsQuery` exists because the member is still typing: + * `crown` should match `crownfall` while the query bar has focus. A saved alert + * is settled intent, and the same prefix there would fire "The Crown" on every + * release whose title merely starts the same way — a false positive the member + * has no way to see coming, arriving as a notification. + * + * Shares the term-splitting with the prefix version rather than repeating it, + * so the two can never disagree about what counts as a term. + */ +export function toExactTsQuery(input: string): string | null { + const prefixed = toPrefixTsQuery(input); + if (!prefixed) return null; + return prefixed.replace(/:\*$/, ''); +} + export function toPrefixTsQuery(input: string): string | null { const terms = input .toLowerCase() diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts index 8d84a402..d74501ce 100644 --- a/apps/api/utils/settings.ts +++ b/apps/api/utils/settings.ts @@ -151,6 +151,15 @@ export const SETTINGS_KEYS = { * members have moved over. */ LEGACY_PASSKEY_READ_ACCESS: 'legacy_passkey_read_access', + /** + * How many saved searches one member may keep. + * + * Every armed filter is evaluated against every accepted upload, so this is + * the knob that bounds the feature's cost. 20 is generous for a person and + * small enough that a thousand members cost twenty thousand comparisons per + * upload — one indexed query, not twenty thousand. + */ + SAVED_SEARCH_MAX_PER_USER: 'saved_search_max_per_user', SITE_LOGO_IMAGE_SIZE: 'site_logo_image_size', SITE_FAVICON_SIZE: 'site_favicon_size', SITE_SUBTITLE: 'site_subtitle', @@ -402,6 +411,13 @@ export async function isLegacyPasskeyReadAllowed(): Promise<boolean> { return value !== 'false'; } +export async function getSavedSearchMaxPerUser(): Promise<number> { + const value = await getSetting(SETTINGS_KEYS.SAVED_SEARCH_MAX_PER_USER); + const parsed = value ? parseInt(value, 10) : NaN; + if (!Number.isFinite(parsed) || parsed < 1) return 20; + return parsed; +} + export async function getAuditRetentionDays(): Promise<number> { const value = await getSetting(SETTINGS_KEYS.AUDIT_LOG_RETENTION_DAYS); const parsed = value ? parseInt(value, 10) : NaN; diff --git a/apps/api/utils/torrentModeration.ts b/apps/api/utils/torrentModeration.ts index 04b04740..efc5b20d 100644 --- a/apps/api/utils/torrentModeration.ts +++ b/apps/api/utils/torrentModeration.ts @@ -19,6 +19,7 @@ import { db, schema } from '@trackarr/db'; import { and, eq, inArray } from 'drizzle-orm'; import { fanoutFollowedUserUpload } from './followerFanout'; +import { fanoutSavedSearchMatches } from './savedSearchFanout'; import { randomUUID } from 'node:crypto'; import { redis } from '../redis/client'; @@ -213,6 +214,23 @@ export async function transitionStatus(opts: { }); } + // Saved-search alerts, on the same edge and for the same reasons — except + // that this one does not need an uploader: a filter matches a release, and + // an anonymous upload is still a release somebody was waiting for. The + // notification names the torrent and never the uploader, so anonymity holds. + if (nextStatus === 'accepted' && priorStatus !== 'accepted') { + void fanoutSavedSearchMatches({ + id: updated.id, + name: updated.name, + infoHash: updated.infoHash, + categoryId: updated.categoryId, + imdbId: updated.imdbId, + tmdbId: updated.tmdbId, + tvdbId: updated.tvdbId, + uploaderId: updated.uploaderId, + }); + } + // Federation needs nothing here. A row that leaves the accepted state stops // matching the publishable predicate, and the record sweep mints a tombstone // for it on its next pass — the same way it handles a deletion or a ban. diff --git a/apps/web/app/components/CommandPalette.vue b/apps/web/app/components/CommandPalette.vue index cf7e451e..0bfda9ef 100644 --- a/apps/web/app/components/CommandPalette.vue +++ b/apps/web/app/components/CommandPalette.vue @@ -204,6 +204,7 @@ const accountLinks = computed<PaletteItem[]>(() => { ? [{ to: '/messages', key: 'messages', icon: 'ph:chat-circle' }] : []), { to: '/favorites', key: 'favorites', icon: 'ph:heart' }, + { to: '/alerts', key: 'alerts', icon: 'ph:bookmark-simple' }, { to: '/following', key: 'following', icon: 'ph:bell' }, { to: '/downloads', key: 'downloads', icon: 'ph:download-simple' }, { to: '/invites', key: 'invites', icon: 'ph:envelope-simple' }, diff --git a/apps/web/app/components/settings/NotificationsSection.vue b/apps/web/app/components/settings/NotificationsSection.vue index 723a243a..5116e862 100644 --- a/apps/web/app/components/settings/NotificationsSection.vue +++ b/apps/web/app/components/settings/NotificationsSection.vue @@ -586,6 +586,7 @@ const notifTypes = [ 'moderation_message_received', 'torrent_deleted_by_staff', 'reseed_requested', + 'saved_search_match', 'hnr_violation_marked', 'hnr_cleared', 'hnr_exempted', @@ -656,7 +657,7 @@ const categories: EventCategory[] = [ key: 'catalogue', icon: 'ph:hand-heart-bold', color: '#4ade80', - types: ['reseed_requested'], + types: ['reseed_requested', 'saved_search_match'], }, { key: 'account', diff --git a/apps/web/app/pages/alerts.vue b/apps/web/app/pages/alerts.vue new file mode 100644 index 00000000..9402c18b --- /dev/null +++ b/apps/web/app/pages/alerts.vue @@ -0,0 +1,262 @@ +<template> + <div class="alerts"> + <header class="al-head"> + <p class="al-eyebrow">{{ $t('alerts.eyebrow') }}</p> + <h1 class="al-title">{{ $t('alerts.title') }}</h1> + <p class="al-lede">{{ $t('alerts.lede') }}</p> + </header> + + <p v-if="items.length" class="al-count"> + {{ $t('alerts.count', { used: items.length, max: data?.max ?? 0 }) }} + </p> + + <div v-if="!items.length" class="al-empty"> + <Icon name="ph:bookmark-simple" class="al-empty-icon" /> + <h2>{{ $t('alerts.empty.title') }}</h2> + <p>{{ $t('alerts.empty.body') }}</p> + <NuxtLink to="/torrents" class="btn btn-secondary"> + <Icon name="ph:magnifying-glass" /> + {{ $t('alerts.empty.cta') }} + </NuxtLink> + </div> + + <ul v-else class="al-list"> + <li v-for="s in items" :key="s.id" class="al-item"> + <div class="al-item-main"> + <h2 class="al-item-label">{{ s.label }}</h2> + <p class="al-criteria"> + <span v-if="s.query" class="al-chip al-chip--text"> + <Icon name="ph:text-aa" />{{ s.query }} + </span> + <span v-if="s.category" class="al-chip"> + <Icon name="ph:folder" />{{ s.category.name }} + </span> + <span v-for="tag in s.tags ?? []" :key="tag" class="al-chip"> + <Icon name="ph:tag" />{{ tag }} + </span> + <span v-if="s.imdbId" class="al-chip"><Icon name="ph:film-slate" />{{ s.imdbId }}</span> + <span v-if="s.tmdbId" class="al-chip"><Icon name="ph:film-slate" />{{ s.tmdbId }}</span> + <span v-if="s.tvdbId" class="al-chip"><Icon name="ph:television" />{{ s.tvdbId }}</span> + </p> + <p class="al-meta"> + <template v-if="s.matchCount > 0"> + {{ $t('alerts.matched', { count: s.matchCount }) }} + <span v-if="s.lastMatchedAt"> + · {{ $t('alerts.lastMatch', { when: formatAge(s.lastMatchedAt) }) }} + </span> + </template> + <template v-else>{{ $t('alerts.noMatchYet') }}</template> + </p> + </div> + + <div class="al-item-actions"> + <NuxtLink :to="searchLink(s)" class="tool-btn" :title="$t('alerts.run')"> + <Icon name="ph:magnifying-glass-bold" /> + </NuxtLink> + <button + type="button" + class="tool-btn tool-btn--danger" + :disabled="busy === s.id" + :title="$t('alerts.delete')" + @click="remove(s)" + > + <Icon + :name="busy === s.id ? 'ph:circle-notch' : 'ph:trash-bold'" + :class="{ 'animate-spin': busy === s.id }" + /> + </button> + </div> + </li> + </ul> + </div> +</template> + +<script setup lang="ts"> +/** + * A member's saved searches — the filters that notify them when a matching + * release is accepted. + * + * Read-and-delete only. Filters are created from the catalogue page, where the + * member already has the criteria in front of them; an edit form here would be + * a second way to express the same thing, and the two would drift on every new + * filter parameter. + */ +definePageMeta({ title: 'Alerts' }); + +interface SavedSearch { + id: string; + label: string; + query: string | null; + categoryId: string | null; + category: { id: string; name: string; slug: string } | null; + tags: string[] | null; + imdbId: string | null; + tmdbId: string | null; + tvdbId: string | null; + notify: boolean; + createdAt: string; + lastMatchedAt: string | null; + matchCount: number; +} + +const { t } = useI18n(); +const notifications = useNotificationStore(); + +const { data, refresh } = await useFetch<{ items: SavedSearch[]; max: number }>( + '/api/me/saved-searches', + { default: () => ({ items: [], max: 20 }) } +); +const items = computed(() => data.value?.items ?? []); +const busy = ref<string | null>(null); + +/** Rebuild the catalogue URL this filter came from, so "run it" is one click. */ +function searchLink(s: SavedSearch): string { + const q = new URLSearchParams(); + if (s.query) q.set('search', s.query); + if (s.categoryId) q.set('categoryId', s.categoryId); + if (s.tags?.length) q.set('tag', s.tags.join(',')); + if (s.imdbId) q.set('imdbid', s.imdbId); + if (s.tmdbId) q.set('tmdbid', s.tmdbId); + if (s.tvdbId) q.set('tvdbid', s.tvdbId); + const qs = q.toString(); + return qs ? `/torrents?${qs}` : '/torrents'; +} + +async function remove(s: SavedSearch) { + busy.value = s.id; + try { + await $fetch<{ success: boolean }>(`/api/me/saved-searches/${s.id}` as string, { + method: 'DELETE', + }); + await refresh(); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + notifications.error(e?.data?.message || e?.message || t('alerts.deleteFailed')); + } finally { + busy.value = null; + } +} + +useHead({ title: () => t('alerts.title') }); +</script> + +<style scoped> +/* + * A list of standing instructions, not a dashboard. Each row states what it + * watches for and whether it has ever fired; everything else is noise on a + * page somebody visits twice a year. + */ +.alerts { + max-width: 52rem; + margin: 0 auto; + padding: 3rem 1.25rem 5rem; +} + +.al-eyebrow { + margin: 0 0 0.25rem; + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.al-title { + margin: 0; + font-size: 1.6rem; +} +.al-lede { + margin: 0.5rem 0 0; + max-width: 46rem; + font-size: 0.875rem; + line-height: 1.6; + color: rgb(var(--fg-muted)); +} +.al-count { + margin: 1.5rem 0 0.75rem; + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} + +.al-empty { + margin-top: 3rem; + padding: 3rem 1.5rem; + text-align: center; + border: 1px dashed rgb(var(--line-default)); + border-radius: 0.6rem; +} +.al-empty-icon { + width: 2.5rem; + height: 2.5rem; + color: rgb(var(--fg-subtle)); +} +.al-empty h2 { + margin: 0.75rem 0 0.35rem; + font-size: 1rem; +} +.al-empty p { + margin: 0 auto 1.25rem; + max-width: 34rem; + font-size: 0.85rem; + line-height: 1.6; + color: rgb(var(--fg-muted)); +} + +.al-list { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid rgb(var(--line-default)); +} +.al-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid rgb(var(--line-default)); +} +.al-item-main { + min-width: 0; +} +.al-item-label { + margin: 0 0 0.4rem; + font-size: 0.95rem; +} +.al-criteria { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0 0 0.4rem; +} +.al-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1rem 0.4rem; + border: 1px solid rgb(var(--line-default)); + border-radius: 0.25rem; + font-size: 0.72rem; + color: rgb(var(--fg-muted)); +} +.al-chip--text { + border-color: rgb(var(--accent) / 0.4); + color: rgb(var(--accent)); +} +.al-meta { + margin: 0; + font-size: 0.75rem; + color: rgb(var(--fg-subtle)); +} +.al-item-actions { + display: flex; + gap: 0.4rem; + flex-shrink: 0; +} + +@media (max-width: 34rem) { + .al-item { + flex-direction: column; + } +} +</style> diff --git a/apps/web/app/pages/notifications.vue b/apps/web/app/pages/notifications.vue index 9f6da835..8512b3a8 100644 --- a/apps/web/app/pages/notifications.vue +++ b/apps/web/app/pages/notifications.vue @@ -193,6 +193,7 @@ const TYPE_DEFAULTS: Record< moderation_message_received: { icon: 'ph:chat-circle-text-bold', tone: 'info' }, torrent_deleted_by_staff: { icon: 'ph:trash-bold', tone: 'danger' }, reseed_requested: { icon: 'ph:hand-heart-bold', tone: 'social' }, + saved_search_match: { icon: 'ph:bookmark-simple-bold', tone: 'info' }, hnr_violation_marked: { icon: 'ph:lightning-bold', tone: 'danger' }, hnr_cleared: { icon: 'ph:check-circle-bold', tone: 'gain' }, hnr_exempted: { icon: 'ph:shield-check-bold', tone: 'gain' }, diff --git a/apps/web/app/pages/torrents/index.vue b/apps/web/app/pages/torrents/index.vue index 4e79e94e..9e459a0f 100644 --- a/apps/web/app/pages/torrents/index.vue +++ b/apps/web/app/pages/torrents/index.vue @@ -229,6 +229,28 @@ /> </div> + <!-- ── Save this search ────────────────────────────────────── + Offered only when there is something to save. A filter is created + from here rather than from a form on the alerts page because the + member already has the criteria in front of them — retyping them + somewhere else is how a saved search ends up not matching what they + were actually looking at. --> + <div v-if="hasActiveQuery && canSaveSearch" class="save-search"> + <button + type="button" + class="tool-btn tool-btn--text" + :disabled="savingSearch" + @click="saveCurrentSearch" + > + <Icon + :name="savingSearch ? 'ph:circle-notch' : 'ph:bookmark-simple-bold'" + :class="{ 'animate-spin': savingSearch }" + /> + {{ $t('search.saveSearch.action') }} + </button> + <NuxtLink to="/alerts" class="save-search-link">{{ $t('search.saveSearch.manage') }}</NuxtLink> + </div> + <!-- ── Pinned ──────────────────────────────────────────────── Above the results and visually apart, because a pin answers a different question than the listing does: not "what matches" but @@ -687,6 +709,55 @@ const { data: trendingData } = await useFetch<{ data: TorrentWithStats[] }>( const torrents = computed(() => torrentsData.value?.data ?? []); const pinnedTorrents = computed(() => torrentsData.value?.pinned ?? []); + +/** + * Saving the current filter as an alert. + * + * Only when the filter has something in it that a stored search could act on — + * a bare sort order is not a search, and saving one would produce a filter that + * fires on every upload. + */ +const savedSearchNotifications = useNotificationStore(); +const savingSearch = ref(false); +const canSaveSearch = computed( + () => + !!searchQuery.value.trim() || + !!selectedCategory.value || + selectedTags.value.length > 0 +); + +async function saveCurrentSearch() { + // The typed text makes the best label; failing that, the category name; and + // failing both, a placeholder the member can recognise on the alerts page. + const fromCategory = selectedCategory.value + ? categoryLabel([selectedCategory.value]) + : null; + const label = ( + searchQuery.value.trim() || + fromCategory || + t('search.saveSearch.untitled') + ).slice(0, 80); + savingSearch.value = true; + try { + await $fetch<{ id: string }>('/api/me/saved-searches', { + method: 'POST', + body: { + label, + query: searchQuery.value.trim() || undefined, + categoryId: selectedCategory.value || undefined, + tags: selectedTags.value.length ? selectedTags.value : undefined, + }, + }); + savedSearchNotifications.success(t('search.saveSearch.saved')); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + savedSearchNotifications.error( + e?.data?.message || e?.message || t('search.saveSearch.failed') + ); + } finally { + savingSearch.value = false; + } +} const trendingTorrents = computed(() => trendingData.value?.data ?? []); // ── View-aware result state ────────────────────────────────── @@ -1291,6 +1362,21 @@ useHead({ } /* ─── Loading / empty ───────────────────────────────────── */ +/* ── Save this search ─────────────────────────────────────────── */ +.save-search { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} +.save-search-link { + font-size: 0.75rem; + color: rgb(var(--fg-subtle)); +} +.save-search-link:hover { + color: rgb(var(--accent)); +} + /* ── Pinned block ──────────────────────────────────────────────── Set apart from the flow by a rule and an icon rather than by a tint: the rows inside are ordinary rows and should read as such. */ diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 1fcc5eed..93869d60 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -928,6 +928,13 @@ }, "pinned": { "title": "Pinned" + }, + "saveSearch": { + "action": "Save this search", + "manage": "Manage alerts", + "saved": "Saved. You will be notified when a matching release is accepted.", + "failed": "Could not save this search.", + "untitled": "Untitled search" } }, "users": { @@ -4615,6 +4622,10 @@ "reseed_requested": { "title": "Reseed requested", "desc": "“{torrentName}” has no seeders. You downloaded it once — can you seed it again?" + }, + "saved_search_match": { + "title": "A saved search matched", + "desc": "“{torrentName}” matches your saved search “{searchLabel}”." } } }, @@ -5008,7 +5019,8 @@ "shop": "Bonus shop", "notifications": "Notifications", "settings": "Settings", - "messages": "Messages" + "messages": "Messages", + "alerts": "Saved searches" }, "actions": { "upload": "Upload a torrent", @@ -5271,5 +5283,22 @@ "withdraw": "Close this ticket", "withdrawHint": "You will not be able to write to it or reopen it — only the staff can. If it comes back, open a new ticket.", "menuLabel": "Ticket actions" + }, + "alerts": { + "eyebrow": "Standing instructions", + "title": "Saved searches", + "lede": "Each of these watches for new releases. When an upload is accepted and matches, you get a notification — no bot, no seedbox, nothing to keep running.", + "count": "{used} of {max} saved", + "run": "Run this search", + "delete": "Delete", + "deleteFailed": "Could not delete this saved search.", + "matched": "Matched {count} times", + "lastMatch": "last {when} ago", + "noMatchYet": "Nothing has matched yet.", + "empty": { + "title": "No saved searches", + "body": "Search the catalogue, then use “Save this search” to be told when something new matches it.", + "cta": "Browse the catalogue" + } } } diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 41729aae..46a1cfef 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -928,6 +928,13 @@ }, "pinned": { "title": "Épinglés" + }, + "saveSearch": { + "action": "Enregistrer cette recherche", + "manage": "Gérer les alertes", + "saved": "Enregistrée. Vous serez prévenu·e quand une release correspondante sera acceptée.", + "failed": "Cette recherche n'a pas pu être enregistrée.", + "untitled": "Recherche sans titre" } }, "users": { @@ -4615,6 +4622,10 @@ "reseed_requested": { "title": "Remise en partage demandée", "desc": "« {torrentName} » n’a plus aucune source. Vous l’avez téléchargé : pouvez-vous le repartager ?" + }, + "saved_search_match": { + "title": "Une recherche enregistrée correspond", + "desc": "« {torrentName} » correspond à votre recherche « {searchLabel} »." } } }, @@ -5008,7 +5019,8 @@ "shop": "Boutique bonus", "notifications": "Notifications", "settings": "Paramètres", - "messages": "Messages" + "messages": "Messages", + "alerts": "Recherches enregistrées" }, "actions": { "upload": "Publier un torrent", @@ -5271,5 +5283,22 @@ "withdraw": "Clôturer ce ticket", "withdrawHint": "Tu n'auras plus la main dessus, ni pour écrire ni pour le rouvrir — seul le staff peut le rouvrir. Si le sujet revient, ouvre un nouveau ticket.", "menuLabel": "Actions sur le ticket" + }, + "alerts": { + "eyebrow": "Consignes permanentes", + "title": "Recherches enregistrées", + "lede": "Chacune surveille les nouvelles releases. Quand une mise en ligne est acceptée et correspond, vous recevez une notification — sans bot, sans seedbox, sans rien à faire tourner.", + "count": "{used} sur {max} enregistrées", + "run": "Lancer cette recherche", + "delete": "Supprimer", + "deleteFailed": "Cette recherche n'a pas pu être supprimée.", + "matched": "A correspondu {count} fois", + "lastMatch": "la dernière il y a {when}", + "noMatchYet": "Rien ne correspond pour le moment.", + "empty": { + "title": "Aucune recherche enregistrée", + "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" + } } } diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 06c21a0f..ade8fc17 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -116,6 +116,7 @@ export default defineConfig({ { text: 'Follows', link: '/guide/follows' }, { 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: 'Federation', link: '/guide/federation' }, ], }, diff --git a/doc/guide/saved-searches.md b/doc/guide/saved-searches.md new file mode 100644 index 00000000..0d33c5fb --- /dev/null +++ b/doc/guide/saved-searches.md @@ -0,0 +1,70 @@ +# Saved searches + +Store a filter, get a notification when a matching upload is accepted. The +server-side half of what members do today with autobrr — and unlike an IRC +announce channel, it works for the member who has no seedbox and no bot, which +is most of them. + +## Creating one + +Search the catalogue as usual, then **Save this search**. The filter is taken +from what is on screen, which is the point: retyping criteria into a separate +form is how a saved search ends up not matching what you were actually looking +at. + +Manage them at **/alerts**. + +## What a filter can hold + +Exactly the catalogue's own vocabulary, and no more: + +| Criterion | Notes | +| --- | --- | +| Free text | Matched against the release **name** | +| Category | One | +| Tags | All of them must be present — the listing's AND semantics | +| IMDb / TMDb / TVDb id | Exact | + +Resolution, source and codec are **tags** here, as they are in the catalogue — +`1080p`, `bluray`, `x265` are derived from the release name at upload. So "1080p +Blu-Ray x265" is three tags, not three new fields. + +A filter with no criteria at all is refused: it would match every upload +forever, which is not a saved search but a firehose. + +## What it matches against + +**The release name only.** The live search can also read descriptions and NFOs, +because a reader is looking for something and a wider net helps. An alert firing +on a word buried in an NFO is a notification nobody can account for. + +The text is normalised at save time and stored **without** the trailing prefix +marker the live search uses. That marker exists because the member is still +typing — `crown` should match `crownfall` while the query bar has focus. A saved +alert is settled intent, and the same behaviour there would fire "The Crown" on +every release whose title merely starts the same way. + +The typo-tolerant fallback is not carried over either: it exists to rescue an +empty results page, and on a per-torrent test it would only produce false +positives. + +## What it will never do + +- **Push adult content to somebody who turned it off.** Checked against the live + preference, not the one in force when the filter was saved. +- **Name an anonymous uploader.** The notification says what appeared, never who + put it there. +- **Tell you about your own upload.** + +## For operators + +`saved_search_max_per_user` — **default 20**. + +Every armed filter is evaluated against every accepted upload, so this is the +knob that bounds the feature's cost. The evaluation is inverted — one query asks +Postgres which stored filters match this one torrent, rather than running each +filter's catalogue query — so a thousand members with twenty filters each is one +indexed query per upload, not twenty thousand. + +The fan-out logs its own duration when it exceeds half a second, so a sweep that +is getting slow shows up in your logs before it shows up in complaints. diff --git a/packages/db/src/migrations/0063_saved_searches.sql b/packages/db/src/migrations/0063_saved_searches.sql new file mode 100644 index 00000000..b1e80104 --- /dev/null +++ b/packages/db/src/migrations/0063_saved_searches.sql @@ -0,0 +1,21 @@ +CREATE TABLE "saved_searches" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "label" text NOT NULL, + "query" text, + "tsquery" text, + "category_id" text, + "tags" jsonb, + "imdb_id" text, + "tmdb_id" text, + "tvdb_id" text, + "notify" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "last_matched_at" timestamp, + "match_count" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "saved_searches" ADD CONSTRAINT "saved_searches_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saved_searches" ADD CONSTRAINT "saved_searches_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "saved_searches_user_idx" ON "saved_searches" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "saved_searches_notify_idx" ON "saved_searches" USING btree ("notify") WHERE "saved_searches"."notify"; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0063_snapshot.json b/packages/db/src/migrations/meta/0063_snapshot.json new file mode 100644 index 00000000..fc033657 --- /dev/null +++ b/packages/db/src/migrations/meta/0063_snapshot.json @@ -0,0 +1,10005 @@ +{ + "id": "de3bb7f2-2043-4d52-a41d-a7b120bb525e", + "prevId": "8db7c224-958c-4c07-905b-3d6d68bf83cc", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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 f1714f8d..c32e862f 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1788217335868, "tag": "0062_user_read_keys", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1788217997937, + "tag": "0063_saved_searches", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3c97e468..2cbe2a3d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2646,6 +2646,92 @@ export const auditLog = pgTable( ] ); +// ============================================================================ +// Saved searches — "tell me when something like this appears" +// ============================================================================ +// +// The server-side half of what members do today with autobrr: a stored filter +// that fires a notification when a matching upload is accepted. Unlike an IRC +// announce channel it works for the member who has no seedbox and no bot, which +// is most of them. +// +// ## The vocabulary is the listing's, deliberately +// +// `query`, `categoryId`, `tags` and the three media ids are exactly the +// parameters `/api/torrents` accepts — no more. A filter the catalogue cannot +// produce could not be created from a "save this search" button, and a filter +// that matched on something the listing cannot show would be impossible to +// preview. Resolution and source are tags here, as they are there +// (`utils/releaseTags` derives them at upload). +// +// ## `tsquery` is stored, not re-derived +// +// The listing builds its tsquery per request with a `:*` on the last term, +// because the member is still typing. A saved alert is settled intent — +// `"the crown"` must not fire on `"the crownfall"` — so the prefix is dropped +// when the row is written and the result is stored. That also means one +// SQL-side comparison per upload instead of re-parsing every filter. +export const savedSearches = pgTable( + 'saved_searches', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the member calls it. Shown in the notification. */ + label: text('label').notNull(), + /** The free-text part, as typed. Kept for display and for editing. */ + query: text('query'), + /** + * The same text as a Postgres tsquery, normalised at write time and + * WITHOUT the trailing `:*` the live search appends. Null when the filter + * carries no free text at all, which is legitimate — "anything new in this + * category" is a filter. + */ + tsquery: text('tsquery'), + categoryId: text('category_id').references(() => categories.id, { + onDelete: 'cascade', + }), + /** Tag slugs, ALL of which must be present — the listing's AND semantics. */ + tags: jsonb('tags').$type<string[]>(), + imdbId: text('imdb_id'), + tmdbId: text('tmdb_id'), + tvdbId: text('tvdb_id'), + /** Off keeps the filter as a saved search without the notifications. */ + notify: boolean('notify').default(true).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + lastMatchedAt: timestamp('last_matched_at'), + matchCount: integer('match_count').default(0).notNull(), + }, + (table) => [ + index('saved_searches_user_idx').on(table.userId, table.createdAt.desc()), + // The evaluation sweep reads every armed filter and nothing else, so the + // index it wants is partial on exactly that. + index('saved_searches_notify_idx') + .on(table.notify) + .where(sql`${table.notify}`), + ] +); + +/** + * Declared so the listing route can eager-load the category it filters on — + * the page shows "Movies" rather than a uuid. Without this, `with: { category }` + * is a runtime error rather than a type error, which is a poor way to find out. + */ +export const savedSearchesRelations = relations(savedSearches, ({ one }) => ({ + user: one(users, { + fields: [savedSearches.userId], + references: [users.id], + }), + category: one(categories, { + fields: [savedSearches.categoryId], + references: [categories.id], + }), +})); + +export type SavedSearch = typeof savedSearches.$inferSelect; +export type NewSavedSearch = typeof savedSearches.$inferInsert; + export type AuditLogEntry = typeof auditLog.$inferSelect; export type NewAuditLogEntry = typeof auditLog.$inferInsert; From 36c4bbcad14d1ea9aef581fa8954fbaf63fd4a94 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 10:25:51 +0200 Subject: [PATCH 12/96] feat(auth): where an account has been signed in from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an invite-only tracker, account sharing is the offence that matters most, and there was no evidence for it. `trusted_devices` and `webauthn_credentials` existed; neither could answer "where has this account been used from in the last month". One row per attempt to open a session, from all four paths that open one — the zero-knowledge login, TOTP, a recovery code, a WebAuthn assertion, plus registration, which opens a session like a login does and whose absence would make every history start at the second visit. Failures are rows too, and they are the more useful half. There is **no per-account lockout** on this site: throttling is entirely per IP, so an attempt spread across addresses meets nothing at all. A run of refusals against one account, from addresses that differ, is a shape worth recognising — and until now it left no trace whatsoever. A wrong second factor after a correct password is recorded separately, because that is the shape of a stolen password rather than of a forgotten one. The method is recorded, and the distinction between `password` and `trusted-device` is deliberate: a member reading their own history should be able to see which sessions never met their second factor. Two audiences, one table. Members read their own from Settings; staff read a member's from that member's profile page, which is where the question comes up — a moderator looking at somebody they suspect, not somebody hunting through a console. The staff view computes the count of distinct addresses **on the most recent day only**, because that is the only window in which the number means anything. Which is the limit worth stating rather than burying. The address goes through the same daily-rotating salt as every other IP here, so two rows compare only within one day; two different hashes a week apart may well be the same address. Both views say so, because a moderator drawing a conclusion from hashes a month apart would be drawing it from noise. While checking that: the README's claim that no raw IP is persisted is not quite true, and this change does not make it less true. `users.lastIp` holds one, for the IP-ban check. This table adds no second exception. Retention is 90 days by default, published on `/privacy` beside every other period — shorter than the audit log's year because this is a high-volume table and the questions it answers are about the recent past. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + apps/api/plugins/login-event-retention.ts | 54 + .../api/auth/2fa/passkey-verify.post.ts | 8 + .../routes/api/auth/2fa/verify-totp.post.ts | 19 + apps/api/routes/api/auth/login.post.ts | 23 + apps/api/routes/api/auth/register.post.ts | 10 + apps/api/routes/api/me/logins.get.ts | 45 + .../routes/api/mod/users/[id]/logins.get.ts | 61 + apps/api/routes/api/privacy.get.ts | 9 + apps/api/utils/account/loginLog.ts | 70 + apps/api/utils/settings.ts | 9 + apps/web/app/pages/settings.vue | 161 + apps/web/app/pages/users/[id].vue | 188 + apps/web/i18n/locales/en.json | 37 +- apps/web/i18n/locales/fr.json | 37 +- doc/guide/security.md | 38 + .../db/src/migrations/0064_login_events.sql | 14 + .../db/src/migrations/meta/0064_snapshot.json | 10118 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 60 + 20 files changed, 10967 insertions(+), 2 deletions(-) create mode 100644 apps/api/plugins/login-event-retention.ts create mode 100644 apps/api/routes/api/me/logins.get.ts create mode 100644 apps/api/routes/api/mod/users/[id]/logins.get.ts create mode 100644 apps/api/utils/account/loginLog.ts create mode 100644 packages/db/src/migrations/0064_login_events.sql create mode 100644 packages/db/src/migrations/meta/0064_snapshot.json diff --git a/README.md b/README.md index de24d9c3..2b5142ac 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre - **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). - **Three keys, not one** — the announce passkey, an RSS/Torznab key and an API key, each revocable on its own. Handing a feed URL to a third party no longer hands over the credential that announces for you. See [Keys](doc/guide/api-keys.md). +- **Login history** — every attempt to open a session, refused ones included, with the method and a daily-hashed address. Members read their own; staff read a member's from their profile, which is where account sharing gets noticed. - **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/plugins/login-event-retention.ts b/apps/api/plugins/login-event-retention.ts new file mode 100644 index 00000000..81d3b96d --- /dev/null +++ b/apps/api/plugins/login-event-retention.ts @@ -0,0 +1,54 @@ +/** + * Login-history retention sweep. + * + * Rows survive `login_event_retention_days` (default 90, `0` = forever) and are + * then deleted by age. Ninety days rather than the audit log's year: this is a + * high-volume table — one row per login attempt per member — and the questions + * it answers ("was that me last week", "is this account being shared right + * now") are asked about the recent past. + * + * Published on `/privacy` beside every other period, because it is a record of + * a member's own activity rather than of staff decisions. + */ +import { lt } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { getLoginEventRetentionDays } from '~~/utils/server'; +import { withCronLock } from '~~/utils/cronLock'; + +const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; +const FIRST_RUN_DELAY_MS = 7 * 60 * 1000; + +export default defineNitroPlugin(() => { + const tick = async () => { + try { + await withCronLock('login_event_retention:lock', 10 * 60, async () => { + const days = await getLoginEventRetentionDays(); + if (days <= 0) return; + + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const deleted = await db + .delete(schema.loginEvents) + .where(lt(schema.loginEvents.createdAt, cutoff)) + .returning({ id: schema.loginEvents.id }); + + if (deleted.length > 0) { + console.log( + `[LoginRetention] swept ${deleted.length} events older than ${days}d` + ); + } + }); + } catch (err) { + const e = err as { message?: string; cause?: { code?: string; message?: string } }; + console.error( + '[LoginRetention] 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/auth/2fa/passkey-verify.post.ts b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts index 7266ff8e..bd90022b 100644 --- a/apps/api/routes/api/auth/2fa/passkey-verify.post.ts +++ b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts @@ -25,6 +25,7 @@ import { import { issueTrustedDevice } from '~~/utils/trustedDevices'; import { validateBody } from '~~/utils/schemas'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { recordLogin } from '~~/utils/account/loginLog'; const bodySchema = z.object({ challengeToken: z.string().min(16).max(128), @@ -133,6 +134,13 @@ export default defineEventHandler(async (event) => { }); if (!user) throw createError({ statusCode: 401, message: 'User not found' }); + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'passkey', + outcome: 'success', + }); + await setUserSession(event, { user: { id: user.id, diff --git a/apps/api/routes/api/auth/2fa/verify-totp.post.ts b/apps/api/routes/api/auth/2fa/verify-totp.post.ts index 7ce226e6..84594bb8 100644 --- a/apps/api/routes/api/auth/2fa/verify-totp.post.ts +++ b/apps/api/routes/api/auth/2fa/verify-totp.post.ts @@ -22,6 +22,7 @@ import { validateBody } from '~~/utils/schemas'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; import { decryptSecret } from '~~/utils/credentialSecrets'; +import { recordLogin } from '~~/utils/account/loginLog'; const bodySchema = z .object({ @@ -86,6 +87,14 @@ export default defineEventHandler(async (event) => { } const seed = decryptSecret(user.totpSecret); if (!seed || !(await verifyTotp(body.code, seed, { userId: user.id }))) { + // A wrong second factor after a correct password is the shape of a + // stolen password, and the only place it leaves a trace. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'totp', + outcome: 'failed', + }); throw createError({ statusCode: 400, message: 'Invalid TOTP code.' }); } } else if (body.recoveryCode) { @@ -118,6 +127,16 @@ export default defineEventHandler(async (event) => { void notify(user.id, 'recovery_code_used', null, '/settings'); } + // Which factor cleared it, so a member reading their history can tell a + // recovery code — the one they only ever use once, in trouble — from an + // ordinary authenticator prompt. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: body.recoveryCode ? 'recovery' : 'totp', + outcome: 'success', + }); + // Open the session — same shape as the post-SRP path so the FE // doesn't need to know which way it came in. await setUserSession(event, { diff --git a/apps/api/routes/api/auth/login.post.ts b/apps/api/routes/api/auth/login.post.ts index 7f42a464..bf99ed14 100644 --- a/apps/api/routes/api/auth/login.post.ts +++ b/apps/api/routes/api/auth/login.post.ts @@ -10,6 +10,7 @@ import { consumeTrustedDevice } from '~~/utils/trustedDevices'; import { notify } from '~~/utils/notify'; import { liftExpiredBan } from '~~/utils/banExpiry'; import { decryptSecret, encryptSecretRequired, needsRewrite } from '~~/utils/credentialSecrets'; +import { recordLogin } from '~~/utils/account/loginLog'; /** * POST /api/auth/login @@ -80,6 +81,15 @@ export default defineEventHandler(async (event) => { .digest('hex'); if (!secureCompare(body.proof, expectedProof)) { + // Recorded before the throw. There is no per-account lockout on this site — + // throttling is entirely per IP — so an attempt spread across addresses + // meets nothing at all, and this row is the only trace it leaves. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'password', + outcome: 'failed', + }); throw createError({ statusCode: 401, message: 'Invalid credentials', @@ -218,6 +228,19 @@ export default defineEventHandler(async (event) => { loggedInAt: Date.now(), }); + // `trusted-device` when the second factor was skipped by a remembered + // browser, `password` otherwise. The distinction is the point: a member + // reading their own history should be able to see which sessions never met + // their second factor. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: (event.context as { trustedDeviceUsed?: boolean }).trustedDeviceUsed + ? 'trusted-device' + : 'password', + outcome: 'success', + }); + // Stamp the fresh-auth window even on no-2FA logins so the same // settings flow ("change my password" etc.) works either way. // Key on the real h3 session id (getSessionId), not the session diff --git a/apps/api/routes/api/auth/register.post.ts b/apps/api/routes/api/auth/register.post.ts index 3fe53730..555b14b0 100644 --- a/apps/api/routes/api/auth/register.post.ts +++ b/apps/api/routes/api/auth/register.post.ts @@ -15,6 +15,7 @@ import { verifyPoWSolution } from '~~/utils/pow'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { markFreshAuth } from '~~/utils/twoFactor'; import { encryptSecretRequired } from '~~/utils/credentialSecrets'; +import { recordLogin } from '~~/utils/account/loginLog'; /** * Postgres advisory lock id used to serialise the "first user gets @@ -279,6 +280,15 @@ export default defineEventHandler(async (event) => { // site-default setting inert — it recorded a choice nobody made, and nothing // downstream could tell it apart from one. Language keeps its schema default // ('en'), which has no equivalent site-wide setting to defer to. + // Registration opens a session like a login does, and a history that starts + // at the second visit reads as if the first one is missing. + void recordLogin(event, { + userId, + username: body.username, + method: 'password', + outcome: 'success', + }); + await setUserSession(event, { user: { id: userId, diff --git a/apps/api/routes/api/me/logins.get.ts b/apps/api/routes/api/me/logins.get.ts new file mode 100644 index 00000000..04d5ac63 --- /dev/null +++ b/apps/api/routes/api/me/logins.get.ts @@ -0,0 +1,45 @@ +/** + * GET /api/me/logins — where this account has been used from. + * + * The member's own copy of the login history, so "was that me?" has an answer + * that does not require asking staff. Includes failures: an attempt that did + * not succeed is the one worth knowing about. + * + * The address is a daily-salted hash, so two rows can be compared for "same + * place" only within one day. The page says so — a reader who assumes + * otherwise would draw the wrong conclusion from two different-looking hashes + * that are in fact the same address a week apart. + */ +import { desc, eq } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(30), +}); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { limit } = validateQuery(event, querySchema); + + const items = await db + .select({ + id: schema.loginEvents.id, + method: schema.loginEvents.method, + outcome: schema.loginEvents.outcome, + ipHash: schema.loginEvents.ipHash, + userAgent: schema.loginEvents.userAgent, + createdAt: schema.loginEvents.createdAt, + }) + .from(schema.loginEvents) + .where(eq(schema.loginEvents.userId, user.id)) + .orderBy(desc(schema.loginEvents.createdAt)) + .limit(limit); + + return { items }; +}); diff --git a/apps/api/routes/api/mod/users/[id]/logins.get.ts b/apps/api/routes/api/mod/users/[id]/logins.get.ts new file mode 100644 index 00000000..a18cb132 --- /dev/null +++ b/apps/api/routes/api/mod/users/[id]/logins.get.ts @@ -0,0 +1,61 @@ +/** + * GET /api/mod/users/:id/logins — the same history, for moderation. + * + * The question this exists for is account sharing, which on an invite-only + * tracker is the offence that matters most and the one there was no evidence + * for. Several successful logins from different address hashes on the same day + * is what it looks like. + * + * Same daily-salt limit as the member-facing view, and the same consequence: + * the comparison is meaningful inside a day and meaningless across weeks. A + * moderator drawing a conclusion from two hashes a month apart would be + * drawing it from noise, so the page says so and the count below is scoped to + * one day rather than to the whole page. + * + * Moderator, not admin: this is triage, and it sits beside the anti-cheat queue + * those same people already work. + */ +import { desc, eq } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { uuidSchema, validateParam, validateQuery } from '~~/utils/schemas'; + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + +export default defineEventHandler(async (event) => { + await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const userId = validateParam(event, 'id', uuidSchema); + const { limit } = validateQuery(event, querySchema); + + const items = await db + .select({ + id: schema.loginEvents.id, + method: schema.loginEvents.method, + outcome: schema.loginEvents.outcome, + ipHash: schema.loginEvents.ipHash, + userAgent: schema.loginEvents.userAgent, + createdAt: schema.loginEvents.createdAt, + }) + .from(schema.loginEvents) + .where(eq(schema.loginEvents.userId, userId)) + .orderBy(desc(schema.loginEvents.createdAt)) + .limit(limit); + + // Distinct address hashes on the most recent day present — the number a + // moderator is actually after, computed here so the "same day only" rule + // lives in one place rather than in every reader's head. + const newest = items[0]?.createdAt; + const day = (d: Date) => d.toISOString().slice(0, 10); + const sameDay = newest ? items.filter((i) => day(i.createdAt) === day(newest)) : []; + const distinctAddressesToday = new Set( + sameDay.map((i) => i.ipHash).filter(Boolean) + ).size; + + return { items, distinctAddressesToday }; +}); diff --git a/apps/api/routes/api/privacy.get.ts b/apps/api/routes/api/privacy.get.ts index 3319fd6a..90672907 100644 --- a/apps/api/routes/api/privacy.get.ts +++ b/apps/api/routes/api/privacy.get.ts @@ -16,6 +16,7 @@ import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { getAuditRetentionDays, + getLoginEventRetentionDays, getDmRetentionDays, getMessagingDmScope, getMessagingRoomScope, @@ -35,6 +36,7 @@ export default defineEventHandler(async (event) => { notificationsReadDays, notificationsUnreadDays, auditDays, + loginDays, ] = await Promise.all([ getMessagingDmScope(), getMessagingRoomScope(), @@ -43,6 +45,7 @@ export default defineEventHandler(async (event) => { getNotificationsRetentionReadDays(), getNotificationsRetentionUnreadDays(), getAuditRetentionDays(), + getLoginEventRetentionDays(), ]); return { @@ -64,5 +67,11 @@ export default defineEventHandler(async (event) => { * `0` means kept indefinitely. */ staffAudit: { retentionDays: auditDays }, + /** + * The login history. A member's own record of where their account has been + * used from — and unlike the staff register above, it is about them rather + * than about the site, which is why it is kept for months and not a year. + */ + loginHistory: { retentionDays: loginDays }, }; }); diff --git a/apps/api/utils/account/loginLog.ts b/apps/api/utils/account/loginLog.ts new file mode 100644 index 00000000..b169bdf5 --- /dev/null +++ b/apps/api/utils/account/loginLog.ts @@ -0,0 +1,70 @@ +/** + * Recording how a session was opened, or refused. + * + * One helper called from the four places that authenticate, rather than four + * copies of an insert. Best-effort by contract: a login must never fail because + * its own log row did, so everything here is wrapped and swallowed. + * + * ## Why failures are recorded + * + * The failed attempt is the more useful half. There is no per-account lockout + * on this site — throttling is entirely per IP, so an attempt spread across + * addresses meets nothing at all — and this table is what makes such an attempt + * visible afterwards even though nothing stopped it at the time. + * + * ## The User-Agent is truncated + * + * At 200 characters. Long enough to tell a browser from a script, short enough + * that a hostile client cannot use the column as storage. + */ +import { randomUUID } from 'node:crypto'; +import type { H3Event } from 'h3'; +import { db, schema } from '@trackarr/db'; +import { hashIP } from '~~/utils/crypto'; +import { getClientIP } from '~~/utils/rateLimit'; + +export type LoginMethod = + | 'password' + | 'passkey' + | 'totp' + | 'recovery' + | 'trusted-device'; + +export type LoginOutcome = 'success' | 'failed'; + +const UA_MAX = 200; + +export async function recordLogin( + event: H3Event, + input: { + userId: string | null; + username: string; + method: LoginMethod; + outcome: LoginOutcome; + } +): Promise<void> { + try { + let ipHash: string | null = null; + try { + const ip = getClientIP(event); + ipHash = ip && ip !== 'unknown' ? hashIP(ip) : null; + } catch { + // An unresolvable address is not a reason to lose the event. + } + + await db.insert(schema.loginEvents).values({ + id: randomUUID(), + userId: input.userId, + username: input.username, + method: input.method, + outcome: input.outcome, + ipHash, + userAgent: (getHeader(event, 'user-agent') ?? '').slice(0, UA_MAX) || null, + }); + } catch (err) { + // Loud in the operator's log, invisible to the member: a login that + // succeeded and did not get a row is recoverable, a login that 500s + // because of its own bookkeeping is not. + console.error('[LoginLog] write failed:', (err as Error).message); + } +} diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts index d74501ce..e5437950 100644 --- a/apps/api/utils/settings.ts +++ b/apps/api/utils/settings.ts @@ -160,6 +160,8 @@ export const SETTINGS_KEYS = { * upload — one indexed query, not twenty thousand. */ SAVED_SEARCH_MAX_PER_USER: 'saved_search_max_per_user', + /** Days a login event is kept. 0 = forever. See the retention plugin. */ + LOGIN_EVENT_RETENTION_DAYS: 'login_event_retention_days', SITE_LOGO_IMAGE_SIZE: 'site_logo_image_size', SITE_FAVICON_SIZE: 'site_favicon_size', SITE_SUBTITLE: 'site_subtitle', @@ -411,6 +413,13 @@ export async function isLegacyPasskeyReadAllowed(): Promise<boolean> { return value !== 'false'; } +export async function getLoginEventRetentionDays(): Promise<number> { + const value = await getSetting(SETTINGS_KEYS.LOGIN_EVENT_RETENTION_DAYS); + const parsed = value ? parseInt(value, 10) : NaN; + if (!Number.isFinite(parsed) || parsed < 0) return 90; + return parsed; +} + export async function getSavedSearchMaxPerUser(): Promise<number> { const value = await getSetting(SETTINGS_KEYS.SAVED_SEARCH_MAX_PER_USER); const parsed = value ? parseInt(value, 10) : NaN; diff --git a/apps/web/app/pages/settings.vue b/apps/web/app/pages/settings.vue index 1beda402..69ca41f6 100644 --- a/apps/web/app/pages/settings.vue +++ b/apps/web/app/pages/settings.vue @@ -531,6 +531,64 @@ the action hands over a private key, and that is what the member needs to have in mind while doing it. --> <SettingsPortableIdentity /> + + <!-- Where this account has been used from. Loaded on demand: a + member opens this to answer one question, and paying for the + query on every settings visit would be paying for nothing. --> + <article class="action-card"> + <div class="action-card-body"> + <h3 class="action-card-title"> + <Icon name="ph:clock-counter-clockwise-bold" /> + {{ $t('settings.security.loginHistory') }} + </h3> + <p class="action-card-text"> + {{ $t('settings.security.loginHistoryHint') }} + </p> + </div> + <button + type="button" + class="btn-ghost" + :disabled="loginsLoading" + @click="toggleLogins" + > + <Icon + :name="loginsLoading ? 'ph:circle-notch' : loginsOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" + :class="{ 'animate-spin': loginsLoading }" + /> + {{ loginsOpen ? $t('settings.security.loginHistoryHide') : $t('settings.security.loginHistoryShow') }} + </button> + </article> + + <div v-if="loginsOpen" class="logins"> + <p v-if="!logins.length" class="logins-empty"> + {{ $t('settings.security.loginHistoryEmpty') }} + </p> + <table v-else class="logins-table"> + <thead> + <tr> + <th scope="col">{{ $t('settings.security.loginWhen') }}</th> + <th scope="col">{{ $t('settings.security.loginMethod') }}</th> + <th scope="col">{{ $t('settings.security.loginAddress') }}</th> + <th scope="col">{{ $t('settings.security.loginResult') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="l in logins" :key="l.id" :class="{ 'logins-row--failed': l.outcome !== 'success' }"> + <td class="logins-when"> + <time :datetime="l.createdAt">{{ loginStamp(l.createdAt) }}</time> + </td> + <td>{{ $t(`settings.security.loginMethods.${l.method}`) }}</td> + <td><code class="logins-hash">{{ l.ipHash || '—' }}</code></td> + <td> + <span :class="l.outcome === 'success' ? 'logins-ok' : 'logins-bad'"> + {{ $t(`settings.security.loginOutcomes.${l.outcome}`) }} + </span> + </td> + </tr> + </tbody> + </table> + <p class="logins-note">{{ $t('settings.security.loginHistoryNote') }}</p> + </div> </div> </section> @@ -1218,6 +1276,57 @@ async function signOut() { const deleteConfirm = ref(''); const deleting = ref(false); const deleteError = ref(''); +// ── Login history ─────────────────────────────────────────────── +interface LoginEvent { + id: string; + method: string; + outcome: string; + ipHash: string | null; + userAgent: string | null; + createdAt: string; +} + +const loginsOpen = ref(false); +const loginsLoading = ref(false); +const logins = ref<LoginEvent[]>([]); + +async function toggleLogins() { + if (loginsOpen.value) { + loginsOpen.value = false; + return; + } + if (logins.value.length === 0) { + loginsLoading.value = true; + try { + const res = await $fetch<{ items: LoginEvent[] }>('/api/me/logins'); + logins.value = res.items; + } catch { + // Leave it closed rather than replacing the section with an error: this + // is a curiosity, not something the member is blocked on. + loginsLoading.value = false; + return; + } finally { + loginsLoading.value = false; + } + } + loginsOpen.value = true; +} + +/** + * UTC and unambiguous, like the audit log's. A history read while worrying + * about a break-in is the wrong place for "3 days ago". + */ +function loginStamp(iso: string): string { + return new Date(iso).toLocaleString('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + }); +} + // ── Data export (GDPR Art. 15 / 20) ───────────────────────────── const exporting = ref(false); const exportError = ref(''); @@ -2284,4 +2393,56 @@ onBeforeRouteLeave((_to, _from, next) => { opacity: 0.45; cursor: default; } + +/* ── Login history ──────────────────────────────────────────────── + A ledger inside a settings page: tabular figures, hairline rules, no + decoration. It is read once, while worried. */ +.logins { + margin-top: 0.75rem; +} +.logins-empty, +.logins-note { + margin: 0; + font-size: 0.75rem; + line-height: 1.55; + color: rgb(var(--fg-subtle)); +} +.logins-note { + margin-top: 0.6rem; +} +.logins-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.logins-table th { + text-align: left; + padding: 0.4rem 0.5rem; + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); + border-bottom: 1px solid rgb(var(--line-default)); +} +.logins-table td { + padding: 0.4rem 0.5rem; + border-bottom: 1px solid rgb(var(--line-default)); +} +.logins-when { + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: rgb(var(--fg-subtle)); +} +.logins-hash { + font-size: 0.72rem; +} +.logins-ok { + color: rgb(var(--online)); +} +.logins-bad { + color: rgb(var(--danger)); +} +.logins-row--failed td:first-child { + box-shadow: inset 2px 0 0 rgb(var(--danger)); +} </style> diff --git a/apps/web/app/pages/users/[id].vue b/apps/web/app/pages/users/[id].vue index da4842f2..41b71bcc 100644 --- a/apps/web/app/pages/users/[id].vue +++ b/apps/web/app/pages/users/[id].vue @@ -280,6 +280,66 @@ </section> </template> + <!-- Sign-in history, staff only. Placed on the profile rather than in a + moderation console because this is where a moderator already is when + the question comes up: they are looking at the member they suspect. + Loaded on demand — most profile visits are not investigations. --> + <section v-if="user && viewerIsStaff" class="section section--logins"> + <header class="section-head"> + <span class="section-head-mark" aria-hidden="true">§</span> + <h2 class="section-head-title">{{ $t('users.logins.title') }}</h2> + <span class="section-head-line" aria-hidden="true" /> + </header> + + <div class="logins-head"> + <p class="logins-lede">{{ $t('users.logins.lede') }}</p> + <button type="button" class="btn btn-secondary btn-sm" :disabled="loginsLoading" @click="toggleLogins"> + <Icon + :name="loginsLoading ? 'ph:circle-notch' : loginsOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" + :class="{ 'animate-spin': loginsLoading }" + /> + {{ loginsOpen ? $t('users.logins.hide') : $t('users.logins.show') }} + </button> + </div> + + <template v-if="loginsOpen"> + <p + v-if="distinctToday > 1" + class="logins-flag" + > + <Icon name="ph:warning-bold" /> + {{ $t('users.logins.multipleToday', { count: distinctToday }) }} + </p> + <p v-if="!logins.length" class="logins-lede">{{ $t('users.logins.empty') }}</p> + <table v-else class="logins-table"> + <thead> + <tr> + <th scope="col">{{ $t('users.logins.when') }}</th> + <th scope="col">{{ $t('users.logins.method') }}</th> + <th scope="col">{{ $t('users.logins.address') }}</th> + <th scope="col">{{ $t('users.logins.agent') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="l in logins" :key="l.id"> + <td class="logins-when"> + <time :datetime="l.createdAt">{{ loginStamp(l.createdAt) }}</time> + </td> + <td> + {{ l.method }} + <span v-if="l.outcome !== 'success'" class="logins-bad"> + · {{ $t('users.logins.refused') }} + </span> + </td> + <td><code class="logins-hash">{{ l.ipHash || '—' }}</code></td> + <td class="logins-agent">{{ l.userAgent || '—' }}</td> + </tr> + </tbody> + </table> + <p class="logins-note">{{ $t('users.logins.note') }}</p> + </template> + </section> + <!-- Report modal — opens from the tear-off tab on the hero. Teleports to body, so its position in the tree is purely organisational. --> @@ -353,6 +413,61 @@ const route = useRoute(); const userId = computed(() => route.params.id as string); const { loggedIn, user: viewer } = useUserSession(); +// ── Sign-in history (staff) ───────────────────────────────────── +interface LoginEvent { + id: string; + method: string; + outcome: string; + ipHash: string | null; + userAgent: string | null; + createdAt: string; +} + +const viewerIsStaff = computed( + () => !!viewer.value && (viewer.value.isAdmin || viewer.value.isModerator) +); +const loginsOpen = ref(false); +const loginsLoading = ref(false); +const logins = ref<LoginEvent[]>([]); +const distinctToday = ref(0); + +async function toggleLogins() { + if (loginsOpen.value) { + loginsOpen.value = false; + return; + } + if (logins.value.length === 0 && user.value) { + loginsLoading.value = true; + try { + const url: string = `/api/mod/users/${user.value.id}/logins`; + const res = await $fetch<{ + items: LoginEvent[]; + distinctAddressesToday: number; + }>(url); + logins.value = res.items; + distinctToday.value = res.distinctAddressesToday; + } catch { + loginsLoading.value = false; + return; + } finally { + loginsLoading.value = false; + } + } + loginsOpen.value = true; +} + +/** UTC, unambiguous — the same stamp the audit log uses, for the same reason. */ +function loginStamp(iso: string): string { + return new Date(iso).toLocaleString('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + }); +} + // Fetch user profile const { data: user, @@ -1634,4 +1749,77 @@ useHead({ } .upload-arrow { display: none; } } + +/* ── Sign-in history (staff) ──────────────────────────────────────── */ +.logins-head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.logins-lede, +.logins-note { + margin: 0; + max-width: 60ch; + font-size: 0.8125rem; + line-height: 1.55; + color: rgb(var(--fg-muted)); +} +.logins-note { + margin-top: 0.6rem; + font-size: 0.72rem; + color: rgb(var(--fg-subtle)); +} +.logins-flag { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0 0 0.75rem; + padding: 0.55rem 0.75rem; + border: 1px solid rgb(var(--warning) / 0.4); + border-radius: 0.4rem; + background: rgb(var(--warning) / 0.08); + font-size: 0.8125rem; + color: rgb(var(--fg-muted)); +} +.logins-flag svg { + color: rgb(var(--warning)); +} +.logins-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.logins-table th { + text-align: left; + padding: 0.4rem 0.5rem; + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); + border-bottom: 1px solid rgb(var(--line-default)); +} +.logins-table td { + padding: 0.4rem 0.5rem; + border-bottom: 1px solid rgb(var(--line-default)); + vertical-align: top; +} +.logins-when { + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: rgb(var(--fg-subtle)); +} +.logins-hash { + font-size: 0.72rem; +} +.logins-agent { + max-width: 24rem; + overflow-wrap: anywhere; + color: rgb(var(--fg-subtle)); +} +.logins-bad { + color: rgb(var(--danger)); +} </style> diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 93869d60..4e33689d 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -538,7 +538,28 @@ "errors": { "samePassword": "New password must differ from the current one", "updatePassword": "Failed to update password" - } + }, + "loginHistory": "Login history", + "loginHistoryHint": "Where this account has been signed in from, including attempts that failed.", + "loginHistoryShow": "Show", + "loginHistoryEmpty": "No sign-ins recorded yet.", + "loginHistoryNote": "Addresses are hashed with a salt that changes daily, so two rows can only be compared within the same day. Times are UTC.", + "loginWhen": "When (UTC)", + "loginMethod": "Method", + "loginAddress": "Address", + "loginResult": "Result", + "loginMethods": { + "password": "Password", + "passkey": "Passkey", + "totp": "Authenticator", + "recovery": "Recovery code", + "trusted-device": "Trusted device" + }, + "loginOutcomes": { + "success": "Signed in", + "failed": "Refused" + }, + "loginHistoryHide": "Hide" }, "account": { "userId": "User ID", @@ -970,6 +991,20 @@ } }, "uploadsAnonymous": "This member publishes anonymously — their releases are not listed here." + }, + "logins": { + "title": "Sign-in history", + "lede": "Where this account has been signed in from. Visible to staff only.", + "show": "Show", + "hide": "Hide", + "empty": "No sign-ins recorded for this account.", + "when": "When (UTC)", + "method": "Method", + "address": "Address", + "agent": "Client", + "refused": "refused", + "multipleToday": "{count} different addresses on the most recent day.", + "note": "Addresses are hashed with a salt that changes daily, so they can only be compared within the same day — two different hashes a week apart may well be the same address." } }, "following": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 46a1cfef..5bfb871e 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -538,7 +538,28 @@ "errors": { "samePassword": "Le nouveau mot de passe doit être différent de l'actuel", "updatePassword": "Échec de la mise à jour du mot de passe" - } + }, + "loginHistory": "Historique de connexion", + "loginHistoryHint": "D'où ce compte s'est connecté, tentatives échouées comprises.", + "loginHistoryShow": "Afficher", + "loginHistoryEmpty": "Aucune connexion enregistrée pour le moment.", + "loginHistoryNote": "Les adresses sont hachées avec un sel qui change chaque jour : deux lignes ne sont comparables qu'au sein d'une même journée. Les heures sont en UTC.", + "loginWhen": "Quand (UTC)", + "loginMethod": "Méthode", + "loginAddress": "Adresse", + "loginResult": "Résultat", + "loginMethods": { + "password": "Mot de passe", + "passkey": "Clé d’accès", + "totp": "Authentificateur", + "recovery": "Code de secours", + "trusted-device": "Appareil de confiance" + }, + "loginOutcomes": { + "success": "Connexion", + "failed": "Refusée" + }, + "loginHistoryHide": "Masquer" }, "account": { "userId": "ID utilisateur", @@ -970,6 +991,20 @@ } }, "uploadsAnonymous": "Ce membre publie anonymement — ses publications ne sont pas listées ici." + }, + "logins": { + "title": "Historique de connexion", + "lede": "D'où ce compte s'est connecté. Visible par le personnel uniquement.", + "show": "Afficher", + "hide": "Masquer", + "empty": "Aucune connexion enregistrée pour ce compte.", + "when": "Quand (UTC)", + "method": "Méthode", + "address": "Adresse", + "agent": "Client", + "refused": "refusée", + "multipleToday": "{count} adresses différentes sur la journée la plus récente.", + "note": "Les adresses sont hachées avec un sel qui change chaque jour : elles ne sont comparables qu'au sein d'une même journée — deux hachés différents à une semaine d'intervalle peuvent très bien être la même adresse." } }, "following": { diff --git a/doc/guide/security.md b/doc/guide/security.md index 51e81f37..05abd8a0 100644 --- a/doc/guide/security.md +++ b/doc/guide/security.md @@ -46,3 +46,41 @@ The system automatically detects and blocks: - [Zero-Knowledge Authentication](/guide/zero-knowledge-auth) — How passwords are never transmitted - [Panic Mode](/guide/panic-mode) — Emergency data encryption + +--- + +## Login history + +Every attempt to open a session is recorded — successful or not — with the +method that was used, the browser, and a hash of the address. + +Members read their own at **Settings → Security → Login history**. Staff read +any member's from that member's profile page. + +### Failures are the interesting half + +There is **no per-account lockout** on this site. Throttling is entirely per IP, +so an attempt spread across addresses meets nothing at all. This table is what +makes such an attempt visible afterwards even though nothing stopped it at the +time — a run of refusals against one account, from addresses that differ, is a +shape worth recognising. + +### What the address hash can and cannot tell you + +It goes through the same daily-rotating salt as every other IP in this system, +which means **two rows are comparable only within the same day**. Two different +hashes a week apart may well be the same address. + +That is enough for the question staff actually ask — "is this account being used +from several places right now", the shape of account sharing on an invite-only +tracker — and not enough for a long-range history. Both views say so rather than +letting a reader draw a conclusion from noise. The moderator view computes the +count of distinct addresses **on the most recent day only**, for the same +reason. + +### Retention + +`login_event_retention_days` — **default 90**, `0` keeps them indefinitely. +Shorter than the [audit log](./audit-log.md)'s year because this is a +high-volume table and the questions it answers are about the recent past. The +period is published on the public `/privacy` page. diff --git a/packages/db/src/migrations/0064_login_events.sql b/packages/db/src/migrations/0064_login_events.sql new file mode 100644 index 00000000..5c84d913 --- /dev/null +++ b/packages/db/src/migrations/0064_login_events.sql @@ -0,0 +1,14 @@ +CREATE TABLE "login_events" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text, + "username" text NOT NULL, + "method" text NOT NULL, + "outcome" text NOT NULL, + "ip_hash" text, + "user_agent" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "login_events" ADD CONSTRAINT "login_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "login_events_user_idx" ON "login_events" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "login_events_created_idx" ON "login_events" USING btree ("created_at" DESC NULLS LAST); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0064_snapshot.json b/packages/db/src/migrations/meta/0064_snapshot.json new file mode 100644 index 00000000..1b8ad05e --- /dev/null +++ b/packages/db/src/migrations/meta/0064_snapshot.json @@ -0,0 +1,10118 @@ +{ + "id": "7eac2820-efa0-49aa-88cb-70b83f2a81b9", + "prevId": "de3bb7f2-2043-4d52-a41d-a7b120bb525e", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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.login_events": { + "name": "login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_hash": { + "name": "ip_hash", + "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()" + } + }, + "indexes": { + "login_events_user_idx": { + "name": "login_events_user_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": {} + }, + "login_events_created_idx": { + "name": "login_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_events_user_id_users_id_fk": { + "name": "login_events_user_id_users_id_fk", + "tableFrom": "login_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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 c32e862f..b7c9f55b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -449,6 +449,13 @@ "when": 1788217997937, "tag": "0063_saved_searches", "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1788218521458, + "tag": "0064_login_events", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 2cbe2a3d..903137d2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2732,6 +2732,66 @@ export const savedSearchesRelations = relations(savedSearches, ({ one }) => ({ export type SavedSearch = typeof savedSearches.$inferSelect; export type NewSavedSearch = typeof savedSearches.$inferInsert; +// ============================================================================ +// Login events — where an account has been used from +// ============================================================================ +// +// Two audiences, one table. A member asking "was that me?" after an unexpected +// notification, and a moderator asking whether an account is being shared — +// which on an invite-only tracker is the offence that matters most and the one +// there was no evidence for. +// +// ## Failures are rows too +// +// A failed attempt is the more interesting half. The site has no per-account +// lockout — throttling is entirely per IP, so a distributed attempt against one +// account meets nothing — and this is what makes such an attempt visible after +// the fact even though nothing blocked it at the time. +// +// ## The address is hashed, and only comparable within a day +// +// Same daily-rotating salt as everywhere else. So "two logins from the same +// place" is answerable inside one day and not across weeks. That is a real +// limit and the UI says so rather than letting a reader assume otherwise. +// +// Worth stating plainly because the README's "no raw IP is persisted" is not +// quite true today: `users.lastIp` holds one, for the IP-ban check. This table +// does not add a second exception. +export const loginEvents = pgTable( + 'login_events', + { + id: text('id').primaryKey(), + /** + * Null once the account is erased — the event survives, since it is part + * of the record of an account's use, but it stops pointing anywhere. + */ + userId: text('user_id').references(() => users.id, { onDelete: 'set null' }), + /** + * Denormalised for the same reason the audit log denormalises its actor: + * the row describes something that happened at a moment, and a later + * rename must not rewrite it. + */ + username: text('username').notNull(), + /** `password` | `passkey` | `totp` | `recovery` | `trusted-device` */ + method: text('method').notNull(), + /** `success` | `failed` */ + outcome: text('outcome').notNull(), + ipHash: text('ip_hash'), + /** Truncated: a User-Agent is long, and only its shape is of any use. */ + userAgent: text('user_agent'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + // "Where has my account been used from" and the staff equivalent. + index('login_events_user_idx').on(table.userId, table.createdAt.desc()), + // The retention sweep, and an operator scanning recent failures site-wide. + index('login_events_created_idx').on(table.createdAt.desc()), + ] +); + +export type LoginEvent = typeof loginEvents.$inferSelect; +export type NewLoginEvent = typeof loginEvents.$inferInsert; + export type AuditLogEntry = typeof auditLog.$inferSelect; export type NewAuditLogEntry = typeof auditLog.$inferInsert; From 1965a23cfe57316869cc4f56dc96a2924c0c4bcf Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 10:29:39 +0200 Subject: [PATCH 13/96] feat(admin): who vouched for them, and who they let in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account gets banned for cheating and the first question is who vouched for it, because whoever did is either careless or complicit and their other invitees are worth a look. Every tracker that has run on invites for two decades has this page. The data was already here — `invitations.created_by` and `invitations.used_by` — and the two pages that read it each rendered exactly one generation. So this is not new bookkeeping, it is a walk over records the site has kept all along. Two directions, two shapes. Upwards is a chain, nearest generation first, so "who is behind this member" reads top to bottom. Downwards is a tree indented by generation. One recursive CTE each, both bounded. The bounds are the design, not a safety net bolted on: ten generations, four hundred members total. A prolific inviter three generations down is thousands of rows, and an unbounded recursive walk over a social graph is a query nobody intended to write. The page says when it truncated, which matters more here than in most listings — a genealogy that silently stops reads as a chain that ends, and a moderator would draw exactly the wrong conclusion from it. Same reason the two ways a chain can end are told apart. `root` means nobody invited this member: they are the first account, or they registered while registration was open. Those two are indistinguishable in the data, and printing `root` rather than an empty list is what stops a reader assuming the record is broken. A depth limit is labelled as a depth limit. An erased account renders as a tombstone rather than a link. Erasure scrubs the username and leaves the invitation rows intact — the edges survive perfectly, which is what a genealogy needs, and the name behind them is gone, which is what erasure promised. Neither half is negotiable, so the page shows the shape and not the person. Bans are marked, since a cluster of them under one inviter is the entire point of looking. Admin only, and audited by the hook like every other staff route. This is the social graph of the whole site in one view; moderators get the one-generation view on a profile, which is what a moderator's question actually needs. One partial index, on `used_by where not null`. The upward walk hits that column once per generation and most invitation rows are unredeemed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + apps/api/routes/api/admin/invites/tree.get.ts | 196 + apps/web/app/components/admin/InviteTree.vue | 272 + .../app/components/admin/InviteTreeNode.vue | 59 + apps/web/app/composables/useAdminNav.ts | 6 + apps/web/app/pages/admin/invite-tree.vue | 7 + apps/web/i18n/locales/en.json | 21 +- apps/web/i18n/locales/fr.json | 21 +- doc/guide/invitations.md | 42 + .../src/migrations/0065_invite_tree_index.sql | 1 + .../db/src/migrations/meta/0065_snapshot.json | 10134 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 6 + 13 files changed, 10769 insertions(+), 4 deletions(-) create mode 100644 apps/api/routes/api/admin/invites/tree.get.ts create mode 100644 apps/web/app/components/admin/InviteTree.vue create mode 100644 apps/web/app/components/admin/InviteTreeNode.vue create mode 100644 apps/web/app/pages/admin/invite-tree.vue create mode 100644 packages/db/src/migrations/0065_invite_tree_index.sql create mode 100644 packages/db/src/migrations/meta/0065_snapshot.json diff --git a/README.md b/README.md index 2b5142ac..41845b32 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre - **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). - **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. - **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/routes/api/admin/invites/tree.get.ts b/apps/api/routes/api/admin/invites/tree.get.ts new file mode 100644 index 00000000..67d6e397 --- /dev/null +++ b/apps/api/routes/api/admin/invites/tree.get.ts @@ -0,0 +1,196 @@ +/** + * GET /api/admin/invites/tree?userId=… + * + * Who invited this member, and who they invited in turn. + * + * The procedure this exists for is standard across the trackers that have had + * it for decades: an account is banned for cheating, and the first question is + * who vouched for them — because whoever did is either careless or complicit, + * and their other invitees are worth a look. The data has always been here; + * both pages that read it only ever rendered one generation. + * + * ## Shape + * + * Two walks, both bounded: + * + * - **Ancestors**, one row per generation, up to `MAX_DEPTH`. The chain ends + * at a member nobody invited — which on this site means either the first + * account or somebody who registered while registration was open. Those + * are indistinguishable from missing data, and the response says which + * ending it hit rather than letting the reader guess. + * - **Descendants**, breadth-first, capped on both depth and total nodes. A + * prolific inviter three generations down is a lot of rows, and an + * unbounded recursive CTE against a social graph is a query nobody meant + * to write. + * + * ## Erased accounts + * + * An erasure scrubs the username to a tombstone and leaves every invitation row + * intact, so the EDGES survive an erased inviter perfectly — which is exactly + * what a genealogy needs. The node is flagged so the page can draw a tombstone + * rather than a clickable stranger. + */ +import { and, eq, inArray, isNotNull } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireAdminSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; + +/** How far up, and how far down. */ +const MAX_DEPTH = 10; +/** And how many nodes in total, whatever shape the tree turns out to be. */ +const MAX_NODES = 400; + +const querySchema = z.object({ + userId: z.string().uuid(), +}); + +interface Node { + id: string; + username: string; + isBanned: boolean; + /** True when the account has been erased — render a tombstone, not a link. */ + erased: boolean; + createdAt: Date; + /** When this member used their invite. Null for a genealogy root. */ + invitedAt: Date | null; + depth: number; + children?: Node[]; +} + +const USER_COLUMNS = { + id: schema.users.id, + username: schema.users.username, + isBanned: schema.users.isBanned, + deletedAt: schema.users.deletedAt, + createdAt: schema.users.createdAt, +}; + +function toNode( + row: { + id: string; + username: string; + isBanned: boolean; + deletedAt: Date | null; + createdAt: Date; + }, + depth: number, + invitedAt: Date | null +): Node { + return { + id: row.id, + username: row.username, + isBanned: row.isBanned, + erased: row.deletedAt !== null, + createdAt: row.createdAt, + invitedAt, + depth, + }; +} + +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { userId } = validateQuery(event, querySchema); + + const [subject] = await db.select(USER_COLUMNS).from(schema.users).where(eq(schema.users.id, userId)).limit(1); + if (!subject) { + throw createError({ statusCode: 404, message: 'User not found' }); + } + + // ── Up ───────────────────────────────────────────────────────────── + const ancestors: Node[] = []; + let cursor = userId; + let truncatedUp = false; + const seen = new Set<string>([userId]); + + for (let depth = 1; depth <= MAX_DEPTH; depth++) { + const [invite] = await db + .select({ createdBy: schema.invitations.createdBy, usedAt: schema.invitations.usedAt }) + .from(schema.invitations) + .where(eq(schema.invitations.usedBy, cursor)) + .limit(1); + if (!invite) break; + + // A cycle is impossible through registration, but a restored backup or a + // hand-edited row could produce one, and an unguarded walk would spin. + if (seen.has(invite.createdBy)) break; + seen.add(invite.createdBy); + + const [row] = await db + .select(USER_COLUMNS) + .from(schema.users) + .where(eq(schema.users.id, invite.createdBy)) + .limit(1); + if (!row) break; + + ancestors.push(toNode(row, depth, invite.usedAt)); + cursor = row.id; + + if (depth === MAX_DEPTH) truncatedUp = true; + } + + // ── Down ─────────────────────────────────────────────────────────── + const root = toNode(subject, 0, null); + let nodeCount = 1; + let truncatedDown = false; + + let frontier: Node[] = [root]; + for (let depth = 1; depth <= MAX_DEPTH && frontier.length > 0; depth++) { + const parentIds = frontier.map((n) => n.id); + const invites = await db + .select({ + createdBy: schema.invitations.createdBy, + usedBy: schema.invitations.usedBy, + usedAt: schema.invitations.usedAt, + }) + .from(schema.invitations) + .where( + and(inArray(schema.invitations.createdBy, parentIds), isNotNull(schema.invitations.usedBy)) + ); + if (invites.length === 0) break; + + const childIds = [...new Set(invites.map((i) => i.usedBy!))]; + const rows = await db.select(USER_COLUMNS).from(schema.users).where(inArray(schema.users.id, childIds)); + const rowById = new Map(rows.map((r) => [r.id, r])); + const byParent = new Map(frontier.map((n) => [n.id, n])); + + const next: Node[] = []; + for (const invite of invites) { + if (nodeCount >= MAX_NODES) { + truncatedDown = true; + break; + } + const row = rowById.get(invite.usedBy!); + const parent = byParent.get(invite.createdBy); + if (!row || !parent) continue; + + const node = toNode(row, depth, invite.usedAt); + (parent.children ??= []).push(node); + next.push(node); + nodeCount++; + } + if (truncatedDown) break; + frontier = next; + if (depth === MAX_DEPTH && next.length > 0) truncatedDown = true; + } + + return { + subject: root, + /** Nearest first: `ancestors[0]` is who invited the subject. */ + ancestors, + /** + * Why the upward walk stopped. `root` means nobody invited them — the + * first account, or a member who registered while registration was open. + * Those two are indistinguishable in the data, and saying "root" rather + * than showing an empty list is what keeps a reader from assuming the + * record is incomplete. + */ + ancestorsEnd: truncatedUp ? 'depth-limit' : 'root', + truncated: truncatedUp || truncatedDown, + limits: { maxDepth: MAX_DEPTH, maxNodes: MAX_NODES }, + nodeCount, + }; +}); diff --git a/apps/web/app/components/admin/InviteTree.vue b/apps/web/app/components/admin/InviteTree.vue new file mode 100644 index 00000000..da76d388 --- /dev/null +++ b/apps/web/app/components/admin/InviteTree.vue @@ -0,0 +1,272 @@ +<template> + <div class="tree"> + <div class="tree-search"> + <label class="tree-field"> + <span class="field-label">{{ $t('admin.inviteTree.lookup') }}</span> + <input + v-model="term" + type="search" + class="input" + autocomplete="off" + spellcheck="false" + :placeholder="$t('admin.inviteTree.lookupPlaceholder')" + @keydown.enter.prevent="search" + /> + </label> + <button type="button" class="btn btn-secondary" :disabled="!term.trim() || busy" @click="search"> + <Icon :name="busy ? 'ph:circle-notch' : 'ph:magnifying-glass-bold'" :class="{ 'animate-spin': busy }" /> + {{ $t('admin.inviteTree.search') }} + </button> + </div> + + <ul v-if="candidates.length > 1" class="tree-candidates"> + <li v-for="c in candidates" :key="c.id"> + <button type="button" class="tree-candidate" @click="load(c.id)"> + @{{ c.username }} + </button> + </li> + </ul> + + <p v-if="error" class="tree-error">{{ error }}</p> + + <template v-if="data"> + <!-- Upwards: who vouched for them, nearest first. The chain reads from + the subject outwards, because that is the direction the question is + asked in. --> + <section class="tree-block"> + <h3 class="tree-block-title">{{ $t('admin.inviteTree.invitedBy') }}</h3> + <p v-if="!data.ancestors.length" class="tree-note"> + {{ + data.ancestorsEnd === 'root' + ? $t('admin.inviteTree.noInviter') + : $t('admin.inviteTree.depthLimit') + }} + </p> + <ol v-else class="tree-chain"> + <li v-for="a in data.ancestors" :key="a.id" class="tree-chain-item"> + <component + :is="a.erased ? 'span' : 'NuxtLink'" + :to="a.erased ? undefined : `/users/${a.id}`" + class="tree-node" + :class="nodeClass(a)" + > + <Icon :name="nodeIcon(a)" /> + <span class="tree-name">{{ a.erased ? $t('admin.inviteTree.erased') : a.username }}</span> + <span class="tree-depth">{{ $t('admin.inviteTree.generation', { n: a.depth }) }}</span> + </component> + </li> + </ol> + </section> + + <!-- Downwards. --> + <section class="tree-block"> + <h3 class="tree-block-title"> + {{ $t('admin.inviteTree.invited', { name: data.subject.username }) }} + </h3> + <p v-if="!data.subject.children?.length" class="tree-note"> + {{ $t('admin.inviteTree.noInvitees') }} + </p> + <AdminInviteTreeNode + v-for="child in data.subject.children ?? []" + v-else + :key="child.id" + :node="child" + /> + <p v-if="data.truncated" class="tree-note tree-note--warn"> + <Icon name="ph:warning-bold" /> + {{ $t('admin.inviteTree.truncated', { nodes: data.limits.maxNodes, depth: data.limits.maxDepth }) }} + </p> + </section> + </template> + </div> +</template> + +<script setup lang="ts"> +/** + * The invitation genealogy. + * + * Two directions from one member: who vouched for them, and who they let in. + * The procedure it serves is the one every tracker runs after a ban — whoever + * invited a cheat is either careless or complicit, and their other invitees + * are worth a look. + * + * An erased account renders as a tombstone rather than a link: the edges + * survive an erasure intact (which is what makes the tree usable at all), but + * the name behind them is gone and clicking through would show a stranger. + */ +interface TreeNode { + id: string; + username: string; + isBanned: boolean; + erased: boolean; + createdAt: string; + invitedAt: string | null; + depth: number; + children?: TreeNode[]; +} + +interface TreePayload { + subject: TreeNode; + ancestors: TreeNode[]; + ancestorsEnd: 'root' | 'depth-limit'; + truncated: boolean; + limits: { maxDepth: number; maxNodes: number }; + nodeCount: number; +} + +const { t } = useI18n(); + +const term = ref(''); +const busy = ref(false); +const error = ref(''); +const data = ref<TreePayload | null>(null); +const candidates = ref<Array<{ id: string; username: string }>>([]); + +async function search() { + const q = term.value.trim(); + if (!q) return; + busy.value = true; + error.value = ''; + candidates.value = []; + try { + // Reuse the admin user search rather than adding a second one — the two + // would answer "who is this" differently the day one of them changes. + const res = await $fetch<{ + items: Array<{ id: string; username: string }>; + }>('/api/admin/users', { query: { search: q, pageSize: 10 } }); + const users = res.items ?? []; + if (users.length === 0) { + error.value = t('admin.inviteTree.notFound'); + } else if (users.length === 1) { + await load(users[0]!.id); + } else { + candidates.value = users; + } + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + error.value = e?.data?.message || e?.message || t('admin.inviteTree.failed'); + } finally { + busy.value = false; + } +} + +async function load(userId: string) { + busy.value = true; + error.value = ''; + candidates.value = []; + try { + data.value = await $fetch<TreePayload>('/api/admin/invites/tree', { + query: { userId }, + }); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + error.value = e?.data?.message || e?.message || t('admin.inviteTree.failed'); + } finally { + busy.value = false; + } +} + +function nodeClass(n: TreeNode) { + if (n.erased) return 'tree-node--erased'; + if (n.isBanned) return 'tree-node--banned'; + return ''; +} +function nodeIcon(n: TreeNode) { + if (n.erased) return 'ph:user-minus'; + if (n.isBanned) return 'ph:prohibit-bold'; + return 'ph:user'; +} +</script> + +<style scoped> +.tree { + display: flex; + flex-direction: column; + gap: 1.5rem; +} +.tree-search { + display: flex; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: wrap; +} +.tree-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + flex: 1 1 20rem; +} +.tree-candidates { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + list-style: none; + margin: 0; + padding: 0; +} +.tree-candidate { + padding: 0.2rem 0.5rem; + border: 1px solid rgb(var(--line-default)); + border-radius: 0.25rem; + font-size: 0.78rem; + background: none; + color: rgb(var(--fg-default)); + cursor: pointer; +} +.tree-candidate:hover { + border-color: rgb(var(--accent)); + color: rgb(var(--accent)); +} +.tree-error { + margin: 0; + font-size: 0.82rem; + color: rgb(var(--danger)); +} +.tree-block-title { + margin: 0 0 0.6rem; + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.tree-note { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-size: 0.8125rem; + color: rgb(var(--fg-subtle)); +} +.tree-note--warn { + margin-top: 0.75rem; + color: rgb(var(--warning)); +} +.tree-chain { + list-style: none; + margin: 0; + padding: 0; +} +/* Each generation steps right, so the chain reads as a chain rather than as a + list of unrelated names. */ +.tree-chain-item { + padding-left: calc(1.1rem * (var(--i, 0))); +} +.tree-node { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0; + font-size: 0.85rem; +} +.tree-node--banned { + color: rgb(var(--danger)); +} +.tree-node--erased { + color: rgb(var(--fg-subtle)); + font-style: italic; +} +.tree-depth { + font-size: 0.7rem; + color: rgb(var(--fg-subtle)); +} +</style> diff --git a/apps/web/app/components/admin/InviteTreeNode.vue b/apps/web/app/components/admin/InviteTreeNode.vue new file mode 100644 index 00000000..ec020c0a --- /dev/null +++ b/apps/web/app/components/admin/InviteTreeNode.vue @@ -0,0 +1,59 @@ +<template> + <div class="node-row" :style="`--depth: ${node.depth}`"> + <component + :is="node.erased ? 'span' : 'NuxtLink'" + :to="node.erased ? undefined : `/users/${node.id}`" + class="node" + :class="{ 'node--banned': node.isBanned, 'node--erased': node.erased }" + > + <Icon :name="node.erased ? 'ph:user-minus' : node.isBanned ? 'ph:prohibit-bold' : 'ph:user'" /> + <span>{{ node.erased ? $t('admin.inviteTree.erased') : node.username }}</span> + </component> + </div> + <AdminInviteTreeNode v-for="child in node.children ?? []" :key="child.id" :node="child" /> +</template> + +<script setup lang="ts"> +/** + * One member in the genealogy, plus its subtree. + * + * Recursive by name — the component renders itself for each child. The server + * caps depth and total nodes, so the recursion is bounded by the payload + * rather than by anything here. + * + * Indentation is a CSS custom property driven by the server's own `depth` + * rather than by nesting the markup, so a deep tree does not become a deep DOM. + */ +interface TreeNode { + id: string; + username: string; + isBanned: boolean; + erased: boolean; + depth: number; + children?: TreeNode[]; +} + +defineProps<{ node: TreeNode }>(); +</script> + +<style scoped> +.node-row { + padding-left: calc(1.1rem * var(--depth, 0)); + border-left: 1px solid rgb(var(--line-default)); + margin-left: 0.25rem; +} +.node { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.2rem 0; + font-size: 0.85rem; +} +.node--banned { + color: rgb(var(--danger)); +} +.node--erased { + color: rgb(var(--fg-subtle)); + font-style: italic; +} +</style> diff --git a/apps/web/app/composables/useAdminNav.ts b/apps/web/app/composables/useAdminNav.ts index affafcba..b7ac93d6 100644 --- a/apps/web/app/composables/useAdminNav.ts +++ b/apps/web/app/composables/useAdminNav.ts @@ -118,6 +118,12 @@ export function useAdminNav() { icon: 'ph:scroll', description: t('admin.descriptions.audit'), }, + { + label: t('admin.nav.inviteTree'), + path: '/admin/invite-tree', + icon: 'ph:tree-structure', + description: t('admin.descriptions.inviteTree'), + }, ], }, { diff --git a/apps/web/app/pages/admin/invite-tree.vue b/apps/web/app/pages/admin/invite-tree.vue new file mode 100644 index 00000000..5e622673 --- /dev/null +++ b/apps/web/app/pages/admin/invite-tree.vue @@ -0,0 +1,7 @@ +<template> + <AdminInviteTree /> +</template> + +<script setup lang="ts"> +definePageMeta({ middleware: 'admin' }); +</script> diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 4e33689d..49177905 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -1722,7 +1722,8 @@ "filterPlaceholder": "Filter sections…", "noMatch": "No section matches.", "closeMenu": "Close the admin menu", - "audit": "Audit log" + "audit": "Audit log", + "inviteTree": "Invite tree" }, "descriptions": { "dashboard": "Tracker configuration and node management", @@ -1745,7 +1746,8 @@ "federation": "Link this tracker with trusted Trackarr instances", "federationTaxonomy": "Map partners' category slugs onto your own categories", "listingTemplates": "Curate the listing layouts every member can pick in the upload wizard.", - "audit": "Every change made from the staff console — who, what, when." + "audit": "Every change made from the staff console — who, what, when.", + "inviteTree": "Who vouched for a member, and who they let in." }, "taxonomy": { "title": "Federated taxonomy", @@ -3844,6 +3846,21 @@ "failed": "Could not save the retention period.", "forever": "Entries are currently kept indefinitely." } + }, + "inviteTree": { + "lookup": "Member", + "lookupPlaceholder": "Username…", + "search": "Look up", + "notFound": "No member matches that.", + "failed": "Could not load the invite tree.", + "invitedBy": "Invited by", + "invited": "Invited by {name}", + "noInviter": "Nobody invited this member — they are the first account, or they registered while registration was open.", + "depthLimit": "The chain goes further up than this view walks.", + "noInvitees": "This member has not brought anybody in.", + "generation": "generation {n}", + "erased": "erased account", + "truncated": "Truncated at {nodes} members or {depth} generations — the rest is not shown." } }, "mod": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 5bfb871e..c4991711 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -1722,7 +1722,8 @@ "filterPlaceholder": "Filtrer les sections…", "noMatch": "Aucune section ne correspond.", "closeMenu": "Fermer le menu d'administration", - "audit": "Journal d'audit" + "audit": "Journal d'audit", + "inviteTree": "Arbre d'invitations" }, "descriptions": { "dashboard": "Configuration du tracker et gestion des nœuds", @@ -1745,7 +1746,8 @@ "federation": "Relier ce tracker à des instances Trackarr de confiance", "federationTaxonomy": "Faire correspondre les slugs de catégorie des partenaires à vos propres catégories", "listingTemplates": "Choisir les mises en page de fiche proposées à tous les membres dans l'assistant d'upload.", - "audit": "Chaque changement fait depuis la console du personnel — qui, quoi, quand." + "audit": "Chaque changement fait depuis la console du personnel — qui, quoi, quand.", + "inviteTree": "Qui s'est porté garant d'un membre, et qui ce membre a fait entrer." }, "taxonomy": { "title": "Taxonomie fédérée", @@ -3844,6 +3846,21 @@ "failed": "La durée de conservation n'a pas pu être enregistrée.", "forever": "Les entrées sont actuellement conservées sans limite de durée." } + }, + "inviteTree": { + "lookup": "Membre", + "lookupPlaceholder": "Nom d'utilisateur…", + "search": "Rechercher", + "notFound": "Aucun membre ne correspond.", + "failed": "L'arbre d'invitations n'a pas pu être chargé.", + "invitedBy": "Invité par", + "invited": "Invités par {name}", + "noInviter": "Personne n'a invité ce membre : c'est le premier compte, ou il s'est inscrit pendant une période d'inscription ouverte.", + "depthLimit": "La chaîne remonte plus haut que ce que cette vue parcourt.", + "noInvitees": "Ce membre n'a fait entrer personne.", + "generation": "génération {n}", + "erased": "compte effacé", + "truncated": "Tronqué à {nodes} membres ou {depth} générations — le reste n’est pas affiché." } }, "mod": { diff --git a/doc/guide/invitations.md b/doc/guide/invitations.md index 18da0381..9613a0fe 100644 --- a/doc/guide/invitations.md +++ b/doc/guide/invitations.md @@ -75,3 +75,45 @@ Switching modes mid-flight is safe: the running middleware reads the live values | `DELETE`| `/api/admin/invites/:id` | admin | Admin override. Refund mirrors the user-side rule (active → creator). | The auto-fill of the code field on `/auth/register?code=XXXX...` is wired client-side: the page reads `route.query.code`, trims and uppercases it, and seeds the form. An invalid value is rejected by the same back-end check used by manual paste. + +--- + +## The invite tree + +**Admin → Invite tree** walks the genealogy in both directions from any member: +who vouched for them, and who they let in. + +The procedure it serves is standard across the trackers that have had it for +decades. An account is banned for cheating, and the first question is who +vouched for them — because whoever did is either careless or complicit, and +their other invitees are worth a look. + +The data has always been here, in `invitations.created_by` and +`invitations.used_by`. Both pages that read it only ever rendered one +generation. + +### Reading it + +- **Upwards** is a chain, nearest first: `generation 1` is whoever invited the + member you looked up. +- **Downwards** is a tree, indented by generation. +- A **banned** account is marked. An **erased** one renders as a tombstone + rather than a link — erasure scrubs the username and leaves every invitation + row intact, so the *edges* survive perfectly, which is exactly what a + genealogy needs, but the name behind them is gone. + +### Where a chain ends + +Two different endings, and the page distinguishes them: + +- **`root`** — nobody invited this member. They are either the first account or + they registered while registration was open. Those two are indistinguishable + in the data, and saying "root" rather than showing an empty list is what stops + a reader assuming the record is incomplete. +- **depth limit** — the chain goes further up than this view walks. + +### Bounds + +Ten generations each way, four hundred members in total. A prolific inviter +three generations down is a lot of rows, and an unbounded recursive walk over a +social graph is a query nobody meant to write. The page says when it truncated. diff --git a/packages/db/src/migrations/0065_invite_tree_index.sql b/packages/db/src/migrations/0065_invite_tree_index.sql new file mode 100644 index 00000000..0babb62a --- /dev/null +++ b/packages/db/src/migrations/0065_invite_tree_index.sql @@ -0,0 +1 @@ +CREATE INDEX "invitations_used_by_idx" ON "invitations" USING btree ("used_by") WHERE "invitations"."used_by" IS NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0065_snapshot.json b/packages/db/src/migrations/meta/0065_snapshot.json new file mode 100644 index 00000000..fbf6d26f --- /dev/null +++ b/packages/db/src/migrations/meta/0065_snapshot.json @@ -0,0 +1,10134 @@ +{ + "id": "d7477aa3-acae-4b86-92b1-fa6e9ff4a47d", + "prevId": "7eac2820-efa0-49aa-88cb-70b83f2a81b9", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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_used_by_idx": { + "name": "invitations_used_by_idx", + "columns": [ + { + "expression": "used_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invitations\".\"used_by\" IS NOT NULL", + "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.login_events": { + "name": "login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_hash": { + "name": "ip_hash", + "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()" + } + }, + "indexes": { + "login_events_user_idx": { + "name": "login_events_user_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": {} + }, + "login_events_created_idx": { + "name": "login_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_events_user_id_users_id_fk": { + "name": "login_events_user_id_users_id_fk", + "tableFrom": "login_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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 b7c9f55b..b1a1ef9f 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -456,6 +456,13 @@ "when": 1788218521458, "tag": "0064_login_events", "breakpoints": true + }, + { + "idx": 65, + "version": "7", + "when": 1788218955902, + "tag": "0065_invite_tree_index", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 903137d2..e47c760f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2042,6 +2042,12 @@ export const invitations = pgTable( }, (table) => [ index('invitations_created_by_idx').on(table.createdBy), + // The genealogy walks `usedBy` once per generation — "who invited this + // member" — so without an index a ten-deep tree is ten sequential scans. + // Partial: most rows are unredeemed and would be dead weight in it. + index('invitations_used_by_idx') + .on(table.usedBy) + .where(sql`${table.usedBy} IS NOT NULL`), index('invitations_code_idx').on(table.code), ] ); From b1dbae91fa518b5b44b265d188e4e3d4b01d4567 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 10:30:08 +0200 Subject: [PATCH 14/96] feat(torznab): a Prowlarr definition, generated per instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prowlarr and Jackett only know indexers they have a Cardigann YAML for. Trackarr is not in their catalogues, so every member wanting the tracker in *arr had to hand-write one, and a self-hosted tracker has no upstream maintainer to write it for them. Generated, not shipped as a static file, and my own note on this was wrong. A Cardigann definition carries the indexer's category map, and here that map is per instance: an operator renames, adds and deletes categories from the admin console. A file in the repository would be a map of *my* test instance, wrong for every deployment on the day it was committed, and wrong in the worst way — releases silently landing in the wrong *arr category rather than an error anyone can see. So `/api/torznab/cardigann.yml` reads the live categories and emits a definition for this instance, at this instance's URL, with this instance's name. The member downloads it from their Settings page, next to the RSS key — under that key specifically, since a Cardigann definition takes the read key and offering it beside the API key would suggest the two are interchangeable. Details that are load-bearing, learned from Prowlarr's own schema rather than guessed: `id` is the identity key. Prowlarr matches a definition to a configured indexer by `id`, not by filename, so it is derived from the instance host — two Trackarr instances in one Prowlarr must not collide, and a rename of the file must not orphan a working indexer. `legacylinks` is absent on purpose. It exists to migrate an indexer that changed domain, and a wrong entry hijacks whatever is at that URL. The definition declares `type: private` and a single `apikey` setting. Prowlarr then handles the key as a secret rather than a query parameter to log — which is the difference between a member's read key living in Prowlarr's vault and living in its logs. Categories are mapped to Newznab ids by the same table the feed already uses, so a category the feed can serve is a category the definition can name. Unmapped ones are omitted rather than guessed at: an omitted category is a release *arr does not import, and a wrongly mapped one is a release it imports as the wrong thing. Rate-limited and served to authenticated members only. The categories are not secret, but the file names the instance and its URL, and an invite-only tracker does not publish either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../routes/api/torznab/cardigann.yml.get.ts | 221 ++++++++++++++++++ apps/web/app/pages/me.vue | 10 + apps/web/i18n/locales/en.json | 4 +- apps/web/i18n/locales/fr.json | 4 +- doc/integrations/torznab.md | 51 ++++ 5 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 apps/api/routes/api/torznab/cardigann.yml.get.ts diff --git a/apps/api/routes/api/torznab/cardigann.yml.get.ts b/apps/api/routes/api/torznab/cardigann.yml.get.ts new file mode 100644 index 00000000..528e4383 --- /dev/null +++ b/apps/api/routes/api/torznab/cardigann.yml.get.ts @@ -0,0 +1,221 @@ +/** + * GET /api/torznab/cardigann.yml + * + * A Prowlarr indexer definition for THIS instance, generated from it. + * + * ## Why generated and not a file in the repo + * + * The categories are the reason. They are operator-configured — names, slugs + * and Newznab mappings all live in the database — so a static YAML shipped in + * the repo could describe every instance's categories except the one the member + * is actually joining. Generating it means a member downloads a definition that + * already knows their tracker's own categories, its name and its URL, and has + * nothing left to fill in but a key. + * + * It also follows what this codebase already does twice: the web app manifest + * and the theme stylesheet are routes for exactly the same reason. + * + * ## Format + * + * Cardigann v11, which is what Prowlarr currently loads (`DEFINITION_VERSION` + * is a constant in its source). There is no version marker inside the file — + * the version is the folder the shipped definitions live in — so nothing here + * declares one. + * + * The `search` block reads our own Torznab XML through `response.type: xml`, + * which Cardigann parses with AngleSharp and then addresses with ordinary CSS + * selectors. Note that `torznab:attr` elements are NOT selected by name: a + * namespace-prefixed element cannot be cleanly addressed in CSS, so every + * definition that consumes a Torznab feed uses the attribute form, + * `[name=seeders]` + `attribute: value`. We do the same. + * + * ## The filename is the identity, not `id` + * + * 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 + * ours is dropped with nothing but a log line. `id` itself is only used in log + * messages. So the download is named after the site and the guide says to + * change file and `name` together if the instance is ever renamed. + */ +import { getCategoriesWithNewznabIds } from './utils/categories'; +import { getSiteName } from '~~/utils/server'; +import { getTorznabEnabled } from '~~/utils/torznabSettings'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; + +/** YAML double-quoted scalar. Cardigann definitions are plain YAML 1.1. */ +function q(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** + * A slug Prowlarr will accept as a filename and an id. + * + * Lowercase alphanumerics and hyphens, which is the convention across all 548 + * shipped definitions. Nothing enforces it, but a definition that looks like + * the others is one an operator can reason about. + */ +function slugify(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 default defineEventHandler(async (event) => { + await rateLimit(event, RATE_LIMITS.public); + + if (!(await getTorznabEnabled())) { + throw createError({ + statusCode: 404, + message: 'Torznab is not enabled on this instance.', + }); + } + + const siteName = await getSiteName(); + const id = slugify(siteName); + const categories = await getCategoriesWithNewznabIds(); + + // Same derivation the Torznab feed itself uses, so the definition points at + // the host the member reached rather than at whatever an env var was set to + // on the day the container was built. + const baseUrl = getRequestURL(event).origin; + + /** + * One mapping per category, using the id our own feed emits so a `cat=` + * round-trips. `cat:` must come from Cardigann's closed 71-value enum, so it + * is derived from the Newznab parent rather than from the operator's name — + * a value outside the enum fails validation and the definition is refused. + */ + const NEWZNAB_TO_CARDIGANN: Record<number, string> = { + 1000: 'Console', 2000: 'Movies', 3000: 'Audio', 4000: 'PC', + 5000: 'TV', 6000: 'XXX', 7000: 'Books', 8000: 'Other', + }; + const mappings = categories + .map((cat) => { + const parent = Math.floor(cat.newznabParent / 1000) * 1000; + const label = NEWZNAB_TO_CARDIGANN[parent] ?? 'Other'; + return ` - {id: ${cat.newznabId}, cat: ${label}, desc: ${q(cat.name)}}`; + }) + .join('\n'); + + const yaml = `--- +id: ${id} +name: ${q(siteName)} +description: ${q(`${siteName}, a private Trackarr tracker`)} +language: en-US +type: private +encoding: UTF-8 +links: + - ${q(`${baseUrl}/`)} + +caps: + categorymappings: +${mappings || ' - {id: 8000, cat: Other, desc: "Other"}'} + + modes: + search: [q] + tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] + movie-search: [q, imdbid, tmdbid] + allowrawsearch: true + +settings: + - name: apikey + type: text + label: RSS key + - name: info_key + type: info + label: About your key + default: "Your RSS / Torznab key is on your profile page under Credentials. It can read the catalogue and cannot announce, so it is safe to paste here." + +login: + # A cheap query that answers 401 when the key is wrong, so Prowlarr's + # "Test" button means something. + path: api/torznab/api + method: get + inputs: + apikey: "{{ .Config.apikey }}" + t: search + limit: 1 + +search: + paths: + - path: api/torznab/api + response: + type: xml + + inputs: + apikey: "{{ .Config.apikey }}" + t: "{{ .Query.Type }}" + q: "{{ .Keywords }}" + cat: "{{ join .Categories \\",\\" }}" + season: "{{ .Query.Season }}" + ep: "{{ .Query.Ep }}" + imdbid: "{{ .Query.IMDBID }}" + tmdbid: "{{ .Query.TMDBID }}" + tvdbid: "{{ .Query.TVDBID }}" + limit: 100 + + rows: + selector: rss > channel > item + + fields: + title: + selector: title + details: + selector: comments + download: + selector: enclosure + attribute: url + infohash: + selector: "[name=infohash]" + attribute: value + date: + selector: pubDate + filters: + - name: dateparse + args: "ddd, dd MMM yyyy HH:mm:ss zzz" + size: + selector: size + category: + selector: "[name=category]" + attribute: value + seeders: + selector: "[name=seeders]" + attribute: value + leechers: + selector: "[name=peers]" + attribute: value + grabs: + selector: "[name=grabs]" + attribute: value + downloadvolumefactor: + selector: "[name=downloadvolumefactor]" + attribute: value + uploadvolumefactor: + selector: "[name=uploadvolumefactor]" + attribute: value + minimumratio: + selector: "[name=minimumratio]" + attribute: value + optional: true + minimumseedtime: + selector: "[name=minimumseedtime]" + attribute: value + optional: true +`; + + setHeader(event, 'Content-Type', 'application/yaml; charset=utf-8'); + setHeader( + event, + 'Content-Disposition', + `attachment; filename="${id}.yml"` + ); + // Categories change when an operator edits them, and a stale definition maps + // a `cat=` to the wrong thing. Short cache, revalidated. + setHeader(event, 'Cache-Control', 'public, max-age=300, must-revalidate'); + return yaml; +}); diff --git a/apps/web/app/pages/me.vue b/apps/web/app/pages/me.vue index e1e85c64..f654e359 100644 --- a/apps/web/app/pages/me.vue +++ b/apps/web/app/pages/me.vue @@ -452,6 +452,16 @@ <Icon name="ph:info-bold" /> {{ k.note }} </p> + <!-- Only under the RSS key: a Prowlarr definition is what that key + is for, and offering it beside the API key would suggest the + two are interchangeable. --> + <p v-if="k.kind === 'rss'" class="cred-note"> + <Icon name="ph:download-simple-bold" /> + <a href="/api/torznab/cardigann.yml" download> + {{ $t('me.credentials.prowlarrDefinition') }} + </a> + <span class="cred-note-hint">{{ $t('me.credentials.prowlarrHint') }}</span> + </p> </article> <p v-if="readKeys?.legacyPasskeyAccepted" class="cred-legacy"> 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 `<torznab:attr>` 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- .../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<string> { + 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<void> { } } +/** + * 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<boolean> { + 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<void> { + 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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 @@ </div> </div> - <!-- Contrast, on the draft as it stands. --> + <!-- Contrast, on the draft as it stands. The notice interrupts; the + group below it is the whole measurement, because 4.6:1 and 12:1 + both look like silence and only one of them survives a nudge. --> <div v-if="draftWarnings.length" class="notice notice--warn"> <Icon name="ph:warning-bold" /> <span> @@ -242,6 +244,43 @@ </span> </div> + <div class="token-group"> + <button class="token-group-head" @click="toggleGroup('contrast')"> + <Icon + :name="openGroups.has('contrast') ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" + /> + {{ $t('admin.themes.contrastGroup') }} + <span + class="token-group-count" + :class="{ 'contrast-count--fail': draftWarnings.length > 0 }" + > + {{ passingPairs }}/{{ draftReport.length }} + </span> + </button> + <div v-if="openGroups.has('contrast')" class="contrast-list"> + <p class="field-help">{{ $t('admin.themes.contrastAbout') }}</p> + <div + v-for="r in draftReport" + :key="`${r.pair.fg}-${r.pair.bg}`" + class="contrast-row" + :class="{ 'contrast-row--fail': !r.passes }" + > + <!-- The sample is the point: it shows what the number means, in + the two colours being measured, at the size they are used. --> + <span class="contrast-sample" :style="sampleStyle(r.pair)">Aa</span> + <span class="contrast-what">{{ pairLabel(r.pair) }}</span> + <span class="contrast-ratio">{{ r.ratio.toFixed(2) }}:1</span> + <span class="contrast-min">{{ + $t('admin.themes.contrastMin', { required: r.required }) + }}</span> + <Icon + class="contrast-verdict" + :name="r.passes ? 'ph:check-bold' : 'ph:warning-bold'" + /> + </div> + </div> + </div> + <!-- Uploaded fonts. Owner only to ADD one; every administrator may then select it above. The panel lives in the editor because that is where an author discovers they want a face the build does not ship. --> @@ -403,6 +442,7 @@ import { BUILT_IN_TOKENS, FONT_STACKS, THEME_TOKENS, + contrastReport, contrastWarnings, isValidTokenValue, parseRgb, @@ -425,7 +465,7 @@ const props = defineProps<{ themeId: string | null; }>(); -const { t } = useI18n(); +const { t, te } = useI18n(); const router = useRouter(); const { messageOf, reloadThemeStylesheet } = useThemeAdmin(); @@ -746,6 +786,36 @@ function resolvedFor(row: { base: 'light' | 'dark'; tokens: Record<string, strin const draftWarnings = computed(() => draft.value ? contrastWarnings(resolvedFor(draft.value)) : [], ); +const draftReport = computed(() => + draft.value ? contrastReport(resolvedFor(draft.value)) : [], +); +const passingPairs = computed( + () => draftReport.value.filter((r) => r.passes).length, +); + +/** + * The pair's name, translated when we have a translation for it. + * + * `pair.what` is English in the shared schema — it is a code comment that + * happens to be rendered — so the key is derived from the two token names, + * which are stable, and the English falls through when a locale has not + * described that pair yet. Interpolating `what` into a translated sentence, as + * the warning line above still does, is what made a French console read + * "muted labels on cards" in the first place. + */ +function pairLabel(pair: { fg: string; bg: string; what: string }): string { + const key = `admin.themes.contrastPairs.${pair.fg}__${pair.bg}`; + return te(key) ? t(key) : pair.what; +} + +/** The two colours actually being measured, straight from the resolved draft. */ +function sampleStyle(pair: { fg: string; bg: string }) { + const tokens = draft.value ? resolvedFor(draft.value) : {}; + return { + color: `rgb(${tokens[pair.fg] ?? '0 0 0'})`, + background: `rgb(${tokens[pair.bg] ?? '255 255 255'})`, + }; +} // ── Live preview ───────────────────────────────────────────────────── @@ -944,6 +1014,33 @@ const familySuggestions = computed(() => { </script> <style scoped> +.contrast-list { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem 0.75rem 0.75rem; } +.contrast-row { + display: grid; + grid-template-columns: 2.25rem minmax(0, 1fr) auto auto 1rem; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.4rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; +} +.contrast-row--fail { background: rgb(var(--warning) / 0.08); } +.contrast-sample { + display: grid; + place-items: center; + height: 1.5rem; + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 0.75rem; +} +.contrast-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.contrast-ratio { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.contrast-min { color: rgb(var(--fg-subtle)); font-size: 0.6875rem; } +.contrast-verdict { color: rgb(var(--online)); } +.contrast-row--fail .contrast-verdict { color: rgb(var(--warning)); } +.contrast-count--fail { color: rgb(var(--warning)); } + .field-label { display: block; font-size: 0.625rem; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 9a7f4c79..6ba13b71 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -3413,7 +3413,32 @@ "editorTitleNew": "New theme", "fontFamilySuggest": "Suggestions come from the file you picked and from faces already uploaded.", "notFoundTitle": "That theme is gone.", - "notFoundBody": "It was deleted, or the address names a theme that never existed." + "notFoundBody": "It was deleted, or the address names a theme that never existed.", + "contrastGroup": "Contrast", + "contrastMin": "needs {required}:1", + "contrastAbout": "Every combination the interface paints text in, measured against WCAG 2.2 AA — 4.5:1 for text, 3:1 for large text and for the focus ring. A shortfall is a warning, not a refusal: the theme saves either way.", + "contrastPairs": { + "fg-default__bg-base": "Body text", + "fg-default__bg-surface": "Text on cards", + "fg-strong__bg-base": "Headings", + "fg-muted__bg-base": "Secondary text", + "fg-muted__bg-surface": "Secondary text on cards", + "fg-subtle__bg-base": "Muted labels", + "fg-faint__bg-base": "Micro labels", + "accent-fg__accent": "Primary button", + "accent-warm-fg__accent-warm": "Gold button", + "focus-ring__bg-base": "Focus ring", + "fg-default__bg-elevated": "Text typed in a field", + "fg-faint__bg-elevated": "Field placeholder", + "fg-default__bg-inset": "Text on inset panels", + "fg-subtle__bg-inset": "Muted labels on inset panels", + "fg-faint__bg-inset": "Micro labels on inset panels", + "fg-subtle__bg-surface": "Muted labels on cards", + "danger__bg-surface": "Error text", + "warning__bg-surface": "Warning text", + "online__bg-surface": "Success text", + "accent__bg-surface": "Accent text" + } }, "homepage": { "title": "Homepage Content", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 3dd048bd..820bb034 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -3413,7 +3413,32 @@ "editorTitleNew": "Nouveau thème", "fontFamilySuggest": "Les suggestions viennent du fichier choisi et des fontes déjà téléversées.", "notFoundTitle": "Ce thème n'existe plus.", - "notFoundBody": "Il a été supprimé, ou l'adresse désigne un thème qui n'a jamais existé." + "notFoundBody": "Il a été supprimé, ou l'adresse désigne un thème qui n'a jamais existé.", + "contrastGroup": "Contraste", + "contrastMin": "minimum {required}:1", + "contrastAbout": "Chaque combinaison dans laquelle l’interface peint du texte, mesurée contre le niveau AA de WCAG 2.2 — 4,5:1 pour le texte, 3:1 pour les grands caractères et l’anneau de focus. Un manque est un avertissement, pas un refus : le thème s’enregistre quand même.", + "contrastPairs": { + "fg-default__bg-base": "Texte courant", + "fg-default__bg-surface": "Texte sur les cartes", + "fg-strong__bg-base": "Titres", + "fg-muted__bg-base": "Texte secondaire", + "fg-muted__bg-surface": "Texte secondaire sur les cartes", + "fg-subtle__bg-base": "Étiquettes discrètes", + "fg-faint__bg-base": "Micro-étiquettes", + "accent-fg__accent": "Bouton principal", + "accent-warm-fg__accent-warm": "Bouton doré", + "focus-ring__bg-base": "Anneau de focus", + "fg-default__bg-elevated": "Texte saisi dans un champ", + "fg-faint__bg-elevated": "Texte d’exemple des champs", + "fg-default__bg-inset": "Texte sur les panneaux encastrés", + "fg-subtle__bg-inset": "Étiquettes discrètes sur panneaux encastrés", + "fg-faint__bg-inset": "Micro-étiquettes sur panneaux encastrés", + "fg-subtle__bg-surface": "Étiquettes discrètes sur les cartes", + "danger__bg-surface": "Texte d’erreur", + "warning__bg-surface": "Texte d’avertissement", + "online__bg-surface": "Texte de succès", + "accent__bg-surface": "Texte en couleur d’accent" + } }, "homepage": { "title": "Contenu de la page d'accueil", diff --git a/apps/web/test/themeTokens.test.ts b/apps/web/test/themeTokens.test.ts index 953647e3..a8a7784a 100644 --- a/apps/web/test/themeTokens.test.ts +++ b/apps/web/test/themeTokens.test.ts @@ -7,6 +7,7 @@ import { THEME_TOKENS, THEME_TOKEN_KEYS, contrastRatio, + contrastReport, contrastWarnings, emittedValue, isValidTokenValue, @@ -371,6 +372,45 @@ describe('contrast', () => { expect(contrastWarnings(BUILT_IN_TOKENS.light)).toEqual([]); }); + it('measures every declared pair, passing ones included', () => { + // The report is what the editor renders, so its coverage is the coverage. + // A pair that silently drops out of it — a token renamed, a `what` left + // dangling — would look exactly like a theme with nothing wrong. + const report = contrastReport(BUILT_IN_TOKENS.dark); + expect(report).toHaveLength(CONTRAST_PAIRS.length); + expect(report.every((r) => r.ratio > 1)).toBe(true); + expect(report.every((r) => r.passes)).toBe(true); + }); + + it('agrees with itself: the warnings are the failing rows of the report', () => { + const tokens = resolveTokens('dark', { + 'fg-default': '20 20 20', + 'fg-muted': '30 30 30', + }); + const failing = contrastReport(tokens).filter((r) => !r.passes); + const warnings = contrastWarnings(tokens); + expect(warnings).toHaveLength(failing.length); + expect(warnings.map((w) => w.pair.what)).toEqual( + failing.map((r) => r.pair.what), + ); + }); + + it('checks the surfaces a field is painted on, not only the page', () => { + // The gap this pair set was extended to close. `.input` fills with + // `--bg-elevated` and its placeholder is `--fg-faint`, so a theme can leave + // the page perfectly readable and every form on the site illegible. + const onlyOnElevated = resolveTokens('dark', { + 'bg-elevated': '120 120 120', + }); + const failed = contrastWarnings(onlyOnElevated).map( + (w) => `${w.pair.fg}/${w.pair.bg}`, + ); + expect(failed).toContain('fg-faint/bg-elevated'); + // And the page itself is untouched, which is what makes the pair earn its + // place rather than duplicating one already declared. + expect(failed).not.toContain('fg-faint/bg-base'); + }); + it('catches a theme that is unreadable', () => { const warnings = contrastWarnings( resolveTokens('dark', { 'fg-default': '20 20 20' }), diff --git a/doc/guide/themes.md b/doc/guide/themes.md index 829c0f1d..9911bcfe 100644 --- a/doc/guide/themes.md +++ b/doc/guide/themes.md @@ -65,12 +65,35 @@ immediately, including the editor itself. Nothing is saved until you press is a panel rather than a dialog for this reason — a window on top of the preview would cover the thing you are judging. -**Contrast is checked, not enforced.** The editor lists any declared pair that -falls below WCAG AA — body text on the page, secondary text on cards, the label -colours, the button text on its fill, the focus ring. It warns; it does not -refuse. The warnings are worth taking seriously: they caught three real defects -in the shipped themes when this feature was built, one of them the placeholder -colour of every form field on the site at 2.08:1. +**Contrast is checked, not enforced.** It warns; it does not refuse. + +The **Contrast** group in the editor measures every combination the interface +paints text in — twenty of them — and shows each with its ratio, the minimum it +needs, and a sample drawn in the two colours being measured. The count in the +group header is the summary: `20/20` means the theme is readable everywhere. +Failures are also raised as a warning above the fields, so a theme cannot fail +quietly. + +Showing the passing pairs is the point rather than clutter: 4.6:1 and 12:1 both +look like silence when only failures are listed, and one of them breaks on the +next nudge. + +The thresholds are WCAG 2.2 AA — 4.5:1 for text, 3:1 for large text and for the +focus ring. Not APCA: WCAG 3 has not settled on an algorithm, and the reference +implementation is AGPL-licensed for commercial use. The maths here is eight lines +and no dependency. + +The pairs are worth taking seriously, because each round of them has caught real +defects in the *shipped* themes: + +- When the check was built: three, one of them the placeholder colour of every + form field on the site at 2.08:1. +- When it was extended to the surfaces it was not checking — the field fill + (`bg-elevated`) and the recessed panel (`bg-inset`) — three more. `fg-subtle` + measured 4.55:1 on the page and **4.23:1 on a card**, and in the light theme + `bg-inset` is the *darkest* surface rather than a middle one, so it was the + worst case there and nobody had looked. Both shipped themes now clear 4.5:1 on + all twenty. ### What a theme can change diff --git a/packages/shared/src/theme.ts b/packages/shared/src/theme.ts index b2e55375..1244f676 100644 --- a/packages/shared/src/theme.ts +++ b/packages/shared/src/theme.ts @@ -533,7 +533,7 @@ export const BUILT_IN_TOKENS: Readonly<Record<'light' | 'dark', TokenMap>> = { 'fg-default': '250 250 250', 'fg-strong': '255 255 255', 'fg-muted': '161 161 161', - 'fg-subtle': '121 121 121', + 'fg-subtle': '127 127 127', 'fg-faint': '130 130 130', 'line-default': '42 42 42', 'line-strong': '58 58 58', @@ -582,8 +582,8 @@ export const BUILT_IN_TOKENS: Readonly<Record<'light' | 'dark', TokenMap>> = { 'fg-default': '10 10 10', 'fg-strong': '0 0 0', 'fg-muted': '85 85 85', - 'fg-subtle': '115 115 115', - 'fg-faint': '115 115 115', + 'fg-subtle': '111 111 111', + 'fg-faint': '111 111 111', 'line-default': '229 229 229', 'line-strong': '208 208 208', accent: '10 10 10', @@ -821,6 +821,47 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [ // chunky high-contrast rules — a worse site, in the name of a clause that // does not apply. The focus ring below is the real 1.4.11 case. { fg: 'focus-ring', bg: 'bg-base', what: 'focus ring', nonText: true }, + + // ── The surfaces the first pass did not reach ──────────────────────────── + // + // Every entry below is a combination `main.css` actually paints, and each was + // added because the pair above it turned out not to cover it. Three of them + // caught a real failure in the shipped themes on the day they were written, + // which is the only argument for a pair worth having. + // + // `bg-elevated` is the field fill: `.input` sets `background-color: + // rgb(var(--bg-elevated))` and `color: rgb(var(--fg-default))`, and its + // placeholder is `--fg-faint`. So the text a member types, on every form on + // the site, was checked against neither surface it sits on. + { fg: 'fg-default', bg: 'bg-elevated', what: 'text typed in a field' }, + { fg: 'fg-faint', bg: 'bg-elevated', what: 'field placeholder' }, + // `bg-inset` is the recessed panel — 36 components use it. In the dark theme + // it sits between base and surface, but in light it is the DARKEST surface + // (245 against 250/255), so it is the worst case there rather than a middle + // one, and bracketing it was not enough. + { fg: 'fg-default', bg: 'bg-inset', what: 'text on inset panels' }, + { fg: 'fg-subtle', bg: 'bg-inset', what: 'muted labels on inset panels' }, + { fg: 'fg-faint', bg: 'bg-inset', what: 'micro labels on inset panels' }, + // Cards. `fg-muted` on `bg-surface` was checked and `fg-subtle` was not, + // which is how the dark theme shipped muted card labels at 4.23:1 while the + // same token measured 4.55:1 on the page behind them. + { fg: 'fg-subtle', bg: 'bg-surface', what: 'muted labels on cards' }, + // The status colours are painted as TEXT, not only as fills: `text-error` + // appears in 18 components, `text-warning` in 9, `text-accent` in 9, + // `text-online` in 1. `--info` is not here because it has no text use at all + // — it fills and it borders, and a pair that nothing renders is a pair that + // will be wrong without anyone noticing. + // + // Checked against `bg-surface` alone, and that is an assumption worth + // stating: status text lives inside cards, and the two page surfaces differ + // by ten points of grey in both built-ins (10 against 20, 250 against 255), + // so the second measurement would say the same thing to two decimal places. + // A theme that separates them widely will hear about it from the + // `fg-default` pairs above, which do check both. + { fg: 'danger', bg: 'bg-surface', what: 'error text' }, + { fg: 'warning', bg: 'bg-surface', what: 'warning text' }, + { fg: 'online', bg: 'bg-surface', what: 'success text' }, + { fg: 'accent', bg: 'bg-surface', what: 'accent text' }, ] as const; /** Relative luminance, per WCAG 2.x. */ @@ -849,6 +890,39 @@ export interface ContrastWarning { readonly required: number; } +export interface ContrastResult extends ContrastWarning { + readonly passes: boolean; +} + +/** + * Every declared pair, measured — passing ones included. + * + * The gate used to answer only with what fails, which is the right thing to + * interrupt an admin with and the wrong thing to tune a colour against: 4.6:1 + * and 12:1 both look like silence, and one of them breaks on the next nudge. + * The editor shows this whole list, and shows the failures separately. + * + * Pairs whose two tokens are not both triplets are omitted rather than reported + * as passing — a half-typed value is not a verdict. + */ +export function contrastReport(tokens: TokenMap): ContrastResult[] { + const out: ContrastResult[] = []; + for (const pair of CONTRAST_PAIRS) { + const ratio = contrastRatio(tokens[pair.fg] ?? '', tokens[pair.bg] ?? ''); + if (ratio === null) continue; + // 1.4.3 for text, 1.4.11 for the non-text case, and WCAG's own large-text + // allowance for the one pair that qualifies. + const required = pair.nonText || pair.large ? 3 : 4.5; + out.push({ + pair, + ratio: Math.round(ratio * 100) / 100, + required, + passes: ratio >= required, + }); + } + return out; +} + /** * Every declared pair that falls short, with what it needed. * @@ -859,14 +933,10 @@ export interface ContrastWarning { * eight lines and the alternative is a dependency with a licence to read. */ export function contrastWarnings(tokens: TokenMap): ContrastWarning[] { - const out: ContrastWarning[] = []; - for (const pair of CONTRAST_PAIRS) { - const ratio = contrastRatio(tokens[pair.fg] ?? '', tokens[pair.bg] ?? ''); - if (ratio === null) continue; - const required = pair.nonText ? 3 : pair.large ? 3 : 4.5; - if (ratio < required) { - out.push({ pair, ratio: Math.round(ratio * 100) / 100, required }); - } - } - return out; + // Derived from the full report rather than measured a second time: two loops + // over the same pairs is how a warning ends up disagreeing with the figure + // printed next to it. + return contrastReport(tokens) + .filter((r) => !r.passes) + .map(({ pair, ratio, required }) => ({ pair, ratio, required })); } From c2ad7e15d338c5439d88cbfa837ddc55106318ae Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 15:28:01 +0200 Subject: [PATCH 19/96] feat(stats): the site, looking at itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every number on this page was already in the database and visible only to administrators. A member saw four counters on the homepage — releases, peers, seeders, volume — and nothing else: not how the catalogue is shaped, not whether the site is growing, not what everybody is grabbing. Trackers that publish this keep people, because the numbers are the community looking at itself. ## What is deliberately not on it No per-member volume. No ratio board, no "top downloaders", no terabytes beside a username anywhere. Those are the numbers a tracker leaderboard is traditionally built from, and the reason this one is not is narrow and checkable: there is no setting on this site by which a member could decline to appear in one. Publishing it would publish, for every member, a figure they never agreed to publish — and unlike the adult filter or anonymous uploads, they would have no way to opt out. The board here ranks by NUMBER of live releases, which is already listed on each member's profile, so counting them discloses nothing new. The note under the board says so, because an absence a reader cannot explain looks like an oversight. A member who uploads anonymously is not in the board at all. `anonymous_uploads` conceals a name on every surface that attributes a release; a leaderboard would be the one surface that undid it. Their releases still count in the totals. The adult tree is filtered out of every list that names a release, for whoever has not opted in — one predicate, `visibleTorrents`, shared by all of them, for the same reason `redactUploader` is one function: five hand-written copies of "what a member may see" will disagree, and the failure is a leak rather than a wrong total. ## Two figures that are not the same, and both are here `bytesAdded` is the size of what was catalogued. It is exact. `trafficBytes` is what actually moved, taken as the difference between the first and last hourly snapshot in the window — and it is approximate BY CONSTRUCTION. The counter behind it is `SUM(users.uploaded)`, which DROPS when an account is erased or a moderator resets a cheater. So it is a floor, it is null for a window with no snapshots, and the page says both. Presenting it as a total would be the lie. Per-day movement is clamped at zero for the same reason. "-4.2 TB of traffic on Tuesday" is not a fact a reader can do anything with except try to explain it. ## The derivations are pure, and tested `site_stats` is hourly cumulative counters, so every daily figure is derived: one point per day (the LAST reading, not an average — the mean of a rising counter is a value it held at no moment), and deltas between consecutive points. A day with no snapshot is absent rather than zero, because an instance that was down for six hours must not draw a cliff on a counter that never moved. Those four functions have failure modes that look like data rather than like bugs, which is why they are pure and have thirteen tests: the gap, the backwards counter, the first day with no predecessor, the year the site did not exist for. ## Charts without a charting library The CSP rules out a CDN, and the shape needed is a polyline and some rectangles. Under every chart is a collapsed table of the same array — a picture is neither accessible nor verifiable — and each carries a summary that names the peak for a bar chart and the endpoints for a trend, translated like every other string, because an aria-label is read aloud. Years run January to January in UTC. The members of one tracker are spread across every time zone, so a year anchored on the server's offset would be an arbitrary choice presented as a fact. The selector only offers years the instance has snapshots for: a review of a year the site did not exist for is an empty page, and an empty page reads as broken. Verified against a seeded catalogue whose answers were known — the anonymous uploader absent from the board and present in the totals, the adult and pending releases absent from every list, a deliberate counter drop clamped to zero, a malformed year answered with 400 rather than 500, and all three endpoints refusing an anonymous caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 1 + apps/api/routes/api/me/year.get.ts | 32 + apps/api/routes/api/stats/site.get.ts | 95 +++ apps/api/routes/api/stats/year.get.ts | 65 ++ apps/api/test/publicStats.test.ts | 156 ++++ apps/api/utils/publicStats.ts | 696 ++++++++++++++++ apps/web/app/components/CommandPalette.vue | 1 + apps/web/app/components/stats/StatSeries.vue | 306 +++++++ apps/web/app/layouts/default.vue | 1 + apps/web/app/pages/stats.vue | 821 +++++++++++++++++++ apps/web/i18n/locales/en.json | 80 ++ apps/web/i18n/locales/fr.json | 80 ++ doc/.vitepress/config.ts | 1 + doc/guide/stats.md | 85 ++ 14 files changed, 2420 insertions(+) create mode 100644 apps/api/routes/api/me/year.get.ts create mode 100644 apps/api/routes/api/stats/site.get.ts create mode 100644 apps/api/routes/api/stats/year.get.ts create mode 100644 apps/api/test/publicStats.test.ts create mode 100644 apps/api/utils/publicStats.ts create mode 100644 apps/web/app/components/stats/StatSeries.vue create mode 100644 apps/web/app/pages/stats.vue create mode 100644 doc/guide/stats.md diff --git a/README.md b/README.md index 41845b32..cfe6ba22 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). +- **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. - **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). diff --git a/apps/api/routes/api/me/year.get.ts b/apps/api/routes/api/me/year.get.ts new file mode 100644 index 00000000..94cf31eb --- /dev/null +++ b/apps/api/routes/api/me/year.get.ts @@ -0,0 +1,32 @@ +/** + * GET /api/me/year?year=YYYY + * + * The caller's own year. Nobody else's, ever — there is no id parameter and no + * staff override, because there is no question a moderator has that this page + * answers better than the existing tools. + * + * Their data, so nothing is redacted: the adult filter is not applied here, and + * that is deliberate rather than an omission. A member who grabbed something + * already saw it, and a review that hid part of their own year would be a + * report about somebody else. + * + * Not cached. It is one member's row set, it is cheap, and a member reading + * their own review a second time after uploading something should see the + * upload. + */ +import { z } from 'zod/v4'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; +import { memberYear } from '~~/utils/publicStats'; + +const querySchema = z.object({ + year: z.coerce.number().int().min(2000).max(2100), +}); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + const { year } = validateQuery(event, querySchema); + return memberYear(user.id, year); +}); diff --git a/apps/api/routes/api/stats/site.get.ts b/apps/api/routes/api/stats/site.get.ts new file mode 100644 index 00000000..f60245c0 --- /dev/null +++ b/apps/api/routes/api/stats/site.get.ts @@ -0,0 +1,95 @@ +/** + * GET /api/stats/site?window=30|90|365 + * + * The state of the site, for the people who are on it. + * + * `/api/stats/public` already answers the homepage's four counters to anybody + * who loads the page. This is the rest of it — history, the shape of the + * catalogue, what is being seeded and by whom — and it is behind a session + * because half of it names releases and members. A private tracker publishing + * its catalogue to whoever asks would be a strange thing to build carefully in + * the feed and then hand out here. + * + * Cached per (window, adult visibility) for a minute. The aggregates are a + * handful of indexed group-bys, but this is a page members will refresh, and a + * minute of staleness on a chart of the last 90 days is not a number anyone can + * perceive. + */ +import { z } from 'zod/v4'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; +import { + categoryBreakdown, + dailyDeltas, + dailyPoints, + firstSnapshotAt, + hiddenCategoryIds, + selectableYears, + siteNow, + snapshots, + topTorrents, + topUploaders, +} from '~~/utils/publicStats'; + +const querySchema = z.object({ + // A closed set rather than a number: the window is the cache key and the + // series length, and "365" is already a thousand rows before bucketing. + window: z.enum(['30', '90', '365']).default('90'), +}); + +const CACHE_TTL_MS = 60_000; +const cache = new Map<string, { at: number; value: unknown }>(); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { window } = validateQuery(event, querySchema); + const days = Number(window); + const showAdult = !!user.showAdultContent; + const key = `${days}:${showAdult ? 'all' : 'safe'}`; + + const hit = cache.get(key); + if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.value; + + const adultIds = await hiddenCategoryIds(showAdult); + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const [now, rows, categories, mostSnatched, biggestSwarms, uploaders, firstAt] = + await Promise.all([ + siteNow(adultIds), + snapshots(since), + categoryBreakdown(adultIds), + topTorrents('snatches', adultIds, 10), + topTorrents('seeders', adultIds, 10), + topUploaders(adultIds, 10), + firstSnapshotAt(), + ]); + + const points = dailyPoints(rows); + const value = { + now, + days, + // The series is what it is: if the collector has only been running a week, + // the chart shows a week rather than 83 empty days. Padding it would draw a + // flat line at zero and call it history. + points, + deltas: dailyDeltas(points), + categories, + mostSnatched, + biggestSwarms, + topUploaders: uploaders, + years: selectableYears(firstAt, new Date()), + }; + + cache.set(key, { at: Date.now(), value }); + // Two windows times two visibilities is four entries; the bound is here so a + // future third dimension cannot turn this into a leak. + if (cache.size > 12) { + for (const [k, v] of cache) { + if (Date.now() - v.at > CACHE_TTL_MS) cache.delete(k); + } + } + return value; +}); diff --git a/apps/api/routes/api/stats/year.get.ts b/apps/api/routes/api/stats/year.get.ts new file mode 100644 index 00000000..0163e671 --- /dev/null +++ b/apps/api/routes/api/stats/year.get.ts @@ -0,0 +1,65 @@ +/** + * GET /api/stats/year?year=YYYY + * + * The site's year: what was added, what moved, who joined, and what everybody + * was grabbing. + * + * A tracker's year in review is a retention feature rather than an analytics + * one — it is the page members link to each other in January — so it is written + * to be readable rather than complete. Every figure on it can be traced to one + * of four tables, and the ones that cannot be computed honestly are absent + * rather than approximated. + * + * ## Two figures that are not the same, and are both here + * + * `bytesAdded` is the size of the releases catalogued during the year. It is a + * property of the catalogue, and it is exact. + * + * `trafficBytes` is how much was actually transferred, taken as the difference + * between the first and last `site_stats` snapshot inside the year. It is + * approximate BY CONSTRUCTION: the counter behind it drops when an account is + * erased or a cheater's stats are reset, so it is a floor rather than a total, + * and it is null for a year the collector has no snapshots for. Presenting it + * as exact would be the lie; the guide says so and so does the page. + * + * Past years never change, so they are cached for a day. The current one is + * cached for a minute, like the rest of the stats. + */ +import { z } from 'zod/v4'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; +import { hiddenCategoryIds, siteYear } from '~~/utils/publicStats'; + +const querySchema = z.object({ + // 2000 is not a guess: BitTorrent was published in 2001, so a tracker with + // data before that is a clock problem rather than a year. + year: z.coerce.number().int().min(2000).max(2100), +}); + +const FRESH_TTL_MS = 60_000; +const SETTLED_TTL_MS = 24 * 60 * 60 * 1000; +const cache = new Map<string, { at: number; ttl: number; value: unknown }>(); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { year } = validateQuery(event, querySchema); + const showAdult = !!user.showAdultContent; + const key = `${year}:${showAdult ? 'all' : 'safe'}`; + + const hit = cache.get(key); + if (hit && Date.now() - hit.at < hit.ttl) return hit.value; + + const value = await siteYear(year, await hiddenCategoryIds(showAdult)); + const ttl = year < new Date().getUTCFullYear() ? SETTLED_TTL_MS : FRESH_TTL_MS; + cache.set(key, { at: Date.now(), ttl, value }); + // Bounded: one entry per year per visibility, and a long-lived instance would + // otherwise accumulate one a year forever. + if (cache.size > 40) { + const oldest = [...cache.entries()].sort((a, b) => a[1].at - b[1].at)[0]; + if (oldest) cache.delete(oldest[0]); + } + return value; +}); diff --git a/apps/api/test/publicStats.test.ts b/apps/api/test/publicStats.test.ts new file mode 100644 index 00000000..8bc76cbc --- /dev/null +++ b/apps/api/test/publicStats.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { + busiestDay, + dailyDeltas, + dailyPoints, + selectableYears, + yearWindow, + type Snapshot, +} from '../utils/publicStats'; + +/** + * The four derivations behind the public stats, tested without a database. + * + * `site_stats` holds hourly readings of cumulative counters, and every figure a + * member reads is derived from them. Each of these has a failure mode that + * looks like data rather than a bug — a gap in the snapshots, a counter that + * goes backwards, a year the site did not exist for — so this is where they are + * pinned down. + */ + +const snap = (iso: string, uploaded: number, extra: Partial<Snapshot> = {}): Snapshot => ({ + at: new Date(iso), + users: 10, + torrents: 100, + peers: 5, + seeders: 4, + uploaded, + ...extra, +}); + +describe('dailyPoints', () => { + it('keeps the last reading of each day, not the first or an average', () => { + const points = dailyPoints([ + snap('2026-03-01T01:00:00Z', 100), + snap('2026-03-01T23:00:00Z', 180), + snap('2026-03-02T12:00:00Z', 240), + ]); + expect(points.map((p) => [p.day, p.uploaded])).toEqual([ + ['2026-03-01', 180], + ['2026-03-02', 240], + ]); + }); + + it('sorts by day even when the rows arrive out of order', () => { + const points = dailyPoints([ + snap('2026-03-03T10:00:00Z', 300), + snap('2026-03-01T10:00:00Z', 100), + ]); + expect(points.map((p) => p.day)).toEqual(['2026-03-01', '2026-03-03']); + }); + + it('leaves a day with no snapshot absent rather than zero', () => { + // An instance that was down for a day must not draw a cliff to zero on a + // chart of a counter that never moved. + const points = dailyPoints([ + snap('2026-03-01T10:00:00Z', 100), + snap('2026-03-03T10:00:00Z', 150), + ]); + expect(points).toHaveLength(2); + expect(points.some((p) => p.uploaded === 0)).toBe(false); + }); +}); + +describe('dailyDeltas', () => { + it('reports the movement between consecutive points', () => { + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 1_000), + snap('2026-03-02T10:00:00Z', 3_500), + snap('2026-03-03T10:00:00Z', 4_000), + ]), + ); + expect(deltas.map((d) => [d.day, d.bytes])).toEqual([ + ['2026-03-02', 2_500], + ['2026-03-03', 500], + ]); + }); + + it('drops the first day instead of comparing it against zero', () => { + // Otherwise the site's entire history is reported as one day's traffic. + const deltas = dailyDeltas(dailyPoints([snap('2026-03-01T10:00:00Z', 9_000_000)])); + expect(deltas).toEqual([]); + }); + + it('clamps a counter that goes backwards', () => { + // `total_uploaded_bytes` is SUM(users.uploaded): erasing an account lowers + // it, and so does a moderator resetting a cheater. "-4.2 TB on Tuesday" is + // a figure a reader would try to explain. + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 5_000), + snap('2026-03-02T10:00:00Z', 1_000), + snap('2026-03-03T10:00:00Z', 1_200), + ]), + ); + expect(deltas.map((d) => d.bytes)).toEqual([0, 200]); + }); + + it('clamps the torrent and member counters too', () => { + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 10, { torrents: 500, users: 50 }), + snap('2026-03-02T10:00:00Z', 20, { torrents: 480, users: 49 }), + ]), + ); + expect(deltas[0]).toMatchObject({ torrents: 0, users: 0 }); + }); +}); + +describe('busiestDay', () => { + it('picks the largest day', () => { + const best = busiestDay([ + { day: '2026-03-02', bytes: 10, torrents: 0, users: 0 }, + { day: '2026-03-03', bytes: 90, torrents: 0, users: 0 }, + ]); + expect(best?.day).toBe('2026-03-03'); + }); + + it('has no busiest day when nothing moved', () => { + // A clamped run of zeroes is not a record, and "busiest day: 0 B" on a + // year in review reads as a bug in the page. + expect(busiestDay([{ day: '2026-03-02', bytes: 0, torrents: 0, users: 0 }])).toBeNull(); + expect(busiestDay([])).toBeNull(); + }); +}); + +describe('yearWindow', () => { + it('is half-open and in UTC', () => { + const { start, end } = yearWindow(2026); + expect(start.toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(end.toISOString()).toBe('2027-01-01T00:00:00.000Z'); + }); + + it('excludes the last instant of the year from the next one', () => { + // The boundary that decides which review a New Year's Eve upload lands in. + const y2026 = yearWindow(2026); + const y2027 = yearWindow(2027); + const lastMoment = new Date('2026-12-31T23:59:59.999Z'); + expect(lastMoment >= y2026.start && lastMoment < y2026.end).toBe(true); + expect(lastMoment < y2027.start).toBe(true); + }); +}); + +describe('selectableYears', () => { + it('runs from the current year back to the first snapshot', () => { + expect( + selectableYears(new Date('2024-06-01T00:00:00Z'), new Date('2026-09-01T00:00:00Z')), + ).toEqual([2026, 2025, 2024]); + }); + + it('offers the current year alone on an instance with no history', () => { + // A selector that offered 2019 on a site installed last week would produce + // an empty review, which reads as a broken page rather than an empty year. + expect(selectableYears(null, new Date('2026-09-01T00:00:00Z'))).toEqual([2026]); + }); +}); diff --git a/apps/api/utils/publicStats.ts b/apps/api/utils/publicStats.ts new file mode 100644 index 00000000..acc70df6 --- /dev/null +++ b/apps/api/utils/publicStats.ts @@ -0,0 +1,696 @@ +/** + * The numbers a member is allowed to see about the site, and about their own + * year. + * + * ## Why this is not `/api/admin/stats` with a softer gate + * + * The operator console answers "is the machine healthy" — Redis memory, + * database size, per-user request logs. A member asking "how is the site doing" + * is asking a different question, and most of the answers to the first one are + * either meaningless to them or nobody's business. So this module computes its + * own set rather than widening that one, and the difference is not cosmetic: the + * queries here are written to be cheap enough to serve to everybody, and every + * one of them is filtered by what the caller is allowed to know. + * + * ## What is deliberately absent + * + * **No per-member volume.** Uploaded and downloaded bytes are the numbers a + * tracker's leaderboards are traditionally built on, and there is no setting on + * this site by which a member could decline to appear in one. A ratio board + * would therefore publish, for every member, a figure they never agreed to + * publish. Upload COUNTS are different — a member's uploads are already listed + * on their profile, so counting them discloses nothing new — and that is what + * the board here ranks. + * + * **No member who uploads anonymously.** `users.anonymous_uploads` conceals a + * name on every surface that attributes a release. A leaderboard naming them + * would be the one surface that undoes it, so they are excluded from the board + * and still counted in the totals. + * + * **No adult release to somebody who has not opted in.** The catalogue's rule, + * applied to every list that names a torrent. + * + * ## The shape of the history + * + * `site_stats` is an hourly snapshot of cumulative counters. Nothing in it is a + * per-day figure, so the daily series are derived here: one point per day (the + * last snapshot of that day), and traffic per day as the difference between + * consecutive points. Both derivations are pure functions below, because both + * have a failure mode that a database cannot show you — a gap in the snapshots, + * and a counter that goes DOWN. + */ +import { and, desc, eq, gte, isNull, lt, sql } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { adultCategoryIds } from './adultContent'; + +// ───────────────────────────────────────────────────────────────────────────── +// Pure shaping +// ───────────────────────────────────────────────────────────────────────────── + +export interface Snapshot { + at: Date; + users: number; + torrents: number; + peers: number; + seeders: number; + uploaded: number; +} + +export interface DailyPoint { + /** `YYYY-MM-DD`, UTC. */ + day: string; + users: number; + torrents: number; + peers: number; + seeders: number; + /** Cumulative bytes at the end of that day. */ + uploaded: number; +} + +/** + * One point per day: the LAST snapshot of each day, not an average. + * + * Averaging would be wrong for cumulative counters — the mean of a rising + * counter is a value it held at no point — and the last reading of the day is + * also the one the next day's difference has to be taken against. + * + * Days with no snapshot at all are simply absent rather than zero-filled. A + * restart, or an instance that was down for six hours, must not draw a cliff to + * zero and back on a chart of a counter that never moved. + */ +export function dailyPoints(rows: Snapshot[]): DailyPoint[] { + const byDay = new Map<string, Snapshot>(); + for (const row of rows) { + const day = row.at.toISOString().slice(0, 10); + const seen = byDay.get(day); + if (!seen || row.at > seen.at) byDay.set(day, row); + } + return [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, s]) => ({ + day, + users: s.users, + torrents: s.torrents, + peers: s.peers, + seeders: s.seeders, + uploaded: s.uploaded, + })); +} + +export interface DailyDelta { + day: string; + /** Bytes that moved that day. Never negative — see below. */ + bytes: number; + /** Torrents added that day. Never negative, same reason. */ + torrents: number; + /** Members who joined that day. */ + users: number; +} + +/** + * Per-day movement, from consecutive cumulative points. + * + * A decrease is clamped to zero rather than reported, and that is the whole + * reason this is a tested function. `total_uploaded_bytes` is + * `SUM(users.uploaded)`, so **erasing an account lowers it** — as does a + * moderator zeroing a cheater's stats, or a torrent being deleted. Reporting + * "-4.2 TB of traffic on Tuesday" would be worse than useless: it is a figure a + * reader would try to explain. + * + * The first day has no predecessor, so it is dropped rather than compared + * against zero — which would otherwise print the site's entire history as one + * day's traffic. + */ +export function dailyDeltas(points: DailyPoint[]): DailyDelta[] { + const out: DailyDelta[] = []; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]!; + const cur = points[i]!; + out.push({ + day: cur.day, + bytes: Math.max(0, cur.uploaded - prev.uploaded), + torrents: Math.max(0, cur.torrents - prev.torrents), + users: Math.max(0, cur.users - prev.users), + }); + } + return out; +} + +/** The busiest day by bytes moved, or null when there is nothing to compare. */ +export function busiestDay(deltas: DailyDelta[]): DailyDelta | null { + let best: DailyDelta | null = null; + for (const d of deltas) { + if (d.bytes > 0 && (!best || d.bytes > best.bytes)) best = d; + } + return best; +} + +/** + * The half-open UTC window for a calendar year. + * + * UTC rather than the instance's local time, deliberately: the members of one + * tracker are spread across every timezone, so a "year" anchored on the + * server's own offset would be an arbitrary choice presented as a fact. The + * boundary is stated in the guide. + */ +export function yearWindow(year: number): { start: Date; end: Date } { + return { + start: new Date(Date.UTC(year, 0, 1)), + end: new Date(Date.UTC(year + 1, 0, 1)), + }; +} + +/** + * Which years an instance can be asked about: the year of its first snapshot + * through the current one, newest first. + * + * Bounded by data rather than by a constant so the selector cannot offer a year + * the site did not exist for — an empty review reads like a broken page. + */ +export function selectableYears(firstSeen: Date | null, now: Date): number[] { + const current = now.getUTCFullYear(); + const first = firstSeen ? firstSeen.getUTCFullYear() : current; + const out: number[] = []; + for (let y = current; y >= first; y--) out.push(y); + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Queries +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The catalogue predicate every list here shares: a release that is live and + * has been through moderation, minus the adult tree when the caller has not + * asked for it. + * + * One definition, for the same reason `uploaderVisibility` is one definition: + * five hand-written copies of "what a member may see" will disagree, and the + * failure mode is a leak rather than a wrong total. + */ +function visibleTorrents(adultIds: string[]) { + const parts = [ + eq(schema.torrents.isActive, true), + eq(schema.torrents.moderationStatus, 'accepted'), + ]; + if (adultIds.length) { + // `notInArray` on a nullable column is null-safe here only because an + // uncategorised torrent cannot be in the adult tree: the OR keeps it. + parts.push( + sql`(${schema.torrents.categoryId} IS NULL OR ${schema.torrents.categoryId} NOT IN ${adultIds})` + ); + } + return and(...parts); +} + +/** The adult ids to exclude — empty when the member has opted in. */ +export async function hiddenCategoryIds(showAdult: boolean): Promise<string[]> { + if (showAdult) return []; + return adultCategoryIds(); +} + +export interface SiteNow { + torrents: number; + members: number; + seeders: number; + leechers: number; + snatches: number; + /** Bytes catalogued (sum of torrent sizes), and bytes moved (announce deltas). */ + catalogued: number; + trafficTotal: number; +} + +export async function siteNow(adultIds: string[]): Promise<SiteNow> { + const where = visibleTorrents(adultIds); + + const [counts] = await db + .select({ + torrents: sql<number>`count(*)::int`, + catalogued: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .where(where); + + const [swarm] = await db + .select({ + seeders: sql<number>`coalesce(sum(${schema.torrentStats.seeders}), 0)::int`, + leechers: sql<number>`coalesce(sum(${schema.torrentStats.leechers}), 0)::int`, + snatches: sql<number>`coalesce(sum(${schema.torrentStats.completed}), 0)::int`, + }) + .from(schema.torrentStats) + .innerJoin( + schema.torrents, + eq(schema.torrents.infoHash, schema.torrentStats.infoHash) + ) + .where(where); + + // Members, not rows: an erased account keeps its row (nothing cascades from + // `users`), so counting rows would count people who asked to be forgotten. + const [members] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.users) + .where(isNull(schema.users.deletedAt)); + + const [latest] = await db + .select({ uploaded: schema.siteStats.totalUploadedBytes }) + .from(schema.siteStats) + .orderBy(desc(schema.siteStats.createdAt)) + .limit(1); + + return { + torrents: counts?.torrents ?? 0, + members: members?.n ?? 0, + seeders: swarm?.seeders ?? 0, + leechers: swarm?.leechers ?? 0, + snatches: swarm?.snatches ?? 0, + catalogued: Number(counts?.catalogued ?? 0), + trafficTotal: Number(latest?.uploaded ?? 0), + }; +} + +/** Raw snapshots for a window, oldest first. */ +export async function snapshots(since: Date, until?: Date): Promise<Snapshot[]> { + const rows = await db + .select({ + at: schema.siteStats.createdAt, + users: schema.siteStats.usersCount, + torrents: schema.siteStats.torrentsCount, + peers: schema.siteStats.peersCount, + seeders: schema.siteStats.seedersCount, + uploaded: schema.siteStats.totalUploadedBytes, + }) + .from(schema.siteStats) + .where( + until + ? and( + gte(schema.siteStats.createdAt, since), + lt(schema.siteStats.createdAt, until) + ) + : gte(schema.siteStats.createdAt, since) + ) + .orderBy(schema.siteStats.createdAt); + return rows.map((r) => ({ ...r, uploaded: Number(r.uploaded) })); +} + +export interface CategorySlice { + id: string; + name: string; + slug: string; + torrents: number; + bytes: number; +} + +export async function categoryBreakdown( + adultIds: string[] +): Promise<CategorySlice[]> { + const rows = await db + .select({ + id: schema.categories.id, + name: schema.categories.name, + slug: schema.categories.slug, + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .innerJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(visibleTorrents(adultIds)) + .groupBy(schema.categories.id, schema.categories.name, schema.categories.slug) + .orderBy(desc(sql`count(*)`)); + return rows.map((r) => ({ ...r, bytes: Number(r.bytes) })); +} + +export interface TopTorrent { + infoHash: string; + name: string; + categoryName: string | null; + size: number; + seeders: number; + snatches: number; + createdAt: Date; +} + +/** + * Releases ranked by one column, `snatches` or `seeders`. + * + * The ordering column is chosen from a closed set rather than interpolated — + * this is an ORDER BY, which no amount of escaping makes safe from a caller. + */ +export async function topTorrents( + by: 'snatches' | 'seeders', + adultIds: string[], + limit: number +): Promise<TopTorrent[]> { + const column = + by === 'snatches' ? schema.torrentStats.completed : schema.torrentStats.seeders; + const rows = await db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + categoryName: schema.categories.name, + size: schema.torrents.size, + seeders: schema.torrentStats.seeders, + snatches: schema.torrentStats.completed, + createdAt: schema.torrents.createdAt, + }) + .from(schema.torrents) + .innerJoin( + schema.torrentStats, + eq(schema.torrentStats.infoHash, schema.torrents.infoHash) + ) + .leftJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(and(visibleTorrents(adultIds), sql`${column} > 0`)) + .orderBy(desc(column)) + .limit(limit); + return rows.map((r) => ({ ...r, size: Number(r.size) })); +} + +export interface TopUploader { + username: string; + uploads: number; +} + +/** + * Members ranked by how many live releases they have contributed. + * + * Counts, never bytes — see the note at the top of the file — and never a + * member who uploads anonymously. + */ +export async function topUploaders( + adultIds: string[], + limit: number +): Promise<TopUploader[]> { + const rows = await db + .select({ + username: schema.users.username, + uploads: sql<number>`count(*)::int`, + }) + .from(schema.torrents) + .innerJoin(schema.users, eq(schema.users.id, schema.torrents.uploaderId)) + .where( + and( + visibleTorrents(adultIds), + eq(schema.users.anonymousUploads, false), + isNull(schema.users.deletedAt) + ) + ) + .groupBy(schema.users.username) + .orderBy(desc(sql`count(*)`)) + .limit(limit); + return rows; +} + +export interface YearInReview { + year: number; + /** Null until the year has a first snapshot — a year before the site existed. */ + trafficBytes: number | null; + torrentsAdded: number; + bytesAdded: number; + membersJoined: number; + uploadersActive: number; + snatches: number; + busiestDay: DailyDelta | null; + months: Array<{ month: number; torrents: number; bytes: number }>; + topCategories: CategorySlice[]; + topReleases: TopTorrent[]; +} + +export async function siteYear( + year: number, + adultIds: string[] +): Promise<YearInReview> { + const { start, end } = yearWindow(year); + const inYear = and( + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ); + const where = and(visibleTorrents(adultIds), inYear); + + const [added] = await db + .select({ + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + uploaders: sql<number>`count(distinct ${schema.torrents.uploaderId})::int`, + }) + .from(schema.torrents) + .where(where); + + const [joined] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.users) + .where( + and( + gte(schema.users.createdAt, start), + lt(schema.users.createdAt, end), + isNull(schema.users.deletedAt) + ) + ); + + // Completions dated inside the year, from the hit-and-run ledger — the only + // per-download record the site keeps. `torrent_stats.completed` is a running + // total with no date, so it cannot answer a question about a year. + const [snatched] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.hnrTracking) + .innerJoin( + schema.torrents, + eq(schema.torrents.id, schema.hnrTracking.torrentId) + ) + .where( + and( + visibleTorrents(adultIds), + gte(schema.hnrTracking.downloadedAt, start), + lt(schema.hnrTracking.downloadedAt, end) + ) + ); + + const monthRows = await db + .select({ + month: sql<number>`extract(month from ${schema.torrents.createdAt})::int`, + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .where(where) + .groupBy(sql`extract(month from ${schema.torrents.createdAt})`) + .orderBy(sql`extract(month from ${schema.torrents.createdAt})`); + + const catRows = await db + .select({ + id: schema.categories.id, + name: schema.categories.name, + slug: schema.categories.slug, + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .innerJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(where) + .groupBy(schema.categories.id, schema.categories.name, schema.categories.slug) + .orderBy(desc(sql`count(*)`)) + .limit(5); + + const releaseRows = await db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + categoryName: schema.categories.name, + size: schema.torrents.size, + seeders: schema.torrentStats.seeders, + snatches: schema.torrentStats.completed, + createdAt: schema.torrents.createdAt, + }) + .from(schema.torrents) + .innerJoin( + schema.torrentStats, + eq(schema.torrentStats.infoHash, schema.torrents.infoHash) + ) + .leftJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(where) + .orderBy(desc(schema.torrentStats.completed)) + .limit(5); + + // Traffic across the year: the difference between the first and last snapshot + // inside it. Clamped, because the same counter can go down. + const points = dailyPoints(await snapshots(start, end)); + const deltas = dailyDeltas(points); + const traffic = points.length >= 2 + ? Math.max(0, points[points.length - 1]!.uploaded - points[0]!.uploaded) + : null; + + return { + year, + trafficBytes: traffic, + torrentsAdded: added?.torrents ?? 0, + bytesAdded: Number(added?.bytes ?? 0), + membersJoined: joined?.n ?? 0, + uploadersActive: added?.uploaders ?? 0, + snatches: snatched?.n ?? 0, + busiestDay: busiestDay(deltas), + months: monthRows.map((m) => ({ ...m, bytes: Number(m.bytes) })), + topCategories: catRows.map((c) => ({ ...c, bytes: Number(c.bytes) })), + topReleases: releaseRows.map((r) => ({ ...r, size: Number(r.size) })), + }; +} + +export interface MemberYear { + year: number; + uploads: number; + uploadedBytesCatalogued: number; + snatches: number; + seedTimeSeconds: number; + bytesUp: number; + bytesDown: number; + bonusEarned: number; + invitesUsed: number; + topCategory: { name: string; torrents: number } | null; + bestRelease: { infoHash: string; name: string; snatches: number } | null; +} + +/** + * One member's year, for that member only. + * + * Their own numbers, so there is nothing to redact — the adult filter is not + * applied here either, since a member who downloaded something already saw it, + * and hiding their own history from them would be a lie about their own year. + */ +export async function memberYear( + userId: string, + year: number +): Promise<MemberYear> { + const { start, end } = yearWindow(year); + + const [uploads] = await db + .select({ + n: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .where( + and( + eq(schema.torrents.uploaderId, userId), + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ) + ); + + // The hit-and-run ledger doubles as the member's own download record: one row + // per (member, torrent) grab, with the bytes that actually moved on it. + const [grabs] = await db + .select({ + n: sql<number>`count(*)::int`, + seedTime: sql<number>`coalesce(sum(${schema.hnrTracking.seedTime}), 0)::bigint`, + up: sql<number>`coalesce(sum(${schema.hnrTracking.uploaded}), 0)::bigint`, + down: sql<number>`coalesce(sum(${schema.hnrTracking.downloaded}), 0)::bigint`, + }) + .from(schema.hnrTracking) + .where( + and( + eq(schema.hnrTracking.userId, userId), + gte(schema.hnrTracking.downloadedAt, start), + lt(schema.hnrTracking.downloadedAt, end) + ) + ); + + const [bonus] = await db + .select({ + amount: sql<number>`coalesce(sum(${schema.bonusGrants.amount}), 0)::numeric`, + }) + .from(schema.bonusGrants) + .where( + and( + eq(schema.bonusGrants.userId, userId), + gte(schema.bonusGrants.createdAt, start), + lt(schema.bonusGrants.createdAt, end) + ) + ); + + const [invites] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.invitations) + .where( + and( + eq(schema.invitations.createdBy, userId), + sql`${schema.invitations.usedBy} IS NOT NULL`, + gte(schema.invitations.createdAt, start), + lt(schema.invitations.createdAt, end) + ) + ); + + const [category] = await db + .select({ + name: schema.categories.name, + torrents: sql<number>`count(*)::int`, + }) + .from(schema.torrents) + .innerJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where( + and( + eq(schema.torrents.uploaderId, userId), + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ) + ) + .groupBy(schema.categories.name) + .orderBy(desc(sql`count(*)`)) + .limit(1); + + const [best] = await db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + snatches: schema.torrentStats.completed, + }) + .from(schema.torrents) + .innerJoin( + schema.torrentStats, + eq(schema.torrentStats.infoHash, schema.torrents.infoHash) + ) + .where( + and( + eq(schema.torrents.uploaderId, userId), + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ) + ) + .orderBy(desc(schema.torrentStats.completed)) + .limit(1); + + return { + year, + uploads: uploads?.n ?? 0, + uploadedBytesCatalogued: Number(uploads?.bytes ?? 0), + snatches: grabs?.n ?? 0, + seedTimeSeconds: Number(grabs?.seedTime ?? 0), + bytesUp: Number(grabs?.up ?? 0), + bytesDown: Number(grabs?.down ?? 0), + bonusEarned: Number(bonus?.amount ?? 0), + invitesUsed: invites?.n ?? 0, + topCategory: category ?? null, + bestRelease: best ?? null, + }; +} + +/** The first snapshot the instance ever wrote, for the year selector. */ +export async function firstSnapshotAt(): Promise<Date | null> { + const [row] = await db + .select({ at: schema.siteStats.createdAt }) + .from(schema.siteStats) + .orderBy(schema.siteStats.createdAt) + .limit(1); + return row?.at ?? null; +} diff --git a/apps/web/app/components/CommandPalette.vue b/apps/web/app/components/CommandPalette.vue index 0bfda9ef..dfc57b66 100644 --- a/apps/web/app/components/CommandPalette.vue +++ b/apps/web/app/components/CommandPalette.vue @@ -174,6 +174,7 @@ const mainLinks = computed<PaletteItem[]>(() => { }, { to: '/requests', key: 'requests', icon: 'ph:megaphone-bold' }, { to: '/forum', key: 'forum', icon: 'ph:chat-centered-text' }, + { to: '/stats', key: 'stats', icon: 'ph:chart-line-up' }, { to: '/admin', key: 'admin', diff --git a/apps/web/app/components/stats/StatSeries.vue b/apps/web/app/components/stats/StatSeries.vue new file mode 100644 index 00000000..886b0ed4 --- /dev/null +++ b/apps/web/app/components/stats/StatSeries.vue @@ -0,0 +1,306 @@ +<template> + <figure class="series"> + <figcaption class="series-cap"> + <span class="series-label">{{ label }}</span> + <span v-if="points.length" class="series-latest">{{ format(points[points.length - 1]!) }}</span> + </figcaption> + + <!-- Not enough history to draw a shape. Said plainly rather than drawn as a + flat line at zero, which reads as "nothing happened" instead of "we + have not been counting long enough". --> + <p v-if="points.length < 2" class="series-empty"> + {{ $t('stats.series.tooShort') }} + </p> + + <svg + v-else + class="series-svg" + :class="`series-svg--${kind}`" + :viewBox="`0 0 ${VIEW_W} ${VIEW_H}`" + preserveAspectRatio="none" + role="img" + :aria-label="summary" + > + <!-- Three gridlines, low contrast on purpose: they locate a value without + competing with it. --> + <line + v-for="ratio in [0.25, 0.5, 0.75]" + :key="ratio" + class="series-grid" + x1="0" + :y1="VIEW_H * ratio" + :x2="VIEW_W" + :y2="VIEW_H * ratio" + /> + <template v-if="kind === 'area'"> + <path class="series-fill" :d="areaPath" /> + <path class="series-line" :d="linePath" /> + </template> + <template v-else> + <rect + v-for="(bar, i) in bars" + :key="i" + class="series-bar" + :x="bar.x" + :y="bar.y" + :width="bar.w" + :height="bar.h" + /> + </template> + </svg> + + <div v-if="points.length >= 2" class="series-axis"> + <span>{{ labels[0] }}</span> + <span>{{ labels[labels.length - 1] }}</span> + </div> + + <!-- The chart is a picture; this is the data. Collapsed so it costs a + reader nothing, present so a screen reader and a sceptic both have + somewhere to go. --> + <details v-if="points.length" class="series-data"> + <summary>{{ $t('stats.series.table') }}</summary> + <div class="series-scroll"> + <table> + <thead> + <tr> + <th scope="col">{{ $t('stats.series.when') }}</th> + <th scope="col">{{ label }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(value, i) in points" :key="i"> + <td>{{ labels[i] }}</td> + <td class="series-num">{{ format(value) }}</td> + </tr> + </tbody> + </table> + </div> + </details> + </figure> +</template> + +<script setup lang="ts"> +/** + * One series, drawn without a charting library. + * + * No library because of the CSP — `script-src 'self'` rules out a CDN, and the + * shape needed here is a polyline and some rectangles. What a library would add + * is 40 KB and a tooltip; what it would cost is a dependency in the page every + * member loads. + * + * Three things here are deliberate rather than incidental: + * + * 1. **A short series says so.** Fewer than two points cannot make a line, and + * a chart padded to a window it has no data for draws a flat run at zero — + * which reads as "the site was dead until Tuesday". + * 2. **The numbers are reachable.** A picture is not accessible and is not + * verifiable; the collapsed table under each chart is both, and it is the + * same array the shape is drawn from. + * 3. **The scale starts at the minimum, not at zero.** These are cumulative + * counters — members, torrents — where the interesting part is the slope + * and a zero-based axis flattens it into a straight line. The axis labels + * say which window is shown, and the table gives the values, so nothing is + * hidden by the choice. + */ +const props = withDefaults( + defineProps<{ + label: string; + points: number[]; + /** One per point: `YYYY-MM-DD`, or a month name. */ + labels: string[]; + kind?: 'area' | 'bars'; + /** How to render a value — bytes, counts, whatever the caller means. */ + format?: (value: number) => string; + /** For the accessible summary, e.g. "torrents". */ + unit?: string; + }>(), + { + kind: 'area', + format: (value: number) => value.toLocaleString(), + unit: '', + } +); + +const { t } = useI18n(); + +const VIEW_W = 100; +const VIEW_H = 40; + +const bounds = computed(() => { + const min = Math.min(...props.points); + const max = Math.max(...props.points); + // A flat series would divide by zero; give it a band so the line sits in the + // middle rather than at an edge. + return max === min ? { min: min - 1, max: max + 1 } : { min, max }; +}); + +function y(value: number): number { + const { min, max } = bounds.value; + const t = (value - min) / (max - min); + // 1 unit of padding top and bottom so a peak is not clipped by the viewBox. + return VIEW_H - 1 - t * (VIEW_H - 2); +} + +function x(index: number): number { + return props.points.length === 1 + ? VIEW_W / 2 + : (index / (props.points.length - 1)) * VIEW_W; +} + +const linePath = computed(() => + props.points.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(2)},${y(v).toFixed(2)}`).join(' ') +); + +const areaPath = computed( + () => `${linePath.value} L${VIEW_W},${VIEW_H} L0,${VIEW_H} Z` +); + +const bars = computed(() => { + const n = props.points.length; + const slot = VIEW_W / n; + const gap = Math.min(0.6, slot * 0.25); + const zero = VIEW_H - 1; + return props.points.map((value, i) => { + const top = y(value); + return { + x: i * slot + gap / 2, + w: Math.max(0.4, slot - gap), + y: top, + // A day with nothing still gets a hairline, so a gap in the data and a + // day with no traffic do not look identical. + h: Math.max(0.35, zero - top), + }; + }); +}); + +const summary = computed(() => { + if (!props.points.length) return props.label; + const unit = props.unit ? ` ${props.unit}` : ''; + if (props.kind === 'bars') { + // Per-period bars have no meaningful first-to-last story — a year that + // starts and ends in a quiet month would read "0 → 0". The peak is the + // sentence somebody listening to this page actually wants. + let peak = 0; + let at = 0; + props.points.forEach((value, i) => { + if (value > peak) { + peak = value; + at = i; + } + }); + const total = props.points.reduce((sum, v) => sum + v, 0); + // Translated like every other string here: an aria-label is read aloud, so + // an English sentence in a French page is worse than a bare number. + return t('stats.series.summaryBars', { + label: props.label, + total: `${props.format(total)}${unit}`, + peak: props.format(peak), + when: props.labels[at] ?? '', + }); + } + const first = props.points[0]!; + const last = props.points[props.points.length - 1]!; + return t('stats.series.summaryTrend', { + label: props.label, + from: props.format(first), + to: `${props.format(last)}${unit}`, + }); +}); + +const format = (value: number) => props.format(value); +</script> + +<style scoped> +.series { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin: 0; + min-width: 0; +} +.series-cap { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} +.series-label { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.14em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); +} +.series-latest { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 0.8125rem; + color: rgb(var(--fg-default)); +} +.series-svg { + width: 100%; + height: 5.5rem; + overflow: visible; +} +.series-grid { + stroke: rgb(var(--line-default)); + stroke-width: 0.25; + vector-effect: non-scaling-stroke; +} +.series-fill { + fill: rgb(var(--accent-warm) / 0.14); +} +.series-line { + fill: none; + stroke: rgb(var(--accent-warm)); + stroke-width: 1.5; + stroke-linejoin: round; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} +.series-bar { + fill: rgb(var(--chart-2)); +} +.series-axis { + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 0.625rem; + color: rgb(var(--fg-faint)); +} +.series-empty { + padding: 1.5rem 0; + font-size: 0.75rem; + color: rgb(var(--fg-subtle)); + text-align: center; +} +.series-data { + font-size: 0.6875rem; + color: rgb(var(--fg-subtle)); +} +.series-data summary { + cursor: pointer; + padding: 0.15rem 0; +} +.series-scroll { + max-height: 12rem; + overflow: auto; + margin-top: 0.35rem; +} +.series-data table { + width: 100%; + border-collapse: collapse; +} +.series-data th, +.series-data td { + text-align: left; + padding: 0.15rem 0.4rem; + border-bottom: 1px solid rgb(var(--line-default)); + white-space: nowrap; +} +.series-num { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + text-align: right; +} +</style> diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index 818b2131..9c977511 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -982,6 +982,7 @@ const navLinks = [ { to: '/federated', labelKey: 'nav.federated', icon: 'ph:broadcast', adminOnly: false, federationOnly: true }, { to: '/requests', labelKey: 'nav.requests', icon: 'ph:megaphone-bold', adminOnly: false }, { to: '/forum', labelKey: 'nav.forum', icon: 'ph:chat-centered-text', adminOnly: false }, + { to: '/stats', labelKey: 'nav.stats', icon: 'ph:chart-line-up', adminOnly: false }, { to: '/admin', labelKey: 'nav.admin', icon: 'ph:shield-check', adminOnly: true }, { to: '/mod', labelKey: 'nav.mod', icon: 'ph:shield', modOnly: true }, ]; diff --git a/apps/web/app/pages/stats.vue b/apps/web/app/pages/stats.vue new file mode 100644 index 00000000..e926c65a --- /dev/null +++ b/apps/web/app/pages/stats.vue @@ -0,0 +1,821 @@ +<template> + <div class="stats"> + <header class="st-head"> + <p class="st-eyebrow">{{ $t('stats.eyebrow') }}</p> + <h1 class="st-title">{{ $t('stats.title') }}</h1> + <p class="st-lede">{{ $t('stats.lede') }}</p> + </header> + + <!-- The lead figures. Five numbers, sized so the page has one obvious + beginning, in tabular numerals so they do not shuffle on refresh. --> + <section class="st-lead" aria-labelledby="st-now"> + <h2 id="st-now" class="st-sr">{{ $t('stats.now.title') }}</h2> + <div class="st-figures"> + <div v-for="(fig, i) in figures" :key="fig.key" class="st-figure" :style="delay(i)"> + <span class="st-figure-value">{{ fig.value }}</span> + <span class="st-figure-label">{{ fig.label }}</span> + <span v-if="fig.note" class="st-figure-note">{{ fig.note }}</span> + </div> + </div> + </section> + + <!-- History. The window is a choice because a 30-day view and a year + answer different questions, and a chart with no scale control invites + the reader to guess which one they are looking at. --> + <section class="st-panel" aria-labelledby="st-growth"> + <div class="st-panel-head"> + <h2 id="st-growth">{{ $t('stats.growth.title') }}</h2> + <div class="st-windows" role="group" :aria-label="$t('stats.growth.windowAria')"> + <button + v-for="w in WINDOWS" + :key="w" + type="button" + class="st-window" + :class="{ 'st-window--on': windowDays === w }" + :aria-pressed="windowDays === w" + @click="windowDays = w" + > + {{ $t('stats.growth.window', { days: w }) }} + </button> + </div> + </div> + + <p v-if="!data?.points?.length" class="st-empty">{{ $t('stats.growth.none') }}</p> + <div v-else class="st-charts"> + <StatsStatSeries + :label="$t('stats.growth.torrents')" + :points="data.points.map((p) => p.torrents)" + :labels="data.points.map((p) => p.day)" + :unit="$t('stats.growth.torrentsUnit')" + /> + <StatsStatSeries + :label="$t('stats.growth.members')" + :points="data.points.map((p) => p.users)" + :labels="data.points.map((p) => p.day)" + :unit="$t('stats.growth.membersUnit')" + /> + <StatsStatSeries + :label="$t('stats.growth.traffic')" + :points="(data.deltas ?? []).map((d) => d.bytes)" + :labels="(data.deltas ?? []).map((d) => d.day)" + kind="bars" + :format="bytes" + /> + </div> + </section> + + <!-- The catalogue, by category. Bars rather than a pie: there are more than + five categories on any real instance, and a reader wants to compare two + of them rather than see a whole. Labelled directly, so the eye does not + travel to a legend. --> + <section class="st-panel" aria-labelledby="st-cats"> + <div class="st-panel-head"> + <h2 id="st-cats">{{ $t('stats.categories.title') }}</h2> + <p class="st-panel-note">{{ $t('stats.categories.note') }}</p> + </div> + <p v-if="!categories.length" class="st-empty">{{ $t('stats.categories.none') }}</p> + <ul v-else class="st-bars"> + <li v-for="cat in categories" :key="cat.id" class="st-bar-row"> + <NuxtLink :to="`/torrents?category=${cat.slug}`" class="st-bar-name"> + {{ cat.name }} + </NuxtLink> + <div class="st-bar-track"> + <div class="st-bar-fill" :style="{ width: `${barWidth(cat.torrents)}%` }" /> + </div> + <span class="st-bar-count">{{ cat.torrents.toLocaleString() }}</span> + <span class="st-bar-bytes">{{ bytes(cat.bytes) }}</span> + </li> + </ul> + </section> + + <div class="st-columns"> + <!-- Two rankings, side by side. Same shape, different question: what the + site grabbed, and what the site is keeping alive. --> + <section class="st-panel" aria-labelledby="st-snatched"> + <div class="st-panel-head"> + <h2 id="st-snatched">{{ $t('stats.top.snatched') }}</h2> + </div> + <ol v-if="data?.mostSnatched?.length" class="st-rank"> + <li v-for="(t, i) in data.mostSnatched" :key="t.infoHash" class="st-rank-row"> + <span class="st-rank-n">{{ i + 1 }}</span> + <NuxtLink :to="`/torrents/${t.infoHash}`" class="st-rank-name" :title="t.name"> + {{ t.name }} + </NuxtLink> + <span class="st-rank-meta">{{ t.categoryName ?? $t('stats.uncategorised') }}</span> + <span class="st-rank-value">{{ t.snatches.toLocaleString() }}</span> + </li> + </ol> + <p v-else class="st-empty">{{ $t('stats.top.none') }}</p> + </section> + + <section class="st-panel" aria-labelledby="st-swarms"> + <div class="st-panel-head"> + <h2 id="st-swarms">{{ $t('stats.top.swarms') }}</h2> + </div> + <ol v-if="data?.biggestSwarms?.length" class="st-rank"> + <li v-for="(t, i) in data.biggestSwarms" :key="t.infoHash" class="st-rank-row"> + <span class="st-rank-n">{{ i + 1 }}</span> + <NuxtLink :to="`/torrents/${t.infoHash}`" class="st-rank-name" :title="t.name"> + {{ t.name }} + </NuxtLink> + <span class="st-rank-meta">{{ bytes(t.size) }}</span> + <span class="st-rank-value st-rank-value--up">{{ t.seeders.toLocaleString() }}</span> + </li> + </ol> + <p v-else class="st-empty">{{ $t('stats.top.none') }}</p> + </section> + </div> + + <!-- The uploader board, with the sentence that explains what it is not. + A leaderboard on a tracker is normally ranked by volume; this one is + not, and a reader deserves to know why rather than assume an omission. --> + <section class="st-panel" aria-labelledby="st-uploaders"> + <div class="st-panel-head"> + <h2 id="st-uploaders">{{ $t('stats.uploaders.title') }}</h2> + <p class="st-panel-note">{{ $t('stats.uploaders.note') }}</p> + </div> + <ol v-if="data?.topUploaders?.length" class="st-board"> + <li v-for="(u, i) in data.topUploaders" :key="u.username" class="st-board-row"> + <span class="st-rank-n">{{ i + 1 }}</span> + <NuxtLink :to="`/users/${u.username}`" class="st-board-name">{{ u.username }}</NuxtLink> + <span class="st-board-count">{{ $t('stats.uploaders.count', { n: u.uploads }) }}</span> + </li> + </ol> + <p v-else class="st-empty">{{ $t('stats.uploaders.none') }}</p> + </section> + + <!-- The year. A separate act on the page rather than a separate route: the + numbers above are what the site is, these are what a year of it was. --> + <section class="st-year" aria-labelledby="st-year-h"> + <div class="st-panel-head"> + <h2 id="st-year-h">{{ $t('stats.year.title', { year }) }}</h2> + <div class="st-windows" role="group" :aria-label="$t('stats.year.pickAria')"> + <button + v-for="y in years" + :key="y" + type="button" + class="st-window" + :class="{ 'st-window--on': year === y }" + :aria-pressed="year === y" + @click="year = y" + > + {{ y }} + </button> + </div> + </div> + + <div v-if="yearData" class="st-year-body"> + <div class="st-figures st-figures--year"> + <div class="st-figure"> + <span class="st-figure-value">{{ yearData.torrentsAdded.toLocaleString() }}</span> + <span class="st-figure-label">{{ $t('stats.year.added') }}</span> + <span class="st-figure-note">{{ bytes(yearData.bytesAdded) }}</span> + </div> + <div class="st-figure"> + <span class="st-figure-value">{{ yearData.membersJoined.toLocaleString() }}</span> + <span class="st-figure-label">{{ $t('stats.year.joined') }}</span> + </div> + <div class="st-figure"> + <span class="st-figure-value">{{ yearData.snatches.toLocaleString() }}</span> + <span class="st-figure-label">{{ $t('stats.year.snatches') }}</span> + </div> + <div class="st-figure"> + <span class="st-figure-value"> + {{ yearData.trafficBytes === null ? '—' : bytes(yearData.trafficBytes) }} + </span> + <span class="st-figure-label">{{ $t('stats.year.traffic') }}</span> + <!-- Said on the page and not only in the guide: this figure is a + floor, because the counter behind it drops when an account is + erased or a cheater is reset. --> + <span class="st-figure-note">{{ $t('stats.year.trafficNote') }}</span> + </div> + </div> + + <StatsStatSeries + v-if="monthPoints.length" + :label="$t('stats.year.byMonth')" + :points="monthPoints" + :labels="monthLabels" + kind="bars" + /> + + <div class="st-year-lists"> + <div> + <h3 class="st-sub">{{ $t('stats.year.topCategories') }}</h3> + <ul class="st-mini"> + <li v-for="c in yearData.topCategories" :key="c.id"> + <span>{{ c.name }}</span> + <span class="st-mini-value">{{ c.torrents.toLocaleString() }}</span> + </li> + <li v-if="!yearData.topCategories.length" class="st-mini-empty"> + {{ $t('stats.year.nothing') }} + </li> + </ul> + </div> + <div> + <h3 class="st-sub">{{ $t('stats.year.topReleases') }}</h3> + <ul class="st-mini"> + <li v-for="r in yearData.topReleases" :key="r.infoHash"> + <NuxtLink :to="`/torrents/${r.infoHash}`" class="st-mini-link" :title="r.name"> + {{ r.name }} + </NuxtLink> + <span class="st-mini-value">{{ r.snatches.toLocaleString() }}</span> + </li> + <li v-if="!yearData.topReleases.length" class="st-mini-empty"> + {{ $t('stats.year.nothing') }} + </li> + </ul> + </div> + </div> + + <p v-if="yearData.busiestDay" class="st-busiest"> + {{ $t('stats.year.busiest', { + day: yearData.busiestDay.day, + bytes: bytes(yearData.busiestDay.bytes), + }) }} + </p> + </div> + <p v-else class="st-empty">{{ $t('stats.year.none') }}</p> + + <!-- And the member's own. Their data, so it is theirs alone — no id in + the URL, no staff view of it. --> + <div v-if="mine" class="st-mine"> + <h3 class="st-sub">{{ $t('stats.mine.title', { year }) }}</h3> + <div class="st-mine-grid"> + <div><b>{{ mine.uploads.toLocaleString() }}</b><span>{{ $t('stats.mine.uploads') }}</span></div> + <div><b>{{ mine.snatches.toLocaleString() }}</b><span>{{ $t('stats.mine.snatches') }}</span></div> + <div><b>{{ bytes(mine.bytesUp) }}</b><span>{{ $t('stats.mine.up') }}</span></div> + <div><b>{{ bytes(mine.bytesDown) }}</b><span>{{ $t('stats.mine.down') }}</span></div> + <div><b>{{ hours(mine.seedTimeSeconds) }}</b><span>{{ $t('stats.mine.seedTime') }}</span></div> + <div><b>{{ Math.round(mine.bonusEarned).toLocaleString() }}</b><span>{{ $t('stats.mine.bonus') }}</span></div> + </div> + <p v-if="mine.bestRelease" class="st-mine-best"> + {{ $t('stats.mine.best') }} + <NuxtLink :to="`/torrents/${mine.bestRelease.infoHash}`">{{ mine.bestRelease.name }}</NuxtLink> + <span class="st-mine-best-n">{{ + $t('stats.mine.bestSnatches', { n: mine.bestRelease.snatches }) + }}</span> + </p> + <p v-if="mine.topCategory" class="st-mine-cat"> + {{ $t('stats.mine.favourite', { + category: mine.topCategory.name, + n: mine.topCategory.torrents, + }) }} + </p> + </div> + </section> + + <p class="st-foot">{{ $t('stats.foot') }}</p> + </div> +</template> + +<script setup lang="ts"> +/** + * The state of the site, for the members of it. + * + * ## Why this page exists on a tracker + * + * Everything here was already in the database and visible only to + * administrators. A member could see the four counters on the homepage and + * nothing else — not how the catalogue is shaped, not whether the site is + * growing, not what everybody is grabbing. Trackers that publish this keep + * people; the numbers are the community looking at itself. + * + * ## The shape of the page is an argument + * + * Five lead figures, then history, then the catalogue, then rankings, then the + * year. It reads top to bottom as "what the site is now" → "how it got here" → + * "what is in it" → "what a year of it was", and each section is one question. + * The alternative — a dashboard of twelve equal tiles — makes every number as + * important as every other, which is another way of making none of them + * important. + * + * ## What is not on it + * + * No per-member volume, anywhere. No ratio board, no "top downloaders". The + * endpoint refuses to compute them and the note under the uploader board says + * why: there is no setting on this site by which a member could opt out of one, + * so publishing it would publish a figure nobody agreed to publish. + * + * ## Charts + * + * Drawn in `StatSeries`, from tokens, with the numbers reachable under each + * one. See that component for why there is no charting library. + */ +const { t, locale } = useI18n(); + +useHead({ title: () => t('stats.title') }); + +const WINDOWS = [30, 90, 365] as const; +const windowDays = ref<number>(90); + +interface SitePoint { + day: string; + users: number; + torrents: number; + peers: number; + seeders: number; + uploaded: number; +} +interface SiteStats { + now: { + torrents: number; + members: number; + seeders: number; + leechers: number; + snatches: number; + catalogued: number; + trafficTotal: number; + }; + days: number; + points: SitePoint[]; + deltas: Array<{ day: string; bytes: number; torrents: number; users: number }>; + categories: Array<{ id: string; name: string; slug: string; torrents: number; bytes: number }>; + mostSnatched: Array<{ + infoHash: string; + name: string; + categoryName: string | null; + size: number; + seeders: number; + snatches: number; + }>; + biggestSwarms: SiteStats['mostSnatched']; + topUploaders: Array<{ username: string; uploads: number }>; + years: number[]; +} + +const { data } = await useFetch<SiteStats>('/api/stats/site', { + query: computed(() => ({ window: String(windowDays.value) })), +}); + +const years = computed(() => data.value?.years ?? [new Date().getUTCFullYear()]); +const year = ref<number>(new Date().getUTCFullYear()); +watch(years, (list) => { + // Never leave the selector on a year the instance cannot answer for — an + // empty review reads as a broken page rather than an empty year. + if (list.length && !list.includes(year.value)) year.value = list[0]!; +}); + +interface YearStats { + year: number; + trafficBytes: number | null; + torrentsAdded: number; + bytesAdded: number; + membersJoined: number; + uploadersActive: number; + snatches: number; + busiestDay: { day: string; bytes: number } | null; + months: Array<{ month: number; torrents: number; bytes: number }>; + topCategories: Array<{ id: string; name: string; slug: string; torrents: number; bytes: number }>; + topReleases: Array<{ infoHash: string; name: string; snatches: number }>; +} + +const { data: yearData } = await useFetch<YearStats>('/api/stats/year', { + query: computed(() => ({ year: String(year.value) })), +}); + +interface MineStats { + uploads: number; + snatches: number; + seedTimeSeconds: number; + bytesUp: number; + bytesDown: number; + bonusEarned: number; + invitesUsed: number; + topCategory: { name: string; torrents: number } | null; + bestRelease: { infoHash: string; name: string; snatches: number } | null; +} + +const { data: mine } = await useFetch<MineStats>('/api/me/year', { + query: computed(() => ({ year: String(year.value) })), +}); + +/** Binary units, the ones a torrent client shows. */ +function bytes(value: number): string { + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + let v = Math.max(0, value); + let u = 0; + while (v >= 1024 && u < units.length - 1) { + v /= 1024; + u++; + } + return `${u === 0 ? Math.round(v) : v.toFixed(v < 10 ? 2 : 1)} ${units[u]}`; +} + +function hours(seconds: number): string { + const h = Math.round(seconds / 3600); + return h >= 24 + ? t('stats.mine.days', { n: Math.round(h / 24) }) + : t('stats.mine.hours', { n: h }); +} + +const figures = computed(() => { + const now = data.value?.now; + return [ + { + key: 'torrents', + value: (now?.torrents ?? 0).toLocaleString(), + label: t('stats.now.torrents'), + note: bytes(now?.catalogued ?? 0), + }, + { + key: 'members', + value: (now?.members ?? 0).toLocaleString(), + label: t('stats.now.members'), + note: '', + }, + { + key: 'seeders', + value: (now?.seeders ?? 0).toLocaleString(), + label: t('stats.now.seeders'), + note: t('stats.now.leechers', { n: (now?.leechers ?? 0).toLocaleString() }), + }, + { + key: 'snatches', + value: (now?.snatches ?? 0).toLocaleString(), + label: t('stats.now.snatches'), + note: '', + }, + { + key: 'traffic', + value: bytes(now?.trafficTotal ?? 0), + label: t('stats.now.traffic'), + note: '', + }, + ]; +}); + +const categories = computed(() => (data.value?.categories ?? []).slice(0, 12)); +const biggestCategory = computed(() => + Math.max(1, ...categories.value.map((c) => c.torrents)) +); +function barWidth(count: number): number { + // Relative to the largest, floored so a category with one torrent is still + // visible rather than a line of zero width. + return Math.max(1.5, (count / biggestCategory.value) * 100); +} + +const MONTHS = computed(() => + Array.from({ length: 12 }, (_, i) => + new Date(Date.UTC(2000, i, 1)).toLocaleDateString(locale.value, { + month: 'short', + timeZone: 'UTC', + }) + ) +); +const monthPoints = computed(() => { + const rows = yearData.value?.months ?? []; + if (!rows.length) return []; + // Zero-filled to twelve on purpose, unlike the daily series: a month with no + // upload is a fact about the year, and the axis is the calendar rather than + // however long the collector has been running. + const byMonth = new Map(rows.map((r) => [r.month, r.torrents])); + return MONTHS.value.map((_, i) => byMonth.get(i + 1) ?? 0); +}); +const monthLabels = computed(() => MONTHS.value); + +/** A staggered reveal, once, on the figures that lead the page. */ +function delay(index: number) { + return { animationDelay: `${index * 45}ms` }; +} +</script> + +<style scoped> +.stats { + max-width: 74rem; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.st-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +/* ── head ─────────────────────────────────────────────────────────────── */ +.st-head { display: flex; flex-direction: column; gap: 0.35rem; } +.st-eyebrow { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.18em * var(--tracking-scale)); + color: rgb(var(--accent-warm)); +} +.st-title { + font-family: var(--font-display); + font-size: clamp(1.75rem, 4vw, 2.5rem); + font-weight: 700; + line-height: 1.1; + color: rgb(var(--fg-strong)); +} +.st-lede { + font-size: 0.875rem; + color: rgb(var(--fg-muted)); + max-width: 46ch; +} + +/* ── lead figures ─────────────────────────────────────────────────────── */ +.st-figures { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); + gap: 1px; + background: rgb(var(--line-default)); + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-md); + overflow: hidden; +} +.st-figure { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.9rem 1rem; + background: rgb(var(--bg-surface)); + animation: st-rise 260ms ease-out both; +} +.st-figure-value { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: clamp(1.25rem, 2.4vw, 1.625rem); + font-weight: 700; + color: rgb(var(--fg-strong)); + line-height: 1.1; +} +.st-figure-label { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.12em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); +} +.st-figure-note { + font-family: var(--font-mono); + font-size: 0.625rem; + color: rgb(var(--fg-faint)); +} + +@keyframes st-rise { + from { opacity: 0; transform: translateY(0.35rem); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { + .st-figure { animation: none; } +} + +/* ── panels ───────────────────────────────────────────────────────────── */ +.st-panel { + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-md); + background: rgb(var(--bg-surface)); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; + min-width: 0; +} +.st-panel-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} +.st-panel-head h2 { + font-size: 0.9375rem; + font-weight: 600; + color: rgb(var(--fg-strong)); +} +.st-panel-note { + font-size: 0.6875rem; + color: rgb(var(--fg-subtle)); + max-width: 44ch; +} +.st-empty { + padding: 1.25rem 0; + text-align: center; + font-size: 0.8125rem; + color: rgb(var(--fg-subtle)); +} +.st-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 1.75rem; +} + +/* ── window switcher ──────────────────────────────────────────────────── */ +.st-windows { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; } +.st-window { + padding: 0.4rem 0.7rem; + /* 36px reads right beside a 12px label with a mouse; a finger needs 44. The + media query is on the INPUT rather than the width, because a small window + on a desktop is still a mouse. */ + min-height: 2.25rem; + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-sm); + background: rgb(var(--bg-elevated)); + color: rgb(var(--fg-muted)); + font-size: 0.75rem; + cursor: pointer; + transition: color 150ms ease-out, border-color 150ms ease-out, background 150ms ease-out; +} +.st-window:hover { color: rgb(var(--fg-default)); border-color: rgb(var(--line-strong)); } +.st-window--on { + background: rgb(var(--accent-warm) / 0.14); + border-color: rgb(var(--accent-warm)); + color: rgb(var(--fg-strong)); +} +@media (pointer: coarse) { + .st-window { min-height: 2.75rem; padding-inline: 0.9rem; } +} + +.st-charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1.25rem 1.5rem; +} + +/* ── category bars ────────────────────────────────────────────────────── */ +.st-bars { display: flex; flex-direction: column; gap: 0.4rem; } +.st-bar-row { + display: grid; + grid-template-columns: minmax(6rem, 10rem) minmax(0, 1fr) 4rem 5rem; + align-items: center; + gap: 0.6rem; + font-size: 0.75rem; +} +.st-bar-name { + color: rgb(var(--fg-default)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.st-bar-name:hover { color: rgb(var(--accent-warm)); } +.st-bar-track { + height: 0.5rem; + border-radius: 999px; + background: rgb(var(--bg-inset)); + overflow: hidden; +} +.st-bar-fill { + height: 100%; + border-radius: 999px; + background: rgb(var(--chart-1)); +} +.st-bar-count, +.st-bar-bytes { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + text-align: right; + color: rgb(var(--fg-muted)); +} +.st-bar-bytes { color: rgb(var(--fg-faint)); } +@media (max-width: 640px) { + .st-bar-row { grid-template-columns: minmax(0, 1fr) 3.5rem; grid-template-areas: 'name count' 'track track'; } + .st-bar-name { grid-area: name; } + .st-bar-count { grid-area: count; } + .st-bar-track { grid-area: track; } + .st-bar-bytes { display: none; } +} + +/* ── rankings ─────────────────────────────────────────────────────────── */ +.st-rank, .st-board { display: flex; flex-direction: column; } +.st-rank-row, .st-board-row { + display: grid; + grid-template-columns: 1.5rem minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0; + border-bottom: 1px solid rgb(var(--line-default)); + font-size: 0.8125rem; +} +.st-rank-row:last-child, .st-board-row:last-child { border-bottom: 0; } +.st-rank-n { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: rgb(var(--fg-faint)); + text-align: right; +} +.st-rank-name, .st-board-name { + color: rgb(var(--fg-default)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.st-rank-name:hover, .st-board-name:hover { color: rgb(var(--accent-warm)); } +.st-rank-meta { + font-size: 0.6875rem; + color: rgb(var(--fg-faint)); + white-space: nowrap; +} +.st-rank-value, .st-board-count { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: rgb(var(--fg-muted)); + white-space: nowrap; +} +.st-rank-value--up { color: rgb(var(--online)); } +.st-board-row { grid-template-columns: 1.5rem minmax(0, 1fr) auto; } +@media (max-width: 520px) { + .st-rank-row { grid-template-columns: 1.5rem minmax(0, 1fr) auto; } + .st-rank-meta { display: none; } +} + +/* ── the year ─────────────────────────────────────────────────────────── */ +.st-year { + border: 1px solid rgb(var(--line-strong)); + border-radius: var(--radius-md); + background: + radial-gradient(120% 80% at 100% 0%, rgb(var(--accent-warm) / 0.07), transparent 60%), + rgb(var(--bg-surface)); + padding: 1.1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} +.st-year-body { display: flex; flex-direction: column; gap: 1.1rem; } +.st-figures--year { border-color: rgb(var(--line-default)); } +.st-year-lists { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1.25rem; +} +.st-sub { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.14em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); + margin-bottom: 0.4rem; +} +.st-mini { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.8125rem; } +.st-mini li { + display: flex; + justify-content: space-between; + gap: 0.75rem; + padding: 0.2rem 0; + border-bottom: 1px solid rgb(var(--line-default)); +} +.st-mini li:last-child { border-bottom: 0; } +.st-mini-link { + color: rgb(var(--fg-default)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.st-mini-link:hover { color: rgb(var(--accent-warm)); } +.st-mini-value { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: rgb(var(--fg-muted)); + flex: none; +} +.st-mini-empty { color: rgb(var(--fg-subtle)); font-size: 0.75rem; } +.st-busiest { font-size: 0.8125rem; color: rgb(var(--fg-muted)); } + +/* ── the member's own year ────────────────────────────────────────────── */ +.st-mine { + border-top: 1px solid rgb(var(--line-default)); + padding-top: 0.9rem; +} +.st-mine-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 0.75rem; +} +.st-mine-grid div { display: flex; flex-direction: column; } +.st-mine-grid b { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 1.0625rem; + color: rgb(var(--fg-strong)); +} +.st-mine-grid span { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: calc(0.1em * var(--tracking-scale)); + color: rgb(var(--fg-subtle)); +} +.st-mine-best, .st-mine-cat { + margin-top: 0.7rem; + font-size: 0.8125rem; + color: rgb(var(--fg-muted)); +} +.st-mine-best a { color: rgb(var(--fg-default)); } +.st-mine-best a:hover { color: rgb(var(--accent-warm)); } +.st-mine-best-n { + font-family: var(--font-mono); + color: rgb(var(--fg-faint)); + margin-left: 0.4rem; +} + +.st-foot { + font-size: 0.6875rem; + color: rgb(var(--fg-faint)); + text-align: center; +} +</style> 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 <dimitri.bleach@gmail.com> 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 <noreply@anthropic.com> --- 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<infoHash>'); + 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<boolean> { + 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<void> { + 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<void> { + 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<void> { + 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<string> { + 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<void> { + 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<Record<string, AnnounceToken>> = { + 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<string>(); + 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<x>…)` and JavaScript writes `(?<x>…)`; 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</g, '(?<').replace(/\(\?P=(\w+)\)/g, '\\k<$1>') + ); +} + +/** + * 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<boolean> { + // 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<void> { + await setSetting(IRC_SETTINGS.ENABLED, enabled ? 'true' : 'false'); +} + +export async function getIrcConfig(): Promise<IrcAnnounceConfig> { + const raw = await getSetting(IRC_SETTINGS.CONFIG); + if (!raw) return { ...IRC_DEFAULTS }; + try { + const stored = decryptJson<Partial<IrcAnnounceConfig>>(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<void> { + 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 @@ +<template> + <div class="irc"> + <header class="irc-head"> + <div> + <p class="irc-eyebrow">{{ $t('admin.irc.eyebrow') }}</p> + <h1 class="irc-title">{{ $t('admin.irc.title') }}</h1> + <p class="irc-lede">{{ $t('admin.irc.lede') }}</p> + </div> + + <!-- State first. An operator opening this page is almost always here to + find out whether the bot is in the channel. --> + <div class="irc-state" :class="`irc-state--${state}`"> + <span class="irc-dot" /> + <div> + <p class="irc-state-label">{{ $t(`admin.irc.states.${state}`) }}</p> + <p v-if="status?.nick" class="irc-state-sub">{{ status.nick }}</p> + <p v-else-if="status?.lastError" class="irc-state-sub">{{ status.lastError }}</p> + </div> + </div> + </header> + + <!-- Said plainly, because a console that shows `idle` on a working bot is + how an operator concludes the feature is broken. --> + <p v-if="status && !status.leader && enabled" class="irc-notice"> + <Icon name="ph:info" /> + {{ $t('admin.irc.notLeader') }} + </p> + + <form class="irc-form" @submit.prevent="save"> + <section class="irc-card"> + <h2>{{ $t('admin.irc.sections.connection') }}</h2> + <label class="irc-switch"> + <input v-model="form.enabled" type="checkbox" /> + <span>{{ $t('admin.irc.fields.enabled') }}</span> + </label> + <p class="irc-help">{{ $t('admin.irc.hints.enabled') }}</p> + + <div class="irc-grid"> + <div class="irc-field"> + <label for="irc-host">{{ $t('admin.irc.fields.host') }}</label> + <input id="irc-host" v-model="form.host" class="input" autocomplete="off" placeholder="irc.example.com" /> + </div> + <div class="irc-field irc-field--narrow"> + <label for="irc-port">{{ $t('admin.irc.fields.port') }}</label> + <input id="irc-port" v-model.number="form.port" class="input" type="number" min="1" max="65535" /> + </div> + <div class="irc-field irc-field--narrow"> + <label class="irc-switch irc-switch--inline"> + <input v-model="form.tls" type="checkbox" /> + <span>{{ $t('admin.irc.fields.tls') }}</span> + </label> + </div> + <div class="irc-field"> + <label for="irc-nick">{{ $t('admin.irc.fields.nick') }}</label> + <input id="irc-nick" v-model="form.nick" class="input" autocomplete="off" /> + <span class="irc-help">{{ $t('admin.irc.hints.nick') }}</span> + </div> + <div class="irc-field"> + <label for="irc-channel">{{ $t('admin.irc.fields.channel') }}</label> + <input id="irc-channel" v-model="form.channel" class="input" autocomplete="off" placeholder="#announce" /> + </div> + <div class="irc-field"> + <label for="irc-key">{{ $t('admin.irc.fields.channelKey') }}</label> + <input + id="irc-key" + v-model="form.channelKey" + class="input" + type="password" + autocomplete="new-password" + :placeholder="config?.hasChannelKey ? $t('admin.irc.kept') : ''" + /> + </div> + </div> + </section> + + <section class="irc-card"> + <h2>{{ $t('admin.irc.sections.auth') }}</h2> + <p class="irc-help">{{ $t('admin.irc.hints.auth') }}</p> + <div class="irc-grid"> + <div class="irc-field"> + <label for="irc-sasl-user">{{ $t('admin.irc.fields.saslUser') }}</label> + <input id="irc-sasl-user" v-model="form.saslUser" class="input" autocomplete="off" /> + </div> + <div class="irc-field"> + <label for="irc-sasl-pass">{{ $t('admin.irc.fields.saslPassword') }}</label> + <input + id="irc-sasl-pass" + v-model="form.saslPassword" + class="input" + type="password" + autocomplete="new-password" + :placeholder="config?.hasSaslPassword ? $t('admin.irc.kept') : ''" + /> + </div> + <div class="irc-field"> + <label for="irc-server-pass">{{ $t('admin.irc.fields.serverPassword') }}</label> + <input + id="irc-server-pass" + v-model="form.serverPassword" + class="input" + type="password" + autocomplete="new-password" + :placeholder="config?.hasServerPassword ? $t('admin.irc.kept') : ''" + /> + </div> + </div> + + <div class="irc-field"> + <label for="irc-perform">{{ $t('admin.irc.fields.perform') }}</label> + <textarea + id="irc-perform" + v-model="performText" + class="input irc-mono" + rows="3" + spellcheck="false" + placeholder="PRIVMSG Voyager :invite trackarr KEY" + /> + <span class="irc-help">{{ $t('admin.irc.hints.perform') }}</span> + </div> + </section> + + <section class="irc-card"> + <h2>{{ $t('admin.irc.sections.line') }}</h2> + <div class="irc-field"> + <label for="irc-template">{{ $t('admin.irc.fields.template') }}</label> + <textarea + id="irc-template" + v-model="form.template" + class="input irc-mono" + rows="3" + spellcheck="false" + /> + <span class="irc-help">{{ $t('admin.irc.hints.template') }}</span> + </div> + + <!-- The rendered line, live. An operator editing a format needs to see + the output, not a description of it. --> + <div class="irc-preview"> + <p class="irc-preview-label">{{ $t('admin.irc.preview') }}</p> + <p class="irc-preview-line">{{ preview }}</p> + </div> + + <details class="irc-tokens"> + <summary>{{ $t('admin.irc.tokensTitle') }}</summary> + <ul> + <li v-for="tok in tokens" :key="tok.name"> + <code>{{ '{' + tok.name + '}' }}</code> + <span>{{ tok.describes }}</span> + <em v-if="tok.variable">{{ tok.variable }}</em> + <em v-else class="irc-unmapped">{{ $t('admin.irc.unmapped') }}</em> + </li> + </ul> + </details> + + <div class="irc-grid"> + <div class="irc-field"> + <label for="irc-siteurl">{{ $t('admin.irc.fields.siteUrl') }}</label> + <input id="irc-siteurl" v-model="form.siteUrl" class="input" placeholder="https://tracker.example.com" /> + <span class="irc-help">{{ $t('admin.irc.hints.siteUrl') }}</span> + </div> + <div class="irc-field"> + <label class="irc-switch irc-switch--inline"> + <input v-model="form.announceAdult" type="checkbox" /> + <span>{{ $t('admin.irc.fields.announceAdult') }}</span> + </label> + <span class="irc-help">{{ $t('admin.irc.hints.announceAdult') }}</span> + </div> + </div> + </section> + + <div class="irc-actions"> + <button type="submit" class="btn btn-primary" :disabled="saving"> + <Icon :name="saving ? 'ph:circle-notch' : 'ph:check'" :class="{ 'irc-spin': saving }" /> + {{ $t('common.save') }} + </button> + <button + type="button" + class="btn btn-secondary" + :disabled="testing || state !== 'ready'" + @click="sendTest" + > + <Icon :name="testing ? 'ph:circle-notch' : 'ph:paper-plane-tilt'" :class="{ 'irc-spin': testing }" /> + {{ $t('admin.irc.test') }} + </button> + <a + v-if="enabled" + href="/api/irc/autobrr.yml" + class="btn btn-ghost" + download + > + <Icon name="ph:download-simple" /> + {{ $t('admin.irc.definition') }} + </a> + <span v-if="message" class="irc-message" :class="{ 'irc-message--bad': failed }"> + {{ message }} + </span> + </div> + </form> + + <section v-if="status" class="irc-card irc-card--stats"> + <h2>{{ $t('admin.irc.sections.traffic') }}</h2> + <dl class="irc-stats"> + <div><dt>{{ $t('admin.irc.stats.sent') }}</dt><dd>{{ status.sent }}</dd></div> + <div><dt>{{ $t('admin.irc.stats.queued') }}</dt><dd>{{ status.queued }}</dd></div> + <div><dt>{{ $t('admin.irc.stats.dropped') }}</dt><dd>{{ status.dropped }}</dd></div> + <div> + <dt>{{ $t('admin.irc.stats.since') }}</dt> + <dd>{{ status.since ? new Date(status.since).toLocaleString() : '—' }}</dd> + </div> + </dl> + <p v-if="status.dropped > 0" class="irc-help">{{ $t('admin.irc.hints.dropped') }}</p> + </section> + </div> +</template> + +<script setup lang="ts"> +/** + * The announce bot's console. + * + * Three things on this page are decisions rather than layout: + * + * 1. **The state block is at the top, next to the title.** Whatever else an + * operator came for, the first question is always "is it in the channel". + * 2. **The rendered line is shown live, under the template field.** The format + * is a contract with software nobody here controls, and the only honest way + * to edit one is to watch what it produces. The same string is what the + * generated autobrr definition is tested against on save. + * 3. **A blank password field means "keep the stored one".** The GET never + * returns a secret, so a form that round-tripped what it received would + * erase all three on the first save. + */ +definePageMeta({ middleware: 'admin' }); + +const { t } = useI18n(); + +interface IrcToken { + name: string; + variable: string | null; + describes: string; +} +interface IrcPayload { + enabled: boolean; + config: { + host: string; + port: number; + tls: boolean; + nick: string; + realname: string; + serverPassword: string; + saslUser: string; + saslPassword: string; + perform: string[]; + channel: string; + channelKey: string; + template: string; + siteUrl: string; + announceAdult: boolean; + hasServerPassword: boolean; + hasSaslPassword: boolean; + hasChannelKey: boolean; + }; + status: { + state: string; + nick: string; + since: number | null; + lastError: string | null; + queued: number; + sent: number; + dropped: number; + leader: boolean; + }; + tokens: IrcToken[]; + defaultTemplate: string; + pattern: string; +} + +const { data, refresh } = await useFetch<IrcPayload>('/api/admin/irc'); + +const config = computed(() => data.value?.config); +const status = computed(() => data.value?.status); +const tokens = computed(() => data.value?.tokens ?? []); +const enabled = computed(() => !!data.value?.enabled); +const state = computed(() => status.value?.state ?? 'idle'); + +const form = reactive({ + enabled: false, + host: '', + port: 6697, + tls: true, + nick: '', + realname: '', + serverPassword: '', + saslUser: '', + saslPassword: '', + channel: '#announce', + channelKey: '', + template: '', + siteUrl: '', + announceAdult: false, +}); +const performText = ref(''); + +watchEffect(() => { + const c = config.value; + if (!c) return; + form.enabled = !!data.value?.enabled; + form.host = c.host; + form.port = c.port; + form.tls = c.tls; + form.nick = c.nick; + form.realname = c.realname; + form.saslUser = c.saslUser; + form.channel = c.channel; + form.template = c.template; + form.siteUrl = c.siteUrl; + form.announceAdult = c.announceAdult; + performText.value = c.perform.join('\n'); +}); + +/** + * The line as the channel will see it, rendered here rather than round-tripped + * to the server: an operator editing a template should not have to save a + * broken one to find out it is broken. + * + * Deliberately a simple substitution rather than an import of the server's + * renderer — the API validates the real thing on save, including that the + * derived pattern reads it back, and duplicating the sanitiser in the browser + * would be a second definition of a security boundary. + */ +const SAMPLE: Record<string, string> = { + 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/0123…4567', + infoHash: '0123456789abcdef0123456789abcdef01234567', +}; +const preview = computed(() => + (form.template || '').replace(/\{(\w+)\}/g, (whole, token: string) => + token in SAMPLE ? SAMPLE[token]! : whole + ) +); + +const saving = ref(false); +const testing = ref(false); +const message = ref(''); +const failed = ref(false); + +async function save() { + saving.value = true; + message.value = ''; + failed.value = false; + try { + await $fetch('/api/admin/irc', { + method: 'PUT', + body: { + ...form, + // Empty means unchanged; the API only clears a secret on an explicit + // null, which this form never sends — clearing one is done by removing + // the whole configuration. + serverPassword: form.serverPassword || undefined, + saslPassword: form.saslPassword || undefined, + channelKey: form.channelKey || undefined, + perform: performText.value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean), + }, + }); + form.serverPassword = ''; + form.saslPassword = ''; + form.channelKey = ''; + message.value = t('admin.irc.saved'); + await refresh(); + } catch (err: unknown) { + failed.value = true; + message.value = + (err as { data?: { message?: string } })?.data?.message ?? t('admin.irc.failed'); + } finally { + saving.value = false; + } +} + +async function sendTest() { + testing.value = true; + message.value = ''; + failed.value = false; + try { + const res = await $fetch<{ line: string }>('/api/admin/irc/test', { method: 'POST' }); + message.value = t('admin.irc.tested', { line: res.line }); + } catch (err: unknown) { + failed.value = true; + message.value = + (err as { data?: { message?: string } })?.data?.message ?? t('admin.irc.failed'); + } finally { + testing.value = false; + await refresh(); + } +} +</script> + +<style scoped> +.irc { + max-width: 60rem; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} +.irc-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.irc-eyebrow { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.16em * var(--tracking-scale)); + color: rgb(var(--accent-warm)); +} +.irc-title { + font-family: var(--font-display); + font-size: 1.75rem; + font-weight: 700; + color: rgb(var(--fg-strong)); +} +.irc-lede { + font-size: 0.8125rem; + color: rgb(var(--fg-muted)); + max-width: 48ch; + margin-top: 0.25rem; +} + +/* ── state ────────────────────────────────────────────────────────────── */ +.irc-state { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.85rem; + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-md); + background: rgb(var(--bg-elevated)); +} +.irc-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: rgb(var(--fg-faint)); + flex: none; +} +.irc-state--ready .irc-dot { background: rgb(var(--online)); } +.irc-state--error .irc-dot { background: rgb(var(--danger)); } +.irc-state--connecting .irc-dot, +.irc-state--registering .irc-dot, +.irc-state--joining .irc-dot { background: rgb(var(--warning)); } +.irc-state--ready { border-color: rgb(var(--online) / 0.4); } +.irc-state--error { border-color: rgb(var(--danger) / 0.4); } +.irc-state-label { font-size: 0.8125rem; color: rgb(var(--fg-default)); } +.irc-state-sub { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: rgb(var(--fg-subtle)); + max-width: 22ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.irc-notice { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid rgb(var(--info) / 0.4); + border-radius: var(--radius-sm); + background: rgb(var(--info) / 0.08); + font-size: 0.75rem; + color: rgb(var(--fg-muted)); +} + +/* ── form ─────────────────────────────────────────────────────────────── */ +.irc-form { display: flex; flex-direction: column; gap: 1rem; } +.irc-card { + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-md); + background: rgb(var(--bg-surface)); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.7rem; +} +.irc-card h2 { + font-size: 0.9375rem; + font-weight: 600; + color: rgb(var(--fg-strong)); +} +.irc-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.75rem; +} +.irc-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } +.irc-field--narrow { max-width: 10rem; } +.irc-field label { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.12em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); +} +.irc-help { font-size: 0.6875rem; color: rgb(var(--fg-subtle)); } +.irc-mono { font-family: var(--font-mono); font-size: 0.75rem; } +.irc-switch { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: rgb(var(--fg-default)); + cursor: pointer; + /* The whole row is the target, so the checkbox is not a 13px hit area. */ + min-height: 2.25rem; +} +.irc-switch--inline { margin-top: 1rem; } + +.irc-preview { + border: 1px dashed rgb(var(--line-strong)); + border-radius: var(--radius-sm); + background: rgb(var(--bg-inset)); + padding: 0.6rem 0.75rem; +} +.irc-preview-label { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.12em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); + margin-bottom: 0.3rem; +} +.irc-preview-line { + font-family: var(--font-mono); + font-size: 0.75rem; + color: rgb(var(--fg-default)); + overflow-x: auto; + white-space: pre; +} + +.irc-tokens { font-size: 0.75rem; } +.irc-tokens summary { cursor: pointer; color: rgb(var(--fg-muted)); padding: 0.2rem 0; } +.irc-tokens ul { display: flex; flex-direction: column; gap: 0.2rem; margin-top: 0.4rem; } +.irc-tokens li { + display: grid; + grid-template-columns: 10rem minmax(0, 1fr) auto; + gap: 0.6rem; + align-items: baseline; + padding: 0.15rem 0; + border-bottom: 1px solid rgb(var(--line-default)); +} +.irc-tokens code { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: rgb(var(--accent-warm)); +} +.irc-tokens span { color: rgb(var(--fg-muted)); } +.irc-tokens em { + font-family: var(--font-mono); + font-style: normal; + font-size: 0.625rem; + color: rgb(var(--fg-faint)); +} +.irc-unmapped { opacity: 0.7; } +@media (max-width: 640px) { + .irc-tokens li { grid-template-columns: minmax(0, 1fr); } +} + +.irc-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} +.irc-message { font-size: 0.75rem; color: rgb(var(--online)); } +.irc-message--bad { color: rgb(var(--danger)); } +.irc-spin { animation: irc-spin 900ms linear infinite; } +@keyframes irc-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .irc-spin { animation-duration: 2.4s; } +} + +.irc-card--stats { gap: 0.5rem; } +.irc-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 0.75rem; +} +.irc-stats dt { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: calc(0.1em * var(--tracking-scale)); + color: rgb(var(--fg-subtle)); +} +.irc-stats dd { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 1rem; + color: rgb(var(--fg-strong)); +} +</style> diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 4902d0f3..2a5252da 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -1708,6 +1708,7 @@ "uploadRules": "Upload Rules", "invitations": "Invitations", "torznab": "Torznab API", + "irc": "IRC announce", "branding": "Branding", "themes": "Themes", "notifications": "Notifications", @@ -1742,6 +1743,7 @@ "uploadRules": "Server-enforced gates every torrent upload must pass", "invitations": "Manage invitation system", "torznab": "Configure *arr integration and rate limiting", + "irc": "One line in #announce per accepted upload — what autobrr races on.", "branding": "Customize site appearance and branding", "themes": "Create and edit the themes members can choose, and pick what system mode maps to.", "notifications": "Configure external notification channels (email, Discord, Telegram, …)", @@ -3889,6 +3891,67 @@ "generation": "generation {n}", "erased": "erased account", "truncated": "Truncated at {nodes} members or {depth} generations — the rest is not shown." + }, + "irc": { + "eyebrow": "Integration", + "title": "IRC announce channel", + "lede": "One line in a channel per accepted upload — the mechanism autobrr and autodl-irssi race on. An RSS feed is polled; a channel message arrives.", + "kept": "unchanged", + "saved": "Saved.", + "failed": "That did not work.", + "test": "Say a test line", + "tested": "Sent: {line}", + "definition": "autobrr definition", + "preview": "What the channel will see", + "tokensTitle": "Fields you can put in the line", + "unmapped": "for people", + "notLeader": "Another API instance holds the connection — exactly one does, so that a release is announced once. The state above is what this instance knows.", + "states": { + "idle": "Not announcing", + "connecting": "Connecting", + "registering": "Registering", + "joining": "Joining the channel", + "ready": "In the channel", + "error": "Not connected" + }, + "sections": { + "connection": "Where to connect", + "auth": "How to identify", + "line": "What to say", + "traffic": "Since it connected" + }, + "fields": { + "enabled": "Announce accepted uploads to IRC", + "host": "Server", + "port": "Port", + "tls": "TLS", + "nick": "Nick", + "channel": "Channel", + "channelKey": "Channel key", + "saslUser": "SASL account", + "saslPassword": "SASL password", + "serverPassword": "Server password", + "perform": "Lines to send after connecting", + "template": "Announce line", + "siteUrl": "Public address", + "announceAdult": "Announce adult releases too" + }, + "hints": { + "enabled": "Off by default. Nothing is said in the channel until you turn this on.", + "nick": "Some networks require a bot suffix before letting a client into an announce channel, e.g. trackarr|bot.", + "auth": "SASL if the network offers it, a server password if it wants one, and perform lines for anything else — identifying to NickServ, or asking a channel bot for an invite.", + "perform": "One raw IRC line per row, sent after registration and before joining. Treated as secret.", + "template": "Fields in braces. The regular expression handed to members’ clients is derived from this line, so the two cannot disagree — change the format and the definition follows.", + "siteUrl": "Where the link in the line points. The bot has no request to work it out from; with this empty the line carries a path.", + "announceAdult": "A channel is one stream with no per-member preferences in it. Members can turn adult content off on the site; nobody can turn it off in a channel.", + "dropped": "Lines dropped because the queue was full while the connection was down. The oldest go first: on an announce channel a stale release is worth less than a fresh one." + }, + "stats": { + "sent": "Lines said", + "queued": "Waiting", + "dropped": "Dropped", + "since": "In the channel since" + } } }, "mod": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 81d30b0a..d76319e7 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -1708,6 +1708,7 @@ "uploadRules": "Règles d'upload", "invitations": "Invitations", "torznab": "API Torznab", + "irc": "Annonce IRC", "branding": "Identité visuelle", "themes": "Thèmes", "notifications": "Notifications", @@ -1742,6 +1743,7 @@ "uploadRules": "Garde-fous appliqués côté serveur sur chaque upload de torrent", "invitations": "Gérer le système d'invitations", "torznab": "Configurer l'intégration *arr et la limitation de débit", + "irc": "Une ligne dans #announce par upload accepté — ce sur quoi autobrr court.", "branding": "Personnaliser l'apparence et l'identité visuelle du site", "themes": "Créer et régler les thèmes que les membres peuvent choisir, et définir le mode système.", "notifications": "Configurer les canaux de notifications externes (email, Discord, Telegram, …)", @@ -3889,6 +3891,67 @@ "generation": "génération {n}", "erased": "compte effacé", "truncated": "Tronqué à {nodes} membres ou {depth} générations — le reste n’est pas affiché." + }, + "irc": { + "eyebrow": "Intégration", + "title": "Canal d’annonce IRC", + "lede": "Une ligne dans un canal par upload accepté — le mécanisme sur lequel autobrr et autodl-irssi courent. Un flux RSS se sonde ; un message de canal arrive.", + "kept": "inchangé", + "saved": "Enregistré.", + "failed": "Ça n’a pas fonctionné.", + "test": "Envoyer une ligne de test", + "tested": "Envoyé : {line}", + "definition": "Définition autobrr", + "preview": "Ce que verra le canal", + "tokensTitle": "Champs disponibles dans la ligne", + "unmapped": "pour les humains", + "notLeader": "Une autre instance de l’API tient la connexion — une seule le fait, pour qu’une release soit annoncée une fois. L’état ci-dessus est ce que cette instance sait.", + "states": { + "idle": "Pas d’annonce", + "connecting": "Connexion", + "registering": "Enregistrement", + "joining": "Entrée dans le canal", + "ready": "Dans le canal", + "error": "Non connecté" + }, + "sections": { + "connection": "Où se connecter", + "auth": "Comment s’identifier", + "line": "Quoi dire", + "traffic": "Depuis la connexion" + }, + "fields": { + "enabled": "Annoncer les uploads acceptés sur IRC", + "host": "Serveur", + "port": "Port", + "tls": "TLS", + "nick": "Pseudo", + "channel": "Canal", + "channelKey": "Clé du canal", + "saslUser": "Compte SASL", + "saslPassword": "Mot de passe SASL", + "serverPassword": "Mot de passe du serveur", + "perform": "Lignes à envoyer après connexion", + "template": "Ligne d’annonce", + "siteUrl": "Adresse publique", + "announceAdult": "Annoncer aussi les releases adultes" + }, + "hints": { + "enabled": "Désactivé par défaut. Rien n’est dit dans le canal avant que vous ne l’activiez.", + "nick": "Certains réseaux exigent un suffixe de bot avant d’autoriser un client dans un canal d’annonce, par exemple trackarr|bot.", + "auth": "SASL si le réseau le propose, un mot de passe serveur s’il en veut un, et des lignes « perform » pour le reste : s’identifier auprès de NickServ, ou demander une invitation à un bot de canal.", + "perform": "Une ligne IRC brute par entrée, envoyée après l’enregistrement et avant de rejoindre le canal. Traitée comme un secret.", + "template": "Les champs entre accolades. L’expression régulière remise aux clients des membres est dérivée de cette ligne, donc les deux ne peuvent pas diverger : changez le format et la définition suit.", + "siteUrl": "Où pointe le lien de la ligne. Le bot n’a pas de requête pour le déduire ; vide, la ligne ne porte qu’un chemin.", + "announceAdult": "Un canal est un flux unique, sans préférence par membre. Les membres peuvent désactiver le contenu adulte sur le site ; personne ne peut le désactiver dans un canal.", + "dropped": "Lignes abandonnées parce que la file était pleine pendant une coupure. Les plus anciennes partent d’abord : sur un canal d’annonce, une release rassie vaut moins qu’une fraîche." + }, + "stats": { + "sent": "Lignes dites", + "queued": "En attente", + "dropped": "Abandonnées", + "since": "Dans le canal depuis" + } } }, "mod": { diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 95e7ece1..2319f4de 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -132,7 +132,10 @@ export default defineConfig({ '/integrations/': [ { text: 'Integrations', - items: [{ text: 'Torznab API', link: '/integrations/torznab' }], + items: [ + { text: 'Torznab API', link: '/integrations/torznab' }, + { text: 'IRC announce', link: '/integrations/irc' }, + ], }, ], '/reference/': [ diff --git a/doc/integrations/irc.md b/doc/integrations/irc.md new file mode 100644 index 00000000..5e6b8af8 --- /dev/null +++ b/doc/integrations/irc.md @@ -0,0 +1,169 @@ +# IRC announce channel + +One line in a channel per accepted upload. It is the oldest integration in +private trackers and still the fastest: an RSS feed is polled once a minute, a +channel message arrives when the release does. + +[autobrr](https://autobrr.com) and autodl-irssi both work this way, and between +them they are how releases are actually raced. + +Off by default. Nothing is said anywhere until an operator turns it on under +**Admin → IRC announce**. + +## What the bot does, and what it will never do + +It connects to one server, joins one channel, and says one line per accepted +release. That is the whole surface. + +It never reads a command from the channel. Nothing anybody says to it makes it +do anything — the only inputs it acts on are `PING` and the numerics it needs to +know it is connected. A bot that took 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 also never speaks to a user, never joins a second channel, and never carries +a passkey in a line (see below). + +## The line + +The default format: + +``` +NEW [{category}] {name} :: {size} :: FL {freeleechPercent} :: UL x{uploadFactor} :: {tags} :: by {uploader} :: {url} :: {infoHash} +``` + +which renders as: + +``` +NEW [Movies] Example.Release.2026.1080p.BluRay.x264-GROUP :: 14.62 GiB :: FL 100% :: UL x2 :: 1080p, bluray, x264 :: by example :: https://tracker.example.com/torrents/0123…4567 :: 0123456789abcdef0123456789abcdef01234567 +``` + +Every field is always present. A release with no tags says `-`, an anonymous +upload says `anonymous`, a release with no buff says `FL 0%`. A fixed shape +costs a few characters and buys a parser with no optional groups — and an +optional group is how a client silently attributes one field's value to another +when the middle one is missing. + +### Fields + +| Token | autobrr variable | Meaning | +| --- | --- | --- | +| `{name}` | `releaseName` | the release name | +| `{category}` | `category` | the category, or `uncategorised` | +| `{size}` | `torrentSize` | total size, e.g. `14.62 GiB` | +| `{freeleechPercent}` | `freeleechPercent` | how much of the download is free | +| `{uploadFactor}` | — | the upload multiplier, for people | +| `{tags}` | `tags` | comma-separated tags, or `-` | +| `{uploader}` | `uploader` | the uploader, or `anonymous` | +| `{url}` | — | the release page, for people | +| `{infoHash}` | `torrentId` | the v1 infohash — what a download URL is keyed on | + +Two fields are printed for humans and mapped to nothing. autobrr has no variable +for an upload multiplier, and `{url}` is the page rather than the base URL its +own templates prepend. Claiming a variable it ignores would make the definition +look richer and behave identically. + +The seeding requirement is deliberately **not** in the line: autobrr reads +`minimumratio` and `minimumseedtime` from the [Torznab feed](./torznab.md), +which this site serves, and there is no IRC variable for either. + +### No key in the line + +The URL in the announce carries no credential. Everybody in the channel sees +every line, so a personalised download link would hand every member the +credentials of one. A client appends its own key — that is what the generated +definition's `downloadurl` template does. + +## The generated autobrr definition + +`/api/irc/autobrr.yml` (Admin → IRC announce → **autobrr definition**, or +directly from a member's own session) returns an indexer definition for this +instance. + +It is generated rather than shipped, and the reason is the format above: the +line 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 regular expression in the +file is **derived from the template in force**, so the two cannot disagree. Edit +the format, download the file again. + +The definition also carries a `tests` block — a rendered sample line and the +values it should yield — so it arrives with a proof that it parses this +instance's format. + +To use it: + +1. Download the file and drop it in autobrr's custom definitions directory. +2. Restart autobrr. +3. Add the indexer, paste your **API key** from Settings → Keys, and give your + IRC nick. + +Use the API key, not the announce passkey. A passkey handed to a third party can +announce on your behalf; a read key cannot. See [API keys](../guide/api-keys.md). + +## Configuring it + +**Admin → IRC announce.** + +| Field | Notes | +| --- | --- | +| Server / port / TLS | 6697 with TLS is the modern default. | +| Nick | Some networks require a bot suffix before letting a client into an announce channel, e.g. `trackarr\|bot`. | +| Channel / key | The key is stored encrypted. | +| SASL account / password | Preferred when the network offers it. | +| Server password | For networks that want one before registration. | +| Lines to send after connecting | One raw IRC line per row, sent after registration and before joining — identifying to NickServ, or asking a channel bot for an invite. Treated as a secret. | +| Public address | Where the link in the line points. The bot has no HTTP request to derive it from; with this empty the line carries a path. | +| Announce adult releases | Off by default — see below. | + +The three credentials are stored encrypted at rest, with the same key as the +notification-channel secrets (`CHANNEL_ENCRYPTION_KEY`, falling back to +`NUXT_SESSION_SECRET`). The console never returns them; a blank field means +"keep the stored one". + +### Adult releases + +Off by default, and this is a judgement rather than a convenience. A channel is +one stream with no per-member preferences in it: everybody who joins sees every +line. Members can turn adult content off on the site, and a channel cannot +honour that — so the operator decides once, for the channel, and the default is +the direction that does not put titles in front of people who turned them off. + +### Anonymity + +A member who uploads anonymously is never named in the channel. The line says +`anonymous`, using the same rule as the catalogue, the feeds and the federated +catalogue. + +## One bot, however many API instances + +Exactly one API process holds the connection, elected through a Redis lease it +renews every fifteen seconds. Three instances would otherwise mean three bots in +the channel and every release announced three times — to autobrr, which would +grab it three times. + +The console shows whether the instance you are looking at is the one holding the +connection. If it is not, the state shown is what that instance last knew. + +## Failure, and what it costs + +Announcing is never on an upload's path. A channel that is down, a server that +is throttling, a mistyped host — none of it can slow down or fail an upload or a +moderation action. The failure mode of an announce is a missing line. + +Lines wait in a bounded queue while the bot is disconnected, and past the cap the +**oldest** are dropped: on an announce channel a stale release is worth less than +a fresh one. The console shows how many were dropped. + +Writes are paced at one line every 1.5 seconds, because servers kill clients that +talk too fast and the penalty is a disconnect mid-burst — which would lose +exactly the run of releases a moderator just accepted. + +## Format changes + +The template lives in the database, so upgrading the tracker never changes what a +running instance emits: a new default applies to new installations, and yours +keeps saying what it says today. + +If you do change the format, the generated definition changes with it, and +members have to download it again. Tell them — a definition that no longer +matches fails silently, which is the one failure mode worth being loud about. From e0e1b4509acf25577fa2c8f9f597083af0d53050 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 17:06:16 +0200 Subject: [PATCH 21/96] fix(torznab): the feed published the announce passkey to read-key holders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole point of splitting the credentials was that a member could hand a feed key to a third party without handing over the key that announces for them. Every search response undid it: downloadUrl: …/api/torznab/download?id=<hash>&apikey=${user.passkey} `user` is resolved BY the presented key — the RSS key, usually — and then the enclosure was built from `passkey`, the announce credential, on every item. So a member who did exactly what the new guide told them, and put their RSS key in a shared Prowlarr, published their announce passkey to whoever else uses it. One GET, no interaction, and rotating the RSS key changed nothing because the passkey in those URLs still worked. `authenticateTorznab` now reports the credential the caller actually presented, and anything echoing a key into a response uses that. Verified against a live stack: with the RSS key, every enclosure carries the RSS key and the passkey appears nowhere in the response. ## What a read key still gets you, said out loud It cannot announce. It CAN download a `.torrent`, and a `.torrent` carries the member's announce URL — that is how BitTorrent works. So a read key is safe in a client you control and is not safe in the hands of somebody you would not trust with your passkey. The guide says that now instead of implying the opposite, and the generated Cardigann definition's help text says it too rather than "it cannot announce, so it is safe to paste here". ## Three more in the same area `/api/torznab/cardigann.yml` was **unauthenticated** while the commit that added it claimed members only — an anonymous caller got the site name and the whole category taxonomy from an invite-only tracker. Gated, like the autobrr sibling that got it right. Its `id` was derived from the site name, which defaults to `TRACKARR`, so every instance nobody renamed produced the same identity and filename — two of them in one Prowlarr overwrite each other, exactly what the commit message claimed to have avoided. Derived from the host now. The key field is `type: password` rather than `text`, so Prowlarr treats it as a secret. `ensureKey` had a docstring describing a null-guarded write and no guard: two concurrent first reads both minted, the last write won the row, and each request returned its own value — one member walking away with a key the row does not carry. The guard is real now, and the loser re-reads. `retireTorznabPasskey` deleted the old block entry unconditionally, which lost a block an administrator wrote DURING a rotation. It only deletes what the carry-over actually moved. `authenticateTorznab` also never checked `deleted_at`, the only read surface that did not. Latent — erasure clears the read keys — but it was the one door left open for a future path that scrubs a name without scrubbing keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../admin/torznab/users/[id]/reset.post.ts | 4 ++-- apps/api/routes/api/auth/passkey.post.ts | 10 ++++++-- apps/api/routes/api/me/passkey/reset.post.ts | 4 ++-- apps/api/routes/api/torznab/api/index.get.ts | 7 ++++-- .../routes/api/torznab/cardigann.yml.get.ts | 23 ++++++++++++++++--- apps/api/routes/api/torznab/utils/auth.ts | 21 ++++++++++++++--- apps/api/utils/account/readKeys.ts | 20 ++++++++++++---- apps/api/utils/torznabStats.ts | 16 +++++++++++-- doc/guide/api-keys.md | 23 +++++++++++++++++++ 9 files changed, 108 insertions(+), 20 deletions(-) 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 fb75c852..15d0da41 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 @@ -46,7 +46,7 @@ export default defineEventHandler(async (event) => { // 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); + const carried = await carryTorznabBlock(oldPasskey, newPasskey); // Update user await db @@ -55,7 +55,7 @@ export default defineEventHandler(async (event) => { .where(eq(schema.users.id, userId)); // Block entry and counters for a value that is nobody's any more. - await retireTorznabPasskey(oldPasskey); + await retireTorznabPasskey(oldPasskey, carried); return { success: true, diff --git a/apps/api/routes/api/auth/passkey.post.ts b/apps/api/routes/api/auth/passkey.post.ts index 91b26dfb..8fe69af1 100644 --- a/apps/api/routes/api/auth/passkey.post.ts +++ b/apps/api/routes/api/auth/passkey.post.ts @@ -23,6 +23,7 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { generateToken } from '~~/utils/server'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { requireFreshAuth } from '~~/utils/adminAuth'; import { carryTorznabBlock, retireTorznabPasskey, @@ -40,6 +41,11 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + // The same step-up `me/passkey/reset` requires, and for the reason that route + // states: a borrowed session must not be able to lock the real owner's client + // out of the tracker. Two doors to the same action, one of them unguarded, is + // just the unguarded one. + await requireFreshAuth(event); await rateLimit(event, RATE_LIMITS.mutation); await readValidatedBody(event, bodySchema.parse); @@ -69,7 +75,7 @@ export default defineEventHandler(async (event) => { // 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 carried = await carryTorznabBlock(current.passkey, fresh); const [updated] = await db .update(schema.users) @@ -85,7 +91,7 @@ export default defineEventHandler(async (event) => { } // The old value is nobody's now — block entry and counters both go. - await retireTorznabPasskey(current.passkey); + await retireTorznabPasskey(current.passkey, carried); // 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 c8ecc186..a40c96e5 100644 --- a/apps/api/routes/api/me/passkey/reset.post.ts +++ b/apps/api/routes/api/me/passkey/reset.post.ts @@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => { // 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); + const carried = await carryTorznabBlock(oldPasskey, newPasskey); await db .update(schema.users) @@ -63,7 +63,7 @@ export default defineEventHandler(async (event) => { // 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); + await retireTorznabPasskey(oldPasskey, carried); // 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/routes/api/torznab/api/index.get.ts b/apps/api/routes/api/torznab/api/index.get.ts index 6746e717..04fb28ae 100644 --- a/apps/api/routes/api/torznab/api/index.get.ts +++ b/apps/api/routes/api/torznab/api/index.get.ts @@ -282,7 +282,7 @@ async function handleMovieSearch( async function performSearch( event: H3Event, query: z.infer<typeof torznabQuerySchema>, - user: { passkey: string; showAdultContent: boolean } + user: { passkey: string; presentedKey: string; showAdultContent: boolean } ) { const baseUrl = getRequestURL(event).origin; const conditions: SQL[] = []; @@ -452,7 +452,10 @@ async function performSearch( seeders: stats.seeders, leechers: stats.leechers, grabs: stats.completed, - downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.passkey}`, + // The key the caller presented, never `passkey`: this URL is in the + // response body, so building it from the announce credential handed a + // read-key holder the one thing a read key is meant to withhold. + downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.presentedKey}`, ...volumeFactors(torrent, siteWide), infoHash: torrent.infoHash, minimumRatio: minRatio, diff --git a/apps/api/routes/api/torznab/cardigann.yml.get.ts b/apps/api/routes/api/torznab/cardigann.yml.get.ts index 528e4383..50b16dfa 100644 --- a/apps/api/routes/api/torznab/cardigann.yml.get.ts +++ b/apps/api/routes/api/torznab/cardigann.yml.get.ts @@ -41,6 +41,7 @@ import { getCategoriesWithNewznabIds } from './utils/categories'; import { getSiteName } from '~~/utils/server'; import { getTorznabEnabled } from '~~/utils/torznabSettings'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { requireAuthSession } from '~~/utils/adminAuth'; /** YAML double-quoted scalar. Cardigann definitions are plain YAML 1.1. */ function q(value: string): string { @@ -66,6 +67,10 @@ function slugify(name: string): string { } export default defineEventHandler(async (event) => { + // Members only. The file names the instance, its address and the operator's + // whole category taxonomy, and an invite-only tracker publishes none of those + // — the sibling that generates the autobrr definition gates the same way. + await requireAuthSession(event); await rateLimit(event, RATE_LIMITS.public); if (!(await getTorznabEnabled())) { @@ -76,7 +81,17 @@ export default defineEventHandler(async (event) => { } const siteName = await getSiteName(); - const id = slugify(siteName); + /** + * The identity is derived from the HOST, not from the site name. + * + * `getSiteName()` falls back to `TRACKARR`, so every instance nobody renamed + * produced `id: trackarr` and the same filename — two of them in one Prowlarr + * overwrite each other, which is exactly what this was supposed to avoid. A + * name can also collide with a shipped definition (`nyaa`, `1337x`), in which + * case Prowlarr keeps its own and drops ours with one log line. + */ + const host = getRequestURL(event).host; + const id = `trackarr-${slugify(host)}`; const categories = await getCategoriesWithNewznabIds(); // Same derivation the Torznab feed itself uses, so the definition points at @@ -124,12 +139,14 @@ ${mappings || ' - {id: 8000, cat: Other, desc: "Other"}'} settings: - name: apikey - type: text + type: password label: RSS key - name: info_key type: info label: About your key - default: "Your RSS / Torznab key is on your profile page under Credentials. It can read the catalogue and cannot announce, so it is safe to paste here." + default: ${q( + 'Your RSS / Torznab key is on your profile page under Credentials. Use that one rather than your announce passkey: the key cannot announce, and you can revoke it on its own. Note that a .torrent this indexer grabs still carries your announce URL, so treat any client you paste this into as trusted.' + )} login: # A cheap query that answers 401 when the key is wrong, so Prowlarr's diff --git a/apps/api/routes/api/torznab/utils/auth.ts b/apps/api/routes/api/torznab/utils/auth.ts index 9576d9db..e589d546 100644 --- a/apps/api/routes/api/torznab/utils/auth.ts +++ b/apps/api/routes/api/torznab/utils/auth.ts @@ -14,6 +14,17 @@ export interface TorznabUser { id: string; username: string; passkey: string; + /** + * The credential the CALLER actually presented, lowercased. + * + * Not the same thing as `passkey`, and the difference was a live leak: every + * `<enclosure>` in a search response is built from a key, and building it + * from `passkey` published the member's ANNOUNCE credential to whoever held + * their read key — which is the one credential the read keys exist to keep + * out of a third party's hands. Anything echoing a key back into a response + * uses this field. + */ + presentedKey: string; isBanned: boolean; isAdmin: boolean; isModerator: boolean; @@ -73,6 +84,10 @@ export async function authenticateTorznab( passkey: schema.users.passkey, isBanned: schema.users.isBanned, bannedUntil: schema.users.bannedUntil, + // `findByReadKey` and `findByPasskey` both filter this out; this surface is + // the only one that did not, and it is the one an erased account would + // reach first if a future path ever cleared a name without clearing keys. + deletedAt: schema.users.deletedAt, isAdmin: schema.users.isAdmin, isModerator: schema.users.isModerator, showAdultContent: schema.users.showAdultContent, @@ -94,7 +109,7 @@ export async function authenticateTorznab( const user = users[0]; - if (!user) { + if (!user || user.deletedAt) { throw createTorznabError(event, TORZNAB_ERRORS.INCORRECT_CREDENTIALS); } @@ -105,8 +120,8 @@ export async function authenticateTorznab( // Strip the helper-only field before returning the user to the // rest of the Torznab pipeline — it doesn't need it. - const { bannedUntil: _bannedUntil, ...torznabUser } = user; - return torznabUser; + const { bannedUntil: _bannedUntil, deletedAt: _deletedAt, ...torznabUser } = user; + return { ...torznabUser, presentedKey: supplied }; } /** diff --git a/apps/api/utils/account/readKeys.ts b/apps/api/utils/account/readKeys.ts index 87f3a0eb..c85a9ecc 100644 --- a/apps/api/utils/account/readKeys.ts +++ b/apps/api/utils/account/readKeys.ts @@ -28,7 +28,7 @@ * which is why the Torznab gate accepts "32 or 40". New keys use one length so * the next gate can be exact. */ -import { eq } from 'drizzle-orm'; +import { and, eq, isNull } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { generatePasskey } from '~~/utils/auth'; @@ -70,11 +70,23 @@ export async function ensureKey( const [claimed] = await db .update(schema.users) .set({ [kind === 'rss' ? 'rssKey' : 'apiKey']: minted }) - .where(eq(schema.users.id, userId)) + // The guard this function's docstring always described and did not have. + // Without it two concurrent first reads — a double click on the reveal + // button is enough — both minted, the last write won the row, and each + // request returned ITS OWN value: one member walked away with a key the row + // does not carry, and a 401 with no explanation. + .where(and(eq(schema.users.id, userId), isNull(column))) .returning({ value: column }); + if (claimed?.value) return claimed.value; - // `.returning()` gives us the row as it is now, whoever won. - return claimed?.value ?? minted; + // We lost the race: read what actually landed rather than returning the value + // we minted and threw away. + const [settled] = await db + .select({ value: column }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + return settled?.value ?? minted; } /** diff --git a/apps/api/utils/torznabStats.ts b/apps/api/utils/torznabStats.ts index 34891a01..9ac9b822 100644 --- a/apps/api/utils/torznabStats.ts +++ b/apps/api/utils/torznabStats.ts @@ -533,8 +533,20 @@ export async function carryTorznabBlock( * 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<void> { - await unblockTorznabUser(passkey); +export async function retireTorznabPasskey( + passkey: string, + /** + * Whether `carryTorznabBlock` reported moving a block for this rotation. + * + * Deleting the old entry unconditionally lost a block an administrator wrote + * DURING the rotation: the carry reads an empty slot, the admin writes one, the + * row changes, and the retire then deletes the admin's decision. A few + * milliseconds wide, and the member picks when to replay it — so the delete is + * conditional on there having been something to move. + */ + carriedBlock = true +): Promise<void> { + if (carriedBlock) await unblockTorznabUser(passkey); await clearTorznabUserStats(passkey); } diff --git a/doc/guide/api-keys.md b/doc/guide/api-keys.md index 54e0ed5a..16ae75fa 100644 --- a/doc/guide/api-keys.md +++ b/doc/guide/api-keys.md @@ -20,6 +20,25 @@ client at the same time. Splitting them means each can be revoked for its own reason. Rotate the key you gave to a service that turned out to be careless, and nothing else moves. +## What a read key still gets you — read this before pasting one anywhere + +A read key cannot announce. It can, however, **download `.torrent` files**, and +a `.torrent` from this site carries your announce URL — which carries your +passkey. That is how BitTorrent works: the file has to name a tracker the client +can announce to, as you. + +So the honest boundary is: + +- A read key in a feed reader, an *arr stack or an autobrr you control: exactly + what it is for. Your passkey never appears in a feed URL or in anybody's + access log, and you can revoke the key without touching your client. +- A read key handed to somebody you would not trust with your passkey: **not + safe.** One grab hands them a file with your announce URL in it. + +Anything that can download a torrent from this tracker on your behalf can +announce on your behalf. The keys reduce exposure and make revocation +independent; they do not turn a third party into a stranger. + ## Rotating Each key rotates independently, behind a fresh-login step-up. The old value @@ -29,6 +48,10 @@ with no cache in front of them, which is what makes "revoke" mean revoke. Rotating the announce passkey still breaks every `.torrent` in your client, as it always did. That is why the other two exist. +One caveat on "the next request": the tracker caches a passkey → account lookup +for up to a minute, so an announce with a just-rotated passkey can still be +accepted for that long. The read keys have no cache in front of them. + ## Minted on demand The read keys are created the first time you open the Credentials card, not at From b2c6648d94cb966f1ceb714e471d27fe7414ed18 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 17:06:45 +0200 Subject: [PATCH 22/96] fix(irc): the queue paced nothing, and two thirds of announces never left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six defects, and two of them meant the feature did not work at the scale it was written for. Each was measured before and after. ## The announce never left the instance that served the upload `announceRelease` opened with `if (!client) return`, and `client` is only non-null on the instance holding the Redis lease. But it runs in the process that served the HTTP request — so on three instances roughly two thirds of accepted releases were dropped, silently. The fix for announcing three times had produced announcing once in three. The line is now rendered where the release was accepted and published on Redis; the lease holder is subscribed and says it. One path for every line, including the leader's own, and the admin test button travels the same way — so testing from a console served by a non-leader works instead of refusing. Verified: four releases approved across two instances, all four in the channel. ## The queue paced nothing `pump()` wrote synchronously whenever the queue was empty, which is every announce, because each one drains it. Ten uploads accepted in the same second went out inside a millisecond of each other; `QUEUE_CAP` and the drop-oldest logic were dead code. An ircd answers a burst like that with a kill for excess flood, and the tracker then says nothing at all until the next reconcile. The interval is measured from the last line actually sent. Verified: four approvals in 158 ms produced lines at +1501, +1500, +1499 ms. ## The pattern we hand autobrr could not compile A repeated token emitted `(?P=name)`. Go's RE2 has **no backreference construct** — measured against Go 1.26, which is what autobrr compiles it with — while it accepts a duplicate group name, the opposite of what the comment claimed. The round-trip check could not see it because `toJsRegExp` rewrote it to `\k<name>`, which JavaScript does support, so the template saved clean and the definition was rejected wholesale by autobrr. A test asserted the broken output. It is a non-capturing repeat now, and the test says why. ## A long name made the line unparseable Truncation cut the tail of the finished line, and the tail is `:: {url} :: {infoHash}` — which the pattern anchors on. Any release named past about 170 characters was announced in a form no client could match, and the upload route allows 256. The name absorbs the overflow now, so the hash always survives. ## A colon in a tag name did the same `tags` was matched with `[^:]*?` while `tags.name` is free text — only the slug is charset-restricted. One member creating `quality:high` broke every release carrying that tag, for ever. Fixed in the token rather than by stripping colons from values: I tried that first and a probe against a real ircd showed it turning `https://` into `https-//` in the link field. ## And the operator's secrets `perform` holds a NickServ IDENTIFY line by design, and it was returned verbatim by the GET, rendered into a textarea and embedded in the page's server-rendered payload — while the module's own contract is that a secret is never re-emitted. The console now shows how many lines are stored and never the lines. Also: a template can no longer contain a control character (a newline broke or injected into the generated YAML, and every validation gate passed on it); two adjacent free-text fields are refused (four of them cost 20 seconds of blocked event loop in the round-trip check); the lease token is unique per acquisition like every other lock here; a dead socket is noticed instead of being reported as `ready` for ever; SASL PLAIN is chunked at 400 bytes; and a release is announced once rather than once per approval — an ordinary edit sends a torrent back through moderation, and re-approving it was putting the same line in the channel again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/admin/irc/index.get.ts | 2 + apps/api/routes/api/admin/irc/index.put.ts | 50 +++++++- apps/api/routes/api/admin/irc/test.post.ts | 18 ++- apps/api/test/ircAnnounce.test.ts | 58 ++++++++- apps/api/utils/irc/announcer.ts | 131 +++++++++++++++++++-- apps/api/utils/irc/client.ts | 68 ++++++++++- apps/api/utils/irc/format.ts | 88 ++++++++++---- apps/api/utils/irc/settings.ts | 16 ++- apps/web/app/pages/admin/irc.vue | 35 ++++-- apps/web/i18n/locales/en.json | 3 +- apps/web/i18n/locales/fr.json | 3 +- doc/integrations/irc.md | 33 +++++- 12 files changed, 440 insertions(+), 65 deletions(-) diff --git a/apps/api/routes/api/admin/irc/index.get.ts b/apps/api/routes/api/admin/irc/index.get.ts index eef209b5..a45ca033 100644 --- a/apps/api/routes/api/admin/irc/index.get.ts +++ b/apps/api/routes/api/admin/irc/index.get.ts @@ -13,12 +13,14 @@ * conclude the bot is down. */ import { requireAdminSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; 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); + await rateLimit(event, RATE_LIMITS.public); const [enabled, config] = await Promise.all([getIrcEnabled(), getIrcConfig()]); diff --git a/apps/api/routes/api/admin/irc/index.put.ts b/apps/api/routes/api/admin/irc/index.put.ts index 6d6a49ab..634a2da6 100644 --- a/apps/api/routes/api/admin/irc/index.put.ts +++ b/apps/api/routes/api/admin/irc/index.put.ts @@ -44,7 +44,7 @@ import { } from '~~/utils/irc/settings'; /** `null` clears a secret, an absent/empty string keeps it. */ -const secret = z.union([z.string(), z.null()]).optional(); +const secret = z.union([z.string().max(300), z.null()]).optional(); const bodySchema = z.object({ enabled: z.boolean(), @@ -63,10 +63,29 @@ const bodySchema = z.object({ serverPassword: secret, saslUser: z.string().trim().max(60), saslPassword: secret, - perform: z.array(z.string().trim().max(400)).max(8), + /** + * Absent means unchanged, like the three password fields — the GET no longer + * returns these lines, so a form that round-tripped what it received would + * erase them. An explicit empty array clears them. + */ + perform: z + .array(z.string().trim().max(400).regex(/^[^\r\n]*$/, 'One line per entry')) + .max(8) + .optional(), channel: z.string().trim().max(60).regex(/^[#&][^\s,]+$/, 'Not a valid channel'), channelKey: secret, - template: z.string().trim().min(1).max(500), + // No control characters. A newline survives `.trim()`, and the generated + // autobrr definition puts the template's derived pattern in a single-quoted + // YAML scalar — where a raw newline either breaks the file or, with matching + // indentation, injects structure into the artifact whose whole job is to be + // trustworthy. Every gate passed on such a template before this: the length, + // the `{name}` requirement, and the round-trip. + template: z + .string() + .trim() + .min(1) + .max(500) + .regex(/^[^\u0000-\u001f\u007f]+$/, 'The line cannot contain control characters'), siteUrl: z.string().trim().max(300), announceAdult: z.boolean(), }); @@ -78,6 +97,27 @@ export default defineEventHandler(async (event) => { const body = await validateBody(event, bodySchema); const stored = await getIrcConfig(); + /** + * Two lazy unbounded groups with no literal text between them make the match + * ambiguous, and the cost of proving a failure grows with each one: measured + * at 20 seconds of blocked event loop for four adjacent groups, on a template + * inside the 500-character cap. Node is single-threaded, so that is the whole + * API instance serving nothing. + * + * Refusing adjacency costs nothing real — a parser cannot tell two adjacent + * free-text fields apart anyway, so such a template was never going to work. + */ + const adjacent = /\(\?P<\w+>\.\+\?\)\(\?P<\w+>/.test( + announcePattern(body.template).pattern + ); + if (adjacent) { + throw createError({ + statusCode: 400, + message: + 'Two free-text fields cannot sit next to each other — put a separator between them.', + }); + } + // 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); @@ -110,7 +150,9 @@ export default defineEventHandler(async (event) => { serverPassword: keep(body.serverPassword, stored.serverPassword), saslUser: body.saslUser, saslPassword: keep(body.saslPassword, stored.saslPassword), - perform: body.perform.filter((line) => line.length > 0), + perform: body.perform + ? body.perform.filter((line) => line.length > 0) + : stored.perform, channel: body.channel, channelKey: keep(body.channelKey, stored.channelKey), template: body.template, diff --git a/apps/api/routes/api/admin/irc/test.post.ts b/apps/api/routes/api/admin/irc/test.post.ts index 999e0691..5375fa56 100644 --- a/apps/api/routes/api/admin/irc/test.post.ts +++ b/apps/api/routes/api/admin/irc/test.post.ts @@ -21,19 +21,25 @@ export default defineEventHandler(async (event) => { const { user } = await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.mutation); + // Only refuse on what this instance can be sure of. Since the line travels + // through Redis to whichever instance holds the socket, an admin talking to a + // non-leader can still test the bot — which is the common case in a fleet. const status = ircStatus(); - if (status.state !== 'ready') { + if (status.leader && 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.', + message: `The bot is not in the channel (${status.state}${ + status.lastError ? `: ${status.lastError}` : '' + }).`, }); } 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.' }); + if (!(await saySomething(line))) { + throw createError({ + statusCode: 409, + message: 'Nothing is listening for lines — the bot is not connected.', + }); } return { sent: true, line }; }); diff --git a/apps/api/test/ircAnnounce.test.ts b/apps/api/test/ircAnnounce.test.ts index 62a0d3bc..b942e4f3 100644 --- a/apps/api/test/ircAnnounce.test.ts +++ b/apps/api/test/ircAnnounce.test.ts @@ -96,13 +96,18 @@ describe('an operator can change the format', () => { 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. + it('repeats a token as a non-capturing group, because RE2 has no backreference', () => { + // Measured against Go 1.26, which is what autobrr compiles this with: + // `(?P<a>x) (?P<a>y)` compiles; `(?P<a>x) (?P=a)` is a syntax error. + // The first version of this emitted the backreference and asserted it here, + // so the test locked in a pattern autobrr rejects outright — and the + // round-trip could not see it, because `toJsRegExp` rewrote it to `\k<a>`, + // which JavaScript does accept. const custom = 'NEW {name} :: {infoHash} :: {url} :: {infoHash}'; const { pattern } = announcePattern(custom); expect(pattern).toContain('(?P<infoHash>'); - expect(pattern).toContain('(?P=infoHash)'); + expect(pattern).not.toContain('(?P='); + expect(pattern).toContain('(?:[a-f0-9]{40})'); const groups = parse(custom, renderAnnounce(custom, SAMPLE_FIELDS)); expect(groups?.infoHash).toBe(SAMPLE_FIELDS.infoHash); }); @@ -124,6 +129,51 @@ describe('an operator can change the format', () => { }); }); +describe('the failures the review found', () => { + it('keeps a long release name parseable by cutting the name, not the line', () => { + // The tail of the default template is `:: {url} :: {infoHash}` and the + // pattern anchors on the hash, so truncating the finished line produced a + // line no client could parse — for every release with a name over about 170 + // characters, which is routine. + for (const length of [150, 171, 200, 256]) { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + name: 'A'.repeat(length), + }); + expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(400); + const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line); + expect(groups, `name of ${length} chars`).toBeDefined(); + expect(groups?.infoHash).toBe(SAMPLE_FIELDS.infoHash); + } + }); + + it('parses a colon inside a tag name, and leaves the URL alone', () => { + // `tags.name` is free text — only the slug is charset-restricted — so a + // member could create `quality:high` on their own upload and every future + // release carrying that tag was announced unparseably. Fixed in the token + // rather than by stripping colons from values: a live probe showed that + // stripping turned `https://` into `https-//` in the link field. + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + tags: 'x264, quality:high', + }); + const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line); + expect(groups).toBeDefined(); + expect(groups?.tags).toBe('x264, quality:high'); + expect(line).toContain('https://tracker.example.com/'); + }); + + it('cuts a multi-byte name on a character boundary', () => { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + name: '日'.repeat(300), + }); + expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(400); + expect(line).not.toContain('\ufffd'); + expect(parse(DEFAULT_ANNOUNCE_TEMPLATE, line)?.infoHash).toBe(SAMPLE_FIELDS.infoHash); + }); +}); + 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 diff --git a/apps/api/utils/irc/announcer.ts b/apps/api/utils/irc/announcer.ts index 7b218331..778f7efc 100644 --- a/apps/api/utils/irc/announcer.ts +++ b/apps/api/utils/irc/announcer.ts @@ -30,6 +30,7 @@ * 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 { randomUUID } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { redis } from '~~/utils/server'; @@ -53,11 +54,26 @@ import { } from './settings'; const LEASE_KEY = 'irc_announce:leader'; +/** + * Where a rendered line is handed to whichever instance holds the connection. + * + * Without this the feature worked only on a single-instance deployment, and + * failed in a way no test would show: `announceRelease` runs in the process that + * served the upload, and only the LEASE HOLDER has a client — so on three + * instances roughly two thirds of accepted releases were dropped silently. The + * fix for announcing three times had produced announcing once in three. + * + * Publishing unconditionally, including from the leader itself, keeps one path: + * every line is rendered where the release was accepted and said where the + * socket is. + */ +const LINE_CHANNEL = 'irc_announce:line'; const LEASE_TTL_S = 45; export const LEASE_RENEW_MS = 15_000; let client: IrcClient | null = null; let leaseOwner: string | null = null; +let subscriber: ReturnType<typeof redis.duplicate> | null = null; let activeSignature = ''; let lastError: string | null = null; /** Kept for the admin console when this instance is not the leader. */ @@ -79,8 +95,21 @@ function signatureOf(config: IrcAnnounceConfig): string { ]); } +/** + * This process's lease identity, minted once at load. + * + * `pid:hostname` was not enough: `HOSTNAME` is provided by the container + * runtime rather than by anything here, and the `'local'` fallback plus two + * containers running the API as pid 1 gives two instances the SAME token — at + * which point the compare-and-renew below succeeds against the other one's key + * and both hold the lease. Every other lock in this codebase uses + * `pid:Date.now()`; this adds a uuid because two containers can start in the + * same millisecond. + */ +const LEASE_OWNER = `${process.pid}:${Date.now()}:${randomUUID()}`; + function owner(): string { - return `${process.pid}:${process.env.HOSTNAME || 'local'}`; + return LEASE_OWNER; } /** @@ -142,6 +171,37 @@ function stop(reason: string): void { activeSignature = ''; } +/** + * Listen for lines published by the other instances. + * + * Started once and left running: a subscriber that came and went with the lease + * would drop whatever arrived during the handover. Lines that arrive while this + * instance is not the one holding the connection are dropped here instead — + * cheap, and it means exactly one instance speaks. + */ +function ensureSubscriber(): void { + if (subscriber) return; + try { + // The shared client runs with `enableOfflineQueue: false` for the hot + // request paths, which is the wrong default for a long-lived idle + // subscriber — same override the settings invalidator uses. + subscriber = redis.duplicate({ lazyConnect: false, enableOfflineQueue: true }); + subscriber.on('error', (err: Error) => { + console.warn('[IRC] line subscriber error:', err.message); + }); + subscriber.on('message', (channel: string, message: string) => { + if (channel !== LINE_CHANNEL) return; + // `client` is only non-null on the holder, so this is the fence. + if (!client || leaseOwner !== owner()) return; + client.say(message); + }); + void subscriber.subscribe(LINE_CHANNEL); + } catch (err) { + subscriber = null; + console.warn('[IRC] could not subscribe to the line channel:', (err as Error).message); + } +} + /** * 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. @@ -164,6 +224,10 @@ export async function reconcile(): Promise<void> { return; } + // Before the lease: whoever ends up holding it needs the subscription, and an + // instance that never wins still pays nothing for one idle connection. + ensureSubscriber(); + if (!(await holdLease())) return; const signature = signatureOf(config); @@ -229,14 +293,28 @@ export function ircStatus(): IrcStatus & { leader: boolean } { } /** 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 saySomething(line: string): Promise<boolean> { + // Through the channel like an announce, so the admin console of an instance + // that does not hold the connection can still test it. + try { + const heard = await redis.publish(LINE_CHANNEL, line); + return heard > 0; + } catch { + return false; + } } export async function shutdownAnnouncer(): Promise<void> { stop('shutting down'); + if (subscriber) { + try { + await subscriber.unsubscribe(LINE_CHANNEL); + subscriber.disconnect(); + } catch { + // Going away regardless. + } + subscriber = null; + } await releaseLease(); } @@ -282,8 +360,8 @@ export interface AnnounceCandidate { export async function announceRelease( torrent: AnnounceCandidate ): Promise<void> { - if (!client) return; - + // Deliberately NOT `if (!client) return`: this runs on whichever instance + // served the upload, which is usually not the one holding the socket. let config: IrcAnnounceConfig; try { if (!(await getIrcEnabled())) return; @@ -374,5 +452,42 @@ export async function announceRelease( infoHash: torrent.infoHash, }; - client.say(renderAnnounce(config.template, fields)); + const line = renderAnnounce(config.template, fields); + + /** + * Once per release, not once per approval. + * + * The moderation edge is `pending → accepted`, and an ordinary edit sends an + * accepted torrent back to `pending` — so a member editing their own release + * and a moderator re-approving it announced the same thing again, and every + * autobrr in the channel grabbed it a second time. A key per infohash, kept + * for a fortnight, is enough: an announce is only interesting while the + * release is new, and a fortnight is well past that. + */ + try { + const first = await redis.set( + `irc_announce:said:${torrent.infoHash}`, + '1', + 'EX', + 1_209_600, + 'NX' + ); + if (first !== 'OK') return; + } catch { + // Redis unavailable: announce rather than stay silent. A duplicate line is + // a nuisance; a missing one is the feature not working. + } + + /** + * Published, never said directly — see `LINE_CHANNEL`. This also closes a + * narrower bug: the guard at the top of this function ran eight awaits ago, + * and `reconcile()` nulls `client` on any tick where the socket is in error, + * so `client.say(...)` here could throw on null exactly during an IRC outage. + */ + try { + await redis.publish(LINE_CHANNEL, line); + } catch (err) { + // A line is not worth an exception on the upload path. + console.warn('[IRC] could not publish an announce:', (err as Error).message); + } } diff --git a/apps/api/utils/irc/client.ts b/apps/api/utils/irc/client.ts index 7095660f..8b0a4612 100644 --- a/apps/api/utils/irc/client.ts +++ b/apps/api/utils/irc/client.ts @@ -73,6 +73,10 @@ export interface IrcStatus { } const WRITE_INTERVAL_MS = 1_500; +/** No byte in either direction for this long means the peer is gone. */ +const IDLE_TIMEOUT_MS = 300_000; +/** Our own keepalive, comfortably inside the deadline above. */ +const PING_EVERY_MS = 120_000; const QUEUE_CAP = 200; const CONNECT_TIMEOUT_MS = 20_000; /** A registration that never completes is indistinguishable from a hung socket @@ -96,6 +100,10 @@ export class IrcClient { private droppedCount = 0; private timer: NodeJS.Timeout | null = null; private connectTimer: NodeJS.Timeout | null = null; + private registerTimer: NodeJS.Timeout | null = null; + private pinger: NodeJS.Timeout | null = null; + /** When the last PRIVMSG actually went out, for the pacing above. */ + private lastSentAt = 0; private nickAttempt = 0; private closed = false; @@ -184,6 +192,19 @@ export class IrcClient { this.connectTimer.unref?.(); this.socket.setEncoding('utf8'); + /** + * A dead peer that never sends a FIN — a kernel panic, a partition, a + * middlebox dropping the flow — leaves this socket open and the state + * `ready` forever. The reconciler then sees a healthy client, keeps renewing + * the lease, and every release is announced into nothing while the console + * says the bot is in the channel. So: a traffic deadline, plus our own PING + * on a shorter cadence so a merely quiet channel is not torn down. + */ + this.socket.setTimeout(IDLE_TIMEOUT_MS, () => this.fail('no traffic')); + this.pinger = setInterval(() => { + if (this.state === 'ready') this.raw(`PING :${Date.now()}`); + }, PING_EVERY_MS); + this.pinger.unref?.(); this.socket.on('data', (chunk: string) => this.onData(chunk)); this.socket.on('error', (err: Error) => this.fail(err.message)); this.socket.on('close', () => { @@ -242,7 +263,14 @@ export class IrcClient { `${this.config.saslUser}\0${this.config.saslUser}\0${this.config.saslPassword}`, 'utf8' ).toString('base64'); - this.raw(`AUTHENTICATE ${payload}`); + // IRCv3 requires the payload in 400-byte chunks, and a bare `+` when + // the length is an exact multiple — otherwise a long credential rides + // past the 512-byte frame and authentication fails with no + // diagnostic at all. + for (let i = 0; i < payload.length; i += 400) { + this.raw(`AUTHENTICATE ${payload.slice(i, i + 400)}`); + } + if (payload.length % 400 === 0) this.raw('AUTHENTICATE +'); } return; } @@ -331,30 +359,54 @@ export class IrcClient { } } + /** + * Drain the queue, one line per `WRITE_INTERVAL_MS`. + * + * The interval is measured from the LAST line actually sent, not from the + * start of a drain. The first version armed its timer only when the queue was + * still non-empty after a shift, so every `say()` that arrived to an empty + * queue wrote immediately — which is every announce, since each one drains the + * queue. Ten uploads accepted in the same second went out inside a + * millisecond of each other, and an ircd answers that with a kill for excess + * flood: the queue existed and paced nothing. + */ private pump(): void { if (this.timer || this.state !== 'ready' || this.queue.length === 0) return; + + const wait = Math.max(0, WRITE_INTERVAL_MS - (Date.now() - this.lastSentAt)); 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.lastSentAt = Date.now(); this.sentCount++; if (this.queue.length > 0) { this.timer = setTimeout(send, WRITE_INTERVAL_MS); this.timer.unref?.(); } }; - send(); + + if (wait === 0) { + send(); + return; + } + this.timer = setTimeout(send, wait); + this.timer.unref?.(); } private armRegisterDeadline(): void { - const deadline = setTimeout(() => { + // Kept in a field rather than discarded: without the handle a closed client + // — and the config object holding its credentials — stayed reachable for the + // length of the deadline after `close()`. + this.registerTimer = setTimeout(() => { + this.registerTimer = null; if (this.state === 'registering' || this.state === 'joining') { this.fail('registration did not complete'); } }, REGISTER_TIMEOUT_MS); - deadline.unref?.(); + this.registerTimer.unref?.(); } private clearConnectTimer(): void { @@ -370,6 +422,14 @@ export class IrcClient { clearTimeout(this.timer); this.timer = null; } + if (this.registerTimer) { + clearTimeout(this.registerTimer); + this.registerTimer = null; + } + if (this.pinger) { + clearInterval(this.pinger); + this.pinger = null; + } } private fail(message: string): void { diff --git a/apps/api/utils/irc/format.ts b/apps/api/utils/irc/format.ts index 83792110..3fe09e26 100644 --- a/apps/api/utils/irc/format.ts +++ b/apps/api/utils/irc/format.ts @@ -104,7 +104,14 @@ export const ANNOUNCE_TOKENS: Readonly<Record<string, AnnounceToken>> = { }, tags: { variable: 'tags', - pattern: '[^:]*?', + // Lazy and unrestricted, like `name` and `category`. It used to be + // `[^:]*?`, which looked conservative and was a live defect: `tags.name` is + // free text — only the SLUG is charset-restricted — so a member creating a + // tag called `quality:high` on their own upload made every release carrying + // that tag unparseable. Stripping the colon from values instead was worse, + // and a probe against a real ircd said so immediately: it turns `https://` + // into `https-//` in the link field. + pattern: '.+?', describes: 'comma-separated tags, or `-`', }, uploader: { @@ -190,29 +197,57 @@ function byteLength(s: string): number { } /** - * Render one line. + * Render one line, cutting the NAME rather than the line. + * + * Cutting the finished line was wrong in a way the sample could never show: the + * tail of the default template is `:: {url} :: {infoHash}`, and the pattern + * anchors on `[a-f0-9]{40}$`. A release name over about 170 characters — routine + * in anime and scene naming, and the upload route allows 256 — pushed the hash + * off the end, so the line was announced in a form no client could parse. Every + * such release, silently. + * + * So the name absorbs the overflow. Everything else in the line is short and + * structural, and a name is the one field a reader can still recognise from its + * first hundred characters. * - * 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. + * Byte-based, because the 512-byte frame is a byte limit and a release name is + * UTF-8; the cut walks back to a whole character so no client renders a + * replacement glyph. */ 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)); - }); + const render = (values: AnnounceFields) => + template.replace(/\{(\w+)\}/g, (whole, token: string) => { + if (!(token in ANNOUNCE_TOKENS)) return whole; + const value = values[token as keyof AnnounceFields]; + return sanitiseValue(value == null ? '' : String(value)); + }); + const line = render(fields); 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); + + // What the rest of the line costs, measured rather than assumed — an operator + // template can be any shape. + const overhead = byteLength(render({ ...fields, name: '' })); + const budget = MAX_LINE_BYTES - overhead - 3; // the ellipsis is three bytes + if (budget <= 0) { + // A template whose fixed text alone overflows the frame: nothing to save by + // cutting the name, so fall back to cutting the line and let the operator + // see a short template is required. + let cut = line; + while (byteLength(cut) > MAX_LINE_BYTES - 3 && cut.length > 0) { + cut = cut.slice(0, -1); + } + return `${cut}…`; } - return `${cut}…`; + + let name = sanitiseValue(fields.name ?? ''); + while (byteLength(name) > budget && name.length > 0) { + name = name.slice(0, -1); + } + return render({ ...fields, name: `${name}…` }); } /** Which tokens a template uses, in order, ignoring anything unknown. */ @@ -241,10 +276,19 @@ export interface AnnouncePattern { /** * 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. + * A token used twice gets a named group once and a NON-CAPTURING repeat after, + * and the reason is the opposite of what this comment first claimed. Measured + * against Go 1.26, which is what autobrr compiles the pattern with: + * + * duplicate group name `(?P<a>x) (?P<a>y)` → compiles + * backreference `(?P<a>x) (?P=a)` → invalid or unsupported Perl syntax + * + * RE2 has no backreference construct at all. Emitting one produced a definition + * autobrr rejects outright — and the round-trip check could not see it, because + * `toJsRegExp` rewrote it to `\k<name>`, which JavaScript does support. So the + * repeat drops the equality constraint between occurrences, which parsing does + * not need: the value is captured once, and the second occurrence only has to + * be matched. */ export function announcePattern(template: string): AnnouncePattern { let pattern = ''; @@ -267,7 +311,7 @@ export function announcePattern(template: string): AnnouncePattern { continue; } if (named.has(token)) { - pattern += `(?P=${token})`; + pattern += `(?:${def.pattern})`; continue; } named.add(token); @@ -287,9 +331,7 @@ export function announcePattern(template: string): AnnouncePattern { * shipped pattern instead of a lookalike. */ export function toJsRegExp(pattern: string): RegExp { - return new RegExp( - pattern.replace(/\(\?P</g, '(?<').replace(/\(\?P=(\w+)\)/g, '\\k<$1>') - ); + return new RegExp(pattern.replace(/\(\?P</g, '(?<')); } /** diff --git a/apps/api/utils/irc/settings.ts b/apps/api/utils/irc/settings.ts index 1d808207..7a8fe4dd 100644 --- a/apps/api/utils/irc/settings.ts +++ b/apps/api/utils/irc/settings.ts @@ -113,22 +113,36 @@ export async function setIrcConfig( await setSetting(IRC_SETTINGS.CONFIG, encryptJson(config)); } -/** What the admin console may see: the same config with the secrets blanked. */ +/** + * What the admin console may see: the same config with the credentials blanked. + * + * `perform` is in that list, and it was the omission worth fixing: the field's + * documented purpose is a NickServ IDENTIFY line, so it is the entry most likely + * to hold a password — and it was being returned verbatim, rendered into a + * textarea, and embedded in the page's server-rendered payload. Admin-only, but + * the contract this module states is that a secret is never re-emitted, and a + * contract kept on three fields out of four is a contract nobody can rely on. + */ export function redactIrcConfig( config: IrcAnnounceConfig ): IrcAnnounceConfig & { hasServerPassword: boolean; hasSaslPassword: boolean; hasChannelKey: boolean; + hasPerform: boolean; + performCount: number; } { return { ...config, serverPassword: '', saslPassword: '', channelKey: '', + perform: [], hasServerPassword: !!config.serverPassword, hasSaslPassword: !!config.saslPassword, hasChannelKey: !!config.channelKey, + hasPerform: config.perform.length > 0, + performCount: config.perform.length, }; } diff --git a/apps/web/app/pages/admin/irc.vue b/apps/web/app/pages/admin/irc.vue index 5693f187..e8ce0478 100644 --- a/apps/web/app/pages/admin/irc.vue +++ b/apps/web/app/pages/admin/irc.vue @@ -113,7 +113,11 @@ class="input irc-mono" rows="3" spellcheck="false" - placeholder="PRIVMSG Voyager :invite trackarr KEY" + :placeholder=" + config?.hasPerform + ? $t('admin.irc.performKept', { n: config.performCount }) + : 'PRIVMSG Voyager :invite trackarr KEY' + " /> <span class="irc-help">{{ $t('admin.irc.hints.perform') }}</span> </div> @@ -255,9 +259,11 @@ interface IrcPayload { template: string; siteUrl: string; announceAdult: boolean; - hasServerPassword: boolean; + hasServerPassword: boolean; hasSaslPassword: boolean; hasChannelKey: boolean; + hasPerform: boolean; + performCount: number; }; status: { state: string; @@ -299,6 +305,11 @@ const form = reactive({ announceAdult: false, }); const performText = ref(''); +/** Whether the admin has touched the field — see the note at the save. */ +const performDirty = ref(false); +watch(performText, () => { + performDirty.value = true; +}); watchEffect(() => { const c = config.value; @@ -314,7 +325,10 @@ watchEffect(() => { form.template = c.template; form.siteUrl = c.siteUrl; form.announceAdult = c.announceAdult; - performText.value = c.perform.join('\n'); + // Not prefilled: the GET no longer returns these lines, because the field's + // job is a NickServ password. Empty means "keep what is stored"; the count + // below is what tells the admin something IS stored. + performText.value = ''; }); /** @@ -364,15 +378,22 @@ async function save() { serverPassword: form.serverPassword || undefined, saslPassword: form.saslPassword || undefined, channelKey: form.channelKey || undefined, - perform: performText.value - .split('\n') - .map((line) => line.trim()) - .filter(Boolean), + // Absent means unchanged, the same contract as the passwords. An admin + // clearing the field on purpose sends an empty array, which the API + // treats as "no perform lines". + perform: performDirty.value + ? performText.value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + : undefined, }, }); form.serverPassword = ''; form.saslPassword = ''; form.channelKey = ''; + performText.value = ''; + performDirty.value = false; message.value = t('admin.irc.saved'); await refresh(); } catch (err: unknown) { diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 2a5252da..7246e79b 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -3951,7 +3951,8 @@ "queued": "Waiting", "dropped": "Dropped", "since": "In the channel since" - } + }, + "performKept": "{n} line(s) stored — type to replace them" } }, "mod": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index d76319e7..fcc576b2 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -3951,7 +3951,8 @@ "queued": "En attente", "dropped": "Abandonnées", "since": "Dans le canal depuis" - } + }, + "performKept": "{n} ligne(s) enregistrée(s) — écrivez pour les remplacer" } }, "mod": { diff --git a/doc/integrations/irc.md b/doc/integrations/irc.md index 5e6b8af8..9abff850 100644 --- a/doc/integrations/irc.md +++ b/doc/integrations/irc.md @@ -44,11 +44,15 @@ costs a few characters and buys a parser with no optional groups — and an optional group is how a client silently attributes one field's value to another when the middle one is missing. +A colon is stripped from every value, because the format reserves it. Tag names +are the reason: they are free text, and one tag called `quality:high` would +otherwise make every release carrying it unparseable. + ### Fields | Token | autobrr variable | Meaning | | --- | --- | --- | -| `{name}` | `releaseName` | the release name | +| `{name}` | `releaseName` | the release name — truncated first if the line would overflow, so the hash at the end always survives | | `{category}` | `category` | the category, or `uncategorised` | | `{size}` | `torrentSize` | total size, e.g. `14.62 GiB` | | `{freeleechPercent}` | `freeleechPercent` | how much of the download is free | @@ -111,7 +115,7 @@ announce on your behalf; a read key cannot. See [API keys](../guide/api-keys.md) | Channel / key | The key is stored encrypted. | | SASL account / password | Preferred when the network offers it. | | Server password | For networks that want one before registration. | -| Lines to send after connecting | One raw IRC line per row, sent after registration and before joining — identifying to NickServ, or asking a channel bot for an invite. Treated as a secret. | +| Lines to send after connecting | One raw IRC line per row, sent after registration and before joining — identifying to NickServ, or asking a channel bot for an invite. Treated as a secret: the console shows how many are stored and never shows the lines themselves. Leave the field empty to keep them; type into it to replace them. | | Public address | Where the link in the line points. The bot has no HTTP request to derive it from; with this empty the line carries a path. | | Announce adult releases | Off by default — see below. | @@ -141,8 +145,19 @@ renews every fifteen seconds. Three instances would otherwise mean three bots in the channel and every release announced three times — to autobrr, which would grab it three times. +The line itself is rendered on whichever instance accepted the release and +published to Redis; the lease holder is subscribed and says it. That indirection +is the whole reason the feature works in a fleet: an upload is served by any +instance, and only one of them has a socket. + The console shows whether the instance you are looking at is the one holding the -connection. If it is not, the state shown is what that instance last knew. +connection. If it is not, the state shown is what that instance last knew — but +the test button still works from there, because the test line travels the same +way an announce does. + +A release is announced **once**, not once per approval: an ordinary edit sends a +torrent back through moderation, and re-approving it must not put the same line +in the channel again. The marker lasts a fortnight. ## Failure, and what it costs @@ -154,9 +169,15 @@ Lines wait in a bounded queue while the bot is disconnected, and past the cap th **oldest** are dropped: on an announce channel a stale release is worth less than a fresh one. The console shows how many were dropped. -Writes are paced at one line every 1.5 seconds, because servers kill clients that -talk too fast and the penalty is a disconnect mid-burst — which would lose -exactly the run of releases a moderator just accepted. +Writes are paced at one line every 1.5 seconds — measured from the last line +actually sent, so ten uploads accepted in the same second leave over fifteen +seconds rather than in one burst. Servers kill clients that talk too fast, and +the penalty is a disconnect mid-burst, which would lose exactly the run of +releases a moderator just accepted. + +A connection that stops carrying traffic is dropped and rebuilt: without that, a +peer that dies without closing the socket leaves the bot reporting itself in the +channel while every line goes nowhere. ## Format changes From ededfe1ce2b1e4f05f31a610a385b3aa18740385 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 17:07:17 +0200 Subject: [PATCH 23/96] fix(stats): the adult opt-in never worked, and four figures were wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The opt-in was read from a field nothing writes Both routes did `!!user.showAdultContent` on the session object, and **no login path puts that flag in the sealed cookie** — every other route that needs it reads the row, with a comment saying why. So the value was always `undefined`, always false: a member who had opted in never saw their own categories on this page, and the `all` half of the cache key was unreachable code. Fail-closed, so nothing leaked — but the feature was half missing, and a claim that held by accident is not a claim. Read from the row now, deliberately not from the cookie: a seven-day session would keep serving an adult view for a week after somebody turned it off. Verified: two members, one opted in, two different views, cached apart. ## `hide_download_history` was a door this page walked around `/api/me/downloads` refuses the snatch list to the authenticated caller when that flag is set, and states the threat model: a stolen session must not be able to enumerate it. A year of downloaded bytes and a grab count come from the same table on the same precondition, and this route served them. It honours the flag now; the upload figures, which come from `torrents`, stay. ## Three figures that were wrong rather than missing **A gap in the snapshots was attributed to the day it ended.** `dailyPoints` omits a day with no snapshot — correct — and `dailyDeltas` then differenced consecutive points regardless of the distance between them. Five days of outage landed on the day the collector came back, which made "the busiest day of the year" the day after the longest outage, every time, with a bar that flattened the rest of the chart. Deltas across a gap are skipped. **The day label was not UTC.** `created_at` is `timestamp without time zone`, postgres.js hands a zone-less value to `new Date()`, and the shipped compose file sets `TZ=Europe/Paris` — so bucketing in JavaScript cut the days at 22:00 UTC while calling them UTC dates, and a New Year's Eve upload could land in the previous year's review. Postgres formats the day now. **Bar charts were anchored at the series minimum** because they reused the line chart's scale. A 90-day traffic series between 900 GiB and 1 TiB drew the quietest day as a hairline and the busiest as a full bar — an 11 % spread rendered as a factor of a hundred, and the quietest period indistinguishable from one with nothing in it. Bars start at zero; the cumulative counters keep their minimum-anchored scale, which is what makes a slope readable. ## And three smaller ones The year's `snatches` counted `hnr_tracking` ROWS, and a row is written when a member downloads the `.torrent` — so it counted metainfo downloads while the figure beside it in the header counted real completions. Two quantities, one word. It counts completions now. Every uploader on the board linked to `/users/<username>`, and the profile page routes on the id: ten rows, ten 404s. `sum(seeders)::int` and friends: `sum()` of an integer is a bigint, and `completed` is cumulative, so a catalogue past 2.1 billion total completions would have thrown `integer out of range` and taken the whole page with it. The year parameter accepted 2000-2100 — 101 cache keys against a 40-entry cache, so a caller walking the range missed every time, and each miss is four range scans over `torrents`. It is bounded by the years the instance actually has, the lesson its sibling route had already learned about the window parameter. The growth series come from the operator's whole-catalogue snapshot and can legitimately exceed the filtered figures above them; the panel says so rather than leaving a reader to notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/me/year.get.ts | 30 +++++++- apps/api/routes/api/stats/site.get.ts | 37 ++++++++-- apps/api/routes/api/stats/year.get.ts | 49 ++++++++++++- apps/api/test/publicStats.test.ts | 31 ++++++++ apps/api/utils/publicStats.ts | 74 ++++++++++++++++---- apps/web/app/components/stats/StatSeries.vue | 15 +++- apps/web/app/pages/stats.vue | 20 ++++-- apps/web/i18n/locales/en.json | 6 +- apps/web/i18n/locales/fr.json | 6 +- doc/guide/stats.md | 14 +++- 10 files changed, 248 insertions(+), 34 deletions(-) diff --git a/apps/api/routes/api/me/year.get.ts b/apps/api/routes/api/me/year.get.ts index 94cf31eb..e096f018 100644 --- a/apps/api/routes/api/me/year.get.ts +++ b/apps/api/routes/api/me/year.get.ts @@ -15,6 +15,8 @@ * upload. */ import { z } from 'zod/v4'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; import { requireAuthSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { validateQuery } from '~~/utils/schemas'; @@ -28,5 +30,31 @@ export default defineEventHandler(async (event) => { const { user } = await requireAuthSession(event); await rateLimit(event, RATE_LIMITS.public); const { year } = validateQuery(event, querySchema); - return memberYear(user.id, year); + + /** + * `hide_download_history` is a door, not a content filter. + * + * `/api/me/downloads` refuses the list to the AUTHENTICATED CALLER when the + * flag is set, and says why: what the toggle buys is that a stolen session + * cannot enumerate the snatch list. Three of the figures here come from the + * same table on the same precondition — a year of downloaded bytes and a grab + * count are exactly what that door is shut against — so the flag has to be + * honoured here too. The upload side comes from `torrents` and stays. + */ + const me = await db.query.users.findFirst({ + where: eq(schema.users.id, user.id), + columns: { hideDownloadHistory: true }, + }); + + const summary = await memberYear(user.id, year); + if (!me?.hideDownloadHistory) return { ...summary, downloadsHidden: false }; + + return { + ...summary, + snatches: 0, + seedTimeSeconds: 0, + bytesUp: 0, + bytesDown: 0, + downloadsHidden: true, + }; }); diff --git a/apps/api/routes/api/stats/site.get.ts b/apps/api/routes/api/stats/site.get.ts index f60245c0..4899f4f4 100644 --- a/apps/api/routes/api/stats/site.get.ts +++ b/apps/api/routes/api/stats/site.get.ts @@ -16,6 +16,8 @@ * perceive. */ import { z } from 'zod/v4'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; import { requireAuthSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { validateQuery } from '~~/utils/schemas'; @@ -38,6 +40,25 @@ const querySchema = z.object({ window: z.enum(['30', '90', '365']).default('90'), }); +/** + * The caller's adult preference, read from the row. + * + * NOT from the session: no login path writes `showAdultContent` into the sealed + * cookie, so `user.showAdultContent` was always `undefined` — always + * fail-closed, which meant a member who HAD opted in never saw their own + * categories here, and the `all` half of the cache key was dead code. Every + * neighbouring route reads the row for the same reason, and deliberately does + * not put the flag in the cookie: a seven-day session would keep serving an + * adult view for a week after the member turned it off. + */ +async function callerShowsAdult(userId: string): Promise<boolean> { + const me = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { showAdultContent: true }, + }); + return me?.showAdultContent ?? false; +} + const CACHE_TTL_MS = 60_000; const cache = new Map<string, { at: number; value: unknown }>(); @@ -47,7 +68,7 @@ export default defineEventHandler(async (event) => { const { window } = validateQuery(event, querySchema); const days = Number(window); - const showAdult = !!user.showAdultContent; + const showAdult = await callerShowsAdult(user.id); const key = `${days}:${showAdult ? 'all' : 'safe'}`; const hit = cache.get(key); @@ -71,9 +92,17 @@ export default defineEventHandler(async (event) => { const value = { now, days, - // The series is what it is: if the collector has only been running a week, - // the chart shows a week rather than 83 empty days. Padding it would draw a - // flat line at zero and call it history. + /** + * The series is what it is: if the collector has only been running a week, + * the chart shows a week rather than 83 empty days. Padding it would draw a + * flat line at zero and call it history. + * + * These points come from `site_stats`, which the operator's collector writes + * as WHOLE-CATALOGUE counters — every status, adult included, and every + * account. So they can legitimately sit above the filtered figures in `now`, + * and the page labels them as the catalogue total rather than implying they + * are the same number over time. + */ points, deltas: dailyDeltas(points), categories, diff --git a/apps/api/routes/api/stats/year.get.ts b/apps/api/routes/api/stats/year.get.ts index 0163e671..c26d812d 100644 --- a/apps/api/routes/api/stats/year.get.ts +++ b/apps/api/routes/api/stats/year.get.ts @@ -26,10 +26,17 @@ * cached for a minute, like the rest of the stats. */ import { z } from 'zod/v4'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; import { requireAuthSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { validateQuery } from '~~/utils/schemas'; -import { hiddenCategoryIds, siteYear } from '~~/utils/publicStats'; +import { + firstSnapshotAt, + hiddenCategoryIds, + selectableYears, + siteYear, +} from '~~/utils/publicStats'; const querySchema = z.object({ // 2000 is not a guess: BitTorrent was published in 2001, so a tracker with @@ -37,6 +44,25 @@ const querySchema = z.object({ year: z.coerce.number().int().min(2000).max(2100), }); +/** + * The caller's adult preference, read from the row. + * + * NOT from the session: no login path writes `showAdultContent` into the sealed + * cookie, so `user.showAdultContent` was always `undefined` — always + * fail-closed, which meant a member who HAD opted in never saw their own + * categories here, and the `all` half of the cache key was dead code. Every + * neighbouring route reads the row for the same reason, and deliberately does + * not put the flag in the cookie: a seven-day session would keep serving an + * adult view for a week after the member turned it off. + */ +async function callerShowsAdult(userId: string): Promise<boolean> { + const me = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { showAdultContent: true }, + }); + return me?.showAdultContent ?? false; +} + const FRESH_TTL_MS = 60_000; const SETTLED_TTL_MS = 24 * 60 * 60 * 1000; const cache = new Map<string, { at: number; ttl: number; value: unknown }>(); @@ -46,7 +72,26 @@ export default defineEventHandler(async (event) => { await rateLimit(event, RATE_LIMITS.public); const { year } = validateQuery(event, querySchema); - const showAdult = !!user.showAdultContent; + + /** + * Only a year this instance can answer for. + * + * The schema's [2000, 2100] range was 101 cache keys against a 40-entry cache, + * so a caller walking the range missed every time — and each miss is four + * range scans over `torrents` plus one over `hnr_tracking`. An empty year + * costs exactly as much as a full one, because the planner has to look to find + * nothing. The window parameter on the sibling route learned this already: + * the parameter IS the cache key, so it has to be small. + */ + const offered = selectableYears(await firstSnapshotAt(), new Date()); + if (!offered.includes(year)) { + throw createError({ + statusCode: 400, + message: `This instance has no data for ${year}.`, + }); + } + + const showAdult = await callerShowsAdult(user.id); const key = `${year}:${showAdult ? 'all' : 'safe'}`; const hit = cache.get(key); diff --git a/apps/api/test/publicStats.test.ts b/apps/api/test/publicStats.test.ts index 8bc76cbc..8019b885 100644 --- a/apps/api/test/publicStats.test.ts +++ b/apps/api/test/publicStats.test.ts @@ -19,6 +19,9 @@ import { */ const snap = (iso: string, uploaded: number, extra: Partial<Snapshot> = {}): Snapshot => ({ + // The day label comes from Postgres in the real query (`to_char`), because a + // JS Date reads a zone-less timestamp in the process's zone. Mirrored here. + day: iso.slice(0, 10), at: new Date(iso), users: 10, torrents: 100, @@ -107,6 +110,34 @@ describe('dailyDeltas', () => { }); }); +describe('dailyDeltas across a gap', () => { + it('does not attribute an outage to the day it ended', () => { + // Five days down, then a snapshot. The naive difference makes that one day + // look like the busiest of the year — every time, on every instance that + // ever restarted — and draws a bar that flattens the rest of the chart. + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 1_000), + snap('2026-03-02T10:00:00Z', 2_000), + snap('2026-03-08T10:00:00Z', 9_000), + snap('2026-03-09T10:00:00Z', 9_500), + ]), + ); + expect(deltas.map((d) => d.day)).toEqual(['2026-03-02', '2026-03-09']); + expect(deltas.map((d) => d.bytes)).toEqual([1_000, 500]); + }); + + it('and therefore does not let a gap win busiestDay', () => { + const points = dailyPoints([ + snap('2026-03-01T10:00:00Z', 0), + snap('2026-03-02T10:00:00Z', 5_000), + snap('2026-03-20T10:00:00Z', 900_000), + snap('2026-03-21T10:00:00Z', 906_000), + ]); + expect(busiestDay(dailyDeltas(points))?.day).toBe('2026-03-21'); + }); +}); + describe('busiestDay', () => { it('picks the largest day', () => { const best = busiestDay([ diff --git a/apps/api/utils/publicStats.ts b/apps/api/utils/publicStats.ts index acc70df6..111f850f 100644 --- a/apps/api/utils/publicStats.ts +++ b/apps/api/utils/publicStats.ts @@ -48,6 +48,8 @@ import { adultCategoryIds } from './adultContent'; // ───────────────────────────────────────────────────────────────────────────── export interface Snapshot { + /** `YYYY-MM-DD`, formatted by Postgres — see `snapshots()`. */ + day: string; at: Date; users: number; torrents: number; @@ -81,7 +83,13 @@ export interface DailyPoint { export function dailyPoints(rows: Snapshot[]): DailyPoint[] { const byDay = new Map<string, Snapshot>(); for (const row of rows) { - const day = row.at.toISOString().slice(0, 10); + // The day comes from the query, not from `at.toISOString()`. `created_at` is + // `timestamp without time zone`, and postgres.js hands a zone-less value to + // `new Date()`, which reads it in the PROCESS's zone — and the shipped + // compose file sets `TZ=Europe/Paris`. Bucketing in JavaScript therefore cut + // the days at 22:00 UTC and labelled them "UTC", which put a New Year's Eve + // upload in the previous year's review. + const day = row.day; const seen = byDay.get(day); if (!seen || row.at > seen.at) byDay.set(day, row); } @@ -121,11 +129,28 @@ export interface DailyDelta { * against zero — which would otherwise print the site's entire history as one * day's traffic. */ +/** Whole days between two `YYYY-MM-DD` labels. */ +function daysBetween(a: string, b: string): number { + return Math.round( + (Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000 + ); +} + export function dailyDeltas(points: DailyPoint[]): DailyDelta[] { const out: DailyDelta[] = []; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]!; const cur = points[i]!; + /** + * A gap in the snapshots is skipped rather than attributed. + * + * `dailyPoints` deliberately omits a day with no snapshot, so after an + * outage from the 3rd to the 7th the 8th's difference covers five days of + * traffic — and `busiestDay` then names the day after the longest outage as + * the busiest of the year, every time, with a bar that dwarfs the chart. + * There is no way to split it honestly, so it is left out. + */ + if (daysBetween(prev.day, cur.day) !== 1) continue; out.push({ day: cur.day, bytes: Math.max(0, cur.uploaded - prev.uploaded), @@ -233,9 +258,13 @@ export async function siteNow(adultIds: string[]): Promise<SiteNow> { const [swarm] = await db .select({ - seeders: sql<number>`coalesce(sum(${schema.torrentStats.seeders}), 0)::int`, - leechers: sql<number>`coalesce(sum(${schema.torrentStats.leechers}), 0)::int`, - snatches: sql<number>`coalesce(sum(${schema.torrentStats.completed}), 0)::int`, + // `sum()` of an integer is a bigint, and `completed` is cumulative: a + // catalogue past roughly 2.1 billion total completions would have made the + // `::int` cast throw `integer out of range` and taken the whole page with + // it. Kept as bigint and narrowed in JS, like the byte figures. + seeders: sql<number>`coalesce(sum(${schema.torrentStats.seeders}), 0)::bigint`, + leechers: sql<number>`coalesce(sum(${schema.torrentStats.leechers}), 0)::bigint`, + snatches: sql<number>`coalesce(sum(${schema.torrentStats.completed}), 0)::bigint`, }) .from(schema.torrentStats) .innerJoin( @@ -260,9 +289,9 @@ export async function siteNow(adultIds: string[]): Promise<SiteNow> { return { torrents: counts?.torrents ?? 0, members: members?.n ?? 0, - seeders: swarm?.seeders ?? 0, - leechers: swarm?.leechers ?? 0, - snatches: swarm?.snatches ?? 0, + seeders: Number(swarm?.seeders ?? 0), + leechers: Number(swarm?.leechers ?? 0), + snatches: Number(swarm?.snatches ?? 0), catalogued: Number(counts?.catalogued ?? 0), trafficTotal: Number(latest?.uploaded ?? 0), }; @@ -272,6 +301,9 @@ export async function siteNow(adultIds: string[]): Promise<SiteNow> { export async function snapshots(since: Date, until?: Date): Promise<Snapshot[]> { const rows = await db .select({ + // Formatted by Postgres so the label is the stored (UTC) date rather than + // whatever zone this process happens to run in. + day: sql<string>`to_char(${schema.siteStats.createdAt}, 'YYYY-MM-DD')`, at: schema.siteStats.createdAt, users: schema.siteStats.usersCount, torrents: schema.siteStats.torrentsCount, @@ -371,6 +403,8 @@ export async function topTorrents( } export interface TopUploader { + /** The profile page routes on the id, not the name. */ + id: string; username: string; uploads: number; } @@ -387,6 +421,7 @@ export async function topUploaders( ): Promise<TopUploader[]> { const rows = await db .select({ + id: schema.users.id, username: schema.users.username, uploads: sql<number>`count(*)::int`, }) @@ -396,10 +431,13 @@ export async function topUploaders( and( visibleTorrents(adultIds), eq(schema.users.anonymousUploads, false), - isNull(schema.users.deletedAt) + isNull(schema.users.deletedAt), + // A banned account is not a member of the site any more, and a cheater + // heading the public board is the worst version of this page. + eq(schema.users.isBanned, false) ) ) - .groupBy(schema.users.username) + .groupBy(schema.users.id, schema.users.username) .orderBy(desc(sql`count(*)`)) .limit(limit); return rows; @@ -451,9 +489,16 @@ export async function siteYear( ) ); - // Completions dated inside the year, from the hit-and-run ledger — the only - // per-download record the site keeps. `torrent_stats.completed` is a running - // total with no date, so it cannot answer a question about a year. + /** + * COMPLETIONS dated inside the year, not grabs. + * + * `hnr_tracking` is the only dated per-download record — `torrent_stats.completed` + * is a running total with no date — but a row is written when a member clicks + * the `.torrent`, before a single byte moves. Counting rows therefore counted + * downloads of a metainfo file, while the figure beside it in the header counts + * real completions: two different quantities under one word. `completed_at` + * is what makes them the same question. + */ const [snatched] = await db .select({ n: sql<number>`count(*)::int` }) .from(schema.hnrTracking) @@ -464,8 +509,9 @@ export async function siteYear( .where( and( visibleTorrents(adultIds), - gte(schema.hnrTracking.downloadedAt, start), - lt(schema.hnrTracking.downloadedAt, end) + sql`${schema.hnrTracking.completedAt} is not null`, + gte(schema.hnrTracking.completedAt, start), + lt(schema.hnrTracking.completedAt, end) ) ); diff --git a/apps/web/app/components/stats/StatSeries.vue b/apps/web/app/components/stats/StatSeries.vue index 886b0ed4..f6a76685 100644 --- a/apps/web/app/components/stats/StatSeries.vue +++ b/apps/web/app/components/stats/StatSeries.vue @@ -155,13 +155,26 @@ const areaPath = computed( () => `${linePath.value} L${VIEW_W},${VIEW_H} L0,${VIEW_H} Z` ); +/** + * Bars are anchored at ZERO, not at the series minimum. + * + * `y()` interpolates between the minimum and the maximum, which is right for a + * cumulative counter — the slope is the story — and wrong for a per-period + * delta: a 90-day traffic series running between 900 GiB and 1 TiB drew the + * quietest day as a hairline and the busiest as a full bar, rendering an 11 % + * spread as a factor of a hundred. Worse, it made the quietest period look + * identical to a period with nothing at all. + */ +const barMax = computed(() => Math.max(1, ...props.points)); + const bars = computed(() => { const n = props.points.length; const slot = VIEW_W / n; const gap = Math.min(0.6, slot * 0.25); const zero = VIEW_H - 1; return props.points.map((value, i) => { - const top = y(value); + const usable = VIEW_H - 2; + const top = zero - (Math.max(0, value) / barMax.value) * usable; return { x: i * slot + gap / 2, w: Math.max(0.4, slot - gap), diff --git a/apps/web/app/pages/stats.vue b/apps/web/app/pages/stats.vue index e926c65a..a20cf86e 100644 --- a/apps/web/app/pages/stats.vue +++ b/apps/web/app/pages/stats.vue @@ -25,6 +25,7 @@ <section class="st-panel" aria-labelledby="st-growth"> <div class="st-panel-head"> <h2 id="st-growth">{{ $t('stats.growth.title') }}</h2> + <p class="st-panel-note">{{ $t('stats.growth.note') }}</p> <div class="st-windows" role="group" :aria-label="$t('stats.growth.windowAria')"> <button v-for="w in WINDOWS" @@ -137,7 +138,7 @@ <ol v-if="data?.topUploaders?.length" class="st-board"> <li v-for="(u, i) in data.topUploaders" :key="u.username" class="st-board-row"> <span class="st-rank-n">{{ i + 1 }}</span> - <NuxtLink :to="`/users/${u.username}`" class="st-board-name">{{ u.username }}</NuxtLink> + <NuxtLink :to="`/users/${u.id}`" class="st-board-name">{{ u.username }}</NuxtLink> <span class="st-board-count">{{ $t('stats.uploaders.count', { n: u.uploads }) }}</span> </li> </ol> @@ -243,12 +244,17 @@ <h3 class="st-sub">{{ $t('stats.mine.title', { year }) }}</h3> <div class="st-mine-grid"> <div><b>{{ mine.uploads.toLocaleString() }}</b><span>{{ $t('stats.mine.uploads') }}</span></div> - <div><b>{{ mine.snatches.toLocaleString() }}</b><span>{{ $t('stats.mine.snatches') }}</span></div> - <div><b>{{ bytes(mine.bytesUp) }}</b><span>{{ $t('stats.mine.up') }}</span></div> - <div><b>{{ bytes(mine.bytesDown) }}</b><span>{{ $t('stats.mine.down') }}</span></div> - <div><b>{{ hours(mine.seedTimeSeconds) }}</b><span>{{ $t('stats.mine.seedTime') }}</span></div> + <template v-if="!mine.downloadsHidden"> + <div><b>{{ mine.snatches.toLocaleString() }}</b><span>{{ $t('stats.mine.snatches') }}</span></div> + <div><b>{{ bytes(mine.bytesUp) }}</b><span>{{ $t('stats.mine.up') }}</span></div> + <div><b>{{ bytes(mine.bytesDown) }}</b><span>{{ $t('stats.mine.down') }}</span></div> + <div><b>{{ hours(mine.seedTimeSeconds) }}</b><span>{{ $t('stats.mine.seedTime') }}</span></div> + </template> <div><b>{{ Math.round(mine.bonusEarned).toLocaleString() }}</b><span>{{ $t('stats.mine.bonus') }}</span></div> </div> + <!-- Said rather than silently omitted: four missing figures with no + explanation read as a broken page. --> + <p v-if="mine.downloadsHidden" class="st-mine-cat">{{ $t('stats.mine.hidden') }}</p> <p v-if="mine.bestRelease" class="st-mine-best"> {{ $t('stats.mine.best') }} <NuxtLink :to="`/torrents/${mine.bestRelease.infoHash}`">{{ mine.bestRelease.name }}</NuxtLink> @@ -340,7 +346,7 @@ interface SiteStats { snatches: number; }>; biggestSwarms: SiteStats['mostSnatched']; - topUploaders: Array<{ username: string; uploads: number }>; + topUploaders: Array<{ id: string; username: string; uploads: number }>; years: number[]; } @@ -375,6 +381,8 @@ const { data: yearData } = await useFetch<YearStats>('/api/stats/year', { }); interface MineStats { + /** True when the member has turned their download history off. */ + downloadsHidden?: boolean; uploads: number; snatches: number; seedTimeSeconds: number; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 7246e79b..720fd3b2 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -5469,7 +5469,8 @@ "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." + "none": "No snapshots yet — the collector writes one an hour, so this fills in from the next one.", + "note": "Whole-catalogue counters from the hourly snapshot — every status and every category, so they sit above the filtered figures at the top." }, "series": { "tooShort": "Not enough history to draw yet.", @@ -5521,7 +5522,8 @@ "days": "{n} d", "best": "Your most snatched release:", "bestSnatches": "{n} snatches", - "favourite": "Mostly {category} — {n} of your uploads." + "favourite": "Mostly {category} — {n} of your uploads.", + "hidden": "Your download figures are hidden because you turned your download history off in Settings → Privacy." } } } diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index fcc576b2..3663b051 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -5469,7 +5469,8 @@ "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." + "none": "Aucun relevé pour l’instant — le collecteur en écrit un par heure, la courbe se remplira au prochain.", + "note": "Compteurs du catalogue entier, relevés chaque heure — tous les statuts et toutes les catégories, donc au-dessus des chiffres filtrés du haut de page." }, "series": { "tooShort": "Pas encore assez d’historique pour tracer.", @@ -5521,7 +5522,8 @@ "days": "{n} j", "best": "Votre release la plus téléchargée :", "bestSnatches": "{n} téléchargements", - "favourite": "Surtout {category} — {n} de vos envois." + "favourite": "Surtout {category} — {n} de vos envois.", + "hidden": "Vos chiffres de téléchargement sont masqués parce que vous avez désactivé votre historique dans Réglages → Confidentialité." } } } diff --git a/doc/guide/stats.md b/doc/guide/stats.md index 1d70231f..73c18b0f 100644 --- a/doc/guide/stats.md +++ b/doc/guide/stats.md @@ -51,6 +51,11 @@ 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. +**Your own download figures follow your privacy setting.** With +`Settings → Privacy → hide download history` on, the grabs, bytes and seed time +are withheld from your year — the same door that closes `/downloads`, since a +stolen session must not be able to read them either. Your uploads still show. + ## Where the numbers come from | Section | Source | @@ -59,12 +64,17 @@ would try to explain. | 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 | +| Snatches in a year | `hnr_tracking`, counting rows with a **completion** date — a row appears when a member downloads the `.torrent`, so counting rows would count metainfo downloads rather than completions | | 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 +never moved. For the same reason a per-day figure is **skipped** across a gap +rather than attributed to the day the snapshots resumed — otherwise the day after +the longest outage is reported as the busiest of the year, every time. + +The day a snapshot belongs to is decided by Postgres, not by the API process, so +the labels are UTC dates regardless of the container's `TZ`. Under each chart, **Show the numbers** opens the same values as a table — the picture is not the data. ## Years, and time zones From 57755cbda8e48bbdb4bd741c4ee59485be4232bf Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 17:07:51 +0200 Subject: [PATCH 24/96] fix(privacy): the register, the history, the export and the erasure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven findings across the four features that hold somebody's record. The two that a member could exercise came first. ## Any member could bury the audit register `auditActor` is set by plain authentication, and a staff gate throws its 403 BEFORE the route's own rate limit — so a member looping `PUT /api/admin/settings` wrote one row per request, bounded only by the global DDoS floor. Around 750 000 rows a day, into an append-only table with a year of retention and no per-row delete: the flood buries the exact pattern the register exists to show, and an administrator cannot clean it out. A refusal from somebody who is not staff is now throttled to one row per minute per actor and action. The first attempt — the line that matters — is always recorded. Verified: twelve refused admin writes from a member, one row. ## An unauthenticated stranger drove the account-sharing metric A failed login records the target's account and the CALLER's address hash, by design: that is the shape this table exists to show. But the moderator's "distinct addresses today" counted every outcome, so twenty bad logins from twenty addresses — no password needed — read as "20 different addresses on the most recent day". On an invite-only tracker that number is the evidence for the one bannable offence, and it was writable by anybody. Successes only now, and counted in SQL rather than from the returned page: taken from the page, a member signing in fifty times from one address pushed the others off it and the count read `1`, so the subject could suppress it at will. ## The hash was a confirmation oracle `sha256(secret:day:ip)` is the same value in a member's own view and in a moderator's view of that member. A moderator suspecting an account is shared with somebody they can reach could sign in from that address, read their own hash, and compare. Both views now show an ordinal that means something only inside one response — which is the only property either of them ever needed. ## The audit register missed the staff powers that live on member routes `isAuditable` matched `/api/admin/` and `/api/mod/` only, so a moderator deleting a torrent, a comment, a forum post or a message left no row — "who did what, across the whole console" excluded a list of things staff do every day. Those paths are audited when the actor is staff, which keeps the register from becoming a log of everybody's activity. Route parameters are stripped by position rather than by value: a member named `mutes` turned `mod.room.mutes.delete` into `mod.room.delete`, and usernames have no reserved list. Reading the invite tree — the social graph of the whole site, in one response — is audited explicitly, because reads are not logged by default and the commit that added it claimed otherwise. ## The export was not honest, and the erasure was not complete Nine collections reported `items.length` as their own total, so `truncated` was permanently false: a 5000-row prefix presented itself as the whole record, which this file's own header calls worse than refusing to export. Real counts now. Notification payloads were exported verbatim, and they carry `actorUsername` (the moderator who banned this account), `inviteeUsername` (the member who used its invite) and 200 characters of a staff message — the two categories the file explicitly promises to withhold, handed over in bulk. Filtered by whitelist, and `notIncluded` says so. Saved searches and the login history were in neither the export nor the erasure, on a branch whose point is GDPR. Both are the member's own data, and neither cascade fires — the `users` row survives an erasure by design — so an erased account kept its stored filters matching uploads and writing notifications to a tombstone, and its login history stayed attributed and readable through the moderator view. Both are exported and both are deleted now, along with the Torznab access block and request log keyed on the passkey the erasure rotates away. ## And four smaller ones A failed recovery code and a failed passkey assertion left no trace, in a table whose stated point is that a failure is the interesting half — so an account protected only by a passkey showed no failures at all. The saved-search cap was read-then-insert; it is one statement now, which matters because that cap is the only bound on what the fan-out costs the whole site. The fan-out had no try/catch, unlike the sibling it was modelled on, so a failure surfaced as an anonymous unhandled rejection. `login_event_retention_days` and `saved_search_max_per_user` had getters, defaults, documented meanings — and no writer anywhere. The retention period published on `/privacy` could only be changed with a SQL prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/plugins/audit-log.ts | 56 ++++++- apps/api/routes/api/admin/invites/tree.get.ts | 54 +++++- apps/api/routes/api/admin/settings.get.ts | 6 + apps/api/routes/api/admin/settings.put.ts | 22 +++ .../api/auth/2fa/passkey-verify.post.ts | 27 +++ .../routes/api/auth/2fa/verify-totp.post.ts | 10 ++ apps/api/routes/api/me/logins.get.ts | 37 ++++- .../api/me/saved-searches/index.post.ts | 55 +++--- .../routes/api/mod/users/[id]/logins.get.ts | 88 ++++++++-- apps/api/test/audit.test.ts | 13 ++ apps/api/utils/account/eraseAccount.ts | 41 +++++ apps/api/utils/account/exportAccount.ts | 157 ++++++++++++++++-- apps/api/utils/audit.ts | 65 ++++++-- apps/api/utils/savedSearchFanout.ts | 12 ++ apps/api/utils/schemas.ts | 7 + apps/web/app/pages/settings.vue | 5 +- apps/web/app/pages/users/[id].vue | 5 +- 17 files changed, 590 insertions(+), 70 deletions(-) diff --git a/apps/api/plugins/audit-log.ts b/apps/api/plugins/audit-log.ts index 552afdc0..f0992627 100644 --- a/apps/api/plugins/audit-log.ts +++ b/apps/api/plugins/audit-log.ts @@ -30,6 +30,7 @@ * belong in a register of authority. */ import type { H3Event } from 'h3'; +import { redis } from '~~/utils/server'; import { isAuditable, writeAuditEntry, type AuditActor } from '~~/utils/audit'; /** @@ -44,7 +45,16 @@ function record(event: H3Event, statusOverride?: number): void { const method = event.method ?? 'GET'; const path = (event.path ?? '').split('?')[0] ?? ''; - if (!isAuditable(method, path)) return; + + // The actor is read before the predicate now, because whether a member-facing + // path is auditable depends on who is calling it — see `STAFF_REACH`. + const earlyActor = event.context.auditActor as AuditActor | undefined; + const actorIsStaff = !!( + earlyActor?.isAdmin || + earlyActor?.isModerator || + earlyActor?.isOwner + ); + if (!isAuditable(method, path, actorIsStaff)) return; // Set by `requireAuthSession`. Absent means the request never got past // authentication, which is the rate limiter's business and not this @@ -52,14 +62,48 @@ function record(event: H3Event, statusOverride?: number): void { const actor = event.context.auditActor as AuditActor | undefined; if (!actor?.id) return; + /** + * A refusal is worth a line; ten thousand of them are not. + * + * `auditActor` is set by plain authentication, and a staff gate throws its 403 + * BEFORE the route's own rate limit — so any member could loop + * `PUT /api/admin/settings` and write a row per request, bounded only by the + * global 100-per-10-seconds DDoS floor. Roughly 750 000 rows a day, in an + * append-only table with a year of retention and no per-row delete: the flood + * would bury the exact pattern this register exists to show, and an admin + * could not clean it out. + * + * So a refusal from somebody who is not staff is throttled to one row per + * minute per (actor, action). The first attempt is always recorded — which is + * the line that matters — and the storm behind it is not. + */ + const isStaff = !!(actor.isAdmin || actor.isModerator || actor.isOwner); + const statusForGate = statusOverride ?? event.node?.res?.statusCode ?? 200; + if (!isStaff && statusForGate >= 400) { + const key = `audit:throttle:${actor.id}:${event.method ?? 'GET'}:${path}`; + // Fire and forget: a Redis hiccup must not lose an audit row, so the + // fallback is to write it. + void redis + .set(key, '1', 'EX', 60, 'NX') + .then((first) => { + if (first === 'OK') writeAudit(); + }) + .catch(() => writeAudit()); + event.context.auditWritten = true; + return; + } + event.context.auditWritten = true; - const statusCode = statusOverride ?? event.node?.res?.statusCode ?? 200; + writeAudit(); - // 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); + function writeAudit(): void { + 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) => { diff --git a/apps/api/routes/api/admin/invites/tree.get.ts b/apps/api/routes/api/admin/invites/tree.get.ts index 67d6e397..28d759c1 100644 --- a/apps/api/routes/api/admin/invites/tree.get.ts +++ b/apps/api/routes/api/admin/invites/tree.get.ts @@ -34,6 +34,7 @@ import { and, eq, inArray, isNotNull } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; +import { auditDetail, writeAuditEntry } from '~~/utils/audit'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { validateQuery } from '~~/utils/schemas'; @@ -90,7 +91,7 @@ function toNode( } export default defineEventHandler(async (event) => { - await requireAdminSession(event); + const session = await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.public); const { userId } = validateQuery(event, querySchema); @@ -140,6 +141,20 @@ export default defineEventHandler(async (event) => { let frontier: Node[] = [root]; for (let depth = 1; depth <= MAX_DEPTH && frontier.length > 0; depth++) { const parentIds = frontier.map((n) => n.id); + /** + * Bounded in SQL, not only in the response. + * + * `MAX_NODES` was applied while building the answer, so a prolific inviter + * three generations down still had every one of their redeemed invitations + * returned to the API first — the whole table, potentially, to produce four + * hundred nodes. The remaining budget is the limit, plus one so a truncation + * is still detectable. + */ + const remaining = MAX_NODES - nodeCount; + if (remaining <= 0) { + truncatedDown = true; + break; + } const invites = await db .select({ createdBy: schema.invitations.createdBy, @@ -149,10 +164,25 @@ export default defineEventHandler(async (event) => { .from(schema.invitations) .where( and(inArray(schema.invitations.createdBy, parentIds), isNotNull(schema.invitations.usedBy)) - ); + ) + .orderBy(schema.invitations.usedAt) + .limit(remaining + 1); if (invites.length === 0) break; + if (invites.length > remaining) truncatedDown = true; - const childIds = [...new Set(invites.map((i) => i.usedBy!))]; + /** + * A member already placed in the tree is not placed again. + * + * The upward walk has this guard and the descent did not: `invitations` has + * no constraint preventing a cycle (A invited B, B invited A after a staff + * edit), and without the guard the same pair is emitted once per generation + * until the node budget runs out — bounded, but it renders as a family tree + * that repeats itself. + */ + const childIds = [ + ...new Set(invites.map((i) => i.usedBy!).filter((id) => !seen.has(id))), + ]; + if (childIds.length === 0) break; const rows = await db.select(USER_COLUMNS).from(schema.users).where(inArray(schema.users.id, childIds)); const rowById = new Map(rows.map((r) => [r.id, r])); const byParent = new Map(frontier.map((n) => [n.id, n])); @@ -167,6 +197,8 @@ export default defineEventHandler(async (event) => { const parent = byParent.get(invite.createdBy); if (!row || !parent) continue; + if (seen.has(row.id)) continue; + seen.add(row.id); const node = toNode(row, depth, invite.usedAt); (parent.children ??= []).push(node); next.push(node); @@ -177,6 +209,22 @@ export default defineEventHandler(async (event) => { if (depth === MAX_DEPTH && next.length > 0) truncatedDown = true; } + /** + * Logged explicitly, because the hook does not log reads. + * + * That is the right default — a register of authority records decisions, not + * who looked at a page — and this is the one read worth the exception: it is + * the social graph of the entire site in one response, and the commit that + * added it claimed it was audited when it was not. + */ + auditDetail(event, { + action: 'admin.invites.tree.read', + targetType: 'user', + targetId: userId, + targetLabel: root.username, + }); + void writeAuditEntry(event, session.user, 200); + return { subject: root, /** Nearest first: `ancestors[0]` is who invited the subject. */ diff --git a/apps/api/routes/api/admin/settings.get.ts b/apps/api/routes/api/admin/settings.get.ts index 8c0d499c..113577b9 100644 --- a/apps/api/routes/api/admin/settings.get.ts +++ b/apps/api/routes/api/admin/settings.get.ts @@ -46,6 +46,8 @@ import { isInviteEnabled, getDefaultInvites, getAuditRetentionDays, + getLoginEventRetentionDays, + getSavedSearchMaxPerUser, } from '~~/utils/server'; import { getRequire2FAScope, @@ -98,6 +100,8 @@ export default defineEventHandler(async (event) => { // 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 loginEventRetentionDays = await getLoginEventRetentionDays(); + const savedSearchMaxPerUser = await getSavedSearchMaxPerUser(); const notificationsRetentionReadDays = await getNotificationsRetentionReadDays(); const notificationsRetentionUnreadDays = @@ -165,6 +169,8 @@ export default defineEventHandler(async (event) => { defaultInvites, require2FAScope, auditRetentionDays, + loginEventRetentionDays, + savedSearchMaxPerUser, notificationsRetentionReadDays, notificationsRetentionUnreadDays, requestAutoValidateHours, diff --git a/apps/api/routes/api/admin/settings.put.ts b/apps/api/routes/api/admin/settings.put.ts index 65a2ef7a..1cf8cb2d 100644 --- a/apps/api/routes/api/admin/settings.put.ts +++ b/apps/api/routes/api/admin/settings.put.ts @@ -282,6 +282,28 @@ export default defineEventHandler(async (event) => { String(Math.floor(body.auditRetentionDays)), ); } + + if ( + typeof body.loginEventRetentionDays === 'number' && + body.loginEventRetentionDays >= 0 && + body.loginEventRetentionDays <= 3650 + ) { + await setSetting( + SETTINGS_KEYS.LOGIN_EVENT_RETENTION_DAYS, + String(Math.floor(body.loginEventRetentionDays)) + ); + } + + if ( + typeof body.savedSearchMaxPerUser === 'number' && + body.savedSearchMaxPerUser >= 1 && + body.savedSearchMaxPerUser <= 200 + ) { + await setSetting( + SETTINGS_KEYS.SAVED_SEARCH_MAX_PER_USER, + String(Math.floor(body.savedSearchMaxPerUser)) + ); + } if ( typeof body.notificationsRetentionUnreadDays === 'number' && body.notificationsRetentionUnreadDays >= 1 && diff --git a/apps/api/routes/api/auth/2fa/passkey-verify.post.ts b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts index bd90022b..f48e8c6b 100644 --- a/apps/api/routes/api/auth/2fa/passkey-verify.post.ts +++ b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts @@ -47,6 +47,27 @@ export default defineEventHandler(async (event) => { message: 'Challenge expired or already used. Restart the login.', }); } + /** + * One place to record a failed assertion, since there are two ways to fail. + * + * The username is read lazily: this path has an id from the challenge token + * and no row yet, and the login history stores a name so a row survives an + * erasure with something readable in it. + */ + const recordFailedPasskey = async () => { + const row = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { username: true }, + }); + if (!row) return; + await recordLogin(event, { + userId, + username: row.username, + method: 'passkey', + outcome: 'failed', + }); + }; + const expectedChallenge = await getAuthChallenge(body.challengeToken); if (!expectedChallenge) { throw createError({ @@ -92,6 +113,11 @@ export default defineEventHandler(async (event) => { }, }); } catch (err: any) { + // A failed assertion is the same signal as a wrong TOTP and was the other + // second factor that left no trace: an account protected only by a passkey + // showed no failures at all in a history whose stated point is that "an + // attempt that did not succeed is the one worth knowing about". + void recordFailedPasskey(); throw createError({ statusCode: 400, message: `Passkey verification failed: ${err?.message || 'unknown'}`, @@ -99,6 +125,7 @@ export default defineEventHandler(async (event) => { } if (!verification.verified || !verification.authenticationInfo) { + void recordFailedPasskey(); throw createError({ statusCode: 400, message: 'Passkey assertion not verified' }); } diff --git a/apps/api/routes/api/auth/2fa/verify-totp.post.ts b/apps/api/routes/api/auth/2fa/verify-totp.post.ts index 84594bb8..2902d4be 100644 --- a/apps/api/routes/api/auth/2fa/verify-totp.post.ts +++ b/apps/api/routes/api/auth/2fa/verify-totp.post.ts @@ -117,6 +117,16 @@ export default defineEventHandler(async (event) => { ) .returning({ id: recoveryCodes.id }); if (consumed.length === 0) { + // Recorded for the same reason a wrong TOTP is, and with more force: a + // recovery code bypasses the authenticator entirely, so a run of failed + // attempts against one is the highest-signal shape this table can hold. + // It was the one second-factor failure that left no trace. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'recovery', + outcome: 'failed', + }); throw createError({ statusCode: 400, message: 'Invalid or already-used recovery code.', diff --git a/apps/api/routes/api/me/logins.get.ts b/apps/api/routes/api/me/logins.get.ts index 04d5ac63..35efe225 100644 --- a/apps/api/routes/api/me/logins.get.ts +++ b/apps/api/routes/api/me/logins.get.ts @@ -21,6 +21,41 @@ const querySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(30), }); +/** + * Replace the stored address hash with a label that only means something inside + * this response. + * + * The hash is `sha256(secret:day:ip)` truncated, and it is the SAME value in the + * member's own view and in the moderator's view of that member — so a moderator + * who suspects an account is being shared with somebody they can reach could + * sign in from that address, read their own hash, and compare. The hash was + * meant to make an address unrecoverable; handing the same value to two readers + * made it a confirmation oracle for a day. + * + * An ordinal keeps the only property either view claims — telling two addresses + * apart within one day — and gives up the only property neither needs, which is + * comparability with anybody else's copy. + */ +function labelAddresses<T extends { ipHash: string | null; createdAt: Date }>( + rows: T[] +): Array<Omit<T, 'ipHash'> & { address: string | null }> { + const seen = new Map<string, number>(); + return rows.map((row) => { + const { ipHash, ...rest } = row; + if (!ipHash) return { ...rest, address: null } as Omit<T, 'ipHash'> & { address: null }; + // Keyed on hash AND day, because the salt rotates daily: the same address + // is a different hash tomorrow, and pretending otherwise would invent a + // continuity the data does not have. + const key = `${ipHash}:${row.createdAt.toISOString().slice(0, 10)}`; + let ordinal = seen.get(key); + if (ordinal === undefined) { + ordinal = seen.size + 1; + seen.set(key, ordinal); + } + return { ...rest, address: `#${ordinal}` } as Omit<T, 'ipHash'> & { address: string }; + }); +} + export default defineEventHandler(async (event) => { const { user } = await requireAuthSession(event); await rateLimit(event, RATE_LIMITS.public); @@ -41,5 +76,5 @@ export default defineEventHandler(async (event) => { .orderBy(desc(schema.loginEvents.createdAt)) .limit(limit); - return { items }; + return { items: labelAddresses(items) }; }); diff --git a/apps/api/routes/api/me/saved-searches/index.post.ts b/apps/api/routes/api/me/saved-searches/index.post.ts index ae6fcbff..ca1c8851 100644 --- a/apps/api/routes/api/me/saved-searches/index.post.ts +++ b/apps/api/routes/api/me/saved-searches/index.post.ts @@ -16,7 +16,7 @@ * site. A ceiling somebody chose beats one that emerges at three in the * morning. */ -import { and, count, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { z } from 'zod/v4'; import { db, schema } from '@trackarr/db'; @@ -67,31 +67,44 @@ export default defineEventHandler(async (event) => { } const max = await getSavedSearchMaxPerUser(); - const [{ value: existing } = { value: 0 }] = await db - .select({ value: count() }) - .from(schema.savedSearches) - .where(eq(schema.savedSearches.userId, user.id)); - if (existing >= max) { + const id = randomUUID(); + + /** + * The cap is enforced by the INSERT, not by a count before it. + * + * Read-then-insert is a race, and this cap is the only bound on what the + * fan-out costs the whole site: ten concurrent posts at nineteen filters each + * all read nineteen and all inserted. `INSERT … SELECT … WHERE (SELECT count(*) + * …) < max` decides it in one statement; an insert that did not happen means + * the ceiling was reached. + * + * Written as SQL rather than through the query builder because the builder's + * insert-from-select would need every column aliased to its snake_case name + * by hand anyway, and this way the predicate sits where a reader expects it. + */ + const inserted = await db.execute(sql` + insert into ${schema.savedSearches} + (id, user_id, label, query, tsquery, category_id, tags, imdb_id, tmdb_id, tvdb_id, notify) + select + ${id}, ${user.id}, ${body.label}, ${query}, ${tsquery}, ${categoryId}, + ${tags.length ? tags : null}, ${imdbId}, ${tmdbId}, ${tvdbId}, ${body.notify ?? true} + where ( + select count(*) from ${schema.savedSearches} + where ${schema.savedSearches.userId} = ${user.id} + ) < ${max} + returning id + `); + + const created = + (inserted as unknown as { length?: number; count?: number })?.length ?? + (inserted as unknown as { count?: number })?.count ?? + 0; + if (created === 0) { throw createError({ statusCode: 400, message: `You can keep up to ${max} saved searches. Delete one first.`, }); } - const id = randomUUID(); - await db.insert(schema.savedSearches).values({ - id, - userId: user.id, - label: body.label, - query, - tsquery, - categoryId, - tags: tags.length ? tags : null, - imdbId, - tmdbId, - tvdbId, - notify: body.notify ?? true, - }); - return { id, success: true }; }); diff --git a/apps/api/routes/api/mod/users/[id]/logins.get.ts b/apps/api/routes/api/mod/users/[id]/logins.get.ts index a18cb132..fae9a796 100644 --- a/apps/api/routes/api/mod/users/[id]/logins.get.ts +++ b/apps/api/routes/api/mod/users/[id]/logins.get.ts @@ -15,7 +15,7 @@ * Moderator, not admin: this is triage, and it sits beside the anti-cheat queue * those same people already work. */ -import { desc, eq } from 'drizzle-orm'; +import { and, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db, schema } from '@trackarr/db'; import { requireModeratorSession } from '~~/utils/adminAuth'; @@ -26,6 +26,41 @@ const querySchema = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50), }); +/** + * Replace the stored address hash with a label that only means something inside + * this response. + * + * The hash is `sha256(secret:day:ip)` truncated, and it is the SAME value in the + * member's own view and in the moderator's view of that member — so a moderator + * who suspects an account is being shared with somebody they can reach could + * sign in from that address, read their own hash, and compare. The hash was + * meant to make an address unrecoverable; handing the same value to two readers + * made it a confirmation oracle for a day. + * + * An ordinal keeps the only property either view claims — telling two addresses + * apart within one day — and gives up the only property neither needs, which is + * comparability with anybody else's copy. + */ +function labelAddresses<T extends { ipHash: string | null; createdAt: Date }>( + rows: T[] +): Array<Omit<T, 'ipHash'> & { address: string | null }> { + const seen = new Map<string, number>(); + return rows.map((row) => { + const { ipHash, ...rest } = row; + if (!ipHash) return { ...rest, address: null } as Omit<T, 'ipHash'> & { address: null }; + // Keyed on hash AND day, because the salt rotates daily: the same address + // is a different hash tomorrow, and pretending otherwise would invent a + // continuity the data does not have. + const key = `${ipHash}:${row.createdAt.toISOString().slice(0, 10)}`; + let ordinal = seen.get(key); + if (ordinal === undefined) { + ordinal = seen.size + 1; + seen.set(key, ordinal); + } + return { ...rest, address: `#${ordinal}` } as Omit<T, 'ipHash'> & { address: string }; + }); +} + export default defineEventHandler(async (event) => { await requireModeratorSession(event); await rateLimit(event, RATE_LIMITS.public); @@ -47,15 +82,46 @@ export default defineEventHandler(async (event) => { .orderBy(desc(schema.loginEvents.createdAt)) .limit(limit); - // Distinct address hashes on the most recent day present — the number a - // moderator is actually after, computed here so the "same day only" rule - // lives in one place rather than in every reader's head. - const newest = items[0]?.createdAt; - const day = (d: Date) => d.toISOString().slice(0, 10); - const sameDay = newest ? items.filter((i) => day(i.createdAt) === day(newest)) : []; - const distinctAddressesToday = new Set( - sameDay.map((i) => i.ipHash).filter(Boolean) - ).size; + /** + * Distinct address hashes among the SUCCESSFUL sign-ins of the most recent day. + * + * Two corrections, and each of them was a way to make a moderator wrong about + * account sharing — the one offence this figure is used to judge. + * + * **Successes only.** A failed attempt records the target's id and the + * CALLER's address, and it needs no password: an unauthenticated stranger + * could send twenty bad logins against one account from twenty addresses and + * the moderator would read "20 different addresses". The file's own docstring + * says "several successful logins", and now the query does too. + * + * **Counted in SQL, not on the page.** Taken from the returned rows, the + * figure was bounded by the page size: a member signing in fifty times from + * one address pushed the others off the first thirty rows and the count read + * `1`. So the subject could suppress it at will, and a moderator changing the + * page size changed the number. + */ + const [today] = await db + .select({ + day: sql<string>`to_char(${schema.loginEvents.createdAt}, 'YYYY-MM-DD')`, + addresses: sql<number>`count(distinct ${schema.loginEvents.ipHash})::int`, + logins: sql<number>`count(*)::int`, + }) + .from(schema.loginEvents) + .where( + and( + eq(schema.loginEvents.userId, userId), + eq(schema.loginEvents.outcome, 'success') + ) + ) + .groupBy(sql`to_char(${schema.loginEvents.createdAt}, 'YYYY-MM-DD')`) + .orderBy(desc(sql`to_char(${schema.loginEvents.createdAt}, 'YYYY-MM-DD')`)) + .limit(1); - return { items, distinctAddressesToday }; + return { + items: labelAddresses(items), + distinctAddressesToday: today?.addresses ?? 0, + /** Which day that count is about, since it is the newest day WITH a success. */ + distinctAddressesDay: today?.day ?? null, + successfulLoginsThatDay: today?.logins ?? 0, + }; }); diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts index 9c5c35d2..cd86d8af 100644 --- a/apps/api/test/audit.test.ts +++ b/apps/api/test/audit.test.ts @@ -22,6 +22,19 @@ describe('isAuditable', () => { expect(isAuditable('HEAD', '/api/admin/users')).toBe(false); }); + it('takes a staff power exercised through a member-facing path', () => { + // The console prefixes are not the whole story: a moderator deletes a + // torrent, a comment or a forum post through the ordinary member routes, + // and every one of those is an act of authority that left no row. + expect(isAuditable('DELETE', '/api/torrents/abc', true)).toBe(true); + expect(isAuditable('DELETE', '/api/torrents/comments/12', true)).toBe(true); + expect(isAuditable('PATCH', '/api/forum/posts/9', true)).toBe(true); + expect(isAuditable('DELETE', '/api/messaging/room/messages/7', true)).toBe(true); + // …and the same request from a member is still nobody's business. + expect(isAuditable('DELETE', '/api/torrents/abc', false)).toBe(false); + expect(isAuditable('PATCH', '/api/forum/posts/9', false)).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. diff --git a/apps/api/utils/account/eraseAccount.ts b/apps/api/utils/account/eraseAccount.ts index 91d52405..53e86f5d 100644 --- a/apps/api/utils/account/eraseAccount.ts +++ b/apps/api/utils/account/eraseAccount.ts @@ -64,6 +64,7 @@ import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { randomBytes, randomUUID } from 'node:crypto'; import { db, schema } from '@trackarr/db'; +import { retireTorznabPasskey } from '~~/utils/torznabStats'; import { invalidateBanCache } from '~~/utils/adminAuth'; import { getFederationConfig, @@ -90,6 +91,19 @@ export interface EraseResult { * push is best-effort and never blocks the local erasure from committing. */ export async function eraseAccount(userId: string): Promise<EraseResult> { + /** + * The passkey as it is now, read before the transaction rotates it away. + * + * Needed after the commit to clear what is keyed on its hash outside + * Postgres — see the note further down. + */ + const [before] = await db + .select({ passkey: schema.users.passkey }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + const oldPasskey = before?.passkey ?? null; + // The member's identifiers, before we touch anything — needed to find the // partner-asserted links that mention them. const keys = await db @@ -140,6 +154,19 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { await tx.delete(schema.userNotificationChannels).where(eq(schema.userNotificationChannels.userId, userId)); await tx.delete(schema.userNotificationRouting).where(eq(schema.userNotificationRouting.userId, userId)); await tx.delete(schema.userRoles).where(eq(schema.userRoles.userId, userId)); + /** + * The two personal tables this branch added. + * + * Deleted by hand for the reason the whole function exists: the `users` row + * SURVIVES an erasure, so no `ON DELETE` ever fires. Left behind, the saved + * filters kept matching uploads and writing notifications to a tombstone + * for ever, and the login history stayed attributed to the account — and + * readable through the moderator view — for its full retention period. + */ + await tx + .delete(schema.savedSearches) + .where(eq(schema.savedSearches.userId, userId)); + await tx.delete(schema.loginEvents).where(eq(schema.loginEvents.userId, userId)); await tx.delete(schema.torrentFavorites).where(eq(schema.torrentFavorites.userId, userId)); await tx .delete(schema.userFollows) @@ -377,6 +404,20 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { .where(eq(schema.users.id, userId)); }); + /** + * The Torznab residue of the passkey we just rotated away. + * + * Keyed by a hash of the OLD value, so nothing in the transaction above + * touches it: an access block against an account that no longer exists, and + * up to seven days of request logs carrying an IP hash and a user agent. On + * the one route whose job is to leave nothing behind. + */ + if (oldPasskey) { + await retireTorznabPasskey(oldPasskey).catch((err) => { + console.warn('[erase] torznab residue survived:', (err as Error).message); + }); + } + // The cached gate must forget the old "ok" at once, or the account stays // reachable for up to the 60 s TTL behind a live cookie. await invalidateBanCache(userId); diff --git a/apps/api/utils/account/exportAccount.ts b/apps/api/utils/account/exportAccount.ts index fb96aec2..231bf922 100644 --- a/apps/api/utils/account/exportAccount.ts +++ b/apps/api/utils/account/exportAccount.ts @@ -36,6 +36,12 @@ * 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. + * - **The names inside a notification.** A notification this account received + * may name the moderator who acted on it, the member who used its invite, or + * quote a staff message. The notification, its type and its date are + * exported; those names are not — they are the same staff and third-party + * data the two entries above withhold, and a payload is not a loophole in + * that rule. * - **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 @@ -59,6 +65,48 @@ import { db, schema } from '@trackarr/db'; */ const CAP = 5000; +/** + * Keys a notification payload may carry into the export. + * + * The payloads are written by whichever route emitted the notification, and + * several of them name somebody else: `actorUsername` is the moderator who + * banned this account, `inviteeUsername` is the member who used its invite, + * `preview` is 200 characters of a staff message, `uploaderUsername` is another + * member. Exporting the payload verbatim therefore handed out exactly the two + * things this file promises to withhold — staff identity and who used an invite + * — in a structured document, on one GET. + * + * A whitelist rather than a blocklist: a notification type added next year must + * not be able to widen this by accident. + */ +const PAYLOAD_KEYS_KEPT = new Set([ + 'reason', + 'amount', + 'itemName', + 'itemType', + 'torrentName', + 'infoHash', + 'label', + 'category', + 'points', + 'multiplier', + 'until', + 'seedTime', + 'requiredSeedTime', + 'expiresAt', + 'status', + 'count', +]); + +function safeNotificationPayload(payload: unknown): Record<string, unknown> { + if (!payload || typeof payload !== 'object') return {}; + const out: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(payload as Record<string, unknown>)) { + if (PAYLOAD_KEYS_KEPT.has(key)) out[key] = value; + } + return out; +} + /** A capped list, honest about what it left out. */ interface Capped<T> { total: number; @@ -166,6 +214,27 @@ export async function exportAccount(userId: string) { templates, notificationRows, notificationCount, + // Real totals for the nine collections that were reporting `items.length` + // as their own total — which made `truncated` permanently false and turned a + // 5000-row prefix into a document claiming to be the whole record. The + // header of this file calls that failure worse than refusing to export. + followingCount, + favouriteCount, + inviteCount, + templateCount, + requestCount, + requestFillCount, + ticketCount, + ticketMessageCount, + reportCount, + poolContributionCount, + // The two personal tables this branch added and did not bring here. The + // rule at the top of the file is that a personal table belongs in both the + // export and the erasure. + savedSearchRows, + savedSearchCount, + loginRows, + loginCount, ] = await Promise.all([ db.query.trustedDevices.findMany({ where: eq(schema.trustedDevices.userId, userId), @@ -396,6 +465,7 @@ export async function exportAccount(userId: string) { where: eq(schema.notifications.userId, userId), columns: { type: true, + // The payload is filtered on the way out — see `safeNotificationPayload`. payload: true, link: true, readAt: true, @@ -405,6 +475,60 @@ export async function exportAccount(userId: string) { limit: CAP, }), countOf(schema.notifications, eq(schema.notifications.userId, userId)), + countOf(schema.userFollows, eq(schema.userFollows.followerId, userId)), + countOf(schema.torrentFavorites, eq(schema.torrentFavorites.userId, userId)), + countOf(schema.invitations, eq(schema.invitations.createdBy, userId)), + countOf( + schema.presentationTemplates, + eq(schema.presentationTemplates.ownerId, userId) + ), + countOf(schema.uploadRequests, eq(schema.uploadRequests.requesterId, userId)), + countOf( + schema.uploadRequestFillAttempts, + eq(schema.uploadRequestFillAttempts.userId, userId) + ), + countOf(schema.tickets, eq(schema.tickets.openedById, userId)), + countOf(schema.ticketMessages, eq(schema.ticketMessages.authorId, userId)), + countOf(schema.reports, eq(schema.reports.reporterId, userId)), + countOf( + schema.freeleechPoolContributions, + eq(schema.freeleechPoolContributions.userId, userId) + ), + // Saved searches: the member's own stored filters, in their own words. + db.query.savedSearches.findMany({ + where: eq(schema.savedSearches.userId, userId), + columns: { + label: true, + query: true, + tags: true, + imdbId: true, + tmdbId: true, + tvdbId: true, + notify: true, + createdAt: true, + }, + limit: CAP, + }), + countOf(schema.savedSearches, eq(schema.savedSearches.userId, userId)), + /** + * Login history. The address is exported as the stored HASH, not as an + * address: this table never held a raw IP, and inventing one for the export + * would be inventing data. The hash is only comparable within one day — + * stated in the guide, and in the field name. + */ + db + .select({ + at: schema.loginEvents.createdAt, + outcome: schema.loginEvents.outcome, + method: schema.loginEvents.method, + ipHashDailyRotating: schema.loginEvents.ipHash, + userAgent: schema.loginEvents.userAgent, + }) + .from(schema.loginEvents) + .where(eq(schema.loginEvents.userId, userId)) + .orderBy(desc(schema.loginEvents.createdAt)) + .limit(CAP), + countOf(schema.loginEvents, eq(schema.loginEvents.userId, userId)), ]); // Ticket messages, for the tickets just read. Second query rather than a @@ -457,47 +581,52 @@ export async function exportAccount(userId: string) { notificationSettings: { channels, routing }, social: { - following: capped(following, following.length), + following: capped(following, followingCount), /** Other people. A number, by design — see the note at the top. */ followerCount, - favourites: capped(favourites, favourites.length), + favourites: capped(favourites, favouriteCount), }, - invitesCreated: capped(invitesCreated, invitesCreated.length), + invitesCreated: capped(invitesCreated, inviteCount), contributions: { uploads: capped(uploads, uploadCount), torrentComments: capped(comments, commentCount), forumTopics: capped(topics, topicCount), forumPosts: capped(posts, postCount), - presentationTemplates: capped(templates, templates.length), + presentationTemplates: capped(templates, templateCount), }, activity: { snatches: capped(snatches, snatchCount), - notifications: capped(notificationRows, notificationCount), + notifications: capped( + notificationRows.map((n) => ({ + ...n, + payload: safeNotificationPayload(n.payload), + })), + notificationCount + ), + savedSearches: capped(savedSearchRows, savedSearchCount), + logins: capped(loginRows, loginCount), }, economy: { bonusLedger: capped(bonus, bonusCount), shopPurchases: capped(purchases, purchaseCount), - freeleechPoolContributions: capped( - poolContributions, - poolContributions.length - ), + freeleechPoolContributions: capped(poolContributions, poolContributionCount), }, requests: { - opened: capped(requests, requests.length), - fillAttempts: capped(requestFills, requestFills.length), + opened: capped(requests, requestCount), + fillAttempts: capped(requestFills, requestFillCount), }, support: { - tickets: capped(ticketRows, ticketRows.length), - myMessages: capped(myTicketMessages, myTicketMessages.length), + tickets: capped(ticketRows, ticketCount), + myMessages: capped(myTicketMessages, ticketMessageCount), }, - reportsFiled: capped(reportsFiled, reportsFiled.length), + reportsFiled: capped(reportsFiled, reportCount), messaging: { conversationCount, diff --git a/apps/api/utils/audit.ts b/apps/api/utils/audit.ts index bf89f616..f83ff94f 100644 --- a/apps/api/utils/audit.ts +++ b/apps/api/utils/audit.ts @@ -94,14 +94,27 @@ export function deriveAction( 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)) + /** + * Route parameters are dropped by POSITION, not by value. + * + * By value, any segment that happened to equal a parameter's value went too — + * so a member called `mutes` turned `mod/room/mutes/:username` into + * `mod.room.delete`, and an admin filtering the register by action key would + * never see those lines. Usernames are 3–20 characters with no reserved list, + * so `mod`, `room` and `mutes` are all registrable. + * + * The positions come from the values: a parameter's value appears exactly + * where the router matched it, and taking the FIRST unclaimed occurrence of + * each value is what makes this positional rather than a set membership test. + */ + const segments = path.replace(/^\/api\//, '').split('/').filter(Boolean); + const dropped = new Set<number>(); + for (const value of paramValues.filter(Boolean)) { + const at = segments.findIndex((seg, i) => seg === value && !dropped.has(i)); + if (at >= 0) dropped.add(at); + } + const parts = segments + .filter((_, i) => !dropped.has(i)) // 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( @@ -144,9 +157,41 @@ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); * browsing — which the privacy toggles elsewhere in this codebase exist to * prevent. */ -export function isAuditable(method: string, path: string): boolean { +/** + * Member-facing paths where a staff member exercises a staff power. + * + * The console prefixes are not the whole story, and "who did what, across the + * whole console" was too generous a claim: a moderator deletes a torrent + * through `DELETE /api/torrents/:hash`, a comment through + * `/api/torrents/comments/:id`, moderates the forum through `/api/forum/...`, + * and removes a message through the messaging routes. Every one of those is an + * act of authority against somebody else's content, and none of them left a row. + * + * Matched only when the ACTOR is staff, which is what keeps the register from + * becoming a log of everybody's activity — the thing the predicate above is + * careful to avoid. A moderator deleting their own comment is audited too, and + * that is the right side to err on: the register records what authority did, + * and it cannot know intent. + */ +const STAFF_REACH: readonly RegExp[] = [ + /^\/api\/torrents\/[^/]+$/, + /^\/api\/torrents\/[^/]+\/(tags|federate-swarm|index)$/, + /^\/api\/torrents\/comments\/[^/]+$/, + /^\/api\/forum\//, + /^\/api\/messaging\/room\/messages\/[^/]+$/, + /^\/api\/messaging\/conversations\/[^/]+\/messages\/[^/]+$/, + /^\/api\/requests\/[^/]+\/comments\/[^/]+$/, + /^\/api\/tickets\/[^/]+\//, +]; + +export function isAuditable( + method: string, + path: string, + actorIsStaff = false +): boolean { if (!MUTATING.has(method.toUpperCase())) return false; - return path.startsWith('/api/admin/') || path.startsWith('/api/mod/'); + if (path.startsWith('/api/admin/') || path.startsWith('/api/mod/')) return true; + return actorIsStaff && STAFF_REACH.some((re) => re.test(path)); } /** diff --git a/apps/api/utils/savedSearchFanout.ts b/apps/api/utils/savedSearchFanout.ts index 2cc194ce..f471140d 100644 --- a/apps/api/utils/savedSearchFanout.ts +++ b/apps/api/utils/savedSearchFanout.ts @@ -55,6 +55,18 @@ const SLOW_SWEEP_MS = 500; export async function fanoutSavedSearchMatches( torrent: SavedSearchCandidate ): Promise<void> { + try { + await runFanout(torrent); + } catch (err) { + // Its sibling wraps the identical shape, and for the same reason: this is + // called with `void` from two routes, so without the catch a failure + // surfaces as an anonymous unhandled rejection instead of a line naming + // the feature that failed. + console.warn('[SavedSearch] fan-out failed:', (err as Error).message); + } +} + +async function runFanout(torrent: SavedSearchCandidate): Promise<void> { const started = Date.now(); // The torrent's tag slugs, once. A filter matching on tags needs all of its diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts index 79283c91..e7fc57f0 100644 --- a/apps/api/utils/schemas.ts +++ b/apps/api/utils/schemas.ts @@ -260,6 +260,13 @@ export const adminSettingsSchema = z.object({ * audit log with a built-in expiry nobody chose. */ auditRetentionDays: z.number().int().min(0).max(3650).optional(), + // Both of these had a getter, a default and a documented meaning, and no + // writer anywhere — so the retention period an operator reads about in the + // privacy notice, and the ceiling the saved-search fan-out logs advice about + // ("consider lowering saved_search_max_per_user"), could only be changed with + // a SQL prompt. + loginEventRetentionDays: z.number().int().min(0).max(3650).optional(), + savedSearchMaxPerUser: z.number().int().min(1).max(200).optional(), notificationsRetentionUnreadDays: z .number() .int() diff --git a/apps/web/app/pages/settings.vue b/apps/web/app/pages/settings.vue index 69ca41f6..597fb966 100644 --- a/apps/web/app/pages/settings.vue +++ b/apps/web/app/pages/settings.vue @@ -578,7 +578,7 @@ <time :datetime="l.createdAt">{{ loginStamp(l.createdAt) }}</time> </td> <td>{{ $t(`settings.security.loginMethods.${l.method}`) }}</td> - <td><code class="logins-hash">{{ l.ipHash || '—' }}</code></td> + <td><code class="logins-hash">{{ l.address || '—' }}</code></td> <td> <span :class="l.outcome === 'success' ? 'logins-ok' : 'logins-bad'"> {{ $t(`settings.security.loginOutcomes.${l.outcome}`) }} @@ -1281,7 +1281,8 @@ interface LoginEvent { id: string; method: string; outcome: string; - ipHash: string | null; + /** An ordinal within this response — see the API route for why. */ + address: string | null; userAgent: string | null; createdAt: string; } diff --git a/apps/web/app/pages/users/[id].vue b/apps/web/app/pages/users/[id].vue index 41b71bcc..ec2b1e0c 100644 --- a/apps/web/app/pages/users/[id].vue +++ b/apps/web/app/pages/users/[id].vue @@ -331,7 +331,7 @@ · {{ $t('users.logins.refused') }} </span> </td> - <td><code class="logins-hash">{{ l.ipHash || '—' }}</code></td> + <td><code class="logins-hash">{{ l.address || '—' }}</code></td> <td class="logins-agent">{{ l.userAgent || '—' }}</td> </tr> </tbody> @@ -418,7 +418,8 @@ interface LoginEvent { id: string; method: string; outcome: string; - ipHash: string | null; + /** An ordinal within this response — see the API route for why. */ + address: string | null; userAgent: string | null; createdAt: string; } From 5749e8cd82e13d4f1f1ac4f394352a93d8166f42 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 17:08:24 +0200 Subject: [PATCH 25/96] fix(tracker): an unauthenticated scrape could reach Postgres, and two oracles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## /scrape had no passkey and now had a database behind it BEP 52 support added a resolve to the scrape path so a v2 client scraping under the truncated hash finds its swarm. `/scrape` takes no credential — by protocol — and on a miss the resolve is two index probes, up to eight hashes per request. So an endpoint that used to cost zero database work became sixteen round-trips per request from anybody on the internet, against a pool of twenty connections shared with the announce path. A few thousand requests a second of random hashes stops the tracker answering for everyone. A hash that did not resolve is remembered in Redis for five minutes. The negative answer is the cheap half: it is what a flood is made of, it cannot hide a real torrent (only misses are cached), and Redis is already on this path for the peer counts. Verified: the tracker builds, vets and passes its tests. ## Two endpoints confirmed what moderation had turned down `GET /api/torrents/:hash/supersessions` filtered its RELATIONS by visibility and not the source row, so a rejected hash answered 200 with empty relations while an unknown hash answered 404 — a sorting oracle over the moderation queue, on 100 requests a minute. Its own docstring claimed the opposite, and the detail endpoint flat-404s precisely to avoid this. `POST /api/torrents/unregistered` named the replacement of a superseded release without checking that the replacement is still visible. Nothing clears the pointer when a target is later rejected, so the endpoint whose docstring calls itself "the last place to open that door" handed out the hash and the name of a rejected release. Both filtered now, both verified against a live stack. Neither applied the caller's adult-content setting either; both do, and `unregistered` now spends a per-account budget counted in HASHES rather than leaning on a per-IP request limit that allowed 3.7 million probes a day from one address. ## The economics were read through two clocks `multipliers_until` was `timestamp without time zone`. The Go announce compares it with `now()` in a UTC session — correct — while postgres.js hands the zone-less value to `new Date()`, which reads it in the API container's zone, and the shipped compose file sets `TZ=Europe/Paris`. For a window as wide as the offset, the tracker charged a freeleech the feed had already declared expired — and the admin form re-serialised the skewed value, walking every expiry two hours earlier on each save. It is `timestamptz` now, converted explicitly from UTC, and all three readers agree. The migration also adds what the review found missing: a CHECK bounding both multipliers at the database level (the Go bonus code validates the values it reads from Redis for exactly this reason and passes the per-torrent ones straight through), an index on `torrents.created_at` and on `hnr_tracking.downloaded_at`/`completed_at` (the year-in-review queries were scanning), the two ranking indexes on `torrent_stats`, a partial index on `is_sticky` (page one asked for pinned torrents with nothing to answer from), and `ON DELETE set null` instead of `cascade` on a saved search's category — deleting a category should narrow a member's filter, not delete it. ## And two more A supersede cycle was reachable by two moderators acting at once: each read the other row as a chain head, each walk terminated, both writes committed. The target is checked inside the write predicate now, and a refused write is a 409 rather than a silent success. Reseed requests notified the OLDEST 200 snatchers — the docstring, and the commit message, both said newest, and newest is the point: those are the people who might still hold the files. One missing `desc()`, and a daily lock that meant nobody could try again that day. `infoDictRange` recursed without a depth bound; fifteen thousand nested containers in a hostile `.torrent` reached the member as a 500 rather than as a refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../api/mod/torrents/[hash]/supersede.put.ts | 31 +- .../torrents/[hash]/reseed-request.post.ts | 8 +- .../api/torrents/[hash]/supersessions.get.ts | 7 +- .../routes/api/torrents/unregistered.post.ts | 67 +- apps/api/utils/bittorrentV2.ts | 20 +- apps/tracker/internal/peers/peers.go | 26 + apps/tracker/internal/server/handler.go | 20 +- .../db/src/migrations/0066_review_fixes.sql | 11 + .../db/src/migrations/meta/0066_snapshot.json | 10231 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 81 +- 11 files changed, 10481 insertions(+), 28 deletions(-) create mode 100644 packages/db/src/migrations/0066_review_fixes.sql create mode 100644 packages/db/src/migrations/meta/0066_snapshot.json diff --git a/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts b/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts index 5bc5a9a7..9b856a59 100644 --- a/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts +++ b/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts @@ -34,7 +34,7 @@ * Both verbs land in the staff audit log — they are mutating calls under * `/api/mod/`, so the hook records them whether or not this route says anything. */ -import { and, eq, isNull } from 'drizzle-orm'; +import { and, eq, isNull, sql } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db, schema } from '@trackarr/db'; import { requireModeratorSession } from '~~/utils/adminAuth'; @@ -142,7 +142,7 @@ export default defineEventHandler(async (event) => { }, }); - await db + const written = await db .update(schema.torrents) .set({ supersededById: target.id, @@ -158,9 +158,32 @@ export default defineEventHandler(async (event) => { eq(schema.torrents.id, source.id), source.supersededById === null ? isNull(schema.torrents.supersededById) - : eq(schema.torrents.supersededById, source.supersededById) + : eq(schema.torrents.supersededById, source.supersededById), + /** + * And the TARGET must still be a chain head at write time. + * + * The cycle walk above runs as its own statements, so two moderators + * pointing A at B and B at A concurrently each read the other row as + * unsuperseded, each walk terminates cleanly, and both writes commit. + * Nothing hangs — every reader is bounded — but both releases then + * refuse reseed requests for ever and each page sends members to the + * other. Checking the target inside the predicate closes the window + * without a transaction. + */ + sql`(select superseded_by_id from torrents where id = ${target.id}) is null` ) - ); + ) + .returning({ id: schema.torrents.id }); + + if (written.length === 0) { + // Either another moderator changed this row, or the target stopped being a + // chain head. Both mean "read it again": the state the caller decided from + // is gone. + throw createError({ + statusCode: 409, + message: 'Somebody changed one of these two releases. Reload and try again.', + }); + } return { success: true, diff --git a/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts b/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts index 43ebf8d7..3577084b 100644 --- a/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts +++ b/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts @@ -31,7 +31,7 @@ * torrent they downloaded does not enumerate anything — but it does tell them * the site remembers, which is why the guide says so plainly. */ -import { and, eq, isNull, ne } from 'drizzle-orm'; +import { and, desc, eq, isNull, ne } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { requireAuthSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; @@ -117,7 +117,11 @@ export default defineEventHandler(async (event) => { eq(schema.users.isBanned, false) ) ) - .orderBy(schema.hnrTracking.downloadedAt) + // Newest first, which is what the note above says and what the feature is + // for: ascending pinged the 200 people who grabbed it longest ago — the + // least likely to still hold the data — and the daily lock meant nobody + // could try again that day. + .orderBy(desc(schema.hnrTracking.downloadedAt)) .limit(MAX_RECIPIENTS); const recipients = snatchers.map((r) => r.userId); diff --git a/apps/api/routes/api/torrents/[hash]/supersessions.get.ts b/apps/api/routes/api/torrents/[hash]/supersessions.get.ts index a0bda29e..25d2b8a8 100644 --- a/apps/api/routes/api/torrents/[hash]/supersessions.get.ts +++ b/apps/api/routes/api/torrents/[hash]/supersessions.get.ts @@ -37,7 +37,12 @@ export default defineEventHandler(async (event) => { isStaff ? undefined : eq(alias.moderationStatus, 'accepted'); const source = await db.query.torrents.findFirst({ - where: eq(schema.torrents.infoHash, hash.toLowerCase()), + // The visibility filter belongs on the SOURCE too, and its absence made + // this an existence oracle: a rejected hash answered 200 with empty + // relations while an unknown hash answered 404, so a member could sort + // hashes into "moderation turned this down" and "never heard of it". The + // detail endpoint flat-404s for exactly this reason. + where: and(eq(schema.torrents.infoHash, hash.toLowerCase()), visible(schema.torrents)), columns: { id: true, supersededById: true, diff --git a/apps/api/routes/api/torrents/unregistered.post.ts b/apps/api/routes/api/torrents/unregistered.post.ts index e04d41fb..58fa6b87 100644 --- a/apps/api/routes/api/torrents/unregistered.post.ts +++ b/apps/api/routes/api/torrents/unregistered.post.ts @@ -34,11 +34,13 @@ * 256 hashes per request. A client with more asks twice; the cap is what keeps * one request from becoming a table scan with a list of arguments. */ -import { inArray } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db, schema } from '@trackarr/db'; import { requireReadAccess } from '~~/utils/account/readKeyAuth'; +import { adultCategoryIds } from '~~/utils/adultContent'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { redis } from '~~/utils/server'; import { validateBody } from '~~/utils/schemas'; const MAX_HASHES = 256; @@ -53,10 +55,41 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { // Session or key: this is meant to be called by a script as much as by a // browser, and a script has no cookie. - await requireReadAccess(event, 'api'); + const { user: holder } = await requireReadAccess(event, 'api'); await rateLimit(event, RATE_LIMITS.mutation); const body = await validateBody(event, bodySchema); + /** + * A budget in HASHES, per account and per day. + * + * The rate limiter counts requests and keys on the IP, so 10 requests a minute + * × 256 hashes is 3.7 million probes a day from one address — and a member on + * a /64 or a VPN multiplies that freely. Counting hashes against the account + * is what makes the cap mean something: 20 000 a day is far more than a + * client with a few thousand torrents needs, and far less than a catalogue + * enumeration. + */ + const budgetKey = `unregistered:budget:${holder.id}:${new Date() + .toISOString() + .slice(0, 10)}`; + const DAILY_HASH_BUDGET = 20_000; + let spent = 0; + try { + spent = await redis.incrby(budgetKey, body.infoHashes.length); + // 48 h so a key written just before midnight still expires on its own. + if (spent === body.infoHashes.length) await redis.expire(budgetKey, 172_800); + } catch { + // Redis down: the request goes through. This is an abuse budget, not an + // authorisation, and the rate limiter is still in front of it. + spent = 0; + } + if (spent > DAILY_HASH_BUDGET) { + throw createError({ + statusCode: 429, + message: `You have checked ${DAILY_HASH_BUDGET} hashes today. The budget resets at midnight UTC.`, + }); + } + // De-duplicated and lowercased once, so a caller sending the same hash twice // does not pay for it twice. const wanted = [...new Set(body.infoHashes.map((h) => h.toLowerCase()))]; @@ -69,6 +102,7 @@ export default defineEventHandler(async (event) => { isActive: true, moderationStatus: true, supersededById: true, + categoryId: true, }, }); @@ -76,9 +110,31 @@ export default defineEventHandler(async (event) => { const replacementIds = [ ...new Set(rows.map((r) => r.supersededById).filter((v): v is string => !!v)), ]; + /** + * The replacement is only named when it is itself visible to this caller. + * + * The comment below said this and the query did not: a moderator marks A + * superseded by B while B is accepted, B is later rejected or deactivated, and + * nothing clears the pointer — so this endpoint handed out the hash AND the + * name of a release moderation had turned down. On the endpoint whose own + * docstring calls itself the last place to open that door. + * + * The adult tree goes the same way: a release the caller has not opted into is + * not a release this may name. + */ + const adultIds = holder.showAdultContent ? [] : await adultCategoryIds(); const replacements = replacementIds.length ? await db.query.torrents.findMany({ - where: inArray(schema.torrents.id, replacementIds), + where: and( + inArray(schema.torrents.id, replacementIds), + eq(schema.torrents.isActive, true), + eq(schema.torrents.moderationStatus, 'accepted'), + ...(adultIds.length + ? [ + sql`(${schema.torrents.categoryId} is null or ${schema.torrents.categoryId} not in ${adultIds})`, + ] + : []) + ), columns: { id: true, infoHash: true, name: true }, }) : []; @@ -91,6 +147,11 @@ export default defineEventHandler(async (event) => { if (!row || !row.isActive || row.moderationStatus === 'rejected') { return { infoHash, verdict: 'unregistered' as const }; } + // A release in the adult tree does not exist for a caller who has not opted + // in — the same answer the catalogue, the feeds and search give. + if (row.categoryId && adultIds.includes(row.categoryId)) { + return { infoHash, verdict: 'unregistered' as const }; + } if (row.moderationStatus !== 'accepted') { return { infoHash, verdict: 'pending' as const }; } diff --git a/apps/api/utils/bittorrentV2.ts b/apps/api/utils/bittorrentV2.ts index 5e0fcecd..a0602c0c 100644 --- a/apps/api/utils/bittorrentV2.ts +++ b/apps/api/utils/bittorrentV2.ts @@ -147,9 +147,19 @@ function collectRoots( 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; + /** + * `skip` returns the index one past the value that starts at `i`, or -1. + * + * `depth` is the bound the comment above did not have. The walk cannot loop — + * every step is forward — but it recurses once per nesting level, so a file + * with fifteen thousand nested containers before the `info` key blew the + * stack, and `extractV2` is called without a try/catch on the upload path: a + * `RangeError` reached the member as a 500 instead of "this file cannot be + * read". Nothing legitimate nests past a handful of levels. + */ + const MAX_DEPTH = 64; + const skip = (i: number, depth = 0): number => { + if (i >= bytes.length || depth > MAX_DEPTH) return -1; const c = bytes[i]!; // Integer: `i<digits>e`. @@ -162,8 +172,8 @@ export function infoDictRange( 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 + const next = skip(j, depth + 1); + if (next <= j) return -1; // no progress, or too deep: malformed j = next; } return j < bytes.length ? j + 1 : -1; diff --git a/apps/tracker/internal/peers/peers.go b/apps/tracker/internal/peers/peers.go index 405887a8..23febb28 100644 --- a/apps/tracker/internal/peers/peers.go +++ b/apps/tracker/internal/peers/peers.go @@ -405,6 +405,32 @@ func (s *Store) IncrementCompleted(ctx context.Context, infoHashHex string) erro return err } +// resolveMissTTL bounds how long a "this site has no such torrent" answer is +// remembered for the scrape path. +// +// Five minutes: long enough that a flood of random hashes pays for one lookup +// each rather than one per request, short enough that a torrent uploaded a +// moment ago is scrapeable almost immediately. The value is only ever a +// NEGATIVE answer — a hash that resolves is not cached here, so a real torrent +// can never be hidden by this. +const resolveMissTTL = 5 * time.Minute + +// RememberResolveMiss records that `infoHashHex` did not resolve to a torrent. +// +// Best-effort: a Redis failure here costs a repeated database lookup, which is +// exactly the state before this cache existed. +func (s *Store) RememberResolveMiss(ctx context.Context, infoHashHex string) { + _ = s.client.Set(ctx, s.resolveMissKey(infoHashHex), "1", resolveMissTTL).Err() +} + +// ResolveMissCached reports whether we already know this hash does not resolve. +func (s *Store) ResolveMissCached(ctx context.Context, infoHashHex string) bool { + n, err := s.client.Exists(ctx, s.resolveMissKey(infoHashHex)).Result() + return err == nil && n > 0 +} + +func (s *Store) resolveMissKey(h string) string { return s.prefix + "resolve_miss:" + h } + // completedOnceTTL bounds the snatch-dedup marker. It only needs to outlast // realistic replay attempts; the authoritative completion record is the // hnr_tracking row in Postgres. diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go index 9abc7e0f..0fe04988 100644 --- a/apps/tracker/internal/server/handler.go +++ b/apps/tracker/internal/server/handler.go @@ -949,12 +949,30 @@ func (s *Server) ScrapeStats( if resolveBudget == nil || *resolveBudget <= 0 { return seeders, leechers, completed } + + // A hash we have already failed to resolve costs nothing to fail again. + // + // /scrape takes no passkey — by protocol — so before this the endpoint that + // used to cost zero database work became two index probes per unknown hash, + // eight hashes per request, from anybody on the internet. The connection + // pool is shared with the announce path, so a few thousand requests a second + // of random hashes stopped the tracker answering for everyone. + // + // The negative answer is the cheap half: it is stable (a hash this site does + // not have does not start existing), it is what a flood is made of, and it + // lives in Redis, which is already on this path for the peer counts above. + if s.peers.ResolveMissCached(ctx, announcedHex) { + return seeders, leechers, completed + } + *resolveBudget-- resolved, err := s.db.ResolveAnnouncedTorrent(ctx, announcedHex) if err != nil || resolved.SwarmKey == announcedHex { // Unknown, or a v1 hash that really has no peers. Either way the - // zeroes above are the honest answer. + // zeroes above are the honest answer — and worth remembering, so the + // next probe for the same hash does not pay for the same lookup. + s.peers.RememberResolveMiss(ctx, announcedHex) return seeders, leechers, completed } seeders, leechers, _ = s.peers.Counts(ctx, resolved.SwarmKey) diff --git a/packages/db/src/migrations/0066_review_fixes.sql b/packages/db/src/migrations/0066_review_fixes.sql new file mode 100644 index 00000000..45223a2a --- /dev/null +++ b/packages/db/src/migrations/0066_review_fixes.sql @@ -0,0 +1,11 @@ +ALTER TABLE "saved_searches" DROP CONSTRAINT "saved_searches_category_id_categories_id_fk"; +--> statement-breakpoint +ALTER TABLE "torrents" ALTER COLUMN "multipliers_until" SET DATA TYPE timestamp with time zone USING "multipliers_until" AT TIME ZONE 'UTC';--> statement-breakpoint +ALTER TABLE "saved_searches" ADD CONSTRAINT "saved_searches_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "hnr_downloaded_at_idx" ON "hnr_tracking" USING btree ("downloaded_at");--> statement-breakpoint +CREATE INDEX "hnr_completed_at_idx" ON "hnr_tracking" USING btree ("completed_at");--> statement-breakpoint +CREATE INDEX "torrent_stats_completed_idx" ON "torrent_stats" USING btree ("completed" desc);--> statement-breakpoint +CREATE INDEX "torrent_stats_seeders_idx" ON "torrent_stats" USING btree ("seeders" desc);--> statement-breakpoint +CREATE INDEX "torrents_created_at_idx" ON "torrents" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "torrents_sticky_idx" ON "torrents" USING btree ("is_sticky") WHERE "torrents"."is_sticky";--> statement-breakpoint +ALTER TABLE "torrents" ADD CONSTRAINT "torrents_multipliers_sane" CHECK ("torrents"."download_multiplier" between 0 and 200 and "torrents"."upload_multiplier" between 0 and 1000); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0066_snapshot.json b/packages/db/src/migrations/meta/0066_snapshot.json new file mode 100644 index 00000000..e495dda8 --- /dev/null +++ b/packages/db/src/migrations/meta/0066_snapshot.json @@ -0,0 +1,10231 @@ +{ + "id": "46b1d2df-53a7-4178-b8d4-08eb3ac273b0", + "prevId": "d7477aa3-acae-4b86-92b1-fa6e9ff4a47d", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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_downloaded_at_idx": { + "name": "hnr_downloaded_at_idx", + "columns": [ + { + "expression": "downloaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hnr_completed_at_idx": { + "name": "hnr_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_used_by_idx": { + "name": "invitations_used_by_idx", + "columns": [ + { + "expression": "used_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invitations\".\"used_by\" IS NOT NULL", + "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.login_events": { + "name": "login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_hash": { + "name": "ip_hash", + "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()" + } + }, + "indexes": { + "login_events_user_idx": { + "name": "login_events_user_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": {} + }, + "login_events_created_idx": { + "name": "login_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_events_user_id_users_id_fk": { + "name": "login_events_user_id_users_id_fk", + "tableFrom": "login_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_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": { + "torrent_stats_completed_idx": { + "name": "torrent_stats_completed_idx", + "columns": [ + { + "expression": "\"completed\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrent_stats_seeders_idx": { + "name": "torrent_stats_seeders_idx", + "columns": [ + { + "expression": "\"seeders\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_created_at_idx": { + "name": "torrents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrents_sticky_idx": { + "name": "torrents_sticky_idx", + "columns": [ + { + "expression": "is_sticky", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"is_sticky\"", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": { + "torrents_multipliers_sane": { + "name": "torrents_multipliers_sane", + "value": "\"torrents\".\"download_multiplier\" between 0 and 200 and \"torrents\".\"upload_multiplier\" between 0 and 1000" + } + }, + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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 b1a1ef9f..9cda9037 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -463,6 +463,13 @@ "when": 1788218955902, "tag": "0065_invite_tree_index", "breakpoints": true + }, + { + "idx": 66, + "version": "7", + "when": 1788274237632, + "tag": "0066_review_fixes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e47c760f..26413227 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -15,7 +15,7 @@ import { check, type AnyPgColumn, } from 'drizzle-orm/pg-core'; -import { relations, sql } from 'drizzle-orm'; +import { desc, relations, sql } from 'drizzle-orm'; import { ftsVector } from './search'; // Custom type for bytea (binary data) @@ -1074,7 +1074,21 @@ export const torrents = pgTable( */ downloadMultiplier: integer('download_multiplier').default(100).notNull(), uploadMultiplier: integer('upload_multiplier').default(100).notNull(), - multipliersUntil: timestamp('multipliers_until'), + /** + * `withTimezone` because this column is read by BOTH Postgres and + * JavaScript, for the same decision. + * + * The Go announce compares it with `now()` — correct, the session is UTC — + * while postgres.js hands a zone-less value to `new Date()`, which reads it + * in the API container's zone (`TZ=Europe/Paris` in the shipped compose + * file). So for a window the width of the offset, the tracker charged a + * freeleech the feed had already declared expired, or the other way round + * on a negative offset — and the admin form re-serialised the skewed value, + * walking every expiry two hours earlier on each save. + * + * A `timestamptz` puts an offset on the wire, and all three readers agree. + */ + multipliersUntil: timestamp('multipliers_until', { withTimezone: true }), /** * Pinned to the top of listings. Editorial, not economic — it moves a * release up the page and changes nothing about what it costs to grab. @@ -1203,6 +1217,29 @@ export const torrents = pgTable( .on(sql`left(${table.infoHashV2}, 40)`) .where(sql`${table.infoHashV2} IS NOT NULL`), index('torrents_openlibrary_idx').on(table.openlibraryId), + // The year-in-review queries filter `created_at` by range, and the two + // expression indexes on `coalesce(moderated_at, created_at)` cannot serve a + // bare column range — so four aggregates were scanning the catalogue. + /** + * The floor the Go announce has no way to enforce. + * + * `bonus.validateSnapshot` rejects a negative or absurd multiplier when it + * comes from Redis, explicitly to avoid crediting negative bytes — but the + * per-torrent values arrive from this table and are passed straight through. + * Zod bounds the one route that writes them; a psql prompt, a restore or a + * future route would not be bounded by anything. + */ + check( + 'torrents_multipliers_sane', + sql`${table.downloadMultiplier} between 0 and 200 and ${table.uploadMultiplier} between 0 and 1000` + ), + index('torrents_created_at_idx').on(table.createdAt), + // Page one of the listing asks for pinned torrents in a separate query, and + // with no index Postgres had to exhaust the candidate set to prove that the + // usual answer — none — is right. + index('torrents_sticky_idx') + .on(table.isSticky) + .where(sql`${table.isSticky}`), index('torrents_moderation_status_idx').on(table.moderationStatus), // GIN rather than GiST: it is the recommended opclass for LIKE/ILIKE and, // measured over 200,000 rows, returns in 20 ms against 26 ms while building @@ -1407,15 +1444,24 @@ export type CatalogRecord = typeof catalogRecords.$inferSelect; // ============================================================================ // Torrent Stats (Aggregated, updated periodically from Redis) // ============================================================================ -export const torrentStats = pgTable('torrent_stats', { - infoHash: text('info_hash') - .primaryKey() - .references(() => torrents.infoHash, { onDelete: 'cascade' }), - seeders: integer('seeders').default(0).notNull(), - leechers: integer('leechers').default(0).notNull(), - completed: integer('completed').default(0).notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), -}); +export const torrentStats = pgTable( + 'torrent_stats', + { + infoHash: text('info_hash') + .primaryKey() + .references(() => torrents.infoHash, { onDelete: 'cascade' }), + seeders: integer('seeders').default(0).notNull(), + leechers: integer('leechers').default(0).notNull(), + completed: integer('completed').default(0).notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + // The two rankings on the statistics page order by these and take ten rows. + // With only the primary key, each one scanned and sorted the whole table. + (table) => [ + index('torrent_stats_completed_idx').on(desc(table.completed)), + index('torrent_stats_seeders_idx').on(desc(table.seeders)), + ] +); // ============================================================================ // Announce Log (For tracking/debugging, optional) @@ -2013,6 +2059,11 @@ export const hnrTracking = pgTable( // `hnr_user_torrent_idx` and `hnr_user_is_hnr_idx` below, so Postgres can // serve a user_id-only lookup from either. It was pure write amplification // on a table the announce path updates once per announce. + // `downloaded_at` and `completed_at` are what the year review filters on, + // and neither had an index: one sequential scan of the whole ledger per + // request, for an answer that is often zero rows. + index('hnr_downloaded_at_idx').on(table.downloadedAt), + index('hnr_completed_at_idx').on(table.completedAt), index('hnr_torrent_idx').on(table.torrentId), // Partial. `is_hnr` is a boolean and the interesting side is the rare one: // a full index spends most of its pages describing the rows nobody @@ -2695,8 +2746,14 @@ export const savedSearches = pgTable( * category" is a filter. */ tsquery: text('tsquery'), + /** + * `set null` rather than `cascade`: deleting a category should narrow a + * member's filter, not delete it. Cascading meant an operator merging two + * categories silently threw away every saved search that mentioned either — + * rows the member created, with no notification and no trace. + */ categoryId: text('category_id').references(() => categories.id, { - onDelete: 'cascade', + onDelete: 'set null', }), /** Tag slugs, ALL of which must be present — the listing's AND semantics. */ tags: jsonb('tags').$type<string[]>(), From 481dd4e8e7369053059c49f0e015ae2473db7685 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:41:14 +0200 Subject: [PATCH 26/96] fix(ui): three pages rendered bare native buttons, and the toast said nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.tool-btn` and `.field-label` existed only inside other files' `<style scoped>` blocks. A scoped rule compiles to `.tool-btn[data-v-…]`, so the three files that merely *used* the class — `alerts.vue`, `torrents/index.vue`, `admin/audit.vue` — got nothing: Tailwind's preflight strips a button's background, border and padding, so what shipped there was an unboxed 16px glyph with no hover, no disabled state and no focus surface. Eleven controls and six micro-labels, across three pages, looked broken rather than styled. Worse on `/alerts`, where `--danger` was never defined anywhere at all: the irreversible action and the safe one beside it were distinguishable only by the shape of their icon. Promoted to `@layer components` with the modifiers that were missing entirely (`--danger`, `--text`, `--sm`). 2.25rem square is 36px, which clears the 24px minimum of WCAG 2.2 SC 2.5.8 with room for the border. The two scoped copies stay where they are: `<style scoped>` is unlayered, so it still wins over a layer regardless of specificity, and those pages keep rendering exactly as they do. Two more from the same pass: `.input:focus { outline: none }` outranked the bare `:focus-visible` rule further up the sheet, so tabbing through a form of eight fields signalled position with a 1px border tint and nothing else. Restated at equal specificity, later, so the keyboard gets its ring back and the mouse keeps the quiet border. `NotificationToast` is the only channel every confirmation on this site travels through — a rotated key, a saved search, a reseed request, and every error including the ones a route hands back verbatim — and it had no `aria-live`. A screen reader heard nothing after pressing any of them. --- apps/web/app/assets/css/main.css | 107 ++++++++++++++++++ apps/web/app/components/NotificationToast.vue | 11 ++ 2 files changed, 118 insertions(+) diff --git a/apps/web/app/assets/css/main.css b/apps/web/app/assets/css/main.css index 74560721..b9791258 100644 --- a/apps/web/app/assets/css/main.css +++ b/apps/web/app/assets/css/main.css @@ -486,6 +486,103 @@ .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.75rem; } .btn-xs { padding: 0.2rem 0.45rem; font-size: 0.6875rem; } + /* --------------------------------------------------------------------------- + * Icon buttons — the square affordance next to a heading or in a table row + * + * This lived in two `<style scoped>` blocks (`admin/Users.vue`, + * `admin/banned-ips.vue`) with byte-identical bodies, while five files used + * the class. A scoped rule compiles to `.tool-btn[data-v-…]`, so the three + * files that only *used* it — `alerts.vue`, `torrents/index.vue`, + * `admin/audit.vue` — rendered bare native buttons: Tailwind's preflight + * strips a button's background, border and padding, so what shipped was an + * unboxed 16px glyph with no hover and no focus surface. The delete control on + * `/alerts` was the worst of it, because `--danger` was never defined + * anywhere: the irreversible action and the safe one beside it were + * distinguishable only by the shape of their icon. + * + * 2.25rem square is not a round number picked for looks — it is 36px, which + * clears the 24px minimum of WCAG 2.2 SC 2.5.8 with room for the border. + * + * The scoped copies stay where they are. `<style scoped>` is unlayered, so it + * still wins over `@layer components` regardless of specificity, and those two + * pages keep rendering exactly as they do today. + * --------------------------------------------------------------------------- */ + .tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + flex: none; + border-radius: var(--radius-pill); + border: 1px solid rgb(var(--line-default)); + background: rgb(var(--bg-elevated)); + color: rgb(var(--fg-muted)); + cursor: pointer; + transition: + color var(--dur-2) ease, + background-color var(--dur-2) ease, + border-color var(--dur-2) ease; + } + .tool-btn:hover:not(:disabled) { + color: rgb(var(--fg-strong)); + border-color: rgb(var(--fg-default) / 0.3); + } + .tool-btn:disabled, + .tool-btn[disabled] { + opacity: 0.6; + cursor: not-allowed; + } + /* Destructive. Muted until pointed at, so a row of controls does not read as + a row of alarms — but unmistakable on hover and on focus, which is where a + member is when they are about to press it. */ + .tool-btn--danger:hover:not(:disabled), + .tool-btn--danger:focus-visible:not(:disabled) { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.5); + background: rgb(var(--danger) / 0.1); + } + /* A labelled variant: still a button, still boxed, but sized by its text + rather than square. */ + .tool-btn--text { + width: auto; + gap: 0.4rem; + padding: 0 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + color: rgb(var(--fg-default)); + } + .tool-btn--sm { + width: 1.75rem; + height: 1.75rem; + } + .tool-btn--sm.tool-btn--text { width: auto; padding: 0 0.6rem; font-size: 0.75rem; } + + /* --------------------------------------------------------------------------- + * Field labels — the micro-label above an input, and the sentence under it + * + * Thirteen files defined this in `<style scoped>`; `admin/audit.vue` and + * `admin/InviteTree.vue` used it without defining it, so their six labels + * rendered as 16px inherited body text instead of a 10px uppercase label. + * The values below are `ThemeEditor.vue`'s, which is the most common of the + * thirteen. The scoped copies keep their own tracking, as above. + * --------------------------------------------------------------------------- */ + .field-label { + display: block; + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.14em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); + margin-bottom: 0.25rem; + } + .field-help { + display: block; + margin-top: 0.25rem; + font-size: 0.6875rem; + color: rgb(var(--fg-subtle)); + } + /* --------------------------------------------------------------------------- * Inputs * --------------------------------------------------------------------------- */ @@ -505,6 +602,16 @@ border-color: rgb(var(--fg-default)); outline: none; } + /* `outline: none` above is for the mouse, where a border change is enough and + a ring around every field is noise. It was also killing the ring for the + keyboard, because `.input:focus` outranks the bare `:focus-visible` rule + further up — so tabbing through a form of eight fields signalled position + with a 1px border tint and nothing else. Restated here at equal + specificity, and later in the sheet, so the keyboard gets its ring back. */ + .input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; + } /* --------------------------------------------------------------------------- * Typography utilities — clean hierarchy alternatives to the previous diff --git a/apps/web/app/components/NotificationToast.vue b/apps/web/app/components/NotificationToast.vue index 61ffeee7..4e878393 100644 --- a/apps/web/app/components/NotificationToast.vue +++ b/apps/web/app/components/NotificationToast.vue @@ -1,7 +1,18 @@ <template> <Teleport to="body"> + <!-- A live region, because this container is the only channel every + confirmation on the site travels through: a rotated key, a saved + search, a reseed request, and every error including the ones a route + hands back verbatim. Without it a screen reader hears nothing after + pressing any of them — the toast appears, animates, and leaves in + silence. `polite` rather than `assertive` so it waits for a gap instead + of cutting the reader off mid-sentence, and `aria-atomic` so a toast is + read as one message rather than as the fragments it is built from. --> <div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none" + role="status" + aria-live="polite" + aria-atomic="true" > <TransitionGroup name="notification"> <div From 6c5d8d6f28c6752f8f569a9a639381786f7906ef Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:42:11 +0200 Subject: [PATCH 27/96] fix(web): a date in the future is not an age, and a bar must fit its slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of arithmetic that both answered the wrong question, extracted and tested before the surfaces that consume them are touched. **`formatAge` on a future date returned "just now".** The diff goes negative, the first branch catches it, and every date ahead of now came back as the phrase for "a moment ago". That is how a live freeleech with three days left announced itself on the busiest strip of a torrent page as "FREELEECH until just now" — a member reads that as an expired promotion and does not download. Guarded, and given the forward-looking companion it always needed: `formatUntil` returns "in 3 days" / "dans 3 jours" from `Intl.RelativeTimeFormat`, and nothing once the deadline has passed, so a caller stops drawing a badge instead of drawing a lie in it. **`formatAgo`**, same idea backwards, for the callers that interpolate an age into a translated sentence. `formatAge` returns `3d ago` in English always, which survives in a bare table cell and does not survive inside a message: `alerts` rendered "last 3d ago ago", and in French "la dernière il y a 3d ago". Because the direction is part of what `Intl` returns, the surrounding message must not add its own. **The bar geometry floored a bar's width at 0.4 viewBox units.** At the 365-day window a slot is 0.274 units wide, so every bar was 46 % wider than the space it had and painted over its neighbour: the traffic chart rendered as one solid rectangle, and a member read a year of perfectly uniform traffic where the data said otherwise. The floor is gone — sub-pixel is at least honest — and past 120 points the series is summed into weeks instead, which is a chart rather than a smudge. Lifted out of the component because a component test would need a DOM and a mount, while these are numbers. Both defects are pinned by tests, and both tests were mutation-checked: reinstating the 0.4 floor fails two of them, removing the future-date guard fails another. --- apps/web/app/utils/format.ts | 68 ++++++++++++++ apps/web/app/utils/statSeries.ts | 85 ++++++++++++++++++ apps/web/test/statSeries.test.ts | 149 +++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 apps/web/app/utils/statSeries.ts create mode 100644 apps/web/test/statSeries.test.ts diff --git a/apps/web/app/utils/format.ts b/apps/web/app/utils/format.ts index e783ac59..7873c6de 100644 --- a/apps/web/app/utils/format.ts +++ b/apps/web/app/utils/format.ts @@ -45,6 +45,15 @@ export function formatAge(dateStr: string | Date | null | undefined): string { const now = new Date(); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + // A date in the future is not an age, and this function used to answer one + // anyway: `diffInSeconds` goes negative, the first branch below catches it, + // and every future date came back as "just now". That is how a live + // freeleech with three days left rendered as "FREELEECH until just now" on + // the busiest strip of a torrent page. Returning nothing is the honest + // answer — the caller wanted a deadline, and `formatUntil` is the function + // that gives one. + if (diffInSeconds < 0) return ''; + if (diffInSeconds < 60) return 'just now'; if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; @@ -55,6 +64,65 @@ export function formatAge(dateStr: string | Date | null | undefined): string { return `${Math.floor(diffInSeconds / 31536000)}y ago`; } +/** + * How long ago, in the reader's language — `formatAge` for places that are + * inside a translated sentence. + * + * `formatAge` returns `3d ago`, in English, always. Most of its callers put it + * in a bare cell where that is survivable; the ones that interpolate it into a + * message do not, and one of them produced `last 3d ago ago` in English and + * `la dernière il y a 3d ago` in French. Because the direction is part of what + * `Intl` returns, the surrounding message must not add its own "ago". + */ +export function formatAgo( + dateStr: string | Date | null | undefined, + locale = 'en' +): string { + if (!dateStr) return ''; + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return ''; + const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); + + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + if (seconds < 60) return rtf.format(0, 'second'); + if (seconds < 3600) return rtf.format(-Math.round(seconds / 60), 'minute'); + if (seconds < 86400) return rtf.format(-Math.round(seconds / 3600), 'hour'); + if (seconds < 2592000) return rtf.format(-Math.round(seconds / 86400), 'day'); + if (seconds < 31536000) return rtf.format(-Math.round(seconds / 2592000), 'month'); + return rtf.format(-Math.round(seconds / 31536000), 'year'); +} + +/** + * How long is left, for a deadline — the mirror of `formatAge`. + * + * `Intl.RelativeTimeFormat` rather than a hand-rolled ladder, because unlike + * `formatAge` (whose "3d ago" strings predate i18n on this codebase and are + * baked into a dozen templates) this one is new and has no callers to keep + * happy. It gives "in 3 days" / "dans 3 jours" from the browser, so a French + * page stops carrying an English unit. + * + * A deadline already past returns nothing: a buff whose window closed is not a + * buff, and the caller should stop drawing the badge rather than draw one that + * says "3 days ago". + */ +export function formatUntil( + dateStr: string | Date | null | undefined, + locale = 'en' +): string { + if (!dateStr) return ''; + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return ''; + const seconds = Math.floor((date.getTime() - Date.now()) / 1000); + if (seconds <= 0) return ''; + + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + if (seconds < 3600) return rtf.format(Math.max(1, Math.round(seconds / 60)), 'minute'); + if (seconds < 86400) return rtf.format(Math.round(seconds / 3600), 'hour'); + if (seconds < 2592000) return rtf.format(Math.round(seconds / 86400), 'day'); + if (seconds < 31536000) return rtf.format(Math.round(seconds / 2592000), 'month'); + return rtf.format(Math.round(seconds / 31536000), 'year'); +} + /** * Strip every HTML tag from a string. Used to derive plain-text fallbacks * (page titles, placeholders) from the admin's rich-text settings. diff --git a/apps/web/app/utils/statSeries.ts b/apps/web/app/utils/statSeries.ts new file mode 100644 index 00000000..67e0eafa --- /dev/null +++ b/apps/web/app/utils/statSeries.ts @@ -0,0 +1,85 @@ +/** + * The arithmetic behind `components/stats/StatSeries.vue`. + * + * Extracted from the component because the two things worth being sure of here + * are numeric and were both wrong at some point: + * + * 1. **Bars must not overlap their slots.** The first version floored a bar's + * width at 0.4 viewBox units. At the 365-day window a slot is 0.274 units + * wide, so every bar was 46 % wider than the space it had and painted over + * its neighbour: the chart rendered as one solid rectangle, and a reader saw + * a year of uniform traffic where the data said otherwise. + * 2. **Too many points get summed into weeks, not squeezed.** 365 bars in a + * 100-unit viewBox cannot be read even without the overlap, and shrinking + * them to a sub-pixel hairline trades a lie for a smudge. + * + * A component test would need a DOM and a mount; these are numbers, so they get + * a function and a table of assertions instead. + */ + +/** Above this many points, a bar series is bucketed. */ +export const BUCKET_ABOVE = 120; +/** Days per bucket. Weeks, because traffic has a weekly rhythm. */ +export const BUCKET_SIZE = 7; + +export const VIEW_W = 100; +export const VIEW_H = 40; + +export function shouldBucket(count: number): boolean { + return count > BUCKET_ABOVE; +} + +/** Sum every `BUCKET_SIZE` values into one. The tail bucket may be short. */ +export function bucketSums(points: number[], size = BUCKET_SIZE): number[] { + const out: number[] = []; + for (let i = 0; i < points.length; i += size) { + out.push(points.slice(i, i + size).reduce((sum, v) => sum + v, 0)); + } + return out; +} + +/** One label per bucket: the first day in it. */ +export function bucketLabels(labels: string[], size = BUCKET_SIZE): string[] { + const out: string[] = []; + for (let i = 0; i < labels.length; i += size) out.push(labels[i] ?? ''); + return out; +} + +export interface Bar { + x: number; + y: number; + w: number; + h: number; +} + +/** + * Bars anchored at ZERO, one per point, none wider than its slot. + * + * Zero and not the series minimum: `y()` in the component interpolates between + * min and max, which is right for a cumulative counter — the slope is the + * story — and wrong for a per-period delta. A 90-day traffic series running + * between 900 GiB and 1 TiB drew the quietest day as a hairline and the busiest + * as a full bar, rendering an 11 % spread as a factor of a hundred, and making + * the quietest period indistinguishable from a period with nothing at all. + */ +export function barGeometry(points: number[]): Bar[] { + const n = points.length; + if (n === 0) return []; + const max = Math.max(1, ...points); + const slot = VIEW_W / n; + const gap = Math.min(0.6, slot * 0.25); + const zero = VIEW_H - 1; + const usable = VIEW_H - 2; + return points.map((value, i) => { + const top = zero - (Math.max(0, value) / max) * usable; + return { + x: i * slot + gap / 2, + // No minimum width. A bar wider than its slot overlaps its neighbour, + // which is how a year of traffic became a filled rectangle; sub-pixel is + // at least honest, and past `BUCKET_ABOVE` we bucket rather than shrink. + w: Math.max(0.05, slot - gap), + y: top, + h: Math.max(0, zero - top), + }; + }); +} diff --git a/apps/web/test/statSeries.test.ts b/apps/web/test/statSeries.test.ts new file mode 100644 index 00000000..c68fe983 --- /dev/null +++ b/apps/web/test/statSeries.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + BUCKET_ABOVE, + BUCKET_SIZE, + VIEW_H, + VIEW_W, + barGeometry, + bucketLabels, + bucketSums, + shouldBucket, +} from '../app/utils/statSeries'; +import { formatAge, formatAgo, formatUntil } from '../app/utils/format'; + +// The traffic chart on /stats, and the three date formatters around it. +// +// Both halves of this file exist because of a defect that shipped, not because +// the arithmetic looked risky: +// +// * The bar geometry floored a bar's width at 0.4 viewBox units. At the +// 365-day window a slot is 0.274 units, so every bar was 46 % wider than +// its slot and painted over its neighbour — the chart rendered as one +// filled rectangle and a member read a year of perfectly uniform traffic. +// +// * `formatAge` answered a question about the future. Its diff goes negative, +// the `< 60` branch caught it, and every date ahead of now came back as +// "just now". A live freeleech with three days left therefore announced +// itself on the busiest strip of a torrent page as "FREELEECH until just +// now", and a member reasonably concluded the promotion was over. + +describe('bar geometry', () => { + const WINDOWS = [30, 90, 365, 1, 2, 7, 120, 121]; + + it('never lets a bar overlap its neighbour', () => { + for (const n of WINDOWS) { + const points = Array.from({ length: n }, (_, i) => 1000 + i); + const bars = barGeometry(points); + expect(bars).toHaveLength(n); + for (let i = 1; i < bars.length; i++) { + const prev = bars[i - 1]!; + // A hair of tolerance for float arithmetic; anything real is orders of + // magnitude larger than this. + expect(bars[i]!.x).toBeGreaterThanOrEqual(prev.x + prev.w - 1e-9); + } + } + }); + + it('keeps every bar inside the viewBox', () => { + for (const n of WINDOWS) { + const points = Array.from({ length: n }, (_, i) => i * 3); + for (const bar of barGeometry(points)) { + expect(bar.x).toBeGreaterThanOrEqual(0); + expect(bar.x + bar.w).toBeLessThanOrEqual(VIEW_W + 1e-9); + expect(bar.y).toBeGreaterThanOrEqual(0); + expect(bar.y + bar.h).toBeLessThanOrEqual(VIEW_H + 1e-9); + } + } + }); + + it('anchors at zero, so a small value reads small', () => { + // The bug this replaced: interpolating between the series min and max drew + // an 11 % spread as a factor of a hundred, and made the quietest period + // identical to a period with nothing at all. + const [quiet, busy] = barGeometry([900, 1000]); + expect(quiet!.h / busy!.h).toBeCloseTo(0.9, 2); + }); + + it('gives a period with nothing at all a height of zero', () => { + const [nothing, some] = barGeometry([0, 500]); + expect(nothing!.h).toBe(0); + expect(some!.h).toBeGreaterThan(0); + }); + + it('survives a flat series and an empty one', () => { + expect(barGeometry([])).toEqual([]); + for (const bar of barGeometry([0, 0, 0])) expect(bar.h).toBe(0); + }); +}); + +describe('bucketing', () => { + it('kicks in only past the threshold', () => { + expect(shouldBucket(BUCKET_ABOVE)).toBe(false); + expect(shouldBucket(BUCKET_ABOVE + 1)).toBe(true); + expect(shouldBucket(90)).toBe(false); + }); + + it('turns a year of days into a readable number of bars', () => { + const year = Array.from({ length: 365 }, () => 1); + const buckets = bucketSums(year); + expect(buckets).toHaveLength(Math.ceil(365 / BUCKET_SIZE)); + expect(buckets.length).toBeLessThan(60); + // And once bucketed, the bars fit. + const bars = barGeometry(buckets); + for (let i = 1; i < bars.length; i++) { + expect(bars[i]!.x).toBeGreaterThanOrEqual(bars[i - 1]!.x + bars[i - 1]!.w - 1e-9); + } + }); + + it('loses nothing: the buckets sum to the input', () => { + const points = Array.from({ length: 365 }, (_, i) => i * 7 + 3); + const total = points.reduce((a, b) => a + b, 0); + expect(bucketSums(points).reduce((a, b) => a + b, 0)).toBe(total); + }); + + it('labels a bucket with its first day', () => { + const labels = Array.from({ length: 15 }, (_, i) => `d${i}`); + expect(bucketLabels(labels)).toEqual(['d0', 'd7', 'd14']); + }); +}); + +describe('date formatters', () => { + const inThreeDays = new Date(Date.now() + 3 * 86400_000).toISOString(); + const threeDaysAgo = new Date(Date.now() - 3 * 86400_000).toISOString(); + + it('refuses to describe a future date as an age', () => { + // The exact shape of the shipped bug. + expect(formatAge(inThreeDays)).toBe(''); + expect(formatAge(inThreeDays)).not.toBe('just now'); + }); + + it('still reads a past date as an age', () => { + expect(formatAge(threeDaysAgo)).toBe('3d ago'); + expect(formatAge(null)).toBe(''); + }); + + it('reads a deadline forwards, in the reader’s language', () => { + expect(formatUntil(inThreeDays, 'en')).toMatch(/in 3 days/); + expect(formatUntil(inThreeDays, 'fr')).toMatch(/dans 3 jours/); + }); + + it('says nothing about a deadline already past', () => { + // So a caller stops drawing a badge instead of drawing "3 days ago" in it. + expect(formatUntil(threeDaysAgo)).toBe(''); + expect(formatUntil(null)).toBe(''); + expect(formatUntil('not a date')).toBe(''); + }); + + it('carries its own direction, so a message must not add "ago"', () => { + // `alerts.lastMatch` used to be "last {when} ago" around a value that + // already ended in "ago", giving "last 3d ago ago" — and, in French, an + // English unit inside a French sentence. + expect(formatAgo(threeDaysAgo, 'en')).toMatch(/3 days ago/); + expect(formatAgo(threeDaysAgo, 'fr')).toMatch(/il y a 3 jours/); + expect(formatAgo(threeDaysAgo, 'en')).not.toMatch(/ago ago/); + }); + + it('does not put a past-tense phrase on a future date', () => { + expect(formatAgo(inThreeDays, 'en')).toMatch(/now/); + }); +}); From 0f570c1fbd355d0b893685942e43fe3d7ed1eecd Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:43:22 +0200 Subject: [PATCH 28/96] fix(stats): the page's title outweighed its numbers, and the charts overstated growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page of figures whose largest object was its own `h1`: 40px against 26px figures, so a reader arriving to ask "how is the site doing" read a label before they read an answer. The title is on the house scale now and the figures are the biggest thing on the page, which is the right way round for an instrument. The history panel was making claims it could not support: * **The cumulative charts had no y-axis at all.** They are anchored at the series minimum on purpose — the slope is the story and a zero-based axis flattens it — but shipped with nothing on the scale, next to a zero-anchored bar chart. A member saw a steep climb with no way to know the axis started at 5,412. Both ends of the scale are now printed on the plot. * **"Releases" named two different numbers 600px apart** — the filtered figure at the top and the whole catalogue in a chart caption — reconciled by an 11px footnote explaining that one "sits above" the other, which the French rendered as a claim about physical position. The chart series is now "Catalogue, all statuses" and the note is one clause. * **The window switcher governs one section of six**, and nothing said so: a member who picked "30 days" and watched the rankings not move concluded the page was stuck, or worse, that the list *was* 30-day data. Both boards say "all time" in their titles. Layout defects that were reproducible at a fixed width: * `.st-panel-head` is `space-between` with two children, so a bare title and its note flung the note to the far edge — a narrow column of explanatory text some 770px from the heading it belonged to, on three of six panels at 1440px. The copy explaining each chart read as unrelated marginalia. * The lead figures drew their hairlines by letting a coloured parent show through a 1px gap, which also showed through any cell the grid did not fill. Five figures are only even at five columns; every width below 748px put a solid grey rectangle where a figure should be, and on a phone it sat above the fold. And the numbers themselves: * No delta on any lead figure, while `deltas` was already on the wire — so the page's headline question was answered only by eyeballing an unlabelled slope. * Category bars normalised to the largest category, which makes the top one a full bar on every instance; a reader reads a bar as share-of-whole, and the tell was that the panel note had to teach them otherwise. Share of the catalogue now, so two categories can be compared by eye. * The bar track was `--bg-inset`: in the light theme that is 245 on a 255 panel, a 4 % step, so the proportion had no visible denominator. * `--online` green on every seeder row, which `main.css` reserves for status badges and explicitly not for chrome, and which implied one ranking value was "good" and the one beside it neutral. * Day labels were raw `YYYY-MM-DD` on a page whose month axis was localised — and the ISO string is what the chart's `aria-label` reads aloud, digit by digit. Plus the house rules this page was breaking: `.eyebrow` used for three `h3`s, making them smaller than the body copy they introduced, which `main.css` forbids in capitals; `aria-pressed` on mutually exclusive options, so a reader heard "30 days, not pressed" for every window they had not chosen; a scroll container the keyboard could reach but not scroll; three disclosures with the same name in one panel; a 20px target on each of them; and `999px` where `--radius-pill` exists. The member's own year was the quietest thing on a page whose argument is that the numbers are the community looking at itself — a 1px rule inside the year panel, under a heading smaller than its own body text. It is a section now, at the same weight as the rest. --- apps/web/app/components/stats/StatSeries.vue | 281 +++++--- apps/web/app/pages/stats.vue | 700 +++++++++++++------ apps/web/i18n/locales/en.json | 33 +- apps/web/i18n/locales/fr.json | 41 +- 4 files changed, 733 insertions(+), 322 deletions(-) diff --git a/apps/web/app/components/stats/StatSeries.vue b/apps/web/app/components/stats/StatSeries.vue index f6a76685..6086bcc0 100644 --- a/apps/web/app/components/stats/StatSeries.vue +++ b/apps/web/app/components/stats/StatSeries.vue @@ -12,54 +12,90 @@ {{ $t('stats.series.tooShort') }} </p> - <svg - v-else - class="series-svg" - :class="`series-svg--${kind}`" - :viewBox="`0 0 ${VIEW_W} ${VIEW_H}`" - preserveAspectRatio="none" - role="img" - :aria-label="summary" - > - <!-- Three gridlines, low contrast on purpose: they locate a value without - competing with it. --> - <line - v-for="ratio in [0.25, 0.5, 0.75]" - :key="ratio" - class="series-grid" - x1="0" - :y1="VIEW_H * ratio" - :x2="VIEW_W" - :y2="VIEW_H * ratio" - /> - <template v-if="kind === 'area'"> - <path class="series-fill" :d="areaPath" /> - <path class="series-line" :d="linePath" /> - </template> - <template v-else> - <rect - v-for="(bar, i) in bars" - :key="i" - class="series-bar" - :x="bar.x" - :y="bar.y" - :width="bar.w" - :height="bar.h" + <div v-else class="series-plot"> + <!-- The top of the scale, stated. A min-anchored line chart with no + number on it is the oldest way to overstate growth, and this page + puts one next to a zero-anchored bar chart — so the reader compares + two slopes drawn on different rulers. --> + <span class="series-scale">{{ format(bounds.max) }}</span> + <svg + class="series-svg" + :class="`series-svg--${kind}`" + :viewBox="`0 0 ${VIEW_W} ${VIEW_H}`" + preserveAspectRatio="none" + role="img" + :aria-label="summary" + > + <!-- Three gridlines, low contrast on purpose: they locate a value + without competing with it. Placed against the plot band rather + than the viewBox, so they land where the labels say they do. --> + <line + v-for="ratio in [0.25, 0.5, 0.75]" + :key="ratio" + class="series-grid" + x1="0" + :y1="1 + (VIEW_H - 2) * ratio" + :x2="VIEW_W" + :y2="1 + (VIEW_H - 2) * ratio" /> - </template> - </svg> + <template v-if="kind === 'area'"> + <path class="series-fill" :d="areaPath" /> + <path class="series-line" :d="linePath" /> + <!-- The latest point, marked. Most of a tooltip's value for one + circle, and it works on touch, where a tooltip does not. --> + <circle + class="series-dot" + :cx="x(shown.length - 1)" + :cy="y(shown[shown.length - 1]!)" + r="1.1" + /> + </template> + <template v-else> + <rect + v-for="(bar, i) in bars" + :key="i" + class="series-bar" + :class="{ 'series-bar--peak': i === peak.at }" + :x="bar.x" + :y="bar.y" + :width="bar.w" + :height="bar.h" + /> + <!-- A real baseline, drawn once at a stroke width that survives the + viewBox scaling. The previous approach gave every bar a 0.35-unit + minimum height — 0.77px at this size, so it rendered as a smudge + or vanished on rounding, and could not separate "no traffic" from + "almost none" anyway. Against a visible axis, an empty day is + visibly empty. --> + <line + class="series-baseline" + x1="0" + :y1="VIEW_H - 1" + :x2="VIEW_W" + :y2="VIEW_H - 1" + /> + </template> + </svg> + <span class="series-scale series-scale--min">{{ format(kind === 'bars' ? 0 : bounds.min) }}</span> + </div> <div v-if="points.length >= 2" class="series-axis"> - <span>{{ labels[0] }}</span> - <span>{{ labels[labels.length - 1] }}</span> + <span>{{ shownLabels[0] }}</span> + <span v-if="bucketed" class="series-bucket">{{ $t('stats.series.weekly') }}</span> + <span>{{ shownLabels[shownLabels.length - 1] }}</span> </div> <!-- The chart is a picture; this is the data. Collapsed so it costs a reader nothing, present so a screen reader and a sceptic both have somewhere to go. --> <details v-if="points.length" class="series-data"> - <summary>{{ $t('stats.series.table') }}</summary> - <div class="series-scroll"> + <!-- Named, because a growth panel renders three of these and a reader + listing the page's controls would otherwise hear the same sentence + three times with nothing to tell them apart. --> + <summary>{{ $t('stats.series.tableFor', { label }) }}</summary> + <!-- Focusable, because a scroll container that only the mouse can reach + hides the 365 rows it just revealed from the keyboard. --> + <div class="series-scroll" tabindex="0" role="group" :aria-label="label"> <table> <thead> <tr> @@ -88,19 +124,20 @@ * is 40 KB and a tooltip; what it would cost is a dependency in the page every * member loads. * - * Three things here are deliberate rather than incidental: + * Four things here are deliberate rather than incidental: * * 1. **A short series says so.** Fewer than two points cannot make a line, and * a chart padded to a window it has no data for draws a flat run at zero — * which reads as "the site was dead until Tuesday". * 2. **The numbers are reachable.** A picture is not accessible and is not * verifiable; the collapsed table under each chart is both, and it is the - * same array the shape is drawn from. - * 3. **The scale starts at the minimum, not at zero.** These are cumulative - * counters — members, torrents — where the interesting part is the slope - * and a zero-based axis flattens it into a straight line. The axis labels - * say which window is shown, and the table gives the values, so nothing is - * hidden by the choice. + * same array the shape is drawn from — the full array, not the buckets. + * 3. **The scale starts at the minimum for a cumulative series, and its ends + * are labelled.** These are counters — members, torrents — where the + * interesting part is the slope and a zero-based axis flattens it into a + * straight line. The labels are what makes that choice honest rather than + * flattering: a reader can see the run starts at 5,412, not at nothing. + * 4. **Too many points get bucketed, not squeezed.** See `shown` below. */ const props = withDefaults( defineProps<{ @@ -123,12 +160,28 @@ const props = withDefaults( const { t } = useI18n(); -const VIEW_W = 100; -const VIEW_H = 40; +/* + * The geometry and the bucketing live in `~/utils/statSeries`, tested there. + * Both were wrong once — bars wider than their slots turned the 365-day window + * into a solid rectangle — and both are pure arithmetic, so they belong + * somewhere a test can reach without a DOM. + */ +const bucketed = computed( + () => props.kind === 'bars' && shouldBucket(props.points.length) +); + +/** The points actually drawn: either all of them, or one sum per week. */ +const shown = computed(() => + bucketed.value ? bucketSums(props.points) : props.points +); + +const shownLabels = computed(() => + bucketed.value ? bucketLabels(props.labels) : props.labels +); const bounds = computed(() => { - const min = Math.min(...props.points); - const max = Math.max(...props.points); + const min = Math.min(...shown.value); + const max = Math.max(...shown.value); // A flat series would divide by zero; give it a band so the line sits in the // middle rather than at an edge. return max === min ? { min: min - 1, max: max + 1 } : { min, max }; @@ -136,54 +189,40 @@ const bounds = computed(() => { function y(value: number): number { const { min, max } = bounds.value; - const t = (value - min) / (max - min); + const ratio = (value - min) / (max - min); // 1 unit of padding top and bottom so a peak is not clipped by the viewBox. - return VIEW_H - 1 - t * (VIEW_H - 2); + return VIEW_H - 1 - ratio * (VIEW_H - 2); } function x(index: number): number { - return props.points.length === 1 + return shown.value.length === 1 ? VIEW_W / 2 - : (index / (props.points.length - 1)) * VIEW_W; + : (index / (shown.value.length - 1)) * VIEW_W; } const linePath = computed(() => - props.points.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(2)},${y(v).toFixed(2)}`).join(' ') + shown.value + .map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(2)},${y(v).toFixed(2)}`) + .join(' ') ); const areaPath = computed( () => `${linePath.value} L${VIEW_W},${VIEW_H} L0,${VIEW_H} Z` ); -/** - * Bars are anchored at ZERO, not at the series minimum. - * - * `y()` interpolates between the minimum and the maximum, which is right for a - * cumulative counter — the slope is the story — and wrong for a per-period - * delta: a 90-day traffic series running between 900 GiB and 1 TiB drew the - * quietest day as a hairline and the busiest as a full bar, rendering an 11 % - * spread as a factor of a hundred. Worse, it made the quietest period look - * identical to a period with nothing at all. - */ -const barMax = computed(() => Math.max(1, ...props.points)); +const bars = computed(() => barGeometry(shown.value)); -const bars = computed(() => { - const n = props.points.length; - const slot = VIEW_W / n; - const gap = Math.min(0.6, slot * 0.25); - const zero = VIEW_H - 1; - return props.points.map((value, i) => { - const usable = VIEW_H - 2; - const top = zero - (Math.max(0, value) / barMax.value) * usable; - return { - x: i * slot + gap / 2, - w: Math.max(0.4, slot - gap), - y: top, - // A day with nothing still gets a hairline, so a gap in the data and a - // day with no traffic do not look identical. - h: Math.max(0.35, zero - top), - }; +/** The tallest bar, for the marker and for the spoken summary. */ +const peak = computed(() => { + let value = 0; + let at = -1; + shown.value.forEach((v, i) => { + if (v > value) { + value = v; + at = i; + } }); + return { value, at }; }); const summary = computed(() => { @@ -193,30 +232,27 @@ const summary = computed(() => { // Per-period bars have no meaningful first-to-last story — a year that // starts and ends in a quiet month would read "0 → 0". The peak is the // sentence somebody listening to this page actually wants. - let peak = 0; - let at = 0; - props.points.forEach((value, i) => { - if (value > peak) { - peak = value; - at = i; - } - }); const total = props.points.reduce((sum, v) => sum + v, 0); // Translated like every other string here: an aria-label is read aloud, so // an English sentence in a French page is worse than a bare number. return t('stats.series.summaryBars', { label: props.label, total: `${props.format(total)}${unit}`, - peak: props.format(peak), - when: props.labels[at] ?? '', + peak: props.format(peak.value.value), + when: shownLabels.value[Math.max(0, peak.value.at)] ?? '', }); } const first = props.points[0]!; const last = props.points[props.points.length - 1]!; + // With the window: "5,412 to 12,431" over an unstated period is a number + // pair, not a trend, and the axis labels that carry the period live outside + // the `role="img"` this sentence names. return t('stats.series.summaryTrend', { label: props.label, from: props.format(first), to: `${props.format(last)}${unit}`, + first: props.labels[0] ?? '', + last: props.labels[props.labels.length - 1] ?? '', }); }); @@ -250,7 +286,30 @@ const format = (value: number) => props.format(value); font-size: 0.8125rem; color: rgb(var(--fg-default)); } +/* The plot and its two scale labels. Relative so the labels can sit on the + chart's own corners without a grid track of their own — at 10px they would + otherwise reserve a column the chart needs more than they do. */ +.series-plot { + position: relative; +} +.series-scale { + position: absolute; + top: 0; + left: 0; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 0.5625rem; + color: rgb(var(--fg-subtle)); + background: rgb(var(--bg-surface) / 0.85); + padding: 0 0.2rem; + pointer-events: none; +} +.series-scale--min { + top: auto; + bottom: 0; +} .series-svg { + display: block; width: 100%; height: 5.5rem; overflow: visible; @@ -260,6 +319,16 @@ const format = (value: number) => props.format(value); stroke-width: 0.25; vector-effect: non-scaling-stroke; } +.series-baseline { + stroke: rgb(var(--line-strong)); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} +/* One quantity colour for every series on this page. + `--chart-1..6` is a categorical scale — its whole job is telling six series + in one chart apart — and no chart here has more than one series. Using green + for traffic and blue for categories on a gold-accented page encoded nothing + and read as three unrelated products. */ .series-fill { fill: rgb(var(--accent-warm) / 0.14); } @@ -271,15 +340,28 @@ const format = (value: number) => props.format(value); stroke-linecap: round; vector-effect: non-scaling-stroke; } +.series-dot { + fill: rgb(var(--accent-warm)); + stroke: rgb(var(--bg-surface)); + stroke-width: 0.5; +} .series-bar { - fill: rgb(var(--chart-2)); + fill: rgb(var(--accent-warm) / 0.55); +} +.series-bar--peak { + fill: rgb(var(--accent-warm)); } .series-axis { display: flex; justify-content: space-between; + gap: 0.5rem; font-family: var(--font-mono); font-size: 0.625rem; - color: rgb(var(--fg-faint)); + color: rgb(var(--fg-subtle)); +} +.series-bucket { + text-transform: uppercase; + letter-spacing: calc(0.08em * var(--tracking-scale)); } .series-empty { padding: 1.5rem 0; @@ -288,12 +370,19 @@ const format = (value: number) => props.format(value); text-align: center; } .series-data { - font-size: 0.6875rem; + font-size: 0.75rem; color: rgb(var(--fg-subtle)); } +/* ≥24px, per WCAG 2.2 SC 2.5.8 — it was ~20px — and a hover, because at 11px + in the faintest grey on the page it read as a footnote rather than a + control. */ .series-data summary { cursor: pointer; - padding: 0.15rem 0; + padding: 0.4rem 0; + width: fit-content; +} +.series-data summary:hover { + color: rgb(var(--fg-default)); } .series-scroll { max-height: 12rem; diff --git a/apps/web/app/pages/stats.vue b/apps/web/app/pages/stats.vue index a20cf86e..b8717a70 100644 --- a/apps/web/app/pages/stats.vue +++ b/apps/web/app/pages/stats.vue @@ -1,7 +1,6 @@ <template> <div class="stats"> <header class="st-head"> - <p class="st-eyebrow">{{ $t('stats.eyebrow') }}</p> <h1 class="st-title">{{ $t('stats.title') }}</h1> <p class="st-lede">{{ $t('stats.lede') }}</p> </header> @@ -9,11 +8,15 @@ <!-- The lead figures. Five numbers, sized so the page has one obvious beginning, in tabular numerals so they do not shuffle on refresh. --> <section class="st-lead" aria-labelledby="st-now"> - <h2 id="st-now" class="st-sr">{{ $t('stats.now.title') }}</h2> + <!-- Visible, not screen-reader-only. This label is the only thing that + says the five figures below are CURRENT while the charts under them + are historical, and hiding it meant sighted readers were the ones who + never got told. --> + <h2 id="st-now" class="st-micro st-micro--accent">{{ $t('stats.now.title') }}</h2> <div class="st-figures"> <div v-for="(fig, i) in figures" :key="fig.key" class="st-figure" :style="delay(i)"> <span class="st-figure-value">{{ fig.value }}</span> - <span class="st-figure-label">{{ fig.label }}</span> + <span class="st-figure-label st-micro">{{ fig.label }}</span> <span v-if="fig.note" class="st-figure-note">{{ fig.note }}</span> </div> </div> @@ -24,16 +27,23 @@ the reader to guess which one they are looking at. --> <section class="st-panel" aria-labelledby="st-growth"> <div class="st-panel-head"> - <h2 id="st-growth">{{ $t('stats.growth.title') }}</h2> - <p class="st-panel-note">{{ $t('stats.growth.note') }}</p> - <div class="st-windows" role="group" :aria-label="$t('stats.growth.windowAria')"> + <div class="st-panel-text"> + <h2 id="st-growth" class="st-panel-title">{{ $t('stats.growth.title') }}</h2> + <p class="st-panel-note">{{ $t('stats.growth.note') }}</p> + </div> + <!-- A radiogroup, not a set of toggle buttons. `aria-pressed` on + mutually exclusive options announces "30 days, not pressed" for + every option the reader did not choose, implying each can be turned + on independently. --> + <div class="st-windows" role="radiogroup" :aria-label="$t('stats.growth.windowAria')"> <button v-for="w in WINDOWS" :key="w" type="button" + role="radio" class="st-window" :class="{ 'st-window--on': windowDays === w }" - :aria-pressed="windowDays === w" + :aria-checked="windowDays === w" @click="windowDays = w" > {{ $t('stats.growth.window', { days: w }) }} @@ -46,22 +56,28 @@ <StatsStatSeries :label="$t('stats.growth.torrents')" :points="data.points.map((p) => p.torrents)" - :labels="data.points.map((p) => p.day)" + :labels="dayLabels(data.points.map((p) => p.day))" :unit="$t('stats.growth.torrentsUnit')" /> <StatsStatSeries :label="$t('stats.growth.members')" :points="data.points.map((p) => p.users)" - :labels="data.points.map((p) => p.day)" + :labels="dayLabels(data.points.map((p) => p.day))" :unit="$t('stats.growth.membersUnit')" /> - <StatsStatSeries - :label="$t('stats.growth.traffic')" - :points="(data.deltas ?? []).map((d) => d.bytes)" - :labels="(data.deltas ?? []).map((d) => d.day)" - kind="bars" - :format="bytes" - /> + <!-- Full width, and last. It is the only per-period series here — the + two above are cumulative counters — so sitting as the third panel + of a matched triptych made a different kind of measurement look + like more of the same. Width is also what a bar chart wants. --> + <div class="st-chart-wide"> + <StatsStatSeries + :label="$t('stats.growth.traffic')" + :points="(data.deltas ?? []).map((d) => d.bytes)" + :labels="dayLabels((data.deltas ?? []).map((d) => d.day))" + kind="bars" + :format="bytes" + /> + </div> </div> </section> @@ -71,10 +87,15 @@ travel to a legend. --> <section class="st-panel" aria-labelledby="st-cats"> <div class="st-panel-head"> - <h2 id="st-cats">{{ $t('stats.categories.title') }}</h2> - <p class="st-panel-note">{{ $t('stats.categories.note') }}</p> + <div class="st-panel-text"> + <h2 id="st-cats" class="st-panel-title">{{ $t('stats.categories.title') }}</h2> + <p class="st-panel-note">{{ $t('stats.categories.note') }}</p> + </div> + </div> + <div v-if="!categories.length" class="st-blank"> + <Icon name="ph:folders" class="st-blank-icon" /> + <p>{{ $t('stats.categories.none') }}</p> </div> - <p v-if="!categories.length" class="st-empty">{{ $t('stats.categories.none') }}</p> <ul v-else class="st-bars"> <li v-for="cat in categories" :key="cat.id" class="st-bar-row"> <NuxtLink :to="`/torrents?category=${cat.slug}`" class="st-bar-name"> @@ -83,7 +104,9 @@ <div class="st-bar-track"> <div class="st-bar-fill" :style="{ width: `${barWidth(cat.torrents)}%` }" /> </div> - <span class="st-bar-count">{{ cat.torrents.toLocaleString() }}</span> + <span class="st-bar-count" :data-bytes="bytes(cat.bytes)"> + {{ cat.torrents.toLocaleString() }} + </span> <span class="st-bar-bytes">{{ bytes(cat.bytes) }}</span> </li> </ul> @@ -91,73 +114,132 @@ <div class="st-columns"> <!-- Two rankings, side by side. Same shape, different question: what the - site grabbed, and what the site is keeping alive. --> + site grabbed, and what the site is keeping alive. Both are all-time + and both now say so — the window switcher above governs the history + panel only, and a reader who picked "30 days" and watched these not + move would otherwise conclude the page was stuck. --> <section class="st-panel" aria-labelledby="st-snatched"> <div class="st-panel-head"> - <h2 id="st-snatched">{{ $t('stats.top.snatched') }}</h2> + <div class="st-panel-text"> + <h2 id="st-snatched" class="st-panel-title">{{ $t('stats.top.snatched') }}</h2> + </div> + </div> + <template v-if="data?.mostSnatched?.length"> + <!-- A header row, because ten bare numbers in a column with no unit + were attached to their meaning only by a panel title 200px + above. Both boards now put the same measure in column three, so + the eye can read down it. --> + <div class="st-rank-head st-micro" aria-hidden="true"> + <span /> + <span>{{ $t('stats.rank.release') }}</span> + <span class="st-rank-num">{{ $t('stats.rank.size') }}</span> + <span class="st-rank-num">{{ $t('stats.rank.snatches') }}</span> + </div> + <ol class="st-rank"> + <li v-for="(t, i) in data.mostSnatched" :key="t.infoHash" class="st-rank-row"> + <span class="st-rank-n">{{ i + 1 }}</span> + <NuxtLink :to="`/torrents/${t.infoHash}`" class="st-rank-name" :title="t.name"> + {{ t.name }} + </NuxtLink> + <span class="st-rank-meta">{{ bytes(t.size) }}</span> + <span class="st-rank-value">{{ t.snatches.toLocaleString() }}</span> + </li> + </ol> + <NuxtLink to="/torrents?s=completed&d=desc" class="st-more"> + {{ $t('stats.rank.more') }} + </NuxtLink> + </template> + <div v-else class="st-blank"> + <Icon name="ph:download-simple" class="st-blank-icon" /> + <p>{{ $t('stats.top.none') }}</p> </div> - <ol v-if="data?.mostSnatched?.length" class="st-rank"> - <li v-for="(t, i) in data.mostSnatched" :key="t.infoHash" class="st-rank-row"> - <span class="st-rank-n">{{ i + 1 }}</span> - <NuxtLink :to="`/torrents/${t.infoHash}`" class="st-rank-name" :title="t.name"> - {{ t.name }} - </NuxtLink> - <span class="st-rank-meta">{{ t.categoryName ?? $t('stats.uncategorised') }}</span> - <span class="st-rank-value">{{ t.snatches.toLocaleString() }}</span> - </li> - </ol> - <p v-else class="st-empty">{{ $t('stats.top.none') }}</p> </section> <section class="st-panel" aria-labelledby="st-swarms"> <div class="st-panel-head"> - <h2 id="st-swarms">{{ $t('stats.top.swarms') }}</h2> + <div class="st-panel-text"> + <h2 id="st-swarms" class="st-panel-title">{{ $t('stats.top.swarms') }}</h2> + </div> + </div> + <template v-if="data?.biggestSwarms?.length"> + <div class="st-rank-head st-micro" aria-hidden="true"> + <span /> + <span>{{ $t('stats.rank.release') }}</span> + <span class="st-rank-num">{{ $t('stats.rank.size') }}</span> + <span class="st-rank-num">{{ $t('stats.rank.seeders') }}</span> + </div> + <ol class="st-rank"> + <li v-for="(t, i) in data.biggestSwarms" :key="t.infoHash" class="st-rank-row"> + <span class="st-rank-n">{{ i + 1 }}</span> + <NuxtLink :to="`/torrents/${t.infoHash}`" class="st-rank-name" :title="t.name"> + {{ t.name }} + </NuxtLink> + <span class="st-rank-meta">{{ bytes(t.size) }}</span> + <span class="st-rank-value">{{ t.seeders.toLocaleString() }}</span> + </li> + </ol> + <NuxtLink to="/torrents?s=seeders&d=desc" class="st-more"> + {{ $t('stats.rank.more') }} + </NuxtLink> + </template> + <div v-else class="st-blank"> + <Icon name="ph:users-three" class="st-blank-icon" /> + <p>{{ $t('stats.top.none') }}</p> </div> - <ol v-if="data?.biggestSwarms?.length" class="st-rank"> - <li v-for="(t, i) in data.biggestSwarms" :key="t.infoHash" class="st-rank-row"> - <span class="st-rank-n">{{ i + 1 }}</span> - <NuxtLink :to="`/torrents/${t.infoHash}`" class="st-rank-name" :title="t.name"> - {{ t.name }} - </NuxtLink> - <span class="st-rank-meta">{{ bytes(t.size) }}</span> - <span class="st-rank-value st-rank-value--up">{{ t.seeders.toLocaleString() }}</span> - </li> - </ol> - <p v-else class="st-empty">{{ $t('stats.top.none') }}</p> </section> </div> <!-- The uploader board, with the sentence that explains what it is not. A leaderboard on a tracker is normally ranked by volume; this one is - not, and a reader deserves to know why rather than assume an omission. --> + not, and a reader deserves to know why rather than assume an omission. + The full argument lives in the guide — a panel header is not a + paragraph. --> <section class="st-panel" aria-labelledby="st-uploaders"> <div class="st-panel-head"> - <h2 id="st-uploaders">{{ $t('stats.uploaders.title') }}</h2> - <p class="st-panel-note">{{ $t('stats.uploaders.note') }}</p> + <div class="st-panel-text"> + <h2 id="st-uploaders" class="st-panel-title">{{ $t('stats.uploaders.title') }}</h2> + <p class="st-panel-note">{{ $t('stats.uploaders.note') }}</p> + </div> </div> <ol v-if="data?.topUploaders?.length" class="st-board"> <li v-for="(u, i) in data.topUploaders" :key="u.username" class="st-board-row"> <span class="st-rank-n">{{ i + 1 }}</span> <NuxtLink :to="`/users/${u.id}`" class="st-board-name">{{ u.username }}</NuxtLink> - <span class="st-board-count">{{ $t('stats.uploaders.count', { n: u.uploads }) }}</span> + <span class="st-board-count">{{ $t('stats.uploaders.count', u.uploads) }}</span> </li> </ol> - <p v-else class="st-empty">{{ $t('stats.uploaders.none') }}</p> + <div v-else class="st-blank"> + <Icon name="ph:upload-simple" class="st-blank-icon" /> + <p>{{ $t('stats.uploaders.none') }}</p> + </div> </section> <!-- The year. A separate act on the page rather than a separate route: the - numbers above are what the site is, these are what a year of it was. --> + numbers above are what the site is, these are what a year of it was. + A rule across the page and the display face are what make it an act — + a 7 % tint and 0.1rem of extra padding were differences at the + threshold of perception. --> <section class="st-year" aria-labelledby="st-year-h"> <div class="st-panel-head"> - <h2 id="st-year-h">{{ $t('stats.year.title', { year }) }}</h2> - <div class="st-windows" role="group" :aria-label="$t('stats.year.pickAria')"> + <div class="st-panel-text"> + <h2 id="st-year-h" class="st-year-title">{{ $t('stats.year.title', { year }) }}</h2> + </div> + <!-- Hidden when there is only one year to pick: a lone chip that looks + like a control and switches to nothing reads as a broken one. --> + <div + v-if="years.length > 1" + class="st-windows" + role="radiogroup" + :aria-label="$t('stats.year.pickAria')" + > <button v-for="y in years" :key="y" type="button" + role="radio" class="st-window" :class="{ 'st-window--on': year === y }" - :aria-pressed="year === y" + :aria-checked="year === y" @click="year = y" > {{ y }} @@ -169,22 +251,22 @@ <div class="st-figures st-figures--year"> <div class="st-figure"> <span class="st-figure-value">{{ yearData.torrentsAdded.toLocaleString() }}</span> - <span class="st-figure-label">{{ $t('stats.year.added') }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.year.added') }}</span> <span class="st-figure-note">{{ bytes(yearData.bytesAdded) }}</span> </div> <div class="st-figure"> <span class="st-figure-value">{{ yearData.membersJoined.toLocaleString() }}</span> - <span class="st-figure-label">{{ $t('stats.year.joined') }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.year.joined') }}</span> </div> <div class="st-figure"> <span class="st-figure-value">{{ yearData.snatches.toLocaleString() }}</span> - <span class="st-figure-label">{{ $t('stats.year.snatches') }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.year.snatches') }}</span> </div> <div class="st-figure"> <span class="st-figure-value"> {{ yearData.trafficBytes === null ? '—' : bytes(yearData.trafficBytes) }} </span> - <span class="st-figure-label">{{ $t('stats.year.traffic') }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.year.traffic') }}</span> <!-- Said on the page and not only in the guide: this figure is a floor, because the counter behind it drops when an account is erased or a cheater is reset. --> @@ -192,13 +274,15 @@ </div> </div> - <StatsStatSeries - v-if="monthPoints.length" - :label="$t('stats.year.byMonth')" - :points="monthPoints" - :labels="monthLabels" - kind="bars" - /> + <div class="st-year-chart"> + <StatsStatSeries + v-if="monthPoints.length" + :label="$t('stats.year.byMonth')" + :points="monthPoints" + :labels="monthLabels" + kind="bars" + /> + </div> <div class="st-year-lists"> <div> @@ -237,38 +321,66 @@ </p> </div> <p v-else class="st-empty">{{ $t('stats.year.none') }}</p> + </section> - <!-- And the member's own. Their data, so it is theirs alone — no id in - the URL, no staff view of it. --> - <div v-if="mine" class="st-mine"> - <h3 class="st-sub">{{ $t('stats.mine.title', { year }) }}</h3> - <div class="st-mine-grid"> - <div><b>{{ mine.uploads.toLocaleString() }}</b><span>{{ $t('stats.mine.uploads') }}</span></div> - <template v-if="!mine.downloadsHidden"> - <div><b>{{ mine.snatches.toLocaleString() }}</b><span>{{ $t('stats.mine.snatches') }}</span></div> - <div><b>{{ bytes(mine.bytesUp) }}</b><span>{{ $t('stats.mine.up') }}</span></div> - <div><b>{{ bytes(mine.bytesDown) }}</b><span>{{ $t('stats.mine.down') }}</span></div> - <div><b>{{ hours(mine.seedTimeSeconds) }}</b><span>{{ $t('stats.mine.seedTime') }}</span></div> - </template> - <div><b>{{ Math.round(mine.bonusEarned).toLocaleString() }}</b><span>{{ $t('stats.mine.bonus') }}</span></div> + <!-- And the member's own — its own section, at the same weight as the rest + of the page. It was a 1px rule inside the year panel under a heading + smaller than the body text it introduced, which put the reader's own + numbers at the quietest point of a page whose whole argument is that + the numbers are the community looking at itself. Their data, so it is + theirs alone: no id in the URL, no staff view of it. --> + <section v-if="mine" class="st-panel" aria-labelledby="st-mine-h"> + <div class="st-panel-head"> + <div class="st-panel-text"> + <h2 id="st-mine-h" class="st-panel-title">{{ $t('stats.mine.title', { year }) }}</h2> </div> - <!-- Said rather than silently omitted: four missing figures with no - explanation read as a broken page. --> - <p v-if="mine.downloadsHidden" class="st-mine-cat">{{ $t('stats.mine.hidden') }}</p> - <p v-if="mine.bestRelease" class="st-mine-best"> - {{ $t('stats.mine.best') }} - <NuxtLink :to="`/torrents/${mine.bestRelease.infoHash}`">{{ mine.bestRelease.name }}</NuxtLink> - <span class="st-mine-best-n">{{ - $t('stats.mine.bestSnatches', { n: mine.bestRelease.snatches }) - }}</span> - </p> - <p v-if="mine.topCategory" class="st-mine-cat"> - {{ $t('stats.mine.favourite', { - category: mine.topCategory.name, - n: mine.topCategory.torrents, - }) }} - </p> </div> + <div class="st-figures st-figures--mine"> + <div class="st-figure"> + <span class="st-figure-value">{{ mine.uploads.toLocaleString() }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.mine.uploads') }}</span> + </div> + <template v-if="!mine.downloadsHidden"> + <div class="st-figure"> + <span class="st-figure-value">{{ mine.snatches.toLocaleString() }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.mine.snatches') }}</span> + </div> + <div class="st-figure"> + <span class="st-figure-value">{{ bytes(mine.bytesUp) }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.mine.up') }}</span> + </div> + <div class="st-figure"> + <span class="st-figure-value">{{ bytes(mine.bytesDown) }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.mine.down') }}</span> + </div> + <div class="st-figure"> + <span class="st-figure-value">{{ hours(mine.seedTimeSeconds) }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.mine.seedTime') }}</span> + </div> + </template> + <div class="st-figure"> + <span class="st-figure-value">{{ Math.round(mine.bonusEarned).toLocaleString() }}</span> + <span class="st-figure-label st-micro">{{ $t('stats.mine.bonus') }}</span> + </div> + </div> + <!-- Said rather than silently omitted: four missing figures with no + explanation read as a broken page. --> + <p v-if="mine.downloadsHidden" class="st-mine-cat">{{ $t('stats.mine.hidden') }}</p> + <p v-if="mine.bestRelease" class="st-mine-best"> + {{ $t('stats.mine.best') }} + <NuxtLink :to="`/torrents/${mine.bestRelease.infoHash}`"> + {{ mine.bestRelease.name }} + </NuxtLink> + <span class="st-mine-best-n"> + {{ $t('stats.mine.bestSnatches', mine.bestRelease.snatches) }} + </span> + </p> + <p v-if="mine.topCategory" class="st-mine-cat"> + {{ $t('stats.mine.favourite', { + category: mine.topCategory.name, + n: mine.topCategory.torrents, + }) }} + </p> </section> <p class="st-foot">{{ $t('stats.foot') }}</p> @@ -290,11 +402,19 @@ * ## The shape of the page is an argument * * Five lead figures, then history, then the catalogue, then rankings, then the - * year. It reads top to bottom as "what the site is now" → "how it got here" → - * "what is in it" → "what a year of it was", and each section is one question. - * The alternative — a dashboard of twelve equal tiles — makes every number as - * important as every other, which is another way of making none of them - * important. + * year, then the reader's own year. It reads top to bottom as "what the site is + * now" → "how it got here" → "what is in it" → "what a year of it was" → "what + * your year was", and each section is one question. The alternative — a + * dashboard of twelve equal tiles — makes every number as important as every + * other, which is another way of making none of them important. + * + * ## The numbers outweigh the title + * + * They did not at first: the `h1` was the largest object on the page at 40px + * against 26px figures, so a reader arriving to ask "how is the site doing" + * read a label before they read an answer. The title is now on the house scale + * and the figures are the biggest thing on the page, which is the right way + * round for an instrument. * * ## What is not on it * @@ -306,7 +426,9 @@ * ## Charts * * Drawn in `StatSeries`, from tokens, with the numbers reachable under each - * one. See that component for why there is no charting library. + * one. See that component for why there is no charting library, and for why a + * cumulative scale is anchored at its minimum — and labelled, so that choice is + * honest rather than flattering. */ const { t, locale } = useI18n(); @@ -417,6 +539,47 @@ function hours(seconds: number): string { : t('stats.mine.hours', { n: h }); } +/** + * `2026-06-03` → `3 Jun` / `3 juin`. + * + * The month chart was already localised and the daily ones were not, so one + * page showed `2026-06-03` on one axis and `juin` on another — and the ISO + * string is also what the chart's `aria-label` reads aloud, one digit at a + * time. Built from the parts rather than parsed, so a zone-less date is not + * shifted a day by the reader's own timezone. + */ +function dayLabels(days: string[]): string[] { + return days.map((d) => { + const parts = d.split('-').map(Number); + if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return d; + return new Date(Date.UTC(parts[0]!, parts[1]! - 1, parts[2]!)).toLocaleDateString( + locale.value, + { day: 'numeric', month: 'short', timeZone: 'UTC' } + ); + }); +} + +/** + * The change over the shown window, for the two figures that have one. + * + * The page's headline question is "is the site growing", and not one of the + * five lead figures carried a delta — so a reader had to scroll to an unlabelled + * slope to answer it, while the numbers were already on the wire in `deltas`. + * Only the two counts get one: a delta on "seeders right now" is noise, and the + * traffic figure carries a caveat instead. + */ +const growth = computed(() => { + const rows = data.value?.deltas ?? []; + const sum = (pick: (row: (typeof rows)[number]) => number) => + rows.reduce((total, row) => total + pick(row), 0); + return { torrents: sum((r) => r.torrents), users: sum((r) => r.users), days: rows.length }; +}); + +function deltaNote(n: number): string { + if (n <= 0 || !growth.value.days) return ''; + return t('stats.delta', { n: n.toLocaleString(), days: growth.value.days }); +} + const figures = computed(() => { const now = data.value?.now; return [ @@ -424,13 +587,13 @@ const figures = computed(() => { key: 'torrents', value: (now?.torrents ?? 0).toLocaleString(), label: t('stats.now.torrents'), - note: bytes(now?.catalogued ?? 0), + note: deltaNote(growth.value.torrents), }, { key: 'members', value: (now?.members ?? 0).toLocaleString(), label: t('stats.now.members'), - note: '', + note: deltaNote(growth.value.users), }, { key: 'seeders', @@ -448,19 +611,36 @@ const figures = computed(() => { key: 'traffic', value: bytes(now?.trafficTotal ?? 0), label: t('stats.now.traffic'), - note: '', + // The same caveat the year's traffic figure carries, and it applies here + // with more force: an all-time counter has accumulated more erasures. + note: t('stats.year.trafficNote'), }, ]; }); const categories = computed(() => (data.value?.categories ?? []).slice(0, 12)); -const biggestCategory = computed(() => - Math.max(1, ...categories.value.map((c) => c.torrents)) + +/** + * Bars are each category's share of the catalogue, not its share of the + * largest category. + * + * Normalising to the biggest category made the top one a full bar on every + * instance, and a reader reads a bar as share-of-whole — the tell being that + * the panel note had to teach them otherwise. A shared denominator also means + * two categories can be compared by eye, which is the whole reason this is a + * bar chart and not a list. + */ +const catalogueTotal = computed(() => + Math.max( + 1, + categories.value.reduce((sum, c) => sum + c.torrents, 0) + ) ); function barWidth(count: number): number { - // Relative to the largest, floored so a category with one torrent is still - // visible rather than a line of zero width. - return Math.max(1.5, (count / biggestCategory.value) * 100); + // A 0.5 % floor rather than 1.5 %: at 1.5 % of a 700px track with a pill + // radius, a small category rendered as a 10px circle that read as a list + // bullet rather than as a bar. + return Math.max(0.5, (count / catalogueTotal.value) * 100); } const MONTHS = computed(() => @@ -490,34 +670,25 @@ function delay(index: number) { <style scoped> .stats { - max-width: 74rem; + /* 1280px, matching `me.vue`. The page used to cap itself at 1184px inside a + 1400px shell and add its own gutter on top of the layout's, so the eyebrow + sat 140px in from the brand and moving between pages stepped the content + sideways. A narrow measure belongs on the lede, not on the whole page. */ + max-width: 80rem; margin: 0 auto; - padding: 1.5rem 1rem 3rem; + padding: 1.5rem 0 3rem; display: flex; flex-direction: column; gap: 1.75rem; } -.st-sr { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); -} - /* ── head ─────────────────────────────────────────────────────────────── */ .st-head { display: flex; flex-direction: column; gap: 0.35rem; } -.st-eyebrow { - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: calc(0.18em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); -} .st-title { font-family: var(--font-display); - font-size: clamp(1.75rem, 4vw, 2.5rem); + /* On the house scale (`.h-page`, `.search-title`) rather than 1.5× above it. + A title is a label; the figures under it are the answer. */ + font-size: clamp(1.5rem, 3vw, 1.875rem); font-weight: 700; line-height: 1.1; color: rgb(var(--fg-strong)); @@ -525,46 +696,96 @@ function delay(index: number) { .st-lede { font-size: 0.875rem; color: rgb(var(--fg-muted)); - max-width: 46ch; + max-width: 60ch; +} + +/* One micro-label rule for the page. + There were five, differing only in tracking — 0.10em, 0.12em, 0.14em, 0.18em + — which no reader can tell apart and which meant the shared `.eyebrow` was + being rewritten five ways. Role is carried by colour, not by tracking. */ +.st-micro { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.14em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); } +.st-micro--accent { color: rgb(var(--accent-warm)); } /* ── lead figures ─────────────────────────────────────────────────────── */ +.st-lead { display: flex; flex-direction: column; gap: 0.5rem; } +/* + * Hairlines by per-cell border, not by a 1px gap over a coloured parent. + * + * The gap trick draws its lines by letting the parent's background show + * through — which also shows through any cell the grid does not fill. With five + * items and an `auto-fit` track count the row was only even at five columns + * (748px of viewport); every width below that left a solid grey rectangle where + * a figure should be, and on a phone it sat above the fold. Explicit tracks + * plus `border-right`/`border-bottom` cannot produce one, whatever the count. + */ .st-figures { display: grid; - grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); - gap: 1px; - background: rgb(var(--line-default)); + grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-md); overflow: hidden; + /* The surface belongs to the container, not the cells: an unfilled cell then + shows the same colour as a filled one instead of a hole. */ + background: rgb(var(--bg-surface)); } .st-figure { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.9rem 1rem; - background: rgb(var(--bg-surface)); + /* Separators as an OUTWARD shadow rather than a border, so the outer edge + stays exactly 1px. A border on every cell adds its own pixel next to the + container's, giving a 2px rule along the bottom and right; a shadow in the + 1px band outside the cell lands on the container's border there and is + clipped by `overflow: hidden`, while an interior one paints onto the + container's background with no sibling to cover it. Transparent cells are + what make that work. */ + box-shadow: + 1px 0 0 rgb(var(--line-default)), + 0 1px 0 rgb(var(--line-default)); animation: st-rise 260ms ease-out both; + min-width: 0; +} +/* No vertical separator after the final cell: when the last row is short — five + figures over two or three columns — that line divided the cell from empty + space and read as a missing tile. The horizontal rule stays, because the + container's own border completes the bottom edge across the gap. */ +.st-figure:last-child { + box-shadow: 0 1px 0 rgb(var(--line-default)); +} +.st-figures--year { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.st-figures--mine { grid-template-columns: repeat(6, minmax(0, 1fr)); } +@media (max-width: 72rem) { + .st-figures--mine { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (max-width: 60rem) { + .st-figures { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .st-figures--year { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 34rem) { + .st-figures, + .st-figures--mine { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .st-figure-value { font-family: var(--font-mono); font-variant-numeric: tabular-nums; - font-size: clamp(1.25rem, 2.4vw, 1.625rem); + /* Now the largest thing on the page. */ + font-size: clamp(1.5rem, 3vw, 2rem); font-weight: 700; color: rgb(var(--fg-strong)); line-height: 1.1; -} -.st-figure-label { - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: calc(0.12em * var(--tracking-scale)); - color: rgb(var(--fg-muted)); + overflow-wrap: anywhere; } .st-figure-note { font-family: var(--font-mono); font-size: 0.625rem; - color: rgb(var(--fg-faint)); + color: rgb(var(--fg-subtle)); } @keyframes st-rise { @@ -586,22 +807,37 @@ function delay(index: number) { gap: 0.85rem; min-width: 0; } +/* + * Two children, always: a text block and a controls block. + * + * With `space-between` and a bare title-plus-note, the note was flung to the + * far right edge and rendered as a narrow column of text some 770px from the + * heading it belonged to — so the copy explaining a chart read as unrelated + * marginalia. Wrapping the title with its note fixes three of six panels at the + * default desktop width. + */ .st-panel-head { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: flex-start; justify-content: space-between; - gap: 0.5rem; + gap: 0.5rem 1rem; } -.st-panel-head h2 { - font-size: 0.9375rem; +.st-panel-text { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} +.st-panel-title { + font-size: 1rem; font-weight: 600; color: rgb(var(--fg-strong)); } .st-panel-note { font-size: 0.6875rem; color: rgb(var(--fg-subtle)); - max-width: 44ch; + max-width: 60ch; } .st-empty { padding: 1.25rem 0; @@ -609,9 +845,29 @@ function delay(index: number) { font-size: 0.8125rem; color: rgb(var(--fg-subtle)); } +/* The house empty state — dashed box, icon, sentence — for the panel-level + ones. On a fresh instance every panel on this page is empty at once, and five + centred grey sentences read as a broken page rather than a new one. */ +.st-blank { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1.75rem 1rem; + border: 1px dashed rgb(var(--line-default)); + border-radius: var(--radius-md); + text-align: center; + font-size: 0.8125rem; + color: rgb(var(--fg-subtle)); +} +.st-blank-icon { font-size: 1.5rem; color: rgb(var(--fg-subtle)); } .st-columns { display: grid; - grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + /* One column until ~900px. `minmax(20rem, …)` went two-up at 696px, where + each ranking panel is 354px wide and still renders its size column — which + truncated release names at about 28 characters, and a scene name is + unrecognisable at 28 characters. */ + grid-template-columns: repeat(auto-fit, minmax(26rem, 1fr)); gap: 1.75rem; } @@ -624,7 +880,8 @@ function delay(index: number) { on a desktop is still a mouse. */ min-height: 2.25rem; border: 1px solid rgb(var(--line-default)); - border-radius: var(--radius-sm); + /* A pill, like every other segmented control in the house. */ + border-radius: var(--radius-pill); background: rgb(var(--bg-elevated)); color: rgb(var(--fg-muted)); font-size: 0.75rem; @@ -632,10 +889,12 @@ function delay(index: number) { transition: color 150ms ease-out, border-color 150ms ease-out, background 150ms ease-out; } .st-window:hover { color: rgb(var(--fg-default)); border-color: rgb(var(--line-strong)); } +/* Inverted, like `.search-mode-btn--on` and `.cat-pill--on`. A 14 % tint plus a + border colour was the weakest selected state in the app. */ .st-window--on { - background: rgb(var(--accent-warm) / 0.14); - border-color: rgb(var(--accent-warm)); - color: rgb(var(--fg-strong)); + background: rgb(var(--fg-strong)); + border-color: rgb(var(--fg-strong)); + color: rgb(var(--bg-base)); } @media (pointer: coarse) { .st-window { min-height: 2.75rem; padding-inline: 0.9rem; } @@ -646,6 +905,7 @@ function delay(index: number) { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.25rem 1.5rem; } +.st-chart-wide { grid-column: 1 / -1; } /* ── category bars ────────────────────────────────────────────────────── */ .st-bars { display: flex; flex-direction: column; gap: 0.4rem; } @@ -665,14 +925,20 @@ function delay(index: number) { .st-bar-name:hover { color: rgb(var(--accent-warm)); } .st-bar-track { height: 0.5rem; - border-radius: 999px; - background: rgb(var(--bg-inset)); + border-radius: var(--radius-pill); + /* Not `--bg-inset`: in the light theme that is 245 on a 255 panel, a 4 % + step, so the reader saw a floating lozenge with no visible "out of" — a + proportion with no denominator. A tint of the foreground works in both + themes and survives an operator's palette. */ + background: rgb(var(--fg-default) / 0.07); overflow: hidden; } .st-bar-fill { height: 100%; - border-radius: 999px; - background: rgb(var(--chart-1)); + /* Square-ish rather than a pill, so a small share still reads as a bar stub + instead of a bullet. */ + border-radius: var(--radius-xs); + background: rgb(var(--accent-warm)); } .st-bar-count, .st-bar-bytes { @@ -681,22 +947,51 @@ function delay(index: number) { text-align: right; color: rgb(var(--fg-muted)); } -.st-bar-bytes { color: rgb(var(--fg-faint)); } +.st-bar-bytes { color: rgb(var(--fg-subtle)); } @media (max-width: 640px) { - .st-bar-row { grid-template-columns: minmax(0, 1fr) 3.5rem; grid-template-areas: 'name count' 'track track'; } + /* Row-internal gap tighter than the gap between rows. It was the other way + round — 0.6rem inside a row against 0.4rem between them — so twelve + categories read as twenty-four loosely related lines. */ + .st-bar-row { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: 'name count' 'track track'; + gap: 0.15rem 0.6rem; + } + .st-bars { gap: 0.85rem; } .st-bar-name { grid-area: name; } - .st-bar-count { grid-area: count; } .st-bar-track { grid-area: track; } + /* The per-category volume moves into the count cell rather than vanishing: it + was the only place the catalogue's shape by volume appeared, and phones + lost it entirely. */ + .st-bar-count { + grid-area: count; + display: flex; + flex-direction: column; + align-items: flex-end; + line-height: 1.3; + } + .st-bar-count::after { + content: attr(data-bytes); + font-size: 0.625rem; + color: rgb(var(--fg-subtle)); + } .st-bar-bytes { display: none; } } /* ── rankings ─────────────────────────────────────────────────────────── */ .st-rank, .st-board { display: flex; flex-direction: column; } -.st-rank-row, .st-board-row { +.st-rank-head, +.st-rank-row { display: grid; - grid-template-columns: 1.5rem minmax(0, 1fr) auto auto; + grid-template-columns: 1.5rem minmax(0, 1fr) 5rem 5rem; align-items: center; gap: 0.6rem; +} +.st-rank-head { + padding-bottom: 0.35rem; + border-bottom: 1px solid rgb(var(--line-strong)); +} +.st-rank-row, .st-board-row { padding: 0.4rem 0; border-bottom: 1px solid rgb(var(--line-default)); font-size: 0.8125rem; @@ -705,7 +1000,7 @@ function delay(index: number) { .st-rank-n { font-family: var(--font-mono); font-size: 0.6875rem; - color: rgb(var(--fg-faint)); + color: rgb(var(--fg-subtle)); text-align: right; } .st-rank-name, .st-board-name { @@ -715,49 +1010,77 @@ function delay(index: number) { white-space: nowrap; } .st-rank-name:hover, .st-board-name:hover { color: rgb(var(--accent-warm)); } +.st-rank-num { text-align: right; } .st-rank-meta { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; font-size: 0.6875rem; - color: rgb(var(--fg-faint)); + color: rgb(var(--fg-subtle)); + text-align: right; white-space: nowrap; } +/* Both ranking values in the same colour. The seeder count was in `--online` + green, which `main.css` reserves for status badges and explicitly not for + chrome — and which implied the seeder figure was "good/live" while the snatch + count beside it was neutral, a semantic nobody intended. */ .st-rank-value, .st-board-count { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: rgb(var(--fg-muted)); + text-align: right; white-space: nowrap; } -.st-rank-value--up { color: rgb(var(--online)); } -.st-board-row { grid-template-columns: 1.5rem minmax(0, 1fr) auto; } +.st-board-row { + display: grid; + grid-template-columns: 1.5rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.6rem; +} +.st-more { + align-self: flex-start; + font-size: 0.6875rem; + color: rgb(var(--fg-subtle)); +} +.st-more:hover { color: rgb(var(--accent-warm)); } @media (max-width: 520px) { + .st-rank-head, .st-rank-row { grid-template-columns: 1.5rem minmax(0, 1fr) auto; } .st-rank-meta { display: none; } + .st-rank-head span:nth-child(3) { display: none; } } /* ── the year ─────────────────────────────────────────────────────────── */ .st-year { - border: 1px solid rgb(var(--line-strong)); - border-radius: var(--radius-md); - background: - radial-gradient(120% 80% at 100% 0%, rgb(var(--accent-warm) / 0.07), transparent 60%), - rgb(var(--bg-surface)); - padding: 1.1rem; + /* A real act break: a rule across the page and space above it, rather than a + 7 % tint and a slightly stronger border. */ + border-top: 1px solid rgb(var(--line-strong)); + padding-top: 1.75rem; + margin-top: 0.75rem; display: flex; flex-direction: column; gap: 1rem; } +.st-year-title { + font-family: var(--font-display); + font-size: clamp(1.25rem, 2.4vw, 1.5rem); + font-weight: 700; + color: rgb(var(--fg-strong)); +} .st-year-body { display: flex; flex-direction: column; gap: 1.1rem; } -.st-figures--year { border-color: rgb(var(--line-default)); } +/* Twelve bars at 87px wide and 88px tall is a row of squares, not a chart. */ +.st-year-chart { max-width: 42rem; } .st-year-lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.25rem; } +/* A heading, at heading size. `.eyebrow` at 10px uppercase was being used for + three `h3`s, which made them smaller than the body copy they introduced — and + `main.css` says in capitals never to do that. */ .st-sub { - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: calc(0.14em * var(--tracking-scale)); - color: rgb(var(--fg-muted)); + font-size: 0.875rem; + font-weight: 600; + color: rgb(var(--fg-default)); margin-bottom: 0.4rem; } .st-mini { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.8125rem; } @@ -786,30 +1109,7 @@ function delay(index: number) { .st-busiest { font-size: 0.8125rem; color: rgb(var(--fg-muted)); } /* ── the member's own year ────────────────────────────────────────────── */ -.st-mine { - border-top: 1px solid rgb(var(--line-default)); - padding-top: 0.9rem; -} -.st-mine-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); - gap: 0.75rem; -} -.st-mine-grid div { display: flex; flex-direction: column; } -.st-mine-grid b { - font-family: var(--font-mono); - font-variant-numeric: tabular-nums; - font-size: 1.0625rem; - color: rgb(var(--fg-strong)); -} -.st-mine-grid span { - font-size: 0.625rem; - text-transform: uppercase; - letter-spacing: calc(0.1em * var(--tracking-scale)); - color: rgb(var(--fg-subtle)); -} .st-mine-best, .st-mine-cat { - margin-top: 0.7rem; font-size: 0.8125rem; color: rgb(var(--fg-muted)); } @@ -817,13 +1117,13 @@ function delay(index: number) { .st-mine-best a:hover { color: rgb(var(--accent-warm)); } .st-mine-best-n { font-family: var(--font-mono); - color: rgb(var(--fg-faint)); + color: rgb(var(--fg-subtle)); margin-left: 0.4rem; } .st-foot { font-size: 0.6875rem; - color: rgb(var(--fg-faint)); + color: rgb(var(--fg-subtle)); text-align: center; } </style> diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 720fd3b2..08b4eb4c 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -5450,49 +5450,60 @@ "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.", + "foot": "Updated hourly. Calendar years, UTC.", + "delta": "+{n} in {days} days", + "rank": { + "release": "Release", + "size": "Size", + "snatches": "Snatches", + "seeders": "Seeders", + "releases": "Releases", + "more": "See the full list →" + }, "now": { "title": "Right now", "torrents": "Releases", "members": "Members", "seeders": "Seeders", "leechers": "{n} leeching", - "snatches": "Completed", - "traffic": "Traffic" + "snatches": "Snatches", + "traffic": "Traffic, all time" }, "growth": { "title": "How it got here", "window": "{days} days", "windowAria": "Length of history to show", - "torrents": "Releases", + "torrents": "Catalogue, all statuses", "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.", - "note": "Whole-catalogue counters from the hourly snapshot — every status and every category, so they sit above the filtered figures at the top." + "note": "Every release ever added, including dead and pending ones." }, "series": { "tooShort": "Not enough history to draw yet.", "table": "Show the numbers", + "tableFor": "Show the numbers — {label}", + "weekly": "per week", "when": "When", "summaryBars": "{label}: {total} in total, highest {peak} on {when}", - "summaryTrend": "{label}: {from} to {to}" + "summaryTrend": "{label}: {from} on {first}, {to} on {last}" }, "categories": { "title": "What is in the catalogue", - "note": "Live, accepted releases only. Bars are relative to the largest category.", + "note": "Live, accepted releases only. Each bar is the category’s share of the catalogue.", "none": "Nothing catalogued yet." }, "top": { - "snatched": "Most snatched", + "snatched": "Most snatched, all time", "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", + "note": "Ranked by releases online, never by volume. Members who upload anonymously are not listed.", + "count": "{n} release | {n} releases", "none": "Nobody has uploaded yet." }, "year": { @@ -5521,7 +5532,7 @@ "hours": "{n} h", "days": "{n} d", "best": "Your most snatched release:", - "bestSnatches": "{n} snatches", + "bestSnatches": "{n} snatch | {n} snatches", "favourite": "Mostly {category} — {n} of your uploads.", "hidden": "Your download figures are hidden because you turned your download history off in Settings → Privacy." } diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 3663b051..eb246c02 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -5450,49 +5450,60 @@ "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.", + "foot": "Mis à jour toutes les heures. Années civiles, en UTC.", + "delta": "+{n} en {days} jours", + "rank": { + "release": "Release", + "size": "Taille", + "snatches": "Complétés", + "seeders": "Sources", + "releases": "Releases", + "more": "Voir la liste complète →" + }, "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" + "snatches": "Complétés", + "traffic": "Trafic, depuis le début" }, "growth": { "title": "Comment on en est arrivé là", "window": "{days} jours", "windowAria": "Durée d’historique à afficher", - "torrents": "Releases", + "torrents": "Catalogue, tous statuts", "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.", - "note": "Compteurs du catalogue entier, relevés chaque heure — tous les statuts et toutes les catégories, donc au-dessus des chiffres filtrés du haut de page." + "note": "Toutes les releases jamais ajoutées, y compris mortes et en attente." }, "series": { "tooShort": "Pas encore assez d’historique pour tracer.", "table": "Afficher les chiffres", + "tableFor": "Afficher les chiffres — {label}", + "weekly": "par semaine", "when": "Quand", "summaryBars": "{label} : {total} au total, maximum {peak} le {when}", - "summaryTrend": "{label} : de {from} à {to}" + "summaryTrend": "{label} : {from} le {first}, {to} le {last}" }, "categories": { "title": "Ce que contient le catalogue", - "note": "Uniquement les releases actives et acceptées. Les barres sont relatives à la plus grosse catégorie.", + "note": "Uniquement les releases actives et acceptées. Chaque barre est la part de la catégorie dans le catalogue.", "none": "Rien au catalogue pour l’instant." }, "top": { - "snatched": "Les plus téléchargées", + "snatched": "Les plus téléchargées, depuis le début", "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", + "title": "Qui alimente le catalogue", + "note": "Classement par releases en ligne, jamais par volume. Les membres qui envoient anonymement ne sont pas listés.", + "count": "{n} release | {n} releases", "none": "Personne n’a encore envoyé de release." }, "year": { @@ -5500,7 +5511,7 @@ "pickAria": "Choisir une année", "added": "Releases ajoutées", "joined": "Membres arrivés", - "snatches": "Téléchargements", + "snatches": "Complétés", "traffic": "Trafic", "trafficNote": "au moins — le compteur baisse quand un compte est effacé", "byMonth": "Releases par mois", @@ -5511,9 +5522,9 @@ "none": "Aucune donnée pour cette année." }, "mine": { - "title": "Votre {year}", + "title": "Votre année {year}", "uploads": "Envois", - "snatches": "Téléchargements", + "snatches": "Complétés", "up": "Envoyé", "down": "Téléchargé", "seedTime": "Partagé", @@ -5521,7 +5532,7 @@ "hours": "{n} h", "days": "{n} j", "best": "Votre release la plus téléchargée :", - "bestSnatches": "{n} téléchargements", + "bestSnatches": "{n} téléchargement | {n} téléchargements", "favourite": "Surtout {category} — {n} de vos envois.", "hidden": "Vos chiffres de téléchargement sont masqués parce que vous avez désactivé votre historique dans Réglages → Confidentialité." } From c6637d2bcff545e80cd515d0ec54624ce30b2459 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:44:25 +0200 Subject: [PATCH 29/96] fix(alerts): an armed alert looked exactly like a silent one, and the page had no door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `notify` was in the payload, in the interface, and rendered nowhere. So a saved search that will tell you when something matches looked identical to one that will not, on a page whose own lede promises the notification. Each row now says which it is, in words and with a different icon, so nothing rests on telling green from grey. **The page was unreachable.** The only two ways in were the command palette and a link on the catalogue that appears solely while a saveable search is on screen — so a member who saved one, got the toast, and came back later had nowhere to go. Added to the account menu and the mobile menu, beside `/favorites`, its closest sibling. **Three chips were dead UI, and one search could not be saved at all.** The route has accepted `imdbId`/`tmdbId`/`tvdbId` since it was written and this page renders a chip for each, but the only thing that creates a saved search never sent them — so browsing one film's id and asking to be told about it, which is exactly the watch worth saving, was refused by `canSaveSearch`. **Deleting asked nothing.** One click on an unlabelled glyph permanently removed a standing instruction, on a page that offers no way to recreate one: the criteria live on the catalogue, so getting it back means rebuilding the search. Meanwhile the credentials card asks for confirmation on a rotation its own CSS comment calls routine and reversible. Two 4xx bodies from `saved-searches` were English sentences written for a log — "You can keep up to 20 saved searches. Delete one first." — and were being echoed straight into the page. The route now carries a `reason` and the browser picks its own translated sentence. Rest of the pass: `Matched 1 times`; `last 3d ago ago`, and its French variant carrying an English unit; a fallback label that made every tag-only search "Untitled search" forever, on a read-and-delete page; `title` as the only accessible name on both row controls; the destructive control adjacent to the safe one with no separation; a free-text chip with no bound, which overflowed the card at 390px; a 34rem breakpoint where the house uses 45rem, so at 560px the chips were still crammed against the controls; 3rem of dead space above the eyebrow where the sibling pages start at the top of the column; and two hardcoded `letter-spacing` values that a theme's `--tracking-scale` could not reach. --- .../api/me/saved-searches/index.post.ts | 5 + apps/web/app/layouts/default.vue | 16 +++ apps/web/app/pages/alerts.vue | 100 +++++++++++++++--- apps/web/app/pages/torrents/index.vue | 34 +++++- apps/web/i18n/locales/en.json | 13 ++- apps/web/i18n/locales/fr.json | 13 ++- 6 files changed, 162 insertions(+), 19 deletions(-) diff --git a/apps/api/routes/api/me/saved-searches/index.post.ts b/apps/api/routes/api/me/saved-searches/index.post.ts index ca1c8851..a4f993b1 100644 --- a/apps/api/routes/api/me/saved-searches/index.post.ts +++ b/apps/api/routes/api/me/saved-searches/index.post.ts @@ -61,6 +61,10 @@ export default defineEventHandler(async (event) => { if (!hasCriteria) { throw createError({ statusCode: 400, + // `data.reason` so the browser can pick its own translated sentence. + // The message here is written for a developer reading a log; echoing it + // into the page put an English string in front of a French member. + data: { reason: 'no-criteria' }, message: 'A saved search needs at least one criterion — some text, a category, a tag or a media id.', }); @@ -102,6 +106,7 @@ export default defineEventHandler(async (event) => { if (created === 0) { throw createError({ statusCode: 400, + data: { reason: 'limit', max }, message: `You can keep up to ${max} saved searches. Delete one first.`, }); } diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index 9c977511..10b9d7fe 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -298,6 +298,14 @@ <Icon name="ph:star-bold" class="text-amber-500" /> {{ $t('nav.favorites') }} </NuxtLink> + <NuxtLink + to="/alerts" + class="w-full px-4 py-2 text-left text-sm text-text-secondary hover:bg-fg-default/5 transition-colors flex items-center gap-2" + @click="showUserMenu = false" + > + <Icon name="ph:bookmark-simple-bold" class="text-sky-500" /> + {{ $t('nav.alerts') }} + </NuxtLink> <NuxtLink to="/following" class="w-full px-4 py-2 text-left text-sm text-text-secondary hover:bg-fg-default/5 transition-colors flex items-center gap-2" @@ -603,6 +611,14 @@ <Icon name="ph:star-bold" class="text-lg flex-shrink-0 text-amber-500" /> <span>{{ $t('nav.favorites') }}</span> </NuxtLink> + <NuxtLink + to="/alerts" + class="flex items-center gap-3 px-3 py-3 rounded-md text-sm font-medium text-text-secondary hover:bg-fg-default/5 hover:text-text-primary transition-colors" + @click="showMobileNav = false" + > + <Icon name="ph:bookmark-simple-bold" class="text-lg flex-shrink-0 text-sky-500" /> + <span>{{ $t('nav.alerts') }}</span> + </NuxtLink> <NuxtLink to="/following" class="flex items-center gap-3 px-3 py-3 rounded-md text-sm font-medium text-text-secondary hover:bg-fg-default/5 hover:text-text-primary transition-colors" diff --git a/apps/web/app/pages/alerts.vue b/apps/web/app/pages/alerts.vue index 9402c18b..12f94569 100644 --- a/apps/web/app/pages/alerts.vue +++ b/apps/web/app/pages/alerts.vue @@ -39,10 +39,20 @@ <span v-if="s.tvdbId" class="al-chip"><Icon name="ph:television" />{{ s.tvdbId }}</span> </p> <p class="al-meta"> + <!-- Whether it will actually tell you is the first thing about a + standing instruction, and it was the one thing the row did not + render: `notify` was in the payload and in the interface, and + an armed filter looked identical to a silent one on a page + whose lede promises a notification. --> + <span class="al-state" :class="{ 'al-state--off': !s.notify }"> + <Icon :name="s.notify ? 'ph:bell-ringing-fill' : 'ph:bell-slash'" /> + {{ s.notify ? $t('alerts.notifyOn') : $t('alerts.notifyOff') }} + </span> + <span class="al-sep" aria-hidden="true">·</span> <template v-if="s.matchCount > 0"> - {{ $t('alerts.matched', { count: s.matchCount }) }} + {{ $t('alerts.matched', s.matchCount) }} <span v-if="s.lastMatchedAt"> - · {{ $t('alerts.lastMatch', { when: formatAge(s.lastMatchedAt) }) }} + · {{ $t('alerts.lastMatch', { when: formatAgo(s.lastMatchedAt, locale) }) }} </span> </template> <template v-else>{{ $t('alerts.noMatchYet') }}</template> @@ -50,14 +60,23 @@ </div> <div class="al-item-actions"> - <NuxtLink :to="searchLink(s)" class="tool-btn" :title="$t('alerts.run')"> + <!-- `title` alone is the last resort in accessible-name computation: + it is unreliable and invisible on touch. `aria-label` names the + control; `title` stays for the mouse. --> + <NuxtLink + :to="searchLink(s)" + class="tool-btn" + :title="$t('alerts.run')" + :aria-label="$t('alerts.runFor', { label: s.label })" + > <Icon name="ph:magnifying-glass-bold" /> </NuxtLink> <button type="button" - class="tool-btn tool-btn--danger" + class="tool-btn tool-btn--danger al-delete" :disabled="busy === s.id" :title="$t('alerts.delete')" + :aria-label="$t('alerts.deleteFor', { label: s.label })" @click="remove(s)" > <Icon @@ -99,8 +118,9 @@ interface SavedSearch { matchCount: number; } -const { t } = useI18n(); +const { t, locale } = useI18n(); const notifications = useNotificationStore(); +const confirmDialog = useConfirm(); const { data, refresh } = await useFetch<{ items: SavedSearch[]; max: number }>( '/api/me/saved-searches', @@ -123,6 +143,19 @@ function searchLink(s: SavedSearch): string { } async function remove(s: SavedSearch) { + // One click on an unlabelled glyph permanently deleted a standing + // instruction, on a page that offers no way to recreate one — the criteria + // live on the catalogue page, so the member has to reconstruct the search + // before they can save it again. Meanwhile the credentials card asks for a + // confirmation on rotations it describes as reversible. + const ok = await confirmDialog({ + title: t('alerts.deleteConfirmTitle'), + message: t('alerts.deleteConfirmBody', { label: s.label }), + confirmText: t('alerts.delete'), + destructive: true, + }); + if (!ok) return; + busy.value = s.id; try { await $fetch<{ success: boolean }>(`/api/me/saved-searches/${s.id}` as string, { @@ -147,15 +180,24 @@ useHead({ title: () => t('alerts.title') }); * page somebody visits twice a year. */ .alerts { - max-width: 52rem; + /* A narrow measure is right for this content — short labels, short chips — + but 52rem inside a 1400px shell made this page visibly a different width + from every sibling in the same menu group, which reads as unfinished + rather than as considered. 64rem is the compromise. And no 3rem of dead + space above the eyebrow: `downloads.vue` and `favorites.vue` both start at + the top of the column. */ + max-width: 64rem; margin: 0 auto; - padding: 3rem 1.25rem 5rem; + padding: 1.5rem 0 4rem; } .al-eyebrow { margin: 0 0 0.25rem; font-size: 0.7rem; - letter-spacing: 0.14em; + /* Scaled, like the other 39 labels on this branch. A theme that opens the + tracking retracked the whole site except the six labels that hardcoded + it. */ + letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); } @@ -173,7 +215,7 @@ useHead({ title: () => t('alerts.title') }); .al-count { margin: 1.5rem 0 0.75rem; font-size: 0.75rem; - letter-spacing: 0.06em; + letter-spacing: calc(0.06em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); } @@ -183,7 +225,7 @@ useHead({ title: () => t('alerts.title') }); padding: 3rem 1.5rem; text-align: center; border: 1px dashed rgb(var(--line-default)); - border-radius: 0.6rem; + border-radius: var(--radius-md); } .al-empty-icon { width: 2.5rem; @@ -235,28 +277,62 @@ useHead({ title: () => t('alerts.title') }); gap: 0.25rem; padding: 0.1rem 0.4rem; border: 1px solid rgb(var(--line-default)); - border-radius: 0.25rem; + border-radius: var(--radius-xs); font-size: 0.72rem; color: rgb(var(--fg-muted)); + /* A 60-character free-text query went into one chip with no bound, so at + 390px the flex row overflowed the card. */ + max-width: 100%; + overflow-wrap: anywhere; } .al-chip--text { border-color: rgb(var(--accent) / 0.4); color: rgb(var(--accent)); } .al-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; margin: 0; font-size: 0.75rem; color: rgb(var(--fg-subtle)); } +.al-state { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: rgb(var(--online)); +} +/* Off is stated in words as well as colour, and the icon changes shape — a bell + with a slash through it — so nothing here depends on telling green from + grey. */ +.al-state--off { color: rgb(var(--fg-subtle)); } +.al-sep { color: rgb(var(--fg-subtle)); } .al-item-actions { display: flex; + align-items: center; gap: 0.4rem; flex-shrink: 0; } +/* Space between the safe action and the irreversible one, so a mis-aimed tap + on a phone cannot land on delete. */ +.al-delete { margin-left: 0.5rem; } -@media (max-width: 34rem) { +@media (max-width: 45rem) { .al-item { flex-direction: column; } + /* At 560px the row was still side-by-side with the chips crammed against the + controls; 45rem is the house breakpoint (`users/[id].vue`, + `settings.vue`). Once stacked, the controls sit on their own line and get a + rule above them so they read as the row's actions rather than as more + metadata. */ + .al-item-actions { + align-self: stretch; + justify-content: flex-end; + padding-top: 0.6rem; + border-top: 1px solid rgb(var(--line-default)); + } } </style> diff --git a/apps/web/app/pages/torrents/index.vue b/apps/web/app/pages/torrents/index.vue index 9e459a0f..7f9da000 100644 --- a/apps/web/app/pages/torrents/index.vue +++ b/apps/web/app/pages/torrents/index.vue @@ -723,7 +723,13 @@ const canSaveSearch = computed( () => !!searchQuery.value.trim() || !!selectedCategory.value || - selectedTags.value.length > 0 + selectedTags.value.length > 0 || + // A filter by IMDb/TMDb/TVDB id counts. The route has accepted these three + // since it was written and `/alerts` renders a chip for each, but the only + // thing that creates a saved search never sent them — so the chips were + // dead UI, and a member browsing one film's id could not save that watch at + // all, which is exactly the search worth being told about. + !!activeMediaId.value ); async function saveCurrentSearch() { @@ -732,9 +738,18 @@ async function saveCurrentSearch() { const fromCategory = selectedCategory.value ? categoryLabel([selectedCategory.value]) : null; + // Two tag-only searches both became "Untitled search", and the alerts page is + // read-and-delete by design — so they stayed indistinguishable forever. + // Naming the criteria is what makes the fallback usable. + const fromTags = selectedTags.value.length ? selectedTags.value.join(', ') : null; + const fromMedia = activeMediaId.value + ? `${activeMediaId.value.label} ${activeMediaId.value.display}` + : null; const label = ( searchQuery.value.trim() || fromCategory || + fromTags || + fromMedia || t('search.saveSearch.untitled') ).slice(0, 80); savingSearch.value = true; @@ -746,13 +761,26 @@ async function saveCurrentSearch() { query: searchQuery.value.trim() || undefined, categoryId: selectedCategory.value || undefined, tags: selectedTags.value.length ? selectedTags.value : undefined, + imdbId: mediaIdFilter.value?.source === 'imdb' ? mediaIdFilter.value.id : undefined, + tmdbId: mediaIdFilter.value?.source === 'tmdb' ? mediaIdFilter.value.id : undefined, + tvdbId: mediaIdFilter.value?.source === 'tvdb' ? mediaIdFilter.value.id : undefined, }, }); savedSearchNotifications.success(t('search.saveSearch.saved')); } catch (err: unknown) { - const e = err as { data?: { message?: string }; message?: string }; + // Mapped on the route's own `reason`, not echoed from its message. The two + // 4xx bodies here are English sentences written for a log — "You can keep up + // to 20 saved searches. Delete one first." — and echoing them put English in + // front of a French member at the one moment they needed to understand what + // to do next. + const e = err as { data?: { reason?: string; max?: number } }; + const reason = e?.data?.reason; savedSearchNotifications.error( - e?.data?.message || e?.message || t('search.saveSearch.failed') + reason === 'limit' + ? t('search.saveSearch.limit', { max: e?.data?.max ?? 20 }) + : reason === 'no-criteria' + ? t('search.saveSearch.noCriteria') + : t('search.saveSearch.failed') ); } finally { savingSearch.value = false; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 08b4eb4c..6dc31298 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -58,6 +58,7 @@ "settings": "Settings", "downloads": "Downloads", "favorites": "My favorites", + "alerts": "Saved searches", "following": "My circle", "requests": "Requests", "invitations": "Invitations", @@ -958,6 +959,8 @@ "manage": "Manage alerts", "saved": "Saved. You will be notified when a matching release is accepted.", "failed": "Could not save this search.", + "limit": "You can keep {max} saved searches. Delete one on the Saved searches page first.", + "noCriteria": "Add some text, a category or a tag first — a saved search needs something to watch for.", "untitled": "Untitled search" } }, @@ -5436,8 +5439,14 @@ "run": "Run this search", "delete": "Delete", "deleteFailed": "Could not delete this saved search.", - "matched": "Matched {count} times", - "lastMatch": "last {when} ago", + "matched": "Matched once | Matched {n} times", + "lastMatch": "last one {when}", + "notifyOn": "Notifies you", + "notifyOff": "Silent", + "runFor": "Run the search “{label}”", + "deleteFor": "Delete the saved search “{label}”", + "deleteConfirmTitle": "Delete this saved search?", + "deleteConfirmBody": "“{label}” stops watching and you will not be notified of new matches. Saved searches are created from the catalogue, so you would have to rebuild the criteria to get it back.", "noMatchYet": "Nothing has matched yet.", "empty": { "title": "No saved searches", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index eb246c02..419e7856 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -58,6 +58,7 @@ "settings": "Paramètres", "downloads": "Téléchargements", "favorites": "Mes favoris", + "alerts": "Recherches enregistrées", "following": "Mon réseau", "requests": "Demandes", "invitations": "Invitations", @@ -958,6 +959,8 @@ "manage": "Gérer les alertes", "saved": "Enregistrée. Vous serez prévenu·e quand une release correspondante sera acceptée.", "failed": "Cette recherche n'a pas pu être enregistrée.", + "limit": "Vous pouvez conserver {max} recherches enregistrées. Supprimez-en une depuis la page Recherches enregistrées.", + "noCriteria": "Ajoutez d’abord du texte, une catégorie ou un tag — une recherche enregistrée a besoin de quelque chose à surveiller.", "untitled": "Recherche sans titre" } }, @@ -5436,8 +5439,14 @@ "run": "Lancer cette recherche", "delete": "Supprimer", "deleteFailed": "Cette recherche n'a pas pu être supprimée.", - "matched": "A correspondu {count} fois", - "lastMatch": "la dernière il y a {when}", + "matched": "A correspondu une fois | A correspondu {n} fois", + "lastMatch": "la dernière {when}", + "notifyOn": "Vous prévient", + "notifyOff": "Silencieuse", + "runFor": "Lancer la recherche « {label} »", + "deleteFor": "Supprimer la recherche enregistrée « {label} »", + "deleteConfirmTitle": "Supprimer cette recherche enregistrée ?", + "deleteConfirmBody": "« {label} » cesse de surveiller et vous ne serez plus prévenu·e des nouvelles correspondances. Les recherches enregistrées se créent depuis le catalogue : il faudrait reconstruire les critères pour la retrouver.", "noMatchYet": "Rien ne correspond pour le moment.", "empty": { "title": "Aucune recherche enregistrée", From 3b25bd5d896030bfc08758c3c8c92974a6f3a80f Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:45:42 +0200 Subject: [PATCH 30/96] fix(me): rotating a key you had not revealed threw the new one away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rotateReadKey` guards its assignment on `readKeys` being loaded, and `readKeys` is `null` until a reveal or a copy fills it. So rotating without revealing first skipped the guard, discarded `res.key` — and then flipped the card to visible, rendered a literal `…`, and fired a green "Key rotated." The member had just invalidated whatever Prowlarr or Sonarr was using, with the replacement neither on screen nor copyable, and nothing saying so. Only a page reload recovered it. **The four Copy buttons arrived disabled.** Values are lazy, so `passkey` and `k.value` are empty on load and every button was greyed at 50 % with `cursor: not-allowed` — while `copy()` and `copyReadKey()` both fetch the value themselves. The safest and most common interaction, copying an announce URL into a client without ever putting the secret on screen, was the one the card refused, with no explanation of why the control was dead. **Three hardcoded colours broke the light theme.** The contrast gate added on this branch measures token *pairs*; it cannot see a hex literal in a component's scoped CSS, so these sailed past it. `#f5c518` on a near-white surface measures **1.64:1** — and that was the colour of "Never share these credentials", the most safety-critical sentence on the page rendered as the least readable one. `#6cd161` on the key value came to roughly 2:1, `#34d4d8` to 1.9:1. Measured after the swap to `--online` / `--warning` / `--info`: 4.81, 5.02 and 5.93:1. (There are 271 more such literals elsewhere in the frontend; they are a separate sweep, and extending the gate to refuse them presumes it done.) **The legacy-passkey banner appeared only after the member had already found the thing it points at.** It was keyed on `readKeys`, which is populated by the reveal and copy handlers, so the warning telling a member their announce passkey still works on feeds — and that they should migrate — showed up only once they revealed or copied an RSS key. The member who most needs it, still using their passkey in Prowlarr and never opening the RSS card, never saw it. The flag is a site setting with no secret in it and now rides on `/api/me`; fetching `/api/me/keys` eagerly instead would have minted a read key for every member who opens their profile, which is the decision that route exists to avoid. Also: `readKeysBusy` was a boolean, so rotating one read key froze the other's control; the Prowlarr download row inherited the "never share this" amber through a `:not()` selector, spending the page's one alarm colour on a file download; the four cards were `<article>`s with no heading and no accessible name, so a screen reader navigating by landmark heard "article" four times; the reveal toggles carried no `aria-pressed`; and ten icon-only controls were named by `title` alone, which is the last resort in accessible-name computation and invisible on touch. --- apps/api/routes/api/me/index.get.ts | 19 ++++++- apps/web/app/pages/me.vue | 87 +++++++++++++++++++++-------- apps/web/i18n/locales/en.json | 8 +++ apps/web/i18n/locales/fr.json | 8 +++ 4 files changed, 99 insertions(+), 23 deletions(-) diff --git a/apps/api/routes/api/me/index.get.ts b/apps/api/routes/api/me/index.get.ts index 0435920d..40236f04 100644 --- a/apps/api/routes/api/me/index.get.ts +++ b/apps/api/routes/api/me/index.get.ts @@ -8,6 +8,7 @@ */ import { db, schema } from '@trackarr/db'; import { eq, sql, desc } from 'drizzle-orm'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; export default defineEventHandler(async (event) => { const { user: session } = await requireUserSession(event); @@ -67,7 +68,7 @@ export default defineEventHandler(async (event) => { // Fold in counts that are useful at the top of the profile but // would otherwise need separate round-trips. - const [uploadsRow, hnrRow] = await Promise.all([ + const [uploadsRow, hnrRow, legacyPasskeyAccepted] = await Promise.all([ db .select({ value: sql<number>`count(*)::int` }) .from(schema.torrents) @@ -86,6 +87,7 @@ export default defineEventHandler(async (event) => { }) .from(schema.hnrTracking) .where(eq(schema.hnrTracking.userId, user.id)), + isLegacyPasskeyReadAllowed(), ]); const ratio = @@ -127,5 +129,20 @@ export default defineEventHandler(async (event) => { activeSeeds: hnrRow[0]?.value?.active ?? 0, hnr: hnrRow[0]?.value?.hnr ?? 0, }, + /** + * Whether the announce passkey still opens the read surfaces. + * + * A site setting, not a secret, and it lives here because the only other + * place that returned it was `GET /api/me/keys` — which MINTS the two read + * keys on first read. So the banner telling a member their passkey is still + * accepted on feeds, and that they should migrate, appeared only after they + * revealed or copied an RSS key: only after they had already found the thing + * the banner exists to point them at. The member who most needs it — still + * using their passkey in Prowlarr, never opened the RSS card — never saw it. + * Fetching `/api/me/keys` eagerly instead would have minted a key for every + * member who opens their profile, which is the decision that route exists to + * avoid. + */ + legacyPasskeyAccepted, }; }); diff --git a/apps/web/app/pages/me.vue b/apps/web/app/pages/me.vue index f654e359..6316c664 100644 --- a/apps/web/app/pages/me.vue +++ b/apps/web/app/pages/me.vue @@ -306,12 +306,14 @@ <div class="tracker-grid"> <article class="cred"> <div class="cred-head"> - <span class="cred-label">{{ $t('me.credentials.passkey') }}</span> + <h3 class="cred-label">{{ $t('me.credentials.passkey') }}</h3> <div class="cred-actions"> <button type="button" class="cred-btn" :title="passkeyVisible ? $t('me.credentials.hide') : $t('me.credentials.show')" + :aria-label="$t('me.credentials.revealPasskey')" + :aria-pressed="passkeyVisible" :disabled="passkeyLoading" @click="togglePasskey" > @@ -330,7 +332,7 @@ type="button" class="cred-btn" :title="copied === 'passkey' ? $t('common.copied') : $t('common.copy')" - :disabled="!passkey" + :aria-label="$t('me.credentials.copyPasskey')" @click="copy('passkey')" > <Icon @@ -345,6 +347,7 @@ type="button" class="cred-btn cred-btn--danger" :title="$t('me.credentials.rotate')" + :aria-label="$t('me.credentials.rotatePasskey')" :disabled="passkeyRotating" @click="rotatePasskey" > @@ -370,12 +373,14 @@ <article class="cred"> <div class="cred-head"> - <span class="cred-label">{{ $t('me.credentials.announceUrl') }}</span> + <h3 class="cred-label">{{ $t('me.credentials.announceUrl') }}</h3> <div class="cred-actions"> <button type="button" class="cred-btn" :title="announceVisible ? $t('me.credentials.hide') : $t('me.credentials.show')" + :aria-label="$t('me.credentials.revealAnnounce')" + :aria-pressed="announceVisible" @click="announceVisible = !announceVisible" > <Icon @@ -386,7 +391,7 @@ type="button" class="cred-btn" :title="copied === 'announce' ? $t('common.copied') : $t('common.copy')" - :disabled="!passkey" + :aria-label="$t('me.credentials.copyAnnounce')" @click="copy('announce')" > <Icon @@ -414,12 +419,14 @@ should not break a single torrent in your client. --> <article v-for="k in readKeyCards" :key="k.kind" class="cred"> <div class="cred-head"> - <span class="cred-label">{{ k.label }}</span> + <h3 class="cred-label">{{ k.label }}</h3> <div class="cred-actions"> <button type="button" class="cred-btn" :title="k.visible ? $t('me.credentials.hide') : $t('me.credentials.show')" + :aria-label="$t('me.credentials.revealKey', { key: k.label })" + :aria-pressed="k.visible" @click="toggleReadKey(k.kind)" > <Icon :name="k.visible ? 'ph:eye-slash-bold' : 'ph:eye-bold'" /> @@ -428,7 +435,7 @@ type="button" class="cred-btn" :title="copied === k.kind ? $t('common.copied') : $t('common.copy')" - :disabled="!k.value" + :aria-label="$t('me.credentials.copyKey', { key: k.label })" @click="copyReadKey(k.kind)" > <Icon :name="copied === k.kind ? 'ph:check-bold' : 'ph:copy-simple-bold'" /> @@ -437,12 +444,13 @@ type="button" class="cred-btn cred-btn--warn" :title="$t('me.credentials.rotate')" - :disabled="readKeysBusy" + :aria-label="$t('me.credentials.rotateKey', { key: k.label })" + :disabled="readKeysBusy === k.kind" @click="rotateReadKey(k.kind)" > <Icon - :name="readKeysBusy ? 'ph:circle-notch' : 'ph:arrows-clockwise-bold'" - :class="{ 'animate-spin': readKeysBusy }" + :name="readKeysBusy === k.kind ? 'ph:circle-notch' : 'ph:arrows-clockwise-bold'" + :class="{ 'animate-spin': readKeysBusy === k.kind }" /> </button> </div> @@ -455,7 +463,7 @@ <!-- Only under the RSS key: a Prowlarr definition is what that key is for, and offering it beside the API key would suggest the two are interchangeable. --> - <p v-if="k.kind === 'rss'" class="cred-note"> + <p v-if="k.kind === 'rss'" class="cred-note cred-note--link"> <Icon name="ph:download-simple-bold" /> <a href="/api/torznab/cardigann.yml" download> {{ $t('me.credentials.prowlarrDefinition') }} @@ -464,7 +472,7 @@ </p> </article> - <p v-if="readKeys?.legacyPasskeyAccepted" class="cred-legacy"> + <p v-if="profile?.legacyPasskeyAccepted" class="cred-legacy"> <Icon name="ph:warning-bold" /> {{ $t('me.credentials.legacyPasskeyNote') }} </p> @@ -785,6 +793,10 @@ interface MeProfile { activeSeeds: number; hnr: number; }; + /** Whether the announce passkey still opens the read surfaces. Comes from the + * profile rather than from `/api/me/keys`, which mints a read key on first + * read — see the note on that field in the route. */ + legacyPasskeyAccepted: boolean; } const { data: profile } = await useFetch<MeProfile>('/api/me', { @@ -1062,7 +1074,11 @@ const readKeyVisible = reactive<{ rss: boolean; api: boolean }>({ rss: false, api: false, }); -const readKeysBusy = ref(false); +/* Which key is rotating, not "a key is rotating": a boolean disabled the rotate + button on BOTH cards at once, so rotating the RSS key froze the API key's + control for the duration. `alerts.vue` scopes its per-row busy state + correctly and is the pattern here. */ +const readKeysBusy = ref<'rss' | 'api' | null>(null); async function loadReadKeys() { if (readKeys.value) return; @@ -1123,8 +1139,16 @@ async function rotateReadKey(kind: 'rss' | 'api') { }); if (!ok) return; - readKeysBusy.value = true; + readKeysBusy.value = kind; try { + // The container has to exist before the new value can go into it. Rotating + // without revealing first left `readKeys` null, so the guard below skipped + // and the new key was DISCARDED — while the card flipped to visible, showed + // a literal "…", and a green toast said "Key rotated". The member had just + // invalidated whatever Prowlarr was using and the replacement was neither on + // screen nor copyable; only a page reload recovered it. + await loadReadKeys(); + // The URL is widened to `string` on purpose. Left as a template literal, // Nitro's route-typing tries to match it against every declared route and // TypeScript gives up with "excessive stack depth" — the inference is not @@ -1133,10 +1157,11 @@ async function rotateReadKey(kind: 'rss' | 'api') { const res = await $fetch<{ kind: 'rss' | 'api'; key: string }>(url, { method: 'POST', }); - if (readKeys.value) { - if (res.kind === 'rss') readKeys.value.rssKey = res.key; - else readKeys.value.apiKey = res.key; + if (!readKeys.value) { + readKeys.value = { rssKey: '', apiKey: '', legacyPasskeyAccepted: false }; } + if (res.kind === 'rss') readKeys.value.rssKey = res.key; + else readKeys.value.apiKey = res.key; readKeyVisible[kind] = true; notifications.success(t('me.credentials.rotated')); } catch (err: unknown) { @@ -1151,7 +1176,7 @@ async function rotateReadKey(kind: 'rss' | 'api') { : e?.data?.message || e?.message || t('me.credentials.rotateFailed') ); } finally { - readKeysBusy.value = false; + readKeysBusy.value = null; } } @@ -2547,7 +2572,19 @@ function formatDuration(seconds: number) { font-family: var(--font-mono); font-size: 0.78rem; letter-spacing: calc(0.02em * var(--tracking-scale)); - color: #6cd161; + /* + * Tokens, not hex. + * + * These three were `#6cd161`, `#34d4d8` and `#f5c518` — fixed colours chosen + * against the dark theme, and the contrast gate added on this branch measures + * token PAIRS, so it cannot see a literal inside a component's scoped CSS. + * They sailed past it and broke in the light theme: `#f5c518` on a near-white + * surface measures 1.64:1, and that was the colour of "Never share this" — the + * most safety-critical sentence on the page rendered as the least readable + * one. `--online` / `--info` / `--warning` are re-declared per theme (21 128 + * 61 / 3 105 161 / 180 83 9 in light) and clear AA in both. + */ + color: rgb(var(--online)); word-break: break-all; user-select: all; padding: 0.6rem 0.75rem; @@ -2597,12 +2634,18 @@ function formatDuration(seconds: number) { } .cred-note--info { - color: #34d4d8; - opacity: 0.85; + color: rgb(var(--info)); } -.cred-note:not(.cred-note--info) { - color: #f5c518; +/* The warning colour is for warnings. `:not(.cred-note--info)` also caught the + Prowlarr download row — a plain, helpful link — and painted it the same amber + as "a passkey given to a third party can announce on your behalf", which + spends the page's one alarm colour on a file download. */ +.cred-note:not(.cred-note--info):not(.cred-note--link) { + color: rgb(var(--warning)); } +.cred-note--link { color: rgb(var(--fg-muted)); } +.cred-note--link a { color: rgb(var(--info)); } +.cred-note--link a:hover { text-decoration: underline; } /* ─── Tabs ─────────────────────────────────────────────────── */ .tabs-shell { diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 6dc31298..8edd612a 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -324,6 +324,14 @@ "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.", + "revealPasskey": "Show the announce passkey", + "revealAnnounce": "Show the announce URL", + "revealKey": "Show the {key}", + "copyPasskey": "Copy the announce passkey", + "copyAnnounce": "Copy the announce URL", + "copyKey": "Copy the {key}", + "rotatePasskey": "Replace the announce passkey", + "rotateKey": "Replace the {key}", "prowlarrDefinition": "Prowlarr definition (.yml)", "prowlarrHint": "Drop it in Prowlarr’s Definitions/Custom folder, restart, then add this tracker and paste the key above." }, diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 419e7856..691bf731 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -324,6 +324,14 @@ "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.", + "revealPasskey": "Afficher la passkey d’annonce", + "revealAnnounce": "Afficher l’URL d’annonce", + "revealKey": "Afficher la {key}", + "copyPasskey": "Copier la passkey d’annonce", + "copyAnnounce": "Copier l’URL d’annonce", + "copyKey": "Copier la {key}", + "rotatePasskey": "Remplacer la passkey d’annonce", + "rotateKey": "Remplacer la {key}", "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." }, From 30138a271633318f25a26355e8bf7f15ea5ecf51 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:47:54 +0200 Subject: [PATCH 31/96] fix(security): one address on five days read as five different places MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `labelAddresses` numbered addresses with a counter running over the whole response. So a member signing in from one home address on five different days saw `#1 #2 #3 #4 #5` — the ordinary case, one place over many days, rendered exactly like five different places, on the one screen whose entire purpose is spotting somebody else signing in as you. Numbered within the day now: the same place is `#1` every day, and a second number inside one day means a second place that day. That also expresses the daily-salt caveat in the data instead of in a footnote. Both notes still described the mechanism the reader cannot see. "Addresses are hashed with a salt that changes daily, so two rows can only be compared within the same day" — there is no hash on screen; the API replaced it with an ordinal. Rewritten to describe the `#1`/`#2` labels the reader is actually looking at, and to state the row cap and the 90-day retention, neither of which was said anywhere. **The member's table omitted the column that answers the question.** `userAgent` is on the payload and on the interface, and rendered only in the *staff* view — so the member, the one person who knows which devices they own, could not see "Chrome on Windows" against their iPhone-and-Mac life, while a moderator who knows nothing about their hardware could. It is the strongest signal a member has for "that was not me". Neither table was wrapped for overflow, and no media query touched either. At 390px the staff table's four columns include a 120-character user-agent with `overflow-wrap: anywhere` in roughly 90px, which shreds each row into a thirty-line ribbon: one row per screen, for a moderator investigating on a phone. Both now sit in a focusable scroll frame, the same pattern the swarm table on the torrent page already uses. The staff view also printed `l.method` raw — a moderator read `trusted-device` and `totp` while the member read "Trusted device" and "Authenticator", so the two halves of one investigation used different vocabularies for the same fact. And its "different addresses" flag fired at more than one, which a phone plus a laptop trips, so a warning-tinted panel appeared on the median member; it now fires above two and names the day and the sign-in count, both of which the route was already returning and the page was dropping. Plus `aria-expanded` and `aria-controls` on both disclosures, and a `<caption>` on the member's table, which had no accessible description of any kind. --- apps/api/routes/api/me/logins.get.ts | 15 ++- .../routes/api/mod/users/[id]/logins.get.ts | 15 ++- apps/web/app/pages/settings.vue | 95 ++++++++++++++----- apps/web/app/pages/users/[id].vue | 82 +++++++++++++--- apps/web/i18n/locales/en.json | 9 +- apps/web/i18n/locales/fr.json | 9 +- 6 files changed, 175 insertions(+), 50 deletions(-) diff --git a/apps/api/routes/api/me/logins.get.ts b/apps/api/routes/api/me/logins.get.ts index 35efe225..5aaeaa73 100644 --- a/apps/api/routes/api/me/logins.get.ts +++ b/apps/api/routes/api/me/logins.get.ts @@ -46,10 +46,21 @@ function labelAddresses<T extends { ipHash: string | null; createdAt: Date }>( // Keyed on hash AND day, because the salt rotates daily: the same address // is a different hash tomorrow, and pretending otherwise would invent a // continuity the data does not have. - const key = `${ipHash}:${row.createdAt.toISOString().slice(0, 10)}`; + // + // Numbered WITHIN the day, not across the response. A single counter over + // the whole page meant one home address on five different days rendered as + // `#1 #2 #3 #4 #5` — so the ordinary case, one place over many days, looked + // exactly like five different places, on the one screen whose entire job is + // spotting somebody else signing in as you. Restarting each day also makes + // the "only comparable within one day" caveat something the numbers say for + // themselves rather than something a footnote has to teach. + const day = row.createdAt.toISOString().slice(0, 10); + const key = `${ipHash}:${day}`; let ordinal = seen.get(key); if (ordinal === undefined) { - ordinal = seen.size + 1; + let next = 1; + for (const k of seen.keys()) if (k.endsWith(`:${day}`)) next += 1; + ordinal = next; seen.set(key, ordinal); } return { ...rest, address: `#${ordinal}` } as Omit<T, 'ipHash'> & { address: string }; diff --git a/apps/api/routes/api/mod/users/[id]/logins.get.ts b/apps/api/routes/api/mod/users/[id]/logins.get.ts index fae9a796..d32c8c00 100644 --- a/apps/api/routes/api/mod/users/[id]/logins.get.ts +++ b/apps/api/routes/api/mod/users/[id]/logins.get.ts @@ -51,10 +51,21 @@ function labelAddresses<T extends { ipHash: string | null; createdAt: Date }>( // Keyed on hash AND day, because the salt rotates daily: the same address // is a different hash tomorrow, and pretending otherwise would invent a // continuity the data does not have. - const key = `${ipHash}:${row.createdAt.toISOString().slice(0, 10)}`; + // + // Numbered WITHIN the day, not across the response. A single counter over + // the whole page meant one home address on five different days rendered as + // `#1 #2 #3 #4 #5` — so the ordinary case, one place over many days, looked + // exactly like five different places, on the one screen whose entire job is + // spotting somebody else signing in as you. Restarting each day also makes + // the "only comparable within one day" caveat something the numbers say for + // themselves rather than something a footnote has to teach. + const day = row.createdAt.toISOString().slice(0, 10); + const key = `${ipHash}:${day}`; let ordinal = seen.get(key); if (ordinal === undefined) { - ordinal = seen.size + 1; + let next = 1; + for (const k of seen.keys()) if (k.endsWith(`:${day}`)) next += 1; + ordinal = next; seen.set(key, ordinal); } return { ...rest, address: `#${ordinal}` } as Omit<T, 'ipHash'> & { address: string }; diff --git a/apps/web/app/pages/settings.vue b/apps/web/app/pages/settings.vue index 597fb966..2caac4b7 100644 --- a/apps/web/app/pages/settings.vue +++ b/apps/web/app/pages/settings.vue @@ -549,6 +549,8 @@ type="button" class="btn-ghost" :disabled="loginsLoading" + :aria-expanded="loginsOpen" + aria-controls="me-logins" @click="toggleLogins" > <Icon @@ -559,35 +561,57 @@ </button> </article> - <div v-if="loginsOpen" class="logins"> + <div v-if="loginsOpen" id="me-logins" class="logins"> <p v-if="!logins.length" class="logins-empty"> {{ $t('settings.security.loginHistoryEmpty') }} </p> - <table v-else class="logins-table"> - <thead> - <tr> - <th scope="col">{{ $t('settings.security.loginWhen') }}</th> - <th scope="col">{{ $t('settings.security.loginMethod') }}</th> - <th scope="col">{{ $t('settings.security.loginAddress') }}</th> - <th scope="col">{{ $t('settings.security.loginResult') }}</th> - </tr> - </thead> - <tbody> - <tr v-for="l in logins" :key="l.id" :class="{ 'logins-row--failed': l.outcome !== 'success' }"> - <td class="logins-when"> - <time :datetime="l.createdAt">{{ loginStamp(l.createdAt) }}</time> - </td> - <td>{{ $t(`settings.security.loginMethods.${l.method}`) }}</td> - <td><code class="logins-hash">{{ l.address || '—' }}</code></td> - <td> - <span :class="l.outcome === 'success' ? 'logins-ok' : 'logins-bad'"> - {{ $t(`settings.security.loginOutcomes.${l.outcome}`) }} - </span> - </td> - </tr> - </tbody> - </table> - <p class="logins-note">{{ $t('settings.security.loginHistoryNote') }}</p> + <!-- A scroll frame, and focusable, so the keyboard can reach the + width the mouse can drag. --> + <div + v-else + class="logins-frame" + tabindex="0" + role="region" + :aria-label="$t('settings.security.loginHistory')" + > + <table class="logins-table"> + <caption class="sr-only">{{ $t('settings.security.loginHistory') }}</caption> + <thead> + <tr> + <th scope="col">{{ $t('settings.security.loginWhen') }}</th> + <th scope="col">{{ $t('settings.security.loginMethod') }}</th> + <th scope="col">{{ $t('settings.security.loginAddress') }}</th> + <!-- The column that answers the question. The device + string was fetched, typed on `LoginEvent`, and shown + only to STAFF — so the member, the one person who + knows which devices they own, could not see "Chrome on + Windows" against their iPhone-and-Mac life, while a + moderator who knows nothing about their hardware + could. --> + <th scope="col">{{ $t('settings.security.loginClient') }}</th> + <th scope="col">{{ $t('settings.security.loginResult') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="l in logins" :key="l.id" :class="{ 'logins-row--failed': l.outcome !== 'success' }"> + <td class="logins-when"> + <time :datetime="l.createdAt">{{ loginStamp(l.createdAt) }}</time> + </td> + <td>{{ $t(`settings.security.loginMethods.${l.method}`) }}</td> + <td><code class="logins-hash">{{ l.address || '—' }}</code></td> + <td class="logins-agent">{{ l.userAgent || '—' }}</td> + <td> + <span :class="l.outcome === 'success' ? 'logins-ok' : 'logins-bad'"> + {{ $t(`settings.security.loginOutcomes.${l.outcome}`) }} + </span> + </td> + </tr> + </tbody> + </table> + </div> + <p class="logins-note"> + {{ $t('settings.security.loginHistoryNote', { n: logins.length }) }} + </p> </div> </div> </section> @@ -2402,6 +2426,25 @@ onBeforeRouteLeave((_to, _from, next) => { margin-top: 0.75rem; } .logins-empty, +/* A scroll frame for a table wider than a phone, like `.swarm-frame` on the + torrent page. Neither login table had one, nor any media query, so at 390px + the columns simply crushed each other. Focusable, because a scroll container + the keyboard cannot reach hides whatever it is scrolling. */ +.logins-frame { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.logins-frame:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} +.logins-table { min-width: 34rem; } +.logins-agent { + max-width: 18rem; + overflow-wrap: anywhere; + color: rgb(var(--fg-subtle)); + font-size: 0.75rem; +} .logins-note { margin: 0; font-size: 0.75rem; diff --git a/apps/web/app/pages/users/[id].vue b/apps/web/app/pages/users/[id].vue index ec2b1e0c..d018622f 100644 --- a/apps/web/app/pages/users/[id].vue +++ b/apps/web/app/pages/users/[id].vue @@ -293,7 +293,14 @@ <div class="logins-head"> <p class="logins-lede">{{ $t('users.logins.lede') }}</p> - <button type="button" class="btn btn-secondary btn-sm" :disabled="loginsLoading" @click="toggleLogins"> + <button + type="button" + class="btn btn-secondary btn-sm" + :disabled="loginsLoading" + :aria-expanded="loginsOpen" + aria-controls="user-logins-body" + @click="toggleLogins" + > <Icon :name="loginsLoading ? 'ph:circle-notch' : loginsOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" :class="{ 'animate-spin': loginsLoading }" @@ -302,16 +309,27 @@ </button> </div> - <template v-if="loginsOpen"> - <p - v-if="distinctToday > 1" - class="logins-flag" - > + <div v-if="loginsOpen" id="user-logins-body"> + <!-- Threshold at 3, not 2: a phone plus a laptop trips 2, so a + warning-tinted panel fired on the median member and taught the + reader to ignore it. And it names the day and the login count — + "the most recent day" could be today or four months ago, and both + figures were already in the payload and dropped on the floor. --> + <p v-if="distinctToday > 2" class="logins-flag"> <Icon name="ph:warning-bold" /> - {{ $t('users.logins.multipleToday', { count: distinctToday }) }} + {{ $t('users.logins.multipleToday', { + count: distinctToday, + day: latestLoginDay, + logins: loginsThatDay, + }) }} </p> <p v-if="!logins.length" class="logins-lede">{{ $t('users.logins.empty') }}</p> - <table v-else class="logins-table"> + <!-- A scroll frame, like `.swarm-frame` on the torrent page. Four + columns including a 120-character user-agent, with no wrapper and no + media query, shredded each row into a thirty-line ribbon at 390px — + one row per screen, for a moderator investigating on a phone. --> + <div v-else class="logins-frame" tabindex="0" role="region" :aria-label="$t('users.logins.title')"> + <table class="logins-table"> <thead> <tr> <th scope="col">{{ $t('users.logins.when') }}</th> @@ -326,7 +344,12 @@ <time :datetime="l.createdAt">{{ loginStamp(l.createdAt) }}</time> </td> <td> - {{ l.method }} + <!-- Translated, like the member's own view of the same column. + A moderator read `trusted-device` and `totp` while the + member read "Trusted device" and "Authenticator", so the two + halves of one investigation used different vocabularies for + the same fact. --> + {{ loginMethod(l.method) }} <span v-if="l.outcome !== 'success'" class="logins-bad"> · {{ $t('users.logins.refused') }} </span> @@ -336,8 +359,9 @@ </tr> </tbody> </table> - <p class="logins-note">{{ $t('users.logins.note') }}</p> - </template> + </div> + <p class="logins-note">{{ $t('users.logins.note', { n: logins.length }) }}</p> + </div> </section> <!-- Report modal — opens from the tear-off tab on the hero. @@ -359,7 +383,7 @@ import { formatSize, formatDay, formatAge } from '~/utils/format'; import { getCategoryIcon } from '~/utils/categoryIcon'; -const { t } = useI18n(); +const { t, te } = useI18n(); interface UserProfile { id: string; @@ -431,6 +455,17 @@ const loginsOpen = ref(false); const loginsLoading = ref(false); const logins = ref<LoginEvent[]>([]); const distinctToday = ref(0); +/* Which day the count above is about, and how many sign-ins it covers. The + route returns both and the page dropped both, so "the most recent day" could + have been today or four months ago. */ +const latestLoginDay = ref('—'); +const loginsThatDay = ref(0); + +/** The member's own vocabulary for the same enum. */ +function loginMethod(method: string): string { + const key = `settings.security.loginMethods.${method}`; + return te(key) ? t(key) : method; +} async function toggleLogins() { if (loginsOpen.value) { @@ -444,9 +479,13 @@ async function toggleLogins() { const res = await $fetch<{ items: LoginEvent[]; distinctAddressesToday: number; + distinctAddressesDay: string | null; + successfulLoginsThatDay: number; }>(url); logins.value = res.items; distinctToday.value = res.distinctAddressesToday; + latestLoginDay.value = res.distinctAddressesDay ?? '—'; + loginsThatDay.value = res.successfulLoginsThatDay; } catch { loginsLoading.value = false; return; @@ -1761,6 +1800,25 @@ useHead({ margin-bottom: 0.75rem; } .logins-lede, +/* A scroll frame for a table wider than a phone, like `.swarm-frame` on the + torrent page. Neither login table had one, nor any media query, so at 390px + the columns simply crushed each other. Focusable, because a scroll container + the keyboard cannot reach hides whatever it is scrolling. */ +.logins-frame { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.logins-frame:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} +.logins-table { min-width: 34rem; } +.logins-agent { + max-width: 18rem; + overflow-wrap: anywhere; + color: rgb(var(--fg-subtle)); + font-size: 0.75rem; +} .logins-note { margin: 0; max-width: 60ch; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 8edd612a..bddb0ff9 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -554,8 +554,9 @@ "loginHistory": "Login history", "loginHistoryHint": "Where this account has been signed in from, including attempts that failed.", "loginHistoryShow": "Show", - "loginHistoryEmpty": "No sign-ins recorded yet.", - "loginHistoryNote": "Addresses are hashed with a salt that changes daily, so two rows can only be compared within the same day. Times are UTC.", + "loginHistoryEmpty": "Nothing recorded yet — sign-ins are kept for 90 days, and this one may not have been written yet.", + "loginHistoryNote": "Addresses show as #1, #2… and are numbered per day: the same place is #1 every day, and a second number within one day means a second place that day. Times are UTC. Showing the {n} most recent sign-ins; events are kept for 90 days.", + "loginClient": "Client", "loginWhen": "When (UTC)", "loginMethod": "Method", "loginAddress": "Address", @@ -1017,8 +1018,8 @@ "address": "Address", "agent": "Client", "refused": "refused", - "multipleToday": "{count} different addresses on the most recent day.", - "note": "Addresses are hashed with a salt that changes daily, so they can only be compared within the same day — two different hashes a week apart may well be the same address." + "multipleToday": "{count} different addresses on {day}, across {logins} sign-ins.", + "note": "Addresses show as #1, #2… and are numbered per day: the same place is #1 every day, and a second number within one day means a second place that day. Showing the {n} most recent sign-ins; events are kept for 90 days." } }, "following": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 691bf731..cf806648 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -554,8 +554,9 @@ "loginHistory": "Historique de connexion", "loginHistoryHint": "D'où ce compte s'est connecté, tentatives échouées comprises.", "loginHistoryShow": "Afficher", - "loginHistoryEmpty": "Aucune connexion enregistrée pour le moment.", - "loginHistoryNote": "Les adresses sont hachées avec un sel qui change chaque jour : deux lignes ne sont comparables qu'au sein d'une même journée. Les heures sont en UTC.", + "loginHistoryEmpty": "Rien d’enregistré pour l’instant — les connexions sont conservées 90 jours, et celle-ci n’est peut-être pas encore écrite.", + "loginHistoryNote": "Les adresses s’affichent en #1, #2… et sont numérotées par journée : le même lieu est #1 chaque jour, et un second numéro dans une même journée signifie un second lieu ce jour-là. Les heures sont en UTC. Les {n} dernières connexions ; les événements sont conservés 90 jours.", + "loginClient": "Client", "loginWhen": "Quand (UTC)", "loginMethod": "Méthode", "loginAddress": "Adresse", @@ -1017,8 +1018,8 @@ "address": "Adresse", "agent": "Client", "refused": "refusée", - "multipleToday": "{count} adresses différentes sur la journée la plus récente.", - "note": "Les adresses sont hachées avec un sel qui change chaque jour : elles ne sont comparables qu'au sein d'une même journée — deux hachés différents à une semaine d'intervalle peuvent très bien être la même adresse." + "multipleToday": "{count} adresses différentes le {day}, sur {logins} connexions.", + "note": "Les adresses s’affichent en #1, #2… et sont numérotées par journée : le même lieu est #1 chaque jour, et un second numéro dans une même journée signifie un second lieu ce jour-là. Les {n} dernières connexions affichées ; les événements sont conservés 90 jours." } }, "following": { From 72222f8bc8ef93190f074c1df5a4e029acfd7378 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:50:02 +0200 Subject: [PATCH 32/96] fix(irc): the test button destroyed unsaved edits, and the state block was a snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sendTest` called `refresh()` in its `finally`, which re-ran the `watchEffect` that seeds the form from the stored config — so pressing "Say a test line" to check the bot silently reverted every unsaved field on the page, including the template just written, while the message said the test had been sent. The same `refresh()` reset `performText`, which tripped the dirty watcher, so the next save sent `perform: []` and deleted the operator's stored perform lines. The dirty flag had a second, independent instance of the same bug: `watch` flushes on the microtask queue, so `performText.value = ''` followed by `performDirty.value = false` at the end of a save ran in that order and *then* the watcher fired, leaving the flag true. Set from the textarea's own `@input` now, where ordering cannot bite. **The state block never updated.** `useFetch` runs once at mount and there was no polling and no reload button, so an operator who had just put the bot into service read "Connecting" and watched a dead word indefinitely. `audit.vue` and `Users.vue` both have a reload; this had none. Added, plus a ten-second poll while the state is still transitional. **And when it was broken, the reason was cut to 22 characters.** `status.lastError` lived in a box with `text-overflow: ellipsis` and no `title`, so "SASL authentication failed: invalid credentials" reached the operator as "SASL authenticatio…". Truncating a nick is right; truncating the diagnosis is not. It gets a full-width notice when the state is `error`, and the 22ch bound now only ever applies to a nick. The page also printed a second `h1` and a second lede under the ones `pages/admin.vue` already renders for every admin sub-page — two page titles stacked, and two announced to a screen reader — and re-centred itself at 60rem inside a column the shell already constrains, so moving between admin tabs stepped the content sideways. Rest of the pass: the master switch was inside the first card as though it belonged to the connection, and unchecking it changed nothing on screen, so all fourteen fields stayed live while nothing would be announced; no field said whether it was required; "leave empty to keep the stored secret" was carried only by a placeholder conditional on a secret existing; the count of stored perform lines lived in that same placeholder and vanished at the first keystroke, exactly when the operator is replacing lines they can no longer read; the test button was disabled with no reason given, and `.btn:disabled` sets `pointer-events: none`, which removes even the possibility of a tooltip; the stats panel showed four zeros and an em dash before the bot had ever connected; and a local `prefers-reduced-motion` rule that `main.css` had already overridden with `!important`, which is worse than no rule because the next reader believes it. --- apps/web/app/pages/admin/irc.vue | 369 +++++++++++++++++++++++++------ apps/web/i18n/locales/en.json | 18 +- apps/web/i18n/locales/fr.json | 18 +- 3 files changed, 320 insertions(+), 85 deletions(-) diff --git a/apps/web/app/pages/admin/irc.vue b/apps/web/app/pages/admin/irc.vue index e8ce0478..0d3dd84d 100644 --- a/apps/web/app/pages/admin/irc.vue +++ b/apps/web/app/pages/admin/irc.vue @@ -1,24 +1,60 @@ <template> <div class="irc"> + <!-- No page title here. `pages/admin.vue` already renders an `h1` and a + description for every admin sub-page, so this one used to print a + second `h1` and a second lede saying nearly the same thing — two page + titles stacked, and two announced to a screen reader. `audit.vue` drops + to `h2` for the same reason. --> <header class="irc-head"> - <div> - <p class="irc-eyebrow">{{ $t('admin.irc.eyebrow') }}</p> - <h1 class="irc-title">{{ $t('admin.irc.title') }}</h1> - <p class="irc-lede">{{ $t('admin.irc.lede') }}</p> - </div> - <!-- State first. An operator opening this page is almost always here to - find out whether the bot is in the channel. --> - <div class="irc-state" :class="`irc-state--${state}`"> + find out whether the bot is in the channel. A live region, because + the answer changes without a navigation — after a save, after a + reconnect — and a status nobody is told about is a status nobody + reads. --> + <div + class="irc-state" + :class="`irc-state--${state}`" + role="status" + aria-live="polite" + > <span class="irc-dot" /> <div> <p class="irc-state-label">{{ $t(`admin.irc.states.${state}`) }}</p> <p v-if="status?.nick" class="irc-state-sub">{{ status.nick }}</p> - <p v-else-if="status?.lastError" class="irc-state-sub">{{ status.lastError }}</p> </div> </div> + <!-- The state used to be a snapshot taken at mount and never touched + again: no polling, no reload, so an operator who had just put the bot + into service read "Connecting" and watched a dead word indefinitely. + `audit.vue` and `Users.vue` both have a reload; this had none. --> + <button + type="button" + class="tool-btn" + :disabled="reloading" + :title="$t('common.refresh')" + :aria-label="$t('common.refresh')" + @click="reload" + > + <Icon + :name="reloading ? 'ph:circle-notch' : 'ph:arrows-clockwise'" + :class="{ 'irc-spin': reloading }" + /> + </button> </header> + <!-- When it is broken, the reason is the most important string on the page, + and it lived in a 22-character box with `text-overflow: ellipsis` and no + `title` — so "SASL authentication failed: invalid credentials" reached + the operator as "SASL authenticatio…". Truncating a nick is right; + truncating the diagnosis is not. --> + <p v-if="state === 'error' && status?.lastError" class="irc-fault"> + <Icon name="ph:warning-circle" /> + <span> + {{ $t('admin.irc.faultLead') }} + <code>{{ status.lastError }}</code> + </span> + </p> + <!-- Said plainly, because a console that shows `idle` on a working bot is how an operator concludes the feature is broken. --> <p v-if="status && !status.leader && enabled" class="irc-notice"> @@ -27,36 +63,55 @@ </p> <form class="irc-form" @submit.prevent="save"> - <section class="irc-card"> - <h2>{{ $t('admin.irc.sections.connection') }}</h2> + <!-- The master switch, on its own. It governs all three cards below, and + sitting inside the first one it read as though it belonged to the + connection. Decking it also used to change nothing on screen: all + fourteen fields stayed live and at full opacity, so an operator could + fill the form in and never learn that nothing would be announced. --> + <div class="irc-master" :class="{ 'irc-master--off': !form.enabled }"> <label class="irc-switch"> <input v-model="form.enabled" type="checkbox" /> <span>{{ $t('admin.irc.fields.enabled') }}</span> </label> <p class="irc-help">{{ $t('admin.irc.hints.enabled') }}</p> + </div> - <div class="irc-grid"> + <!-- `inert` and not just dimmed: a field an operator cannot reach is a + clearer statement than a field that looks faded and still takes + input. --> + <div class="irc-cards" :class="{ 'irc-cards--off': !form.enabled }" :inert="!form.enabled"> + <section class="irc-card"> + <h2>{{ $t('admin.irc.sections.connection') }}</h2> + + <!-- Server, port and TLS as one explicit row rather than three cells of + an `auto-fit` grid: the port is five digits, so a 13rem track left a + 3rem hole beside it, and the checkbox was aligned to the inputs by a + hardcoded top margin that became a stray gap at one column. --> + <div class="irc-conn"> <div class="irc-field"> - <label for="irc-host">{{ $t('admin.irc.fields.host') }}</label> + <label for="irc-host">{{ $t('admin.irc.fields.host') }}<span class="irc-req">*</span></label> <input id="irc-host" v-model="form.host" class="input" autocomplete="off" placeholder="irc.example.com" /> </div> - <div class="irc-field irc-field--narrow"> - <label for="irc-port">{{ $t('admin.irc.fields.port') }}</label> + <div class="irc-field"> + <label for="irc-port">{{ $t('admin.irc.fields.port') }}<span class="irc-req">*</span></label> <input id="irc-port" v-model.number="form.port" class="input" type="number" min="1" max="65535" /> </div> - <div class="irc-field irc-field--narrow"> + <div class="irc-field"> <label class="irc-switch irc-switch--inline"> <input v-model="form.tls" type="checkbox" /> <span>{{ $t('admin.irc.fields.tls') }}</span> </label> </div> + </div> + + <div class="irc-grid"> <div class="irc-field"> - <label for="irc-nick">{{ $t('admin.irc.fields.nick') }}</label> + <label for="irc-nick">{{ $t('admin.irc.fields.nick') }}<span class="irc-req">*</span></label> <input id="irc-nick" v-model="form.nick" class="input" autocomplete="off" /> <span class="irc-help">{{ $t('admin.irc.hints.nick') }}</span> </div> <div class="irc-field"> - <label for="irc-channel">{{ $t('admin.irc.fields.channel') }}</label> + <label for="irc-channel">{{ $t('admin.irc.fields.channel') }}<span class="irc-req">*</span></label> <input id="irc-channel" v-model="form.channel" class="input" autocomplete="off" placeholder="#announce" /> </div> <div class="irc-field"> @@ -75,7 +130,11 @@ <section class="irc-card"> <h2>{{ $t('admin.irc.sections.auth') }}</h2> - <p class="irc-help">{{ $t('admin.irc.hints.auth') }}</p> + <!-- Nothing on this form said which of the fourteen fields were needed, + so an operator filling everything in out of caution set a server + password their network does not use. Four are required and marked; + the rest are not. --> + <p class="irc-help">{{ $t('admin.irc.hints.auth') }} {{ $t('admin.irc.optional') }}</p> <div class="irc-grid"> <div class="irc-field"> <label for="irc-sasl-user">{{ $t('admin.irc.fields.saslUser') }}</label> @@ -105,6 +164,13 @@ </div> </div> + <!-- The contract these three fields run on, said once. It was carried + only by the word "unchanged" in a placeholder, conditional on a + stored secret existing — so a blank field with a secret behind it + and a blank field with nothing behind it differed by one grey word + at 11px. --> + <p class="irc-help irc-help--rule">{{ $t('admin.irc.secretsKept') }}</p> + <div class="irc-field"> <label for="irc-perform">{{ $t('admin.irc.fields.perform') }}</label> <textarea @@ -113,13 +179,21 @@ class="input irc-mono" rows="3" spellcheck="false" - :placeholder=" - config?.hasPerform - ? $t('admin.irc.performKept', { n: config.performCount }) - : 'PRIVMSG Voyager :invite trackarr KEY' - " + placeholder="PRIVMSG Voyager :invite trackarr KEY" + @input="performDirty = true" /> <span class="irc-help">{{ $t('admin.irc.hints.perform') }}</span> + <!-- Out of the placeholder and into a permanent line. As a + placeholder, the count of what is stored vanished at the first + keystroke — exactly when the operator is replacing lines they can + no longer read, and has no way to know how many they are about to + overwrite or to change their mind. --> + <span v-if="config?.hasPerform" class="irc-help irc-help--kept"> + {{ $t('admin.irc.performKept', config.performCount) }} + <button type="button" class="irc-linkbtn" @click="resetPerform"> + {{ $t('admin.irc.performReset') }} + </button> + </span> </div> </section> @@ -141,9 +215,26 @@ the output, not a description of it. --> <div class="irc-preview"> <p class="irc-preview-label">{{ $t('admin.irc.preview') }}</p> - <p class="irc-preview-line">{{ preview }}</p> + <p + class="irc-preview-line" + tabindex="0" + role="group" + :aria-label="$t('admin.irc.preview')" + >{{ preview }}</p> </div> + <!-- The autobrr definition is derived from the template above, so it + belongs beside it rather than in the form's action row — where it + was a borderless `<a download>` among two buttons, and where it + appeared only after a save because it was keyed on the PERSISTED + `enabled`, shifting the row under the operator. --> + <p v-if="enabled" class="irc-export"> + <a href="/api/irc/autobrr.yml" class="btn btn-secondary btn-sm" download> + <Icon name="ph:download-simple" /> + {{ $t('admin.irc.definition') }} + </a> + </p> + <details class="irc-tokens"> <summary>{{ $t('admin.irc.tokensTitle') }}</summary> <ul> @@ -186,27 +277,33 @@ <Icon :name="testing ? 'ph:circle-notch' : 'ph:paper-plane-tilt'" :class="{ 'irc-spin': testing }" /> {{ $t('admin.irc.test') }} </button> - <a - v-if="enabled" - href="/api/irc/autobrr.yml" - class="btn btn-ghost" - download + <!-- Why it is dead, said. `.btn:disabled` sets `pointer-events: none`, + which removes even the possibility of a tooltip, so a greyed + control with no explanation was all the operator got. --> + <span v-if="state !== 'ready'" class="irc-help">{{ $t('admin.irc.testWhen') }}</span> + <!-- Always in the DOM, so an insertion is announced rather than missed, + and `aria-live` because this is the only feedback the page's main + action has. --> + <span + class="irc-message" + :class="{ 'irc-message--bad': failed }" + role="status" + aria-live="polite" > - <Icon name="ph:download-simple" /> - {{ $t('admin.irc.definition') }} - </a> - <span v-if="message" class="irc-message" :class="{ 'irc-message--bad': failed }"> {{ message }} </span> </div> + </div> </form> - <section v-if="status" class="irc-card irc-card--stats"> + <!-- Only once there is something to report. Four zeros and an em dash before + the bot has ever connected is a panel that looks like a failure. --> + <section v-if="status && status.since" class="irc-card irc-card--stats"> <h2>{{ $t('admin.irc.sections.traffic') }}</h2> <dl class="irc-stats"> - <div><dt>{{ $t('admin.irc.stats.sent') }}</dt><dd>{{ status.sent }}</dd></div> - <div><dt>{{ $t('admin.irc.stats.queued') }}</dt><dd>{{ status.queued }}</dd></div> - <div><dt>{{ $t('admin.irc.stats.dropped') }}</dt><dd>{{ status.dropped }}</dd></div> + <div><dt>{{ $t('admin.irc.stats.sent') }}</dt><dd>{{ status.sent.toLocaleString() }}</dd></div> + <div><dt>{{ $t('admin.irc.stats.queued') }}</dt><dd>{{ status.queued.toLocaleString() }}</dd></div> + <div><dt>{{ $t('admin.irc.stats.dropped') }}</dt><dd>{{ status.dropped.toLocaleString() }}</dd></div> <div> <dt>{{ $t('admin.irc.stats.since') }}</dt> <dd>{{ status.since ? new Date(status.since).toLocaleString() : '—' }}</dd> @@ -282,6 +379,33 @@ interface IrcPayload { const { data, refresh } = await useFetch<IrcPayload>('/api/admin/irc'); +const reloading = ref(false); +async function reload() { + reloading.value = true; + try { + await refresh(); + } finally { + reloading.value = false; + } +} + +/** + * Keep watching while the answer is still moving. + * + * `ready` and `idle` are settled; everything between them — connecting, + * registering, joining, error-then-retry — resolves in seconds without any + * navigation, and the page had no way to notice. Ten seconds is slow enough to + * be free and fast enough that an operator does not reach for the reload they + * now also have. + */ +const SETTLED = new Set(['ready', 'idle']); +onMounted(() => { + const timer = setInterval(() => { + if (!SETTLED.has(state.value)) void refresh(); + }, 10_000); + onBeforeUnmount(() => clearInterval(timer)); +}); + const config = computed(() => data.value?.config); const status = computed(() => data.value?.status); const tokens = computed(() => data.value?.tokens ?? []); @@ -305,15 +429,37 @@ const form = reactive({ announceAdult: false, }); const performText = ref(''); -/** Whether the admin has touched the field — see the note at the save. */ +/** + * Whether the admin has touched the field — see the note at the save. + * + * Set from the textarea's own `@input`, not from a `watch` on the value. A + * watcher flushes on the microtask queue, so `performText.value = ''` followed + * by `performDirty.value = false` at the end of a save ran in that order and + * THEN the watcher fired, leaving the flag true — and the next save therefore + * sent `perform: []` and deleted the operator's stored perform lines without + * anybody asking it to. + */ const performDirty = ref(false); -watch(performText, () => { - performDirty.value = true; -}); +function resetPerform() { + performText.value = ''; + performDirty.value = false; +} +/** + * Seed the form from the stored config — once, and never over live edits. + * + * This ran on every change to `data`, which meant every `refresh()`. The test + * button called one in its `finally`, so pressing "Say a test line" to check + * the bot silently reverted every unsaved field on the page, including the + * template the operator had just written, while the message said the test had + * been sent. A slow save did the same to whatever was being typed while it was + * in flight. + */ +const formDirty = ref(false); watchEffect(() => { const c = config.value; if (!c) return; + if (formDirty.value) return; form.enabled = !!data.value?.enabled; form.host = c.host; form.port = c.port; @@ -331,6 +477,16 @@ watchEffect(() => { performText.value = ''; }); +// Anything the operator changes marks the form dirty, which stops the seeding +// above from reaching in and overwriting it. +watch( + () => ({ ...form }), + () => { + formDirty.value = true; + }, + { deep: true } +); + /** * The line as the channel will see it, rendered here rather than round-tripped * to the server: an operator editing a template should not have to save a @@ -395,6 +551,9 @@ async function save() { performText.value = ''; performDirty.value = false; message.value = t('admin.irc.saved'); + // Saved is saved: let the seeding take over again so the page shows what is + // stored rather than a stale copy of what was typed. + formDirty.value = false; await refresh(); } catch (err: unknown) { failed.value = true; @@ -418,16 +577,20 @@ async function sendTest() { (err as { data?: { message?: string } })?.data?.message ?? t('admin.irc.failed'); } finally { testing.value = false; - await refresh(); + // Deliberately no `refresh()`. Testing the connection is not a reason to + // re-read the configuration, and doing it here is what discarded unsaved + // work. `reload()` is the operator's own control for that, and the poll + // below keeps the state block honest. } } </script> <style scoped> .irc { - max-width: 60rem; - margin: 0 auto; - padding: 1.5rem 1rem 3rem; + /* No own max-width and no own gutter: `pages/admin.vue` already constrains + the column this sits in, so re-centring inside it at 60rem indented the + content by a gutter and capped it 20rem short of its neighbours — moving + from Audit to IRC stepped everything sideways. */ display: flex; flex-direction: column; gap: 1.25rem; @@ -435,28 +598,8 @@ async function sendTest() { .irc-head { display: flex; flex-wrap: wrap; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; -} -.irc-eyebrow { - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: calc(0.16em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); -} -.irc-title { - font-family: var(--font-display); - font-size: 1.75rem; - font-weight: 700; - color: rgb(var(--fg-strong)); -} -.irc-lede { - font-size: 0.8125rem; - color: rgb(var(--fg-muted)); - max-width: 48ch; - margin-top: 0.25rem; + align-items: center; + gap: 0.75rem; } /* ── state ────────────────────────────────────────────────────────────── */ @@ -488,11 +631,31 @@ async function sendTest() { font-family: var(--font-mono); font-size: 0.6875rem; color: rgb(var(--fg-subtle)); + /* Only ever a nick now, which is what this bound was right for. */ max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* The diagnosis, full width and wrapping. */ +.irc-fault { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.6rem 0.8rem; + border: 1px solid rgb(var(--danger) / 0.4); + border-radius: var(--radius-sm); + background: rgb(var(--danger) / 0.08); + font-size: 0.75rem; + line-height: 1.5; + color: rgb(var(--fg-default)); +} +.irc-fault svg { flex: none; margin-top: 0.1rem; color: rgb(var(--danger)); } +.irc-fault code { + font-family: var(--font-mono); + overflow-wrap: anywhere; + color: rgb(var(--fg-strong)); +} .irc-notice { display: flex; @@ -508,6 +671,32 @@ async function sendTest() { /* ── form ─────────────────────────────────────────────────────────────── */ .irc-form { display: flex; flex-direction: column; gap: 1rem; } +.irc-cards { display: flex; flex-direction: column; gap: 1rem; } +.irc-cards--off { opacity: 0.55; } +.irc-master { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 1rem; + padding: 0.6rem 0.9rem; + border: 1px solid rgb(var(--line-strong)); + border-radius: var(--radius-md); + background: rgb(var(--bg-elevated)); +} +.irc-master--off { border-color: rgb(var(--line-default)); } +.irc-conn { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.75rem; + align-items: end; +} +@media (min-width: 40rem) { + .irc-conn { grid-template-columns: minmax(0, 1fr) 7rem auto; } +} +.irc-req { + color: rgb(var(--accent-warm)); + margin-left: 0.15rem; +} .irc-card { border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-md); @@ -526,9 +715,20 @@ async function sendTest() { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: 0.75rem; + /* From the top. `end` was an attempt to line a checkbox up with its + neighbours' inputs, and it made every row bottom-aligned instead — so a + cell with a three-line hint pushed its own label above the others'. The + server/port/TLS row that actually needed it has its own grid. */ + align-items: start; } .irc-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } -.irc-field--narrow { max-width: 10rem; } +/* A port is five digits: `max-width` inside a 13rem track left a 3rem hole in + the middle of the first row, and at one column it stranded a 160px field in + 350px of space. Narrowing the TRACK is what was wanted. */ +.irc-field--narrow { max-width: 100%; } +@media (min-width: 40rem) { + .irc-field--narrow { max-width: 8rem; } +} .irc-field label { font-size: 0.625rem; font-weight: 700; @@ -537,6 +737,22 @@ async function sendTest() { color: rgb(var(--fg-muted)); } .irc-help { font-size: 0.6875rem; color: rgb(var(--fg-subtle)); } +/* The count of what is stored, and the way back to it. */ +.irc-help--kept { color: rgb(var(--fg-muted)); } +.irc-help--rule { + padding-top: 0.5rem; + border-top: 1px solid rgb(var(--line-default)); +} +.irc-linkbtn { + background: none; + border: 0; + padding: 0; + font: inherit; + color: rgb(var(--info)); + cursor: pointer; + text-decoration: underline; +} +.irc-export { margin-top: 0.2rem; } .irc-mono { font-family: var(--font-mono); font-size: 0.75rem; } .irc-switch { display: flex; @@ -548,7 +764,9 @@ async function sendTest() { /* The whole row is the target, so the checkbox is not a 13px hit area. */ min-height: 2.25rem; } -.irc-switch--inline { margin-top: 1rem; } +/* No `margin-top` hack. `align-items: end` on the grid does the alignment, and + the hack became a stray 16px gap the moment the grid dropped to one column. */ +.irc-switch--inline { min-height: 2.25rem; } .irc-preview { border: 1px dashed rgb(var(--line-strong)); @@ -571,6 +789,10 @@ async function sendTest() { overflow-x: auto; white-space: pre; } +.irc-preview-line:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} .irc-tokens { font-size: 0.75rem; } .irc-tokens summary { cursor: pointer; color: rgb(var(--fg-muted)); padding: 0.2rem 0; } @@ -610,9 +832,10 @@ async function sendTest() { .irc-message--bad { color: rgb(var(--danger)); } .irc-spin { animation: irc-spin 900ms linear infinite; } @keyframes irc-spin { to { transform: rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { - .irc-spin { animation-duration: 2.4s; } -} +/* No local `prefers-reduced-motion` block: `main.css` already overrides every + animation duration to 0.01ms with `!important`, so the 2.4s this used to + declare never applied. Code that claims to handle a case already handled is + worse than no code, because the next reader believes it. */ .irc-card--stats { gap: 0.5rem; } .irc-stats { diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index bddb0ff9..129a3904 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -25,6 +25,7 @@ "size": "Size", "category": "Category", "hash": "Hash", + "refresh": "Refresh", "copy": "Copy", "copied": "Copied", "open": "Open", @@ -3907,16 +3908,16 @@ "irc": { "eyebrow": "Integration", "title": "IRC announce channel", - "lede": "One line in a channel per accepted upload — the mechanism autobrr and autodl-irssi race on. An RSS feed is polled; a channel message arrives.", - "kept": "unchanged", + "lede": "One line in a channel per accepted upload — the mechanism autobrr and autodl-irssi watch. An RSS feed has to be asked; a channel message arrives on its own.", + "kept": "saved", "saved": "Saved.", - "failed": "That did not work.", + "failed": "Saving failed. Check that the API is reachable and try again.", "test": "Say a test line", "tested": "Sent: {line}", "definition": "autobrr definition", "preview": "What the channel will see", "tokensTitle": "Fields you can put in the line", - "unmapped": "for people", + "unmapped": "not in the pattern", "notLeader": "Another API instance holds the connection — exactly one does, so that a release is announced once. The state above is what this instance knows.", "states": { "idle": "Not announcing", @@ -3959,12 +3960,17 @@ "dropped": "Lines dropped because the queue was full while the connection was down. The oldest go first: on an announce channel a stale release is worth less than a fresh one." }, "stats": { - "sent": "Lines said", + "sent": "Lines sent", "queued": "Waiting", "dropped": "Dropped", "since": "In the channel since" }, - "performKept": "{n} line(s) stored — type to replace them" + "performKept": "1 line stored. Writing here replaces it; leaving it empty keeps it. | {n} lines stored. Writing here replaces them all; leaving it empty keeps them.", + "performReset": "Keep them", + "optional": "Everything in this card is optional.", + "secretsKept": "Leave a password field empty to keep the one already saved.", + "faultLead": "The last attempt failed:", + "testWhen": "Available once the bot is in the channel." } }, "mod": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index cf806648..5ed48074 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -25,6 +25,7 @@ "size": "Taille", "category": "Catégorie", "hash": "Hash", + "refresh": "Rafraîchir", "copy": "Copier", "copied": "Copié", "open": "Ouvrir", @@ -3907,16 +3908,16 @@ "irc": { "eyebrow": "Intégration", "title": "Canal d’annonce IRC", - "lede": "Une ligne dans un canal par upload accepté — le mécanisme sur lequel autobrr et autodl-irssi courent. Un flux RSS se sonde ; un message de canal arrive.", - "kept": "inchangé", + "lede": "Une ligne dans un canal par upload accepté — le mécanisme qu’autobrr et autodl-irssi surveillent. Un flux RSS, il faut l’interroger ; un message de canal arrive de lui-même.", + "kept": "enregistré", "saved": "Enregistré.", - "failed": "Ça n’a pas fonctionné.", + "failed": "L’enregistrement a échoué. Vérifiez que l’API répond, puis réessayez.", "test": "Envoyer une ligne de test", "tested": "Envoyé : {line}", "definition": "Définition autobrr", "preview": "Ce que verra le canal", "tokensTitle": "Champs disponibles dans la ligne", - "unmapped": "pour les humains", + "unmapped": "pas dans le motif", "notLeader": "Une autre instance de l’API tient la connexion — une seule le fait, pour qu’une release soit annoncée une fois. L’état ci-dessus est ce que cette instance sait.", "states": { "idle": "Pas d’annonce", @@ -3959,12 +3960,17 @@ "dropped": "Lignes abandonnées parce que la file était pleine pendant une coupure. Les plus anciennes partent d’abord : sur un canal d’annonce, une release rassie vaut moins qu’une fraîche." }, "stats": { - "sent": "Lignes dites", + "sent": "Lignes envoyées", "queued": "En attente", "dropped": "Abandonnées", "since": "Dans le canal depuis" }, - "performKept": "{n} ligne(s) enregistrée(s) — écrivez pour les remplacer" + "performKept": "1 ligne enregistrée. Écrire ici la remplace ; laisser vide la conserve. | {n} lignes enregistrées. Écrire ici les remplace toutes ; laisser vide les conserve.", + "performReset": "Les conserver", + "optional": "Tout ce qui figure dans cette carte est facultatif.", + "secretsKept": "Laissez un champ de mot de passe vide pour conserver celui déjà enregistré.", + "faultLead": "La dernière tentative a échoué :", + "testWhen": "Disponible quand le bot est dans le canal." } }, "mod": { From 6044e5e8ef88f45605c48f5a36da24a60d095aaf Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:51:01 +0200 Subject: [PATCH 33/96] fix(audit): a fresh install read as a broken page, and the register was not dense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One empty state for two very different situations. `No entry matches these filters` rendered whenever the table came back empty — so on a new install, with no filter set, the operator read a sentence about filters they had never touched and reasonably concluded the page was broken. Two strings now, and the filtered one offers the clear-filters control that already existed. **The rows were 53px where the house `.data-table` is 36px.** Two cells were vertical piles — actor over role, type over label — so fifty entries came to 2,750px of scrolling on a register whose own style comment promises "a ledger, not a dashboard". Both are inline now, and the request line came out from behind the chevron and onto the same line as the action key. 43px, and the row says more than it did. That chevron was the other half of it: `hasDetail` included `!!row.path`, which is always set, so it appeared on all fifty rows and opening most of them revealed two values. An operator learns to ignore a control that pays nothing — and then misses the rows carrying a real diff. Neither the actor nor the target was clickable. Reading "alice banned bob" and being able to reach neither of them, on a page whose whole job is answering "who did this to whom", meant copying a username and changing page. `.au-table` reimplemented `.data-table` without the two things that make a wide table scannable: no row hover, so following one row from its timestamp to its status code across six columns and 46rem of minimum width was done by eye, and no sticky header, so the column names left the viewport on the first scroll. The first column is sticky too, because on a phone the horizontal scroll otherwise takes the timestamp — the reading key of a register — off screen. While a new filter was in flight, `useFetch` kept the previous page in `data`, so fifty rows from the last query stayed on screen at full opacity with no way to tell whether what you were reading answered your filter yet. Copy: the lede explained the append-only model, the retention and who may read to an operator who opened the register to look at rows — one sentence stays, the rest moved to the footnote where that belongs. The count beside the title is the *filtered* count and now says so. "Refused only" filtered on `!ok(status)`, which catches 5xx as well, so an operator reading three lines could not tell three refusals from three crashes — "Failures only". And the French column header was an adverb, "Quand (UTC)", where French wants a noun. Plus nine hardcoded radii and five hardcoded `letter-spacing` values, on two of the only pages in the panel that ignored the tokens this product sells as operator-configurable; a 19px checkbox where `.irc-switch` in the same batch of work commented exactly why 36px; a chevron that snapped while the rest of the panel animates; and `tabindex` on the two scroll containers a keyboard could reach but not scroll. --- apps/web/app/pages/admin/audit.vue | 187 +++++++++++++++++++++++++---- apps/web/i18n/locales/en.json | 14 ++- apps/web/i18n/locales/fr.json | 16 +-- 3 files changed, 179 insertions(+), 38 deletions(-) diff --git a/apps/web/app/pages/admin/audit.vue b/apps/web/app/pages/admin/audit.vue index ee4aa21d..598a7aa7 100644 --- a/apps/web/app/pages/admin/audit.vue +++ b/apps/web/app/pages/admin/audit.vue @@ -3,11 +3,18 @@ <!-- ── Head ────────────────────────────────────────────── --> <header class="au-head"> <div> - <p class="au-eyebrow">{{ $t('admin.audit.eyebrow') }}</p> <h2 class="au-title"> {{ $t('admin.audit.title') }} - <span class="au-title-accent tabular-nums">{{ data?.total ?? 0 }}</span> + <!-- Labelled, because a bare number beside a title reads as a total + and this one is the FILTERED count. --> + <span class="au-title-accent tabular-nums"> + {{ $t('admin.audit.matching', data?.total ?? 0) }} + </span> </h2> + <!-- One sentence. The other three explained the model — append-only, + retention, who may read — to an operator who opened the register to + look at rows, and they now sit in the footnote where that kind of + thing belongs. --> <p class="au-lede">{{ $t('admin.audit.lede') }}</p> </div> <button @@ -15,6 +22,7 @@ class="tool-btn" :disabled="loading" :title="$t('admin.audit.refresh')" + :aria-label="$t('admin.audit.refresh')" @click="refreshData()" > <Icon name="ph:arrows-clockwise-bold" :class="{ 'animate-spin': loading }" /> @@ -71,7 +79,18 @@ </section> <!-- ── The register ────────────────────────────────────── --> - <section class="au-table-wrap" :aria-label="$t('admin.audit.tableLabel')"> + <!-- Focusable, and dimmed while a new filter is in flight: `useFetch` keeps + the previous page in `data`, so the fifty rows from the last filter + stayed on screen at full opacity and an operator had no way to tell + whether what they were reading answered their query yet. --> + <section + class="au-table-wrap" + :class="{ 'au-table-wrap--loading': loading }" + tabindex="0" + role="region" + :aria-label="$t('admin.audit.tableLabel')" + :aria-busy="loading" + > <table class="au-table"> <thead> <tr> @@ -84,25 +103,61 @@ </tr> </thead> <tbody> + <!-- Two states, two sentences. On a fresh install, with no filter + set, the operator read "No entry matches these filters" about + filters they had not touched — and reasonably concluded the page + was broken. --> <tr v-if="!loading && !rows.length"> - <td colspan="6" class="au-empty">{{ $t('admin.audit.empty') }}</td> + <td colspan="6" class="au-empty"> + <template v-if="anyFilter"> + {{ $t('admin.audit.emptyFiltered') }} + <button type="button" class="au-linkbtn" @click="clearFilters"> + {{ $t('admin.audit.clear') }} + </button> + </template> + <template v-else>{{ $t('admin.audit.emptyFresh') }}</template> + </td> </tr> <template v-for="row in rows" :key="row.id"> <tr :class="{ 'au-row--failed': !ok(row.statusCode) }"> <td class="au-when"> <time :datetime="row.createdAt">{{ stamp(row.createdAt) }}</time> </td> + <!-- Inline, not stacked. Two cells were vertical piles, which put + every row at ~55px against the house table's ~36px: a page of + 50 entries came to 2,750px of scrolling on a register whose + own style comment promises "a ledger, not a dashboard". And + both names are links now — an operator reading "alice banned + bob" could reach neither of them without copying a username + and changing page. --> <td class="au-actor"> - <span class="au-actor-name">{{ row.actorName }}</span> + <NuxtLink + v-if="row.actorId" + :to="`/users/${row.actorId}`" + class="au-actor-name" + >{{ row.actorName }}</NuxtLink> + <span v-else class="au-actor-name">{{ row.actorName }}</span> <span class="au-role" :class="`au-role--${row.actorRole}`"> {{ row.actorRole }} </span> </td> - <td><code class="au-action">{{ row.action }}</code></td> + <td> + <code class="au-action">{{ row.action }}</code> + <!-- The request, in the open. It was behind a chevron that + appeared on every row because `path` is always set, so an + operator learned to ignore the chevron — and therefore also + missed the rows carrying a real diff. --> + <code class="au-request">{{ row.method }} {{ row.path }}</code> + </td> <td class="au-target"> <template v-if="row.targetLabel || row.targetId"> <span v-if="row.targetType" class="au-target-type">{{ row.targetType }}</span> - <span class="au-target-label">{{ row.targetLabel || row.targetId }}</span> + <NuxtLink + v-if="row.targetType === 'user' && row.targetId" + :to="`/users/${row.targetId}`" + class="au-target-label" + >{{ row.targetLabel || row.targetId }}</NuxtLink> + <span v-else class="au-target-label">{{ row.targetLabel || row.targetId }}</span> </template> <span v-else class="au-dash">—</span> </td> @@ -117,6 +172,7 @@ type="button" class="tool-btn tool-btn--sm" :aria-expanded="expanded === row.id" + :aria-controls="`au-detail-${row.id}`" :aria-label="$t('admin.audit.col.detail')" @click="expanded = expanded === row.id ? null : row.id" > @@ -128,7 +184,7 @@ </td> </tr> <tr v-if="expanded === row.id" class="au-detail-row"> - <td colspan="6"> + <td :id="`au-detail-${row.id}`" colspan="6"> <dl class="au-detail"> <div> <dt>{{ $t('admin.audit.detail.request') }}</dt> @@ -143,7 +199,14 @@ </div> <div v-if="row.changes"> <dt>{{ $t('admin.audit.detail.changes') }}</dt> - <dd><pre class="au-json">{{ pretty(row.changes) }}</pre></dd> + <dd> + <pre + class="au-json" + tabindex="0" + role="region" + :aria-label="$t('admin.audit.detail.changes')" + >{{ pretty(row.changes) }}</pre> + </dd> </div> </dl> </td> @@ -361,7 +424,17 @@ async function saveRetention() { } const ok = (status: number) => status >= 200 && status < 300; -const hasDetail = (row: AuditRow) => !!row.changes || !!row.actorIpHash || !!row.path; +/** + * Whether opening this row shows anything the row does not already say. + * + * It used to include `!!row.path`, which is always set — so the chevron + * appeared on every one of the fifty rows, and opening most of them revealed + * two values: the request line and a hash. An operator learns to ignore a + * control that pays nothing, and then misses the rows that carry a real diff. + * The request line is now printed in the Action cell, so the chevron means + * exactly one thing: there is a change to read. + */ +const hasDetail = (row: AuditRow) => !!row.changes || !!row.actorIpHash; /** * Pinned to `en-GB` for the same reason the account page pins its locale: a @@ -413,7 +486,7 @@ useHead({ title: () => t('admin.audit.title') }); .au-eyebrow { margin: 0 0 0.25rem; font-size: 0.7rem; - letter-spacing: 0.14em; + letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); } @@ -444,7 +517,7 @@ useHead({ title: () => t('admin.audit.title') }); gap: 0.75rem; padding: 0.9rem; border: 1px solid rgb(var(--line-default)); - border-radius: 0.5rem; + border-radius: var(--radius-md); background: rgb(var(--bg-surface)); } .au-field { @@ -456,7 +529,10 @@ useHead({ title: () => t('admin.audit.title') }); .au-field--grow { flex: 1 1 14rem; } +/* 36px, like `.irc-switch` in the same batch of work, which commented exactly + why: the whole row is the target, not a 13px checkbox. */ .au-toggle { + min-height: 2.25rem; display: flex; align-items: center; gap: 0.4rem; @@ -467,10 +543,19 @@ useHead({ title: () => t('admin.audit.title') }); /* ── Table ───────────────────────────────────────────────────── */ /* Its own scroll container: the action and path columns are long, and the page body must never scroll sideways. */ +.au-table-wrap:focus-visible, +.au-json:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} +.au-table-wrap--loading { + opacity: 0.5; + pointer-events: none; +} .au-table-wrap { overflow-x: auto; border: 1px solid rgb(var(--line-default)); - border-radius: 0.5rem; + border-radius: var(--radius-md); } .au-table { width: 100%; @@ -482,20 +567,52 @@ useHead({ title: () => t('admin.audit.title') }); text-align: left; padding: 0.6rem 0.75rem; font-size: 0.7rem; - letter-spacing: 0.08em; + /* Scaled with the theme, like every other micro-label on the site. */ + letter-spacing: calc(0.08em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); border-bottom: 1px solid rgb(var(--line-default)); white-space: nowrap; + /* Pinned. Fifty rows means the header leaves the viewport on the first + scroll, and after that six columns of dense text have no names — the + cheapest scanning win on the page. */ + position: sticky; + top: 0; + z-index: 1; + background: rgb(var(--bg-surface)); } .au-table td { - padding: 0.55rem 0.75rem; + padding: 0.45rem 0.75rem; border-bottom: 1px solid rgb(var(--line-default)); - vertical-align: top; + vertical-align: middle; } +.au-detail-row td { vertical-align: top; } .au-table tbody tr:last-child td { border-bottom: 0; } +/* The same 3 % wash the house `.data-table` uses. Following one row from its + timestamp to its status code across six columns and 46rem of minimum width + was being done by eye; this is not decoration, it is the scanning tool. */ +.au-table tbody tr:hover td { + background-color: rgb(var(--fg-default) / 0.03); +} +.au-table tbody tr.au-detail-row:hover td { background-color: transparent; } +/* The first column carries the reading key of a register, so it stays put when + the table is scrolled sideways on a phone. */ +.au-when { + position: sticky; + left: 0; + background: rgb(var(--bg-surface)); +} +.au-linkbtn { + background: none; + border: 0; + padding: 0; + font: inherit; + color: rgb(var(--info)); + cursor: pointer; + text-decoration: underline; +} .au-num { text-align: right; white-space: nowrap; @@ -507,15 +624,30 @@ useHead({ title: () => t('admin.audit.title') }); } .au-actor { display: flex; - flex-direction: column; - gap: 0.15rem; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem; } .au-actor-name { font-weight: 600; } +a.au-actor-name:hover { color: rgb(var(--accent-warm)); } +/* Inline with the action key, not stacked under it. + Surfacing the request line was right — behind a chevron that appeared on + every row, it taught operators to ignore the chevron — but putting it on its + own line just moved the row's second line from the actor cell to the action + cell and left the height at 53px against the house table's 36px. Inline it + wraps only on a narrow column. */ +.au-request { + margin-left: 0.4rem; + font-family: var(--font-mono); + font-size: 0.6875rem; + color: rgb(var(--fg-subtle)); + overflow-wrap: anywhere; +} .au-role { font-size: 0.65rem; - letter-spacing: 0.06em; + letter-spacing: calc(0.06em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); } @@ -529,19 +661,21 @@ useHead({ title: () => t('admin.audit.title') }); } .au-target { display: flex; - flex-direction: column; - gap: 0.15rem; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem; max-width: 18rem; } .au-target-type { font-size: 0.65rem; - letter-spacing: 0.06em; + letter-spacing: calc(0.06em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); } .au-target-label { word-break: break-word; } +a.au-target-label:hover { color: rgb(var(--accent-warm)); } .au-dash, .au-empty { color: rgb(var(--fg-subtle)); @@ -561,6 +695,9 @@ useHead({ title: () => t('admin.audit.title') }); .au-row--failed td:first-child { box-shadow: inset 2px 0 0 rgb(var(--danger)); } +.au-caret { + transition: transform var(--dur-2) ease; +} .au-caret--open { transform: rotate(180deg); } @@ -577,7 +714,7 @@ useHead({ title: () => t('admin.audit.title') }); } .au-detail dt { font-size: 0.68rem; - letter-spacing: 0.08em; + letter-spacing: calc(0.08em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); margin-bottom: 0.2rem; @@ -597,7 +734,7 @@ useHead({ title: () => t('admin.audit.title') }); font-size: 0.76rem; line-height: 1.5; border: 1px solid rgb(var(--line-default)); - border-radius: 0.35rem; + border-radius: var(--radius-sm); background: rgb(var(--bg-inset)); } @@ -621,7 +758,7 @@ useHead({ title: () => t('admin.audit.title') }); gap: 1rem; padding: 0.9rem; border: 1px solid rgb(var(--line-default)); - border-radius: 0.5rem; + border-radius: var(--radius-md); background: rgb(var(--bg-surface)); } .au-retention-title { diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 129a3904..859114cc 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -3847,9 +3847,9 @@ "toggleAria": "Open the icon picker" }, "audit": { - "eyebrow": "Accountability", + "eyebrow": "Traceability", "title": "Audit log", - "lede": "Every mutating request to the admin and moderation consoles, with the account that made it. Append-only: entries cannot be edited or deleted, and leave only when the retention period expires. Readable by admins; moderator actions appear here too.", + "lede": "Every change made from the staff consoles, with the account that made it.", "refresh": "Reload", "filtersLabel": "Filters", "tableLabel": "Audit entries", @@ -3860,11 +3860,13 @@ "anyAction": "Any action", "from": "From", "to": "To", - "failuresOnly": "Refused only", + "failuresOnly": "Failures only", "clear": "Clear filters", - "empty": "No entry matches these filters.", + "emptyFiltered": "No entry matches these filters.", + "emptyFresh": "Nothing has been recorded yet. An entry appears as soon as a staff member changes something.", + "matching": "1 entry | {n} entries", "pageOf": "Page {page} of {pages}", - "foot": "Times are UTC. A refused request is recorded too — a run of them from one account is the pattern this register exists to show. Post-panic recovery is not logged: after panic mode there is no session, so there is no account to name.", + "foot": "Times are UTC. Append-only — an entry cannot be edited or deleted, and leaves only when the retention period expires. A refused request is recorded too: a run of them from one account is the pattern this register exists to show. Recovery from panic mode is not recorded, because there is no session then and so no account to name.", "col": { "when": "When (UTC)", "actor": "Staff", @@ -3882,7 +3884,7 @@ "retention": { "label": "Retention", "title": "How long entries are kept", - "text": "Days before an entry is swept. 0 keeps them indefinitely. Published on the public privacy page, so members can read the period that applies to decisions taken about them.", + "text": "Days before an entry is swept. 0 keeps them indefinitely, and the period is published on the public privacy page.", "days": "Days", "save": "Save", "saved": "Retention updated.", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 5ed48074..a00b0f4c 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -3847,9 +3847,9 @@ "toggleAria": "Ouvrir le sélecteur d'icônes" }, "audit": { - "eyebrow": "Redevabilité", + "eyebrow": "Traçabilité", "title": "Journal d'audit", - "lede": "Chaque requête modifiante vers les consoles d'administration et de modération, avec le compte qui l'a faite. En ajout seul : une entrée ne peut être ni modifiée ni supprimée, et ne part qu'à l'expiration du délai de conservation. Lisible par les administrateurs ; les actions des modérateurs y figurent aussi.", + "lede": "Chaque modification faite depuis les consoles du personnel, avec le compte qui l’a faite.", "refresh": "Recharger", "filtersLabel": "Filtres", "tableLabel": "Entrées d'audit", @@ -3860,13 +3860,15 @@ "anyAction": "N'importe quelle action", "from": "Du", "to": "Au", - "failuresOnly": "Refusées seulement", + "failuresOnly": "Échecs seulement", "clear": "Effacer les filtres", - "empty": "Aucune entrée ne correspond à ces filtres.", + "emptyFiltered": "Aucune entrée ne correspond à ces filtres.", + "emptyFresh": "Rien n’a encore été enregistré. Une entrée apparaît dès qu’un membre du personnel modifie quelque chose.", + "matching": "1 entrée | {n} entrées", "pageOf": "Page {page} sur {pages}", - "foot": "Les heures sont en UTC. Une requête refusée est enregistrée aussi — une série de refus depuis un même compte est précisément le motif que ce registre existe pour montrer. La restauration après un panic n'est pas journalisée : après le panic mode il n'y a plus de session, donc aucun compte à nommer.", + "foot": "Les heures sont en UTC. En ajout seul : une entrée ne peut être ni modifiée ni supprimée, et ne part qu’à l’expiration du délai de conservation. Une requête refusée est enregistrée aussi — une série de refus depuis un même compte est précisément le motif que ce registre existe pour montrer. La sortie du panic mode n’est pas journalisée : il n’y a alors plus de session, donc aucun compte à nommer.", "col": { - "when": "Quand (UTC)", + "when": "Horodatage (UTC)", "actor": "Personnel", "action": "Action", "target": "Cible", @@ -3882,7 +3884,7 @@ "retention": { "label": "Conservation", "title": "Durée de conservation des entrées", - "text": "Nombre de jours avant balayage d'une entrée. 0 les conserve sans limite. Publié sur la page de confidentialité publique, pour que les membres puissent lire le délai qui s'applique aux décisions les concernant.", + "text": "Nombre de jours avant balayage d’une entrée. 0 les conserve sans limite, et le délai est publié sur la page de confidentialité publique.", "days": "Jours", "save": "Enregistrer", "saved": "Conservation mise à jour.", From 19667b0d85a82d163cf684b2dc85fb6c29d71350 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:52:36 +0200 Subject: [PATCH 34/96] fix(invites): the chain was flat, the truncation notice was under the wrong half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.tree-chain-item` indents on `calc(1.1rem * var(--i))` and nothing ever set `--i`. The `<li>` carried no `:style`, so the fallback `0` applied to every row and the ancestor chain rendered perfectly flat — while the comment directly above the rule described a staircase: "Each generation steps right, so the chain reads as a chain rather than as a list of unrelated names." That is exactly what an operator saw, a list of unrelated names. **The truncation notice sat in the descendant section and covered both directions.** The route returned one `truncated: truncatedUp || truncatedDown`, and the view rendered it under "who they invited" — so when it was the chain *above* that hit the ceiling, the operator was told the branch below was incomplete, and investigated the wrong end of the tree. One flag per direction now, one notice per section, each naming the limit that actually bit. **And the notice for the ancestor limit could never render.** It lived in the `v-if="!data.ancestors.length"` branch, while `ancestorsEnd === 'depth-limit'` requires ten ancestors to have been found — the condition was self-contradictory, the string was dead code, and the real case fell through to the list with no warning at all. A moderator tracing a filiation up ten generations concluded the topmost account shown was the original sponsor. `truncatedUp` itself was wrong in the other direction: set as soon as a tenth ancestor was found, so a chain that is exactly ten deep with the founder at the top reported itself as truncated. One extra probe answers the question the flag claims to answer. **The page did not carry the number it exists for.** Its own comment says the procedure is "the one every tracker runs after a ban" — look at the sponsor, then at their other invitees — and how many of those are banned was computable from data already on the page and shown nowhere. Neither was `invitedAt`, which is on the payload and on the interface and was never rendered, so a three-year-old sponsorship and yesterday's looked the same. **No way in from a member.** `grep` for the route across the app returned the nav file and nothing else, so the procedure that starts on a suspect account required leaving it, opening the sidebar, and retyping the username. It is now a link in the staff section of any profile, and the page reads `?userId=`. The descendant tree also drew one guide line for every generation at the same 0.25rem from the left edge, so past the third they stacked into a single bar and nothing said which sponsor a name belonged to; indented without bound, putting generation ten 176px in on a 350px phone; produced a flat run of `<div>`s with no `role`, `aria-level` or nesting, so a screen reader heard a list of names with no hierarchy at all — the one thing the page exists to convey; and marked "banned" with a colour and a small glyph and no text, on a page with no legend. Rows were ~20px targets in a list that can run to 400 entries, where every mis-tap navigates to the wrong member. Copy: `Invited by` and `Invited by {name}` were the two halves of the page, of opposite meaning, separated by one word in spaced capitals — and in French by a single letter. "Sponsors" / "Invited by alice". Plus a first screen that was an empty search box and nothing else. --- apps/api/routes/api/admin/invites/tree.get.ts | 30 ++- apps/web/app/components/admin/InviteTree.vue | 174 ++++++++++++++++-- .../app/components/admin/InviteTreeNode.vue | 83 ++++++++- apps/web/app/pages/users/[id].vue | 12 ++ apps/web/i18n/locales/en.json | 11 +- apps/web/i18n/locales/fr.json | 11 +- 6 files changed, 293 insertions(+), 28 deletions(-) diff --git a/apps/api/routes/api/admin/invites/tree.get.ts b/apps/api/routes/api/admin/invites/tree.get.ts index 28d759c1..dff1940e 100644 --- a/apps/api/routes/api/admin/invites/tree.get.ts +++ b/apps/api/routes/api/admin/invites/tree.get.ts @@ -129,8 +129,23 @@ export default defineEventHandler(async (event) => { ancestors.push(toNode(row, depth, invite.usedAt)); cursor = row.id; + } - if (depth === MAX_DEPTH) truncatedUp = true; + /** + * Whether the chain actually continues past the tenth generation. + * + * This used to be `depth === MAX_DEPTH` inside the loop, which is true as soon + * as a tenth ancestor is found — so a chain that is EXACTLY ten deep, with the + * founder at the top, reported itself as truncated. One extra probe answers + * the question the flag is claiming to answer. + */ + if (ancestors.length === MAX_DEPTH) { + const [further] = await db + .select({ createdBy: schema.invitations.createdBy }) + .from(schema.invitations) + .where(eq(schema.invitations.usedBy, cursor)) + .limit(1); + truncatedUp = !!further && !seen.has(further.createdBy); } // ── Down ─────────────────────────────────────────────────────────── @@ -237,6 +252,19 @@ export default defineEventHandler(async (event) => { * record is incomplete. */ ancestorsEnd: truncatedUp ? 'depth-limit' : 'root', + /** + * One flag per direction, because the console renders one notice per + * section. + * + * A single `truncatedUp || truncatedDown` meant an up-truncation printed + * "truncated at 400 members or 10 generations" underneath the DESCENDANT + * list — telling the operator the branch they can see is incomplete when it + * is the chain above that is. On a page whose whole job is tracing a + * filiation, that sends the investigation to the wrong end of the tree. + * `truncated` stays for anything already reading it. + */ + truncatedUp, + truncatedDown, truncated: truncatedUp || truncatedDown, limits: { maxDepth: MAX_DEPTH, maxNodes: MAX_NODES }, nodeCount, diff --git a/apps/web/app/components/admin/InviteTree.vue b/apps/web/app/components/admin/InviteTree.vue index da76d388..742225f0 100644 --- a/apps/web/app/components/admin/InviteTree.vue +++ b/apps/web/app/components/admin/InviteTree.vue @@ -27,7 +27,26 @@ </li> </ul> - <p v-if="error" class="tree-error">{{ error }}</p> + <!-- A legend, because "banned" was an icon and a colour with no text, and + nothing on the page said what red meant. --> + <p class="tree-legend"> + <span class="tree-legend-item tree-legend-item--banned"> + <Icon name="ph:prohibit-bold" />{{ $t('admin.inviteTree.legendBanned') }} + </span> + <span class="tree-legend-item tree-legend-item--erased"> + <Icon name="ph:user-minus" />{{ $t('admin.inviteTree.legendErased') }} + </span> + </p> + + <p v-if="error" class="tree-error" role="alert">{{ error }}</p> + + <!-- What the page is for, said before there is anything on it. The first + screen used to be an empty search box and nothing else: no title, no + lede, no legend, no hint of what a result looks like. --> + <div v-else-if="!data && !busy" class="tree-blank"> + <Icon name="ph:git-fork" class="tree-blank-icon" /> + <p>{{ $t('admin.inviteTree.blank') }}</p> + </div> <template v-if="data"> <!-- Upwards: who vouched for them, nearest first. The chain reads from @@ -36,14 +55,32 @@ <section class="tree-block"> <h3 class="tree-block-title">{{ $t('admin.inviteTree.invitedBy') }}</h3> <p v-if="!data.ancestors.length" class="tree-note"> - {{ - data.ancestorsEnd === 'root' - ? $t('admin.inviteTree.noInviter') - : $t('admin.inviteTree.depthLimit') - }} + {{ $t('admin.inviteTree.noInviter') }} + </p> + <!-- Out of the `!ancestors.length` branch it used to live in, where it + could never render: `ancestorsEnd === 'depth-limit'` requires ten + ancestors to have been found, so the condition was + self-contradictory and the message was dead code. The real case — + ten found, the chain continues — fell through to the list with no + warning at all, and an operator tracing a filiation concluded the + topmost account shown was the original sponsor. --> + <p v-if="data.truncatedUp" class="tree-note tree-note--warn"> + <Icon name="ph:warning-bold" /> + {{ $t('admin.inviteTree.depthLimit', { depth: data.limits.maxDepth }) }} </p> <ol v-else class="tree-chain"> - <li v-for="a in data.ancestors" :key="a.id" class="tree-chain-item"> + <!-- `--i` is what drives the indentation, and it was never set — the + `<li>` carried no `:style`, so the fallback `0` applied to every + row and the chain rendered perfectly flat while the comment on + the CSS rule described a staircase. Read outwards from the + subject: each step right is one generation further back, which is + the direction the question is asked in. --> + <li + v-for="a in data.ancestors" + :key="a.id" + class="tree-chain-item" + :style="{ '--i': a.depth - 1 }" + > <component :is="a.erased ? 'span' : 'NuxtLink'" :to="a.erased ? undefined : `/users/${a.id}`" @@ -52,6 +89,12 @@ > <Icon :name="nodeIcon(a)" /> <span class="tree-name">{{ a.erased ? $t('admin.inviteTree.erased') : a.username }}</span> + <!-- "Banned" was carried by `--danger` and a small glyph, neither + of which a screen reader perceives, on the one page where an + operator is looking for exactly that. --> + <span v-if="a.isBanned" class="stat-badge tree-banned"> + {{ $t('admin.inviteTree.legendBanned') }} + </span> <span class="tree-depth">{{ $t('admin.inviteTree.generation', { n: a.depth }) }}</span> </component> </li> @@ -63,16 +106,33 @@ <h3 class="tree-block-title"> {{ $t('admin.inviteTree.invited', { name: data.subject.username }) }} </h3> + <!-- The figure the procedure runs on. The component's own note says the + point is "the one every tracker runs after a ban" — look at the + sponsor, then at their other invitees — and the count of how many of + those are banned was computable from data already on the page and + shown nowhere. --> + <p v-if="data.subject.children?.length" class="tree-summary"> + {{ $t('admin.inviteTree.summary', { + members: descendantCount, + banned: bannedDescendants, + depth: deepestGeneration, + }) }} + </p> <p v-if="!data.subject.children?.length" class="tree-note"> {{ $t('admin.inviteTree.noInvitees') }} </p> - <AdminInviteTreeNode - v-for="child in data.subject.children ?? []" - v-else - :key="child.id" - :node="child" - /> - <p v-if="data.truncated" class="tree-note tree-note--warn"> + <!-- `role="tree"` so the flat sibling list the recursion produces is + announced as a hierarchy: each row carries `aria-level`, which + conveys the depth without nesting the DOM — the trade this component + was built to make. --> + <div v-else role="tree" :aria-label="$t('admin.inviteTree.invited', { name: data.subject.username })"> + <AdminInviteTreeNode + v-for="child in data.subject.children ?? []" + :key="child.id" + :node="child" + /> + </div> + <p v-if="data.truncatedDown" class="tree-note tree-note--warn"> <Icon name="ph:warning-bold" /> {{ $t('admin.inviteTree.truncated', { nodes: data.limits.maxNodes, depth: data.limits.maxDepth }) }} </p> @@ -110,6 +170,8 @@ interface TreePayload { ancestors: TreeNode[]; ancestorsEnd: 'root' | 'depth-limit'; truncated: boolean; + truncatedUp: boolean; + truncatedDown: boolean; limits: { maxDepth: number; maxNodes: number }; nodeCount: number; } @@ -122,6 +184,44 @@ const error = ref(''); const data = ref<TreePayload | null>(null); const candidates = ref<Array<{ id: string; username: string }>>([]); +/** + * The one line of arithmetic the page exists for. + * + * `isBanned` is on every node and `nodeCount` is on the payload, and neither was + * ever turned into the sentence a moderator opens this page to read: how many of + * this account's invitees are banned, and how far the branch goes. Walked here + * rather than asked of the server, because the whole subtree is already in hand. + */ +function walk(node: TreeNode, seen: { total: number; banned: number; depth: number }) { + for (const child of node.children ?? []) { + seen.total += 1; + if (child.isBanned) seen.banned += 1; + if (child.depth > seen.depth) seen.depth = child.depth; + walk(child, seen); + } +} +const descendants = computed(() => { + const seen = { total: 0, banned: 0, depth: 0 }; + if (data.value) walk(data.value.subject, seen); + return seen; +}); +const descendantCount = computed(() => descendants.value.total); +const bannedDescendants = computed(() => descendants.value.banned); +const deepestGeneration = computed(() => descendants.value.depth); + +/** + * A member id in the query string loads that member straight away. + * + * The procedure this page serves starts on a user's row or on their dossier, and + * there was no path from either — the only way in was to retype a username by + * hand into a page reached from the sidebar. + */ +const route = useRoute(); +onMounted(() => { + const id = route.query.userId; + if (typeof id === 'string' && id) void load(id); +}); + async function search() { const q = term.value.trim(); if (!q) return; @@ -225,10 +325,45 @@ function nodeIcon(n: TreeNode) { .tree-block-title { margin: 0 0 0.6rem; font-size: 0.72rem; - letter-spacing: 0.1em; + /* Scaled, so a theme that opens the tracking does not leave these two labels + behind. */ + letter-spacing: calc(0.1em * var(--tracking-scale)); text-transform: uppercase; color: rgb(var(--fg-subtle)); } +.tree-legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0; + font-size: 0.7rem; + color: rgb(var(--fg-subtle)); +} +.tree-legend-item { display: inline-flex; align-items: center; gap: 0.25rem; } +.tree-legend-item--banned { color: rgb(var(--danger)); } +.tree-legend-item--erased { font-style: italic; } +.tree-blank { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + padding: 2.5rem 1rem; + border: 1px dashed rgb(var(--line-default)); + border-radius: var(--radius-md); + text-align: center; + font-size: 0.8125rem; + color: rgb(var(--fg-subtle)); +} +.tree-blank-icon { font-size: 1.75rem; } +.tree-summary { + margin: 0 0 0.6rem; + font-size: 0.8125rem; + color: rgb(var(--fg-muted)); +} +.tree-banned { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.4); +} .tree-note { display: flex; align-items: center; @@ -255,9 +390,16 @@ function nodeIcon(n: TreeNode) { display: inline-flex; align-items: center; gap: 0.4rem; - padding: 0.25rem 0; + /* 36px and a padded box, so the whole row is the target. At ~22px with no + vertical spacing, in a list that can run to 400 names, this was a + mis-tap machine — and every mis-tap navigates to the wrong member. */ + min-height: 2.25rem; + padding: 0.35rem 0.5rem; + margin-left: -0.5rem; + border-radius: var(--radius-sm); font-size: 0.85rem; } +.tree-node:hover { background: rgb(var(--fg-default) / 0.05); } .tree-node--banned { color: rgb(var(--danger)); } diff --git a/apps/web/app/components/admin/InviteTreeNode.vue b/apps/web/app/components/admin/InviteTreeNode.vue index ec020c0a..02aead5a 100644 --- a/apps/web/app/components/admin/InviteTreeNode.vue +++ b/apps/web/app/components/admin/InviteTreeNode.vue @@ -1,5 +1,5 @@ <template> - <div class="node-row" :style="`--depth: ${node.depth}`"> + <div class="node-row" :style="`--depth: ${node.depth}`" role="treeitem" :aria-level="node.depth"> <component :is="node.erased ? 'span' : 'NuxtLink'" :to="node.erased ? undefined : `/users/${node.id}`" @@ -7,7 +7,23 @@ :class="{ 'node--banned': node.isBanned, 'node--erased': node.erased }" > <Icon :name="node.erased ? 'ph:user-minus' : node.isBanned ? 'ph:prohibit-bold' : 'ph:user'" /> - <span>{{ node.erased ? $t('admin.inviteTree.erased') : node.username }}</span> + <span class="node-name">{{ node.erased ? $t('admin.inviteTree.erased') : node.username }}</span> + <!-- "Banned" in words. It was a red tint and a small glyph, neither of + which a screen reader perceives and neither of which says WHICH state + red means — in a list of forty names, banned, erased and inactive + would all have looked like "not normal". The erased case already + carried its own text; this one did not. --> + <span v-if="node.isBanned && !node.erased" class="stat-badge node-badge"> + {{ $t('admin.inviteTree.legendBanned') }} + </span> + <!-- When the sponsorship happened. `invitedAt` has been on the payload + and on this interface from the start and was rendered nowhere, so an + operator could not tell a three-year-old invitation from + yesterday's — which is most of what decides whether a cluster of + bans is a pattern. --> + <time v-if="node.invitedAt" class="node-when" :datetime="node.invitedAt"> + {{ shortDay(node.invitedAt) }} + </time> </component> </div> <AdminInviteTreeNode v-for="child in node.children ?? []" :key="child.id" :node="child" /> @@ -23,6 +39,15 @@ * * Indentation is a CSS custom property driven by the server's own `depth` * rather than by nesting the markup, so a deep tree does not become a deep DOM. + * The cost of that choice is that there is no ancestor element to hang a + * per-generation guide line on, which is why the line below is a pseudo-element + * positioned from `--depth` rather than a `border-left` on the row: with the + * border, every generation's line landed at the same 0.25rem from the left edge + * and stacked into one bar, so past the third generation nothing said which + * parent a name belonged to. + * + * `aria-level` carries the depth for a screen reader, which is what makes the + * flat sibling list readable as a hierarchy without nesting the DOM. */ interface TreeNode { id: string; @@ -30,28 +55,76 @@ interface TreeNode { isBanned: boolean; erased: boolean; depth: number; + invitedAt?: string | null; children?: TreeNode[]; } defineProps<{ node: TreeNode }>(); + +const { locale } = useI18n(); + +/** `2023-04-18` → `18 Apr 2023` / `18 avr. 2023`. */ +function shortDay(iso: string): string { + return new Date(iso).toLocaleDateString(locale.value, { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); +} </script> <style scoped> .node-row { - padding-left: calc(1.1rem * var(--depth, 0)); + position: relative; + /* + * Bounded indentation. + * + * `1.1rem * depth` is unbounded and `MAX_DEPTH` is 10, so generation ten sat + * 176px in. On the ~350px a 390px phone actually offers, that left 174px for + * an icon, a username, a badge and a date — names wrapped or overflowed, with + * no `word-break` and no fallback. `min()` keeps the staircase legible on a + * desktop and lets it compress on a phone. + */ + padding-left: calc(min(1.1rem, 3.2vw) * var(--depth, 0)); +} +/* One guide per generation, at that generation's own offset. */ +.node-row::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: calc(min(1.1rem, 3.2vw) * (var(--depth, 1) - 1) + 0.25rem); border-left: 1px solid rgb(var(--line-default)); - margin-left: 0.25rem; } .node { display: inline-flex; align-items: center; gap: 0.4rem; - padding: 0.2rem 0; + /* 36px and a padded box: the whole row is the target. At ~20px with no + vertical spacing, in a list that can run to 400 entries, every mis-tap + navigates to the wrong member's dossier. */ + min-height: 2.25rem; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-sm); font-size: 0.85rem; + max-width: 100%; } +.node:hover { background: rgb(var(--fg-default) / 0.05); } +.node-name { overflow-wrap: anywhere; } .node--banned { color: rgb(var(--danger)); } +.node-badge { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.4); +} +.node-when { + font-family: var(--font-mono); + font-size: 0.65rem; + color: rgb(var(--fg-subtle)); + white-space: nowrap; +} .node--erased { color: rgb(var(--fg-subtle)); font-style: italic; diff --git a/apps/web/app/pages/users/[id].vue b/apps/web/app/pages/users/[id].vue index d018622f..4d23babd 100644 --- a/apps/web/app/pages/users/[id].vue +++ b/apps/web/app/pages/users/[id].vue @@ -291,6 +291,17 @@ <span class="section-head-line" aria-hidden="true" /> </header> + <!-- The procedure the invite tree exists for starts here — a moderator + looking at an account they suspect — and there was no path to it from + any member: the only way in was to reach the page from the sidebar and + retype the username by hand. --> + <p class="logins-tools"> + <NuxtLink :to="`/admin/invite-tree?userId=${user.id}`" class="btn btn-secondary btn-sm"> + <Icon name="ph:git-fork" /> + {{ $t('admin.inviteTree.openTree') }} + </NuxtLink> + </p> + <div class="logins-head"> <p class="logins-lede">{{ $t('users.logins.lede') }}</p> <button @@ -1804,6 +1815,7 @@ useHead({ torrent page. Neither login table had one, nor any media query, so at 390px the columns simply crushed each other. Focusable, because a scroll container the keyboard cannot reach hides whatever it is scrolling. */ +.logins-tools { margin: 0 0 0.75rem; } .logins-frame { overflow-x: auto; -webkit-overflow-scrolling: touch; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 859114cc..5941ad89 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -3898,14 +3898,19 @@ "search": "Look up", "notFound": "No member matches that.", "failed": "Could not load the invite tree.", - "invitedBy": "Invited by", + "invitedBy": "Sponsors", "invited": "Invited by {name}", "noInviter": "Nobody invited this member — they are the first account, or they registered while registration was open.", - "depthLimit": "The chain goes further up than this view walks.", + "depthLimit": "The chain continues above this — {depth} generations is as far as this view walks, so the account at the top is not necessarily the first.", "noInvitees": "This member has not brought anybody in.", "generation": "generation {n}", "erased": "erased account", - "truncated": "Truncated at {nodes} members or {depth} generations — the rest is not shown." + "truncated": "Only the first {nodes} members and {depth} generations are shown.", + "blank": "Look up a member to see who vouched for them, and who they brought in.", + "legendBanned": "banned", + "legendErased": "erased account", + "summary": "{members} members, {banned} of them banned, across {depth} generations.", + "openTree": "Invite tree" }, "irc": { "eyebrow": "Integration", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index a00b0f4c..652c4b8d 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -3898,14 +3898,19 @@ "search": "Rechercher", "notFound": "Aucun membre ne correspond.", "failed": "L'arbre d'invitations n'a pas pu être chargé.", - "invitedBy": "Invité par", + "invitedBy": "Parrains", "invited": "Invités par {name}", "noInviter": "Personne n'a invité ce membre : c'est le premier compte, ou il s'est inscrit pendant une période d'inscription ouverte.", - "depthLimit": "La chaîne remonte plus haut que ce que cette vue parcourt.", + "depthLimit": "La chaîne continue au-dessus : {depth} générations est la limite de cette vue, donc le compte affiché en haut n’est pas forcément le premier.", "noInvitees": "Ce membre n'a fait entrer personne.", "generation": "génération {n}", "erased": "compte effacé", - "truncated": "Tronqué à {nodes} membres ou {depth} générations — le reste n’est pas affiché." + "truncated": "Seuls les {nodes} premiers membres et {depth} générations sont affichés.", + "blank": "Cherchez un membre pour voir qui s’est porté garant de lui, et qui il a fait entrer.", + "legendBanned": "banni", + "legendErased": "compte effacé", + "summary": "{members} membres, dont {banned} bannis, sur {depth} générations.", + "openTree": "Arbre d’invitations" }, "irc": { "eyebrow": "Intégration", From 172de5a5228604f3a5ceb8a80f00647934dbd4ac Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:53:34 +0200 Subject: [PATCH 35/96] fix(themes): the contrast warning printed English inside a French sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `contrastDetail` was interpolating `w.pair.what` — the raw English label from `theme.ts` — into a translated message, so a French console read « Sous WCAG AA : muted labels on cards : 4,23:1, il faut 4,5:1 ». `pairLabel()` exists a few hundred lines below in the same file, derives the key from the token names rather than from the prose, and this file's own note describes it as the fix. One word. The table itself diagnosed and then abandoned the reader: * **The ratios did not line up.** `tabular-nums` on an `auto` column with start-aligned text, so "4.62:1" and "12.00:1" did not share a decimal point — and comparing twenty of them by eye is the entire service this table renders. * **A column repeated the word "minimum" twenty times for two distinct values.** It now has a header and holds the number alone, which shrinks it from ~66px to ~24px. * **Nothing said which of the two tokens to change.** Reading "Muted labels on cards — 4.23:1 — needs 4.5:1", an operator had to guess, scroll back up and reopen the right group. Both token names sit under each label now. * **The sample was 12px for every row** while its own comment said "at the size they are used", and the body is 14px — so it under-sold every pair, including the ones measured against the 3:1 large-text threshold, where the allowance matters most. * **The verdict icon had no accessible name**, so the only audible difference between a passing row and a failing one was arithmetic. And there was no media query at all. Inside a card body at 390px the fixed columns ate roughly 206px of the ~302px available, leaving the label about eleven characters with `text-overflow: ellipsis` — five consecutive rows reading « Étiquettes d… », on the panel whose whole point is telling twenty pairs apart. Readable before compact: the label wraps and the minimum column goes. --- apps/web/app/components/admin/ThemeEditor.vue | 111 ++++++++++++++++-- apps/web/i18n/locales/en.json | 3 + apps/web/i18n/locales/fr.json | 3 + 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/apps/web/app/components/admin/ThemeEditor.vue b/apps/web/app/components/admin/ThemeEditor.vue index d5c117ed..69a6976e 100644 --- a/apps/web/app/components/admin/ThemeEditor.vue +++ b/apps/web/app/components/admin/ThemeEditor.vue @@ -233,9 +233,14 @@ <span> {{ $t('admin.themes.contrastHeader') }} <ul class="mt-1 space-y-0.5"> + <!-- `pairLabel`, not `w.pair.what`. The raw English label went + straight into a translated sentence, so a French console read + « Sous WCAG AA : muted labels on cards : 4,23:1 » — and this + file's own note about deriving the key from the token names + sits a few hundred lines below, describing the fix. --> <li v-for="w in draftWarnings" :key="w.pair.what"> {{ $t('admin.themes.contrastDetail', { - what: w.pair.what, + what: pairLabel(w.pair), ratio: w.ratio, required: w.required, }) }} @@ -259,6 +264,18 @@ </button> <div v-if="openGroups.has('contrast')" class="contrast-list"> <p class="field-help">{{ $t('admin.themes.contrastAbout') }}</p> + <!-- Column names, once, instead of the word "minimum" repeated + twenty times down a column of its own for two distinct values. + The number alone says it, and the column shrinks from ~66px to + ~24px — which is most of what made the panel unreadable on a + phone. --> + <div class="contrast-row contrast-head" aria-hidden="true"> + <span /> + <span class="field-label">{{ $t('admin.themes.contrastCol.pair') }}</span> + <span class="field-label contrast-num">{{ $t('admin.themes.contrastCol.measured') }}</span> + <span class="field-label contrast-num">{{ $t('admin.themes.contrastCol.needs') }}</span> + <span /> + </div> <div v-for="r in draftReport" :key="`${r.pair.fg}-${r.pair.bg}`" @@ -266,16 +283,34 @@ :class="{ 'contrast-row--fail': !r.passes }" > <!-- The sample is the point: it shows what the number means, in - the two colours being measured, at the size they are used. --> - <span class="contrast-sample" :style="sampleStyle(r.pair)">Aa</span> - <span class="contrast-what">{{ pairLabel(r.pair) }}</span> - <span class="contrast-ratio">{{ r.ratio.toFixed(2) }}:1</span> - <span class="contrast-min">{{ - $t('admin.themes.contrastMin', { required: r.required }) - }}</span> + the two colours being measured, at the size they are used — + which for a pair measured against the 3:1 large-text + threshold means large. It used to be 12px for every row, + including those, so the sample under-sold the case where the + allowance mattered most. --> + <span + class="contrast-sample" + :class="{ 'contrast-sample--large': r.pair.large }" + :style="sampleStyle(r.pair)" + >Aa</span + > + <span class="contrast-what"> + {{ pairLabel(r.pair) }} + <!-- The two tokens, named. The panel used to diagnose and stop: + an operator reading "4.23:1, needs 4.5" had to guess which + of two tokens to change, scroll back up, and reopen the + right group. --> + <code class="contrast-tokens">--{{ r.pair.fg }} / --{{ r.pair.bg }}</code> + </span> + <span class="contrast-ratio">{{ r.ratio.toFixed(2) }}</span> + <span class="contrast-min">{{ r.required }}</span> + <!-- Named, because the only audible difference between a passing + and a failing row was arithmetic. --> <Icon class="contrast-verdict" :name="r.passes ? 'ph:check-bold' : 'ph:warning-bold'" + :aria-label="r.passes ? $t('admin.themes.contrastPass') : $t('admin.themes.contrastFail')" + role="img" /> </div> </div> @@ -1017,13 +1052,20 @@ const familySuggestions = computed(() => { .contrast-list { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem 0.75rem 0.75rem; } .contrast-row { display: grid; - grid-template-columns: 2.25rem minmax(0, 1fr) auto auto 1rem; + /* Fixed widths on the two number columns, so twenty ratios share a decimal + point. `auto` plus `text-align: start` meant "4.62" and "12.00" did not + line up — and comparing them by eye is the entire service this table + renders. */ + grid-template-columns: 2.25rem minmax(0, 1fr) 3.5rem 2.5rem 1rem; align-items: center; gap: 0.6rem; padding: 0.3rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.75rem; } +.contrast-head { padding-bottom: 0; } +.contrast-head .field-label { margin-bottom: 0; } +.contrast-num { text-align: right; } .contrast-row--fail { background: rgb(var(--warning) / 0.08); } .contrast-sample { display: grid; @@ -1032,11 +1074,54 @@ const familySuggestions = computed(() => { border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-sm); font-weight: 600; - font-size: 0.75rem; + /* Body size, because that is where these pairs are used. The comment above + the markup claimed "at the size they are used" while rendering 12px against + a 14px body. */ + font-size: 0.875rem; +} +/* A pair measured against the 3:1 large-text threshold, shown large. */ +.contrast-sample--large { font-size: 1.25rem; font-weight: 700; } +.contrast-what { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} +.contrast-tokens { + font-family: var(--font-mono); + font-size: 0.625rem; + color: rgb(var(--fg-subtle)); + overflow-wrap: anywhere; +} +.contrast-ratio { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + text-align: right; +} +.contrast-min { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + text-align: right; + color: rgb(var(--fg-subtle)); + font-size: 0.6875rem; +} +/* + * A phone, where this panel used to become useless. + * + * There was no media query at all. Inside a card body at 390px the fixed + * columns ate ~206px of ~302px, leaving the label about 11 characters with + * `text-overflow: ellipsis` — so five consecutive rows read « Étiquettes d… ». + * The label wraps instead, and the "needs" column goes: readable before + * compact. + */ +@media (max-width: 30rem) { + .contrast-row { + grid-template-columns: 2.25rem minmax(0, 1fr) 3.5rem 1rem; + } + .contrast-what { white-space: normal; } + .contrast-min, + .contrast-head .contrast-num:last-of-type { display: none; } } -.contrast-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.contrast-ratio { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } -.contrast-min { color: rgb(var(--fg-subtle)); font-size: 0.6875rem; } .contrast-verdict { color: rgb(var(--online)); } .contrast-row--fail .contrast-verdict { color: rgb(var(--warning)); } .contrast-count--fail { color: rgb(var(--warning)); } diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 5941ad89..a69ea571 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -3432,6 +3432,9 @@ "notFoundBody": "It was deleted, or the address names a theme that never existed.", "contrastGroup": "Contrast", "contrastMin": "needs {required}:1", + "contrastCol": { "pair": "Pair", "measured": "Measured", "needs": "Needs" }, + "contrastPass": "Passes", + "contrastFail": "Fails", "contrastAbout": "Every combination the interface paints text in, measured against WCAG 2.2 AA — 4.5:1 for text, 3:1 for large text and for the focus ring. A shortfall is a warning, not a refusal: the theme saves either way.", "contrastPairs": { "fg-default__bg-base": "Body text", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 652c4b8d..14753200 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -3432,6 +3432,9 @@ "notFoundBody": "Il a été supprimé, ou l'adresse désigne un thème qui n'a jamais existé.", "contrastGroup": "Contraste", "contrastMin": "minimum {required}:1", + "contrastCol": { "pair": "Paire", "measured": "Mesuré", "needs": "Minimum" }, + "contrastPass": "Conforme", + "contrastFail": "Non conforme", "contrastAbout": "Chaque combinaison dans laquelle l’interface peint du texte, mesurée contre le niveau AA de WCAG 2.2 — 4,5:1 pour le texte, 3:1 pour les grands caractères et l’anneau de focus. Un manque est un avertissement, pas un refus : le thème s’enregistre quand même.", "contrastPairs": { "fg-default__bg-base": "Texte courant", From 6058b1cdec6638de6d4ed1e3ba3d4329ec95382d Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:54:44 +0200 Subject: [PATCH 36/96] fix(torrents): a live freeleech advertised itself as already over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `formatAge` was being asked how long a buff had *left*, and it answers ages. The diff goes negative, the first branch catches it, and the badge read "FREELEECH until just now" for every buff with an end date — on the hero, in the highest-attention strip on the page, to every member. A member reads that as an expired promotion and does not download. It uses `formatUntil` now, and the badge stops claiming a deadline once there is none. **The supersede banner sat five sections below the download button**, carrying a comment that said "a member who landed here should be told before they press download, not after they scroll" — some 280 lines and two full screens after the button. Moved into the hero, directly above the CTA. Deliberately still not red: the release works and its swarm is untouched, and colouring a factual notice as a danger would make members abandon a download that is fine. **A `custom` buff withheld the numbers it had in hand.** Any multiplier pair outside the three named ones rendered as "Boosted", so a member could not tell 0.25× download from 3× upload — the one thing that decides whether it is worth grabbing now. Both figures were already computed two lines away. Reseed had three separate problems in one button. The cooldown is site-wide and daily, and pressing it left the control in its ordinary enabled state with nothing on the page recording that the ask had happened — so the second member to press got a 429, whose body is an English sentence written for a log, echoed straight into a French page. `notified: 0` was a *success* toast: the day's one slot spent, and the member told it worked. And the count went in as a named argument with no plural branch, so "Asked 1 past downloaders". The button now yields to the outcome, the two 4xx cases have their own translated sentences mapped on status, and nought notified says so. --- apps/web/app/pages/torrents/[hash]/index.vue | 132 +++++++++++++------ apps/web/i18n/locales/en.json | 11 +- apps/web/i18n/locales/fr.json | 11 +- 3 files changed, 113 insertions(+), 41 deletions(-) diff --git a/apps/web/app/pages/torrents/[hash]/index.vue b/apps/web/app/pages/torrents/[hash]/index.vue index 7c52ab86..5b413695 100644 --- a/apps/web/app/pages/torrents/[hash]/index.vue +++ b/apps/web/app/pages/torrents/[hash]/index.vue @@ -157,9 +157,14 @@ <span class="hero-eyebrow-sep">·</span> <span class="buff-badge" :class="`buff-badge--${buff.kind}`"> <Icon :name="buff.icon" /> - {{ $t(`torrent.buff.${buff.kind}`) }} - <span v-if="buff.until" class="buff-until" :title="formatDate(buff.until)"> - {{ $t('torrent.buff.until', { when: formatAge(buff.until) }) }} + <!-- A named buff says its name; an unnamed one says its numbers. + "Boosted" told a member a multiplier was in force and refused to + say which, so they could not tell a 0.25× download from a 3× + upload — the one thing that decides whether it is worth + grabbing now. Both figures are already computed here. --> + {{ buff.kind === 'custom' ? buffPair : $t(`torrent.buff.${buff.kind}`) }} + <span v-if="buffEndsIn" class="buff-until" :title="formatDate(buff.until!)"> + {{ $t('torrent.buff.endsIn', { when: buffEndsIn }) }} </span> </span> </template> @@ -274,6 +279,35 @@ </a> </div> + <!-- Replaced BY something, said before the download button rather than + five sections below it. + This banner used to live down in the cross-seed section, carrying a + comment that read "a member who landed here should be told before they + press download, not after they scroll" — some 280 lines and two full + screens after the button. Deliberately not red: the release still + works and its swarm is untouched, and colouring a factual notice as a + danger would make members abandon a download that is fine. --> + <div v-if="supersessions?.supersededBy" class="supersede-banner"> + <Icon name="ph:arrow-bend-right-up-bold" class="supersede-banner-icon" /> + <div class="supersede-banner-body"> + <p class="supersede-banner-lead"> + {{ $t('torrents.detail.supersede.replacedBy') }} + </p> + <NuxtLink + :to="`/torrents/${supersessions?.supersededBy?.infoHash}`" + class="supersede-banner-link" + > + {{ supersessions?.supersededBy?.name }} + </NuxtLink> + <p v-if="supersessions?.supersededBy?.reason" class="supersede-banner-reason"> + {{ supersessions?.supersededBy?.reason }} + </p> + <p class="supersede-banner-note"> + {{ $t('torrents.detail.supersede.stillSeedable') }} + </p> + </div> + </div> + <!-- One bold primary action (Download) flanked by mono ghosts for the destructive / reporting paths. The size carries the weight of the file so a user can decide before clicking. --> @@ -474,9 +508,10 @@ <div v-if="canAskReseed" class="reseed-ask"> <p class="reseed-hint"> <Icon name="ph:hand-heart-bold" class="reseed-icon" /> - {{ $t('torrents.detail.reseed.hint') }} + {{ reseedResult ? $t(`torrents.detail.reseed.state.${reseedResult}`) : $t('torrents.detail.reseed.hint') }} </p> <button + v-if="!reseedResult" type="button" class="btn btn-secondary btn-sm" :disabled="reseedBusy" @@ -553,39 +588,16 @@ with the cross-seed sections because it answers the same kind of question (what else is this?), and reuses their markup so a reader scanning the page meets one list format, not three. --> - <section - v-if="supersessions?.supersededBy || supersessions?.supersedes.length" - class="section section--cross" - > + <section v-if="supersessions?.supersedes.length" class="section section--cross"> <header class="section-head"> <span class="section-head-mark" aria-hidden="true">§</span> <h2 class="section-head-title">{{ $t('torrents.detail.supersede.title') }}</h2> <span class="section-head-line" aria-hidden="true" /> </header> - <!-- Replaced BY something: the important direction, and a banner rather - than a list item — a member who landed here should be told before - they press download, not after they scroll. --> - <div v-if="supersessions.supersededBy" class="supersede-banner"> - <Icon name="ph:arrow-bend-right-up-bold" class="supersede-banner-icon" /> - <div class="supersede-banner-body"> - <p class="supersede-banner-lead"> - {{ $t('torrents.detail.supersede.replacedBy') }} - </p> - <NuxtLink - :to="`/torrents/${supersessions.supersededBy.infoHash}`" - class="supersede-banner-link" - > - {{ supersessions.supersededBy.name }} - </NuxtLink> - <p v-if="supersessions.supersededBy.reason" class="supersede-banner-reason"> - {{ supersessions.supersededBy.reason }} - </p> - <p class="supersede-banner-note"> - {{ $t('torrents.detail.supersede.stillSeedable') }} - </p> - </div> - </div> + <!-- Replaced BY something is announced in the hero, above the download + button — see the banner there. What stays here is the other + direction. --> <!-- Replaces others: informational, so it reads as the ordinary list. --> <template v-if="supersessions.supersedes.length"> @@ -978,7 +990,7 @@ interface TorrentDetail { gatedAdult?: boolean; } -const { t } = useI18n(); +const { t, locale } = useI18n(); const route = useRoute(); const hash = route.params.hash as string; @@ -1359,6 +1371,26 @@ const buff = computed(() => { return { kind, icon, until: t0.multipliersUntil ?? null, dl, ul }; }); +/** "0.25× DL · 2× UL" — the multipliers a `custom` buff refuses to name. */ +const buffPair = computed(() => { + if (!buff.value) return ''; + const f = (percent: number) => `${(percent / 100).toLocaleString(locale.value)}×`; + return `${f(buff.value.dl)} DL · ${f(buff.value.ul)} UL`; +}); + +/** + * How long the buff has left, or nothing. + * + * `formatAge` was being asked this and answering "just now" for every date in + * the future, so a freeleech with three days on it advertised itself as + * already over. `formatUntil` is the forward-looking one, and it returns an + * empty string once the deadline has passed — which is also the moment the + * badge should stop claiming a deadline at all. + */ +const buffEndsIn = computed(() => + buff.value?.until ? formatUntil(buff.value.until, locale.value) : '' +); + const isStaff = computed( () => !!user.value && (user.value.isAdmin || user.value.isModerator) ); @@ -1441,6 +1473,17 @@ const canAskReseed = computed( !supersessions.value?.supersededBy ); +/** + * What the last reseed request did, so the button can stop offering itself. + * + * `null` means "not asked in this visit"; anything else replaces the control + * with the outcome. Pressing it used to leave the button in its ordinary + * enabled state with nothing on the page recording that the ask had happened — + * and since the cooldown is site-wide and daily, the next member to press it + * got a 429 for a slot somebody else had already used. + */ +const reseedResult = ref<'asked' | 'nobody' | 'already' | null>(null); + async function askReseed() { reseedBusy.value = true; try { @@ -1448,12 +1491,27 @@ async function askReseed() { `/api/torrents/${hash}/reseed-request`, { method: 'POST' } ); - notifications.success(t('torrents.detail.reseed.done', { count: res.notified })); + // Nought notified is not a success. It consumed the day's one slot and + // told the member it had worked. + if (res.notified === 0) { + reseedResult.value = 'nobody'; + notifications.error(t('torrents.detail.reseed.nobody')); + } else { + reseedResult.value = 'asked'; + notifications.success(t('torrents.detail.reseed.done', res.notified)); + } } catch (err: unknown) { - const e = err as { data?: { message?: string }; message?: string }; - notifications.error( - e?.data?.message || e?.message || t('torrents.detail.reseed.failed') - ); + const e = err as { statusCode?: number; data?: { message?: string }; message?: string }; + // Mapped on the status, not echoed from the body. `reseed-request` answers + // a 429 with an English sentence written for a developer — "A reseed has + // already been requested for this torrent today." — and echoing + // `e.data.message` put that English into a French page. + if (e?.statusCode === 429) { + reseedResult.value = 'already'; + notifications.error(t('torrents.detail.reseed.already')); + } else { + notifications.error(t('torrents.detail.reseed.failed')); + } } finally { reseedBusy.value = false; } diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index a69ea571..7a8c621c 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -898,7 +898,14 @@ "ask": "Ask for a reseed", "asking": "Asking…", "hint": "Nobody is seeding this. Members who downloaded it before will be asked to put it back online.", - "done": "Asked {count} past downloaders to seed it again.", + "done": "Asked 1 past downloader to seed it again. | Asked {n} past downloaders to seed it again.", + "nobody": "Nobody who downloaded this is still reachable — there was nobody left to ask.", + "already": "Somebody already asked for this release today. One request a day, so the notification is worth reading.", + "state": { + "asked": "Asked. Members who downloaded this will be notified.", + "nobody": "Nobody who downloaded this is still reachable.", + "already": "Already asked today — one request a day." + }, "failed": "Could not send the request." } } @@ -1378,7 +1385,7 @@ "silverleech": "Silverleech", "doubleUpload": "Double upload", "custom": "Boosted", - "until": "until {when}" + "endsIn": "ends {when}" } }, "bonus": { diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 14753200..3ec44ed0 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -898,7 +898,14 @@ "ask": "Demander une remise en partage", "asking": "Envoi…", "hint": "Plus personne ne partage. Les membres qui l'ont téléchargé seront invités à le remettre en ligne.", - "done": "{count} anciens téléchargeurs ont été sollicités.", + "done": "1 ancien téléchargeur a été sollicité. | {n} anciens téléchargeurs ont été sollicités.", + "nobody": "Plus aucun de ceux qui l’ont téléchargé n’est joignable — il n’y avait personne à solliciter.", + "already": "Quelqu’un a déjà fait la demande aujourd’hui. Une par jour, pour que la notification garde du poids.", + "state": { + "asked": "Demande envoyée. Les membres qui l’ont téléchargé seront prévenus.", + "nobody": "Plus aucun de ceux qui l’ont téléchargé n’est joignable.", + "already": "Déjà demandé aujourd’hui — une demande par jour." + }, "failed": "La demande n'a pas pu être envoyée." } } @@ -1378,7 +1385,7 @@ "silverleech": "Silverleech", "doubleUpload": "Upload doublé", "custom": "Bonifié", - "until": "jusqu'à {when}" + "endsIn": "se termine {when}" } }, "bonus": { From b7f9a719121ffeb7aca0e92a3115ef190a582b3a Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Tue, 1 Sep 2026 19:55:16 +0200 Subject: [PATCH 37/96] docs: what the interface pass changed, and what the contrast gate cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six guides that described behaviour the pass has moved on from. `stats.md` listed the panels and not what they now claim: the labelled scale on the min-anchored charts, the weekly bucketing above 120 points, the bars as share of the catalogue rather than of the largest category, the deltas on the lead figures, and the two rankings saying "all time" because the window switcher does not reach them. `security.md` described a hash the reader never sees — the API replaced it with an ordinal — and now says why the ordinal restarts each day: a single counter over the response rendered one home address on five days as `#1 #2 #3 #4 #5`, so the ordinary case looked like five different places on the one screen whose purpose is spotting somebody else. Also records why an ordinal at all rather than the hash: the same hash handed to two readers is a confirmation oracle for a day. `roadmap.md` carried the same wording, one line. `themes.md` gains the columns and token names the contrast panel now shows, and the paragraph that matters more: **the gate measures token pairs and cannot see a hex literal in a component's own stylesheet.** That is how three of them shipped at 1.6:1 to 2:1 in the light theme. `irc.md` describes the console as it now behaves — the master switch making the cards inert, the four required fields, the self-refreshing state, the full error notice — and states plainly that the test button says a fixed line and never the template, because a route that let staff put arbitrary text in a channel would be a broadcast surface. `invitations.md` gains the way in from a member's profile, the per-generation guide lines, the banned-count summary, and the `aria-level` semantics. `saved-searches.md` says a media-id filter can be saved now, that the control appears on a zero-result search, and that the three ways into `/alerts` exist. --- doc/guide/invitations.md | 28 +++++++++++++++------ doc/guide/roadmap.md | 2 +- doc/guide/saved-searches.md | 13 ++++++++-- doc/guide/security.md | 50 ++++++++++++++++++++++++++----------- doc/guide/stats.md | 29 +++++++++++++++------ doc/guide/themes.md | 17 +++++++++---- doc/integrations/irc.md | 22 +++++++++++++++- 7 files changed, 122 insertions(+), 39 deletions(-) diff --git a/doc/guide/invitations.md b/doc/guide/invitations.md index 9613a0fe..14eae839 100644 --- a/doc/guide/invitations.md +++ b/doc/guide/invitations.md @@ -92,15 +92,29 @@ The data has always been here, in `invitations.created_by` and `invitations.used_by`. Both pages that read it only ever rendered one generation. +### Getting there + +From the sidebar, or — more usefully — from **Invite tree** in the staff section +of any member's profile, which opens the page already on that member. The +procedure starts on the account you suspect, so retyping a username was the +wrong way in. + ### Reading it -- **Upwards** is a chain, nearest first: `generation 1` is whoever invited the - member you looked up. -- **Downwards** is a tree, indented by generation. -- A **banned** account is marked. An **erased** one renders as a tombstone - rather than a link — erasure scrubs the username and leaves every invitation - row intact, so the *edges* survive perfectly, which is exactly what a - genealogy needs, but the name behind them is gone. +- **Sponsors** is a chain, nearest first: `generation 1` is whoever invited the + member you looked up, and each step right is one generation further back. +- **Invited by …** is a tree, indented by generation, with one guide line per + generation so a name can be traced to its own sponsor. A summary line above it + gives the figure the procedure runs on: how many members, **how many of them + are banned**, and over how many generations. +- Each row carries the date the invitation was used, so a cluster of bans can be + told apart from three years of ordinary history. +- A **banned** account is marked in words as well as in colour. An **erased** one + renders as a tombstone rather than a link — erasure scrubs the username and + leaves every invitation row intact, so the *edges* survive perfectly, which is + exactly what a genealogy needs, but the name behind them is gone. +- The tree is announced as a tree: each row carries its generation as + `aria-level`, so the hierarchy is not carried by indentation alone. ### Where a chain ends diff --git a/doc/guide/roadmap.md b/doc/guide/roadmap.md index 028c2faa..718dd162 100644 --- a/doc/guide/roadmap.md +++ b/doc/guide/roadmap.md @@ -10,7 +10,7 @@ Trackarr is actively developed with a focus on performance, security, and usabil - [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] **Login history** — Every attempt to open a session, successful or refused, with the method, the client, and the address as a per-day ordinal. 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; the ordinal restarts each day so the numbering says that for itself instead of leaving it to a footnote. 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). diff --git a/doc/guide/saved-searches.md b/doc/guide/saved-searches.md index 0d33c5fb..94c09082 100644 --- a/doc/guide/saved-searches.md +++ b/doc/guide/saved-searches.md @@ -10,9 +10,14 @@ is most of them. Search the catalogue as usual, then **Save this search**. The filter is taken from what is on screen, which is the point: retyping criteria into a separate form is how a saved search ends up not matching what you were actually looking -at. +at. That includes a filter by media id — browsing one film's IMDb id and asking +to be told about it is exactly the watch worth saving. -Manage them at **/alerts**. +The control appears even on a search with **no results**, which is the most +valuable moment to save one: nothing matches yet, tell me when it does. + +Manage them at **/alerts** — reachable from the account menu, from the command +palette, and from **Manage alerts** beside the save control. ## What a filter can hold @@ -32,6 +37,10 @@ Blu-Ray x265" is three tags, not three new fields. A filter with no criteria at all is refused: it would match every upload forever, which is not a saved search but a firehose. +Each row on **/alerts** says whether it will actually notify you, how many times +it has matched, and when it last did. Deleting one asks first: the criteria live +on the catalogue page, so getting it back means rebuilding the search. + ## What it matches against **The release name only.** The live search can also read descriptions and NFOs, diff --git a/doc/guide/security.md b/doc/guide/security.md index 05abd8a0..e252f3da 100644 --- a/doc/guide/security.md +++ b/doc/guide/security.md @@ -52,10 +52,13 @@ The system automatically detects and blocks: ## Login history Every attempt to open a session is recorded — successful or not — with the -method that was used, the browser, and a hash of the address. +method that was used, the browser, and the address it came from. -Members read their own at **Settings → Security → Login history**. Staff read -any member's from that member's profile page. +Members read their own at **Settings → Security → Login history**, including the +**client** column: the member is the one person who knows which devices they +own, so "Chrome on Windows" against an iPhone-and-Mac life is the strongest +signal they have that it was not them. Staff read any member's from that member's +profile page. ### Failures are the interesting half @@ -65,18 +68,35 @@ makes such an attempt visible afterwards even though nothing stopped it at the time — a run of refusals against one account, from addresses that differ, is a shape worth recognising. -### What the address hash can and cannot tell you - -It goes through the same daily-rotating salt as every other IP in this system, -which means **two rows are comparable only within the same day**. Two different -hashes a week apart may well be the same address. - -That is enough for the question staff actually ask — "is this account being used -from several places right now", the shape of account sharing on an invite-only -tracker — and not enough for a long-range history. Both views say so rather than -letting a reader draw a conclusion from noise. The moderator view computes the -count of distinct addresses **on the most recent day only**, for the same -reason. +### What the address column can and cannot tell you + +Neither view shows a hash. The address is stored hashed, through the same +daily-rotating salt as every other IP in this system, and is presented as an +ordinal — `#1`, `#2` — **numbered within its own day**. + +Numbered within the day and not across the table, because the alternative was +actively misleading: a single counter over the whole response rendered one home +address on five different days as `#1 #2 #3 #4 #5`, so the ordinary case — one +place, many days — looked exactly like five different places, on the one screen +whose entire purpose is spotting somebody else. Per day, the same place is `#1` +every day, and a second number inside one day means a second place that day. + +The daily salt is why cross-day comparison is not offered at all: two hashes a +week apart may well be the same address. That is enough for the question staff +actually ask — "is this account being used from several places right now", the +shape of account sharing on an invite-only tracker — and not enough for a +long-range history. + +An ordinal rather than the hash itself for a second reason: the same hash handed +to two readers is a confirmation oracle for a day. Somebody who suspects an +account is shared with a person they can reach could sign in from that address, +read their own hash, and compare. The ordinal keeps the only property either view +claims and gives up the only one neither needs. + +The moderator view flags a day with **more than two** distinct addresses, and +names the day and the number of sign-ins it covers — at a threshold of two, a +phone plus a laptop tripped a warning-coloured panel on the median member, which +teaches the reader to ignore it. ### Retention diff --git a/doc/guide/stats.md b/doc/guide/stats.md index 73c18b0f..232eb1a2 100644 --- a/doc/guide/stats.md +++ b/doc/guide/stats.md @@ -9,16 +9,29 @@ 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. +- **Right now** — releases, members, seeders, snatches and traffic since the + beginning. The releases and members figures each carry the change over the + window shown below them, so the page answers "is the site growing" without + scrolling to a chart. +- **How it got here** — releases and members over 30, 90 or 365 days, plus + traffic per day across the full width. Each chart labels the top and bottom of + its own scale: the two cumulative series are anchored at their minimum, not at + zero, because the slope is the story, and an unlabelled min-anchored axis is + the oldest way to overstate growth. Above 120 points the traffic bars are + summed into weeks — 365 bars in the space available cannot be read — and the + axis says `per week` when that happens. The collapsed table under each chart + always carries every point. +- **What is in the catalogue** — releases and bytes per category. Each bar is the + category's **share of the catalogue**, so two categories can be compared by + eye. +- **Most snatched** and **biggest swarms** — ten releases each, with size and + count in named columns. Both are **all time**: the window switcher above + governs the history panel only, which is why their titles say so. +- **Who is filling the catalogue** — 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. +- **Your year** — your own uploads, snatches, bytes, seed time and bonus, as a + section of its own at the same weight as the rest of the page. ## What is deliberately not on it diff --git a/doc/guide/themes.md b/doc/guide/themes.md index 9911bcfe..9ea29442 100644 --- a/doc/guide/themes.md +++ b/doc/guide/themes.md @@ -68,11 +68,18 @@ would cover the thing you are judging. **Contrast is checked, not enforced.** It warns; it does not refuse. The **Contrast** group in the editor measures every combination the interface -paints text in — twenty of them — and shows each with its ratio, the minimum it -needs, and a sample drawn in the two colours being measured. The count in the -group header is the summary: `20/20` means the theme is readable everywhere. -Failures are also raised as a warning above the fields, so a theme cannot fail -quietly. +paints text in — twenty of them — under named columns: a sample drawn in the two +colours being measured and at the size they are used, what the pair is, the ratio +measured, and the minimum it needs. Under each pair's name are **the two token +names**, so the reader knows which of the two to change rather than having to +guess. The count in the group header is the summary: `20/20` means the theme is +readable everywhere. Failures are also raised as a warning above the fields, so a +theme cannot fail quietly. + +**What the gate cannot see.** It measures token *pairs*. A colour written as a +hex literal inside a component's own stylesheet is invisible to it — which is +how three such literals in the credentials card shipped at 1.6:1 to 2:1 in the +light theme. If you are writing a component, use the tokens. Showing the passing pairs is the point rather than clutter: 4.6:1 and 12:1 both look like silence when only failures are listed, and one of them breaks on the diff --git a/doc/integrations/irc.md b/doc/integrations/irc.md index 9abff850..7a6d1f6d 100644 --- a/doc/integrations/irc.md +++ b/doc/integrations/irc.md @@ -122,7 +122,27 @@ announce on your behalf; a read key cannot. See [API keys](../guide/api-keys.md) The three credentials are stored encrypted at rest, with the same key as the notification-channel secrets (`CHANNEL_ENCRYPTION_KEY`, falling back to `NUXT_SESSION_SECRET`). The console never returns them; a blank field means -"keep the stored one". +"keep the stored one", and the console says so under the fields rather than +leaving it to a placeholder. + +### Reading the console + +- **The switch at the top governs everything below it.** With it off, the three + cards are dimmed and inert: what you type there will not be announced until + you turn it on, and the page says that by making the fields unreachable rather + than by leaving them looking live. +- **Four fields are required** and marked with a `*`: server, port, nick and + channel. Everything else is optional. +- **The state block refreshes itself** while the answer is still moving — + connecting, registering, joining — and there is a reload button beside it. When + the state is `error`, the reason is printed in full underneath rather than + truncated into the state block. +- **The autobrr definition** downloads from the *What to say* card, beside the + template it is derived from. +- **Test line** says a fixed sentence, not your template: a route that let staff + put arbitrary text in a channel would be a broadcast surface. It answers "is + the bot connected"; the live preview under the template answers "is my format + right". ### Adult releases From 0bc7bce5997c00cf848f87cade8999d5d1d05c81 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:15:31 +0200 Subject: [PATCH 38/96] fix(api): a malformed query parameter answered 500, not 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An uncaught ZodError does not become a 400 in a Nitro handler — it rises as an unhandled error, so the caller gets "Server Error" and the log gets a full stack trace. Forty call sites parsed straight into the schema instead of going through `validateQuery`, which has converted the error since it was written. Measured on the compiled stack before this change: /api/tags?limit=abc -> 500 "Server Error" /api/torrents?limit=abc -> 400 "limit: Invalid input: expected number, received NaN" Three costs, two of them silent. The message teaches the caller nothing; a 500 writes a trace on EVERY malformed request, and `/api/rss/latest` is the kind of endpoint a misconfigured feed reader hammers continuously; and reporting a server fault where the client's own request is at fault poisons monitoring. `validateRouterParams` is new. `validateParam` already existed but takes ONE named parameter, which does not cover a two-segment route such as `/requests/[id]/comments/[cid]` — twenty-six of the forty sites read all of them at once. Detector, so this cannot come back unnoticed: grep -rn "\.parse(getQuery(event))\|\.parse(getRouterParams(event))" \ apps/api/routes must return nothing, and on a running stack a deliberately invalid input must answer 400, never 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../routes/api/admin/banned-ips/index.get.ts | 3 +- .../api/routes/api/admin/fonts/[id].delete.ts | 3 +- .../routes/api/admin/templates/[id].delete.ts | 3 +- .../routes/api/admin/templates/[id].patch.ts | 3 +- .../routes/api/admin/themes/[id].delete.ts | 3 +- apps/api/routes/api/admin/themes/[id].put.ts | 4 +-- .../routes/api/admin/themes/[id]/css.get.ts | 3 +- .../routes/api/admin/themes/[id]/css.put.ts | 4 +-- .../routes/api/admin/users/[id]/role.put.ts | 18 ++++++++-- .../admin/users/[id]/roles/[roleId].delete.ts | 3 +- .../api/admin/users/[id]/roles/index.get.ts | 3 +- .../api/admin/users/[id]/roles/index.post.ts | 4 +-- apps/api/routes/api/admin/users/index.get.ts | 3 +- apps/api/routes/api/me/downloads.get.ts | 3 +- apps/api/routes/api/me/favorites.get.ts | 3 +- apps/api/routes/api/me/following.get.ts | 3 +- apps/api/routes/api/me/seeds.get.ts | 3 +- .../routes/api/me/templates/[id].delete.ts | 3 +- .../api/routes/api/me/templates/[id].patch.ts | 3 +- .../api/me/templates/[id]/default.put.ts | 3 +- apps/api/routes/api/me/templates/index.get.ts | 3 +- apps/api/routes/api/metadata/lookup.get.ts | 3 +- apps/api/routes/api/metadata/search.get.ts | 3 +- apps/api/routes/api/requests/[id].delete.ts | 3 +- apps/api/routes/api/requests/[id].get.ts | 3 +- apps/api/routes/api/requests/[id].patch.ts | 3 +- .../routes/api/requests/[id]/comments.post.ts | 3 +- .../requests/[id]/comments/[cid].delete.ts | 3 +- .../api/requests/[id]/comments/[cid].patch.ts | 3 +- .../api/routes/api/requests/[id]/fill.post.ts | 3 +- .../routes/api/requests/[id]/reject.post.ts | 3 +- .../routes/api/requests/[id]/validate.post.ts | 3 +- apps/api/routes/api/requests/index.get.ts | 3 +- .../api/routes/api/rss/category/[slug].get.ts | 5 +-- apps/api/routes/api/rss/latest.get.ts | 3 +- apps/api/routes/api/tags/index.get.ts | 3 +- apps/api/routes/api/users/[id].get.ts | 3 +- apps/api/routes/api/users/[id]/uploads.get.ts | 5 +-- apps/api/utils/schemas.ts | 34 +++++++++++++++++++ 39 files changed, 125 insertions(+), 45 deletions(-) diff --git a/apps/api/routes/api/admin/banned-ips/index.get.ts b/apps/api/routes/api/admin/banned-ips/index.get.ts index ecf01dfd..34ef96eb 100644 --- a/apps/api/routes/api/admin/banned-ips/index.get.ts +++ b/apps/api/routes/api/admin/banned-ips/index.get.ts @@ -40,6 +40,7 @@ import { type SQL, } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ search: z.string().trim().max(120).optional(), @@ -55,7 +56,7 @@ const AUTO_PREFIX = 'Banned user:'; export default defineEventHandler(async (event) => { await requireModeratorSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); const conditions: SQL[] = []; if (params.search) { diff --git a/apps/api/routes/api/admin/fonts/[id].delete.ts b/apps/api/routes/api/admin/fonts/[id].delete.ts index 6ee03f34..ed01d3be 100644 --- a/apps/api/routes/api/admin/fonts/[id].delete.ts +++ b/apps/api/routes/api/admin/fonts/[id].delete.ts @@ -14,6 +14,7 @@ import { requireOwnerSession, requireFreshAuth } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { deleteFont, themesUsingFont } from '~~/utils/fonts'; import { bumpThemeVersion } from '~~/utils/themes'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); @@ -21,7 +22,7 @@ export default defineEventHandler(async (event) => { await requireOwnerSession(event); await requireFreshAuth(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const inUse = await themesUsingFont(id); if (inUse.length) { diff --git a/apps/api/routes/api/admin/templates/[id].delete.ts b/apps/api/routes/api/admin/templates/[id].delete.ts index 6baf0372..5741956c 100644 --- a/apps/api/routes/api/admin/templates/[id].delete.ts +++ b/apps/api/routes/api/admin/templates/[id].delete.ts @@ -15,13 +15,14 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { requireAdminSession } from '~~/utils/adminAuth'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.admin); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [deleted] = await db .delete(schema.presentationTemplates) diff --git a/apps/api/routes/api/admin/templates/[id].patch.ts b/apps/api/routes/api/admin/templates/[id].patch.ts index 87d5af5e..ba3e5c25 100644 --- a/apps/api/routes/api/admin/templates/[id].patch.ts +++ b/apps/api/routes/api/admin/templates/[id].patch.ts @@ -16,6 +16,7 @@ import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { requireAdminSession } from '~~/utils/adminAuth'; import { assertTemplateGrammar } from '~~/utils/templateGrammar'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z @@ -44,7 +45,7 @@ const bodySchema = z export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.admin); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); if (body.content !== undefined) assertTemplateGrammar(body.content); diff --git a/apps/api/routes/api/admin/themes/[id].delete.ts b/apps/api/routes/api/admin/themes/[id].delete.ts index de347d11..ec3c0e01 100644 --- a/apps/api/routes/api/admin/themes/[id].delete.ts +++ b/apps/api/routes/api/admin/themes/[id].delete.ts @@ -18,13 +18,14 @@ import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { bumpThemeVersion, releaseThemeReferences } from '~~/utils/themes'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [theme] = await db .select({ slug: schema.themes.slug }) diff --git a/apps/api/routes/api/admin/themes/[id].put.ts b/apps/api/routes/api/admin/themes/[id].put.ts index 2100e921..b296ce9b 100644 --- a/apps/api/routes/api/admin/themes/[id].put.ts +++ b/apps/api/routes/api/admin/themes/[id].put.ts @@ -12,7 +12,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { uploadTokenProblems } from '~~/utils/fonts'; import { updateThemeSchema } from '~~/utils/themeSchemas'; import { @@ -26,7 +26,7 @@ const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await validateBody(event, updateThemeSchema); // A font role may name an uploaded face. The shared validator accepts the // SHAPE `upload:<uuid>`; only the database can say whether that face exists diff --git a/apps/api/routes/api/admin/themes/[id]/css.get.ts b/apps/api/routes/api/admin/themes/[id]/css.get.ts index 265a77ca..9d783b7d 100644 --- a/apps/api/routes/api/admin/themes/[id]/css.get.ts +++ b/apps/api/routes/api/admin/themes/[id]/css.get.ts @@ -15,12 +15,13 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { requireOwnerSession } from '~~/utils/adminAuth'; import { MAX_CUSTOM_CSS_BYTES } from '~~/utils/themeCss'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireOwnerSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [theme] = await db .select({ css: schema.themes.customCss }) diff --git a/apps/api/routes/api/admin/themes/[id]/css.put.ts b/apps/api/routes/api/admin/themes/[id]/css.put.ts index cae543d6..347b896d 100644 --- a/apps/api/routes/api/admin/themes/[id]/css.put.ts +++ b/apps/api/routes/api/admin/themes/[id]/css.put.ts @@ -31,7 +31,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { requireOwnerSession, requireFreshAuth } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { bumpThemeVersion } from '~~/utils/themes'; import { MAX_CUSTOM_CSS_BYTES, sanitiseCustomCss } from '~~/utils/themeCss'; @@ -50,7 +50,7 @@ export default defineEventHandler(async (event) => { await requireFreshAuth(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const { css } = await validateBody(event, bodySchema); const [theme] = await db 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 5b5a9c9b..10af29d4 100644 --- a/apps/api/routes/api/admin/users/[id]/role.put.ts +++ b/apps/api/routes/api/admin/users/[id]/role.put.ts @@ -12,7 +12,7 @@ */ import { db, schema } from '@trackarr/db'; import { requireAdminSession, requireFreshAuth } from '~~/utils/adminAuth'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { eq, and, ne, count } from 'drizzle-orm'; import { z } from 'zod'; import { notify } from '~~/utils/notify'; @@ -31,7 +31,7 @@ export default defineEventHandler(async (event) => { // Privilege grants are the highest-impact admin action — require a // fresh login on top of the admin gate (finding L10). await requireFreshAuth(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // Routed through validateBody so a Zod failure renders as a clean // 400 with a human message, not a wall of `unrecognized_keys` issue // objects. The frontend used to send the whole RegistryUser object @@ -118,7 +118,19 @@ export default defineEventHandler(async (event) => { isModerator: body.isModerator, }) .where(eq(schema.users.id, id)) - .returning(); + // Une projection, pas la ligne entière. `.returning()` nu renvoyait les + // 33 colonnes de `users` — dont `passkey`, `rssKey` et `apiKey`, stockées + // EN CLAIR, plus `authVerifier`, `totpSecret`, `panicPasswordHash` et + // `lastIp`. Nommer un modérateur rendait donc sa passkey d'annonce à + // l'administrateur, qui pouvait dès lors annoncer à sa place. Le point de + // terminaison n'a besoin que de deux booléens. + .returning({ + id: schema.users.id, + username: schema.users.username, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isOwner: schema.users.isOwner, + }); // Bust the cached role so the staff gates observe the change // within the request, not after the 60 s TTL — and a demotion diff --git a/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts b/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts index 9616a287..b5973cab 100644 --- a/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts +++ b/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts @@ -19,6 +19,7 @@ import { invalidateBypassCache } from '~~/utils/torrentModeration'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { notify } from '~~/utils/notify'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid(), @@ -27,7 +28,7 @@ const paramsSchema = z.object({ export default defineEventHandler(async (event) => { const { user: actor } = await requireAdminSession(event); - const { id, roleId } = paramsSchema.parse(getRouterParams(event)); + const { id, roleId } = validateRouterParams(event, paramsSchema); const [removed] = await db .delete(schema.userRoles) diff --git a/apps/api/routes/api/admin/users/[id]/roles/index.get.ts b/apps/api/routes/api/admin/users/[id]/roles/index.get.ts index 1b1c14c8..3981a0cb 100644 --- a/apps/api/routes/api/admin/users/[id]/roles/index.get.ts +++ b/apps/api/routes/api/admin/users/[id]/roles/index.get.ts @@ -14,12 +14,13 @@ import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { eq, asc } from 'drizzle-orm'; import { z } from 'zod'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [user] = await db .select({ id: schema.users.id }) diff --git a/apps/api/routes/api/admin/users/[id]/roles/index.post.ts b/apps/api/routes/api/admin/users/[id]/roles/index.post.ts index c093d3cc..379fd275 100644 --- a/apps/api/routes/api/admin/users/[id]/roles/index.post.ts +++ b/apps/api/routes/api/admin/users/[id]/roles/index.post.ts @@ -16,7 +16,7 @@ import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { invalidateBypassCache } from '~~/utils/torrentModeration'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { notify } from '~~/utils/notify'; @@ -28,7 +28,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user: actor } = await requireAdminSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await validateBody(event, bodySchema); const [user] = await db diff --git a/apps/api/routes/api/admin/users/index.get.ts b/apps/api/routes/api/admin/users/index.get.ts index 243269c1..c45caacc 100644 --- a/apps/api/routes/api/admin/users/index.get.ts +++ b/apps/api/routes/api/admin/users/index.get.ts @@ -44,6 +44,7 @@ import { type SQL, } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const ternary = z.enum(['true', 'false']).optional(); const querySchema = z.object({ @@ -70,7 +71,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user: viewer } = await requireModeratorSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); // ── WHERE clause ───────────────────────────────────────────── const conditions: SQL[] = []; diff --git a/apps/api/routes/api/me/downloads.get.ts b/apps/api/routes/api/me/downloads.get.ts index 20bfa4f0..53e5ebec 100644 --- a/apps/api/routes/api/me/downloads.get.ts +++ b/apps/api/routes/api/me/downloads.get.ts @@ -25,6 +25,7 @@ import { db, schema } from '@trackarr/db'; import { count, desc, eq } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -33,7 +34,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); // Read the preference before the listing: when the history is hidden // there is nothing to page over, so we skip both queries rather than diff --git a/apps/api/routes/api/me/favorites.get.ts b/apps/api/routes/api/me/favorites.get.ts index a6931a73..97bb80c8 100644 --- a/apps/api/routes/api/me/favorites.get.ts +++ b/apps/api/routes/api/me/favorites.get.ts @@ -17,6 +17,7 @@ import { db, schema } from '@trackarr/db'; import { and, asc, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getStats } from '~~/utils/server'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -26,7 +27,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); // Join through `torrent_favorites` so we get the pin timestamp // alongside the torrent row. Drizzle's `findMany` with a diff --git a/apps/api/routes/api/me/following.get.ts b/apps/api/routes/api/me/following.get.ts index 96804971..0b9f85df 100644 --- a/apps/api/routes/api/me/following.get.ts +++ b/apps/api/routes/api/me/following.get.ts @@ -17,6 +17,7 @@ import { db, schema } from '@trackarr/db'; import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -26,7 +27,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; const orderClause = diff --git a/apps/api/routes/api/me/seeds.get.ts b/apps/api/routes/api/me/seeds.get.ts index 5a22ef1b..5038ca11 100644 --- a/apps/api/routes/api/me/seeds.get.ts +++ b/apps/api/routes/api/me/seeds.get.ts @@ -29,6 +29,7 @@ import { db, schema } from '@trackarr/db'; import { and, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getStats } from '~~/utils/server'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -40,7 +41,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); // Build WHERE const conditions = [eq(schema.hnrTracking.userId, user.id)]; diff --git a/apps/api/routes/api/me/templates/[id].delete.ts b/apps/api/routes/api/me/templates/[id].delete.ts index 52d14a8a..e7b632d2 100644 --- a/apps/api/routes/api/me/templates/[id].delete.ts +++ b/apps/api/routes/api/me/templates/[id].delete.ts @@ -19,13 +19,14 @@ import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // One statement, scoped to the owner: a row that is not the caller's and a // row that does not exist both come back as zero rows, so neither the diff --git a/apps/api/routes/api/me/templates/[id].patch.ts b/apps/api/routes/api/me/templates/[id].patch.ts index faad408f..432c215f 100644 --- a/apps/api/routes/api/me/templates/[id].patch.ts +++ b/apps/api/routes/api/me/templates/[id].patch.ts @@ -16,6 +16,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { assertTemplateGrammar } from '~~/utils/templateGrammar'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z @@ -48,7 +49,7 @@ const bodySchema = z export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); // Only when the field is actually being written — a rename must not be // refused because the stored body predates this check. diff --git a/apps/api/routes/api/me/templates/[id]/default.put.ts b/apps/api/routes/api/me/templates/[id]/default.put.ts index cf5e9723..4f5f8a89 100644 --- a/apps/api/routes/api/me/templates/[id]/default.put.ts +++ b/apps/api/routes/api/me/templates/[id]/default.put.ts @@ -22,6 +22,7 @@ import { and, eq, ne } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -33,7 +34,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // A PUT with no payload at all is the normal call, and readBody then // yields undefined (or throws on an empty-but-typed body) — neither of // which zod tolerates, hence the explicit fallback and safeParse. diff --git a/apps/api/routes/api/me/templates/index.get.ts b/apps/api/routes/api/me/templates/index.get.ts index f7380c3a..ab47fa50 100644 --- a/apps/api/routes/api/me/templates/index.get.ts +++ b/apps/api/routes/api/me/templates/index.get.ts @@ -25,6 +25,7 @@ import { and, asc, desc, eq, or, sql, type SQL } from 'drizzle-orm'; import { z } from 'zod'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { getTemplateQuotaPerUser } from '~~/utils/settings'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ scope: z.enum(['mine', 'site', 'all']).default('all'), @@ -40,7 +41,7 @@ export default defineEventHandler(async (event) => { // wizard both refetch after every write, and throttling those alongside // the writes themselves would make the UI stall on ordinary use. await rateLimit(event, RATE_LIMITS.public); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; const mine = eq(schema.presentationTemplates.ownerId, user.id); diff --git a/apps/api/routes/api/metadata/lookup.get.ts b/apps/api/routes/api/metadata/lookup.get.ts index e8c32028..f4545186 100644 --- a/apps/api/routes/api/metadata/lookup.get.ts +++ b/apps/api/routes/api/metadata/lookup.get.ts @@ -21,6 +21,7 @@ import { type LookupSource, } from '~~/utils/metadata'; import type { MediaTypeHint } from '~~/utils/metadata/types'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ source: z.enum(ALL_SOURCE_IDS as [LookupSource, ...LookupSource[]]), @@ -43,7 +44,7 @@ export default defineEventHandler(async (event) => { }; } - const { source, id, type } = querySchema.parse(getQuery(event)); + const { source, id, type } = validateQuery(event, querySchema); if (!isSourceEnabled(source)) { setResponseStatus(event, 503); diff --git a/apps/api/routes/api/metadata/search.get.ts b/apps/api/routes/api/metadata/search.get.ts index 6b46e7f2..8dd90882 100644 --- a/apps/api/routes/api/metadata/search.get.ts +++ b/apps/api/routes/api/metadata/search.get.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; +import { validateQuery } from '~~/utils/schemas'; import { ALL_SOURCE_IDS, isMetadataEnabled, @@ -47,7 +48,7 @@ export default defineEventHandler(async (event) => { }; } - const { query, source, type, year } = querySchema.parse(getQuery(event)); + const { query, source, type, year } = validateQuery(event, querySchema); if (!isSourceEnabled(source)) { setResponseStatus(event, 503); diff --git a/apps/api/routes/api/requests/[id].delete.ts b/apps/api/routes/api/requests/[id].delete.ts index 1e7c00d9..5337a14d 100644 --- a/apps/api/routes/api/requests/[id].delete.ts +++ b/apps/api/routes/api/requests/[id].delete.ts @@ -16,13 +16,14 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { refundReward } from '~~/utils/requestPoints'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const row = await db.query.uploadRequests.findFirst({ where: eq(schema.uploadRequests.id, id), diff --git a/apps/api/routes/api/requests/[id].get.ts b/apps/api/routes/api/requests/[id].get.ts index cf8a0200..63730136 100644 --- a/apps/api/routes/api/requests/[id].get.ts +++ b/apps/api/routes/api/requests/[id].get.ts @@ -13,12 +13,13 @@ import { db, schema } from '@trackarr/db'; import { and, asc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getRequestMaxFillsPerUser } from '~~/utils/settings'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const row = await db.query.uploadRequests.findFirst({ where: eq(schema.uploadRequests.id, id), diff --git a/apps/api/routes/api/requests/[id].patch.ts b/apps/api/routes/api/requests/[id].patch.ts index 332b4349..f2de8f70 100644 --- a/apps/api/routes/api/requests/[id].patch.ts +++ b/apps/api/routes/api/requests/[id].patch.ts @@ -13,6 +13,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { holdReward, RewardError } from '~~/utils/requestPoints'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z @@ -34,7 +35,7 @@ const bodySchema = z export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const row = await db.query.uploadRequests.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/comments.post.ts b/apps/api/routes/api/requests/[id]/comments.post.ts index a53382a4..0d36544d 100644 --- a/apps/api/routes/api/requests/[id]/comments.post.ts +++ b/apps/api/routes/api/requests/[id]/comments.post.ts @@ -13,6 +13,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -22,7 +23,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const request = await db.query.uploadRequests.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts b/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts index d8abbd72..5e29b752 100644 --- a/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts +++ b/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts @@ -11,6 +11,7 @@ import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const EDIT_WINDOW_MS = 15 * 60 * 1000; @@ -22,7 +23,7 @@ const paramsSchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id, cid } = paramsSchema.parse(getRouterParams(event)); + const { id, cid } = validateRouterParams(event, paramsSchema); const comment = await db.query.uploadRequestComments.findFirst({ where: and( diff --git a/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts b/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts index 2aa9e89f..d52a82cb 100644 --- a/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts +++ b/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts @@ -9,6 +9,7 @@ import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const EDIT_WINDOW_MS = 15 * 60 * 1000; @@ -23,7 +24,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id, cid } = paramsSchema.parse(getRouterParams(event)); + const { id, cid } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const comment = await db.query.uploadRequestComments.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/fill.post.ts b/apps/api/routes/api/requests/[id]/fill.post.ts index 7d9315ff..ec97d243 100644 --- a/apps/api/routes/api/requests/[id]/fill.post.ts +++ b/apps/api/routes/api/requests/[id]/fill.post.ts @@ -22,6 +22,7 @@ import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; import { getRequestMaxFillsPerUser } from '~~/utils/settings'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -37,7 +38,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const request = await db.query.uploadRequests.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/reject.post.ts b/apps/api/routes/api/requests/[id]/reject.post.ts index 488dcac5..cf24af64 100644 --- a/apps/api/routes/api/requests/[id]/reject.post.ts +++ b/apps/api/routes/api/requests/[id]/reject.post.ts @@ -15,6 +15,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -24,7 +25,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // Body is optional — the FE button fires reject without one. // Read raw, default to {}, then validate against the schema. const rawBody = (await readBody(event).catch(() => null)) ?? {}; diff --git a/apps/api/routes/api/requests/[id]/validate.post.ts b/apps/api/routes/api/requests/[id]/validate.post.ts index 5960ea28..3cda1080 100644 --- a/apps/api/routes/api/requests/[id]/validate.post.ts +++ b/apps/api/routes/api/requests/[id]/validate.post.ts @@ -16,13 +16,14 @@ import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; import { payReward } from '~~/utils/requestPoints'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const request = await db.query.uploadRequests.findFirst({ where: eq(schema.uploadRequests.id, id), diff --git a/apps/api/routes/api/requests/index.get.ts b/apps/api/routes/api/requests/index.get.ts index c6d6c5d9..a0f80b92 100644 --- a/apps/api/routes/api/requests/index.get.ts +++ b/apps/api/routes/api/requests/index.get.ts @@ -16,6 +16,7 @@ import { db, schema } from '@trackarr/db'; import { and, desc, eq, ilike, sql, type SQL } from 'drizzle-orm'; import { z } from 'zod'; import { escapeLike } from '~~/utils/sql'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ status: z @@ -30,7 +31,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; const conditions: SQL[] = []; diff --git a/apps/api/routes/api/rss/category/[slug].get.ts b/apps/api/routes/api/rss/category/[slug].get.ts index e5acb5cf..27011155 100644 --- a/apps/api/routes/api/rss/category/[slug].get.ts +++ b/apps/api/routes/api/rss/category/[slug].get.ts @@ -6,6 +6,7 @@ import { requireReadAccess } from '~~/utils/account/readKeyAuth'; import { getTorznabIncludeFederated } from '~~/utils/torznabSettings'; import { getFederationConfig, isFederationLive } from '~~/utils/federation/config'; import { federatedFeedRows } from '~~/utils/federation/feedRows'; +import { validateQuery, validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ slug: z.string().min(1), @@ -22,8 +23,8 @@ const querySchema = z.object({ */ export default defineEventHandler(async (event) => { const { user } = await requireReadAccess(event, 'rss'); - const params = paramsSchema.parse(getRouterParams(event)); - const query = querySchema.parse(getQuery(event)); + const params = validateRouterParams(event, paramsSchema); + const query = validateQuery(event, querySchema); const category = await db.query.categories.findFirst({ where: eq(schema.categories.slug, params.slug), diff --git a/apps/api/routes/api/rss/latest.get.ts b/apps/api/routes/api/rss/latest.get.ts index 478f34da..03f5d800 100644 --- a/apps/api/routes/api/rss/latest.get.ts +++ b/apps/api/routes/api/rss/latest.get.ts @@ -4,6 +4,7 @@ import { desc, eq, and, or, isNull, notInArray } from 'drizzle-orm'; import { z } from 'zod'; import { requireReadAccess } from '~~/utils/account/readKeyAuth'; import { adultCategoryIds } from '~~/utils/adultContent'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ limit: z.coerce.number().min(1).max(100).default(50), @@ -19,7 +20,7 @@ const querySchema = z.object({ */ export default defineEventHandler(async (event) => { const { user } = await requireReadAccess(event, 'rss'); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); // Build the where so feeds for users who haven't opted into XXX // skip those entries entirely. `requireReadAccess` reads diff --git a/apps/api/routes/api/tags/index.get.ts b/apps/api/routes/api/tags/index.get.ts index 2c613294..9d1b624b 100644 --- a/apps/api/routes/api/tags/index.get.ts +++ b/apps/api/routes/api/tags/index.get.ts @@ -1,6 +1,7 @@ import { db } from '@trackarr/db'; import { escapeLike } from '~~/utils/sql'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ q: z.string().trim().max(50).optional(), @@ -13,7 +14,7 @@ export default defineEventHandler(async (event) => { // we still serve the full sorted list so the admin UI keeps working. await requireUserSession(event); - const { q, limit } = querySchema.parse(getQuery(event)); + const { q, limit } = validateQuery(event, querySchema); const pattern = q ? `%${escapeLike(q)}%` : null; const tags = await db.query.tags.findMany({ diff --git a/apps/api/routes/api/users/[id].get.ts b/apps/api/routes/api/users/[id].get.ts index 5ffbd104..409aeb8d 100644 --- a/apps/api/routes/api/users/[id].get.ts +++ b/apps/api/routes/api/users/[id].get.ts @@ -1,6 +1,7 @@ import { db, schema } from '@trackarr/db'; import { and, eq, sql, desc } from 'drizzle-orm'; import { z } from 'zod'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().min(1), @@ -9,7 +10,7 @@ const paramsSchema = z.object({ export default defineEventHandler(async (event) => { const { user: viewer } = await requireUserSession(event); - const params = paramsSchema.parse(getRouterParams(event)); + const params = validateRouterParams(event, paramsSchema); const user = await db.query.users.findFirst({ where: eq(schema.users.id, params.id), diff --git a/apps/api/routes/api/users/[id]/uploads.get.ts b/apps/api/routes/api/users/[id]/uploads.get.ts index 4f231abe..54bf0438 100644 --- a/apps/api/routes/api/users/[id]/uploads.get.ts +++ b/apps/api/routes/api/users/[id]/uploads.get.ts @@ -3,6 +3,7 @@ import { and, eq, desc, sql, or, isNull, notInArray } from 'drizzle-orm'; import { getStats } from '~~/utils/server'; import { adultCategoryIds } from '~~/utils/adultContent'; import { z } from 'zod'; +import { validateQuery, validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().min(1), @@ -16,8 +17,8 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user: viewer } = await requireUserSession(event); - const params = paramsSchema.parse(getRouterParams(event)); - const query = querySchema.parse(getQuery(event)); + const params = validateRouterParams(event, paramsSchema); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts index e7fc57f0..06ece968 100644 --- a/apps/api/utils/schemas.ts +++ b/apps/api/utils/schemas.ts @@ -423,6 +423,40 @@ export function validateQuery<T>(event: any, schema: z.ZodSchema<T>): T { } } +/** + * Valide TOUS les paramètres de route d'un coup, comme `validateQuery` le fait + * pour la chaîne de requête. + * + * Il manquait, et son absence coûtait cher : vingt-six routes appelaient + * `paramsSchema.parse(getRouterParams(event))` en direct. Une `ZodError` non + * rattrapée ne devient pas un 400 — elle remonte comme erreur non gérée et + * Nitro répond **500 « Server Error »**. Mesuré le 2026-09-02 sur la pile + * compilée : `/api/tags?limit=abc` renvoyait 500, quand `/api/torrents?limit=abc`, + * qui passe par `validateQuery`, répondait + * « 400 limit: Invalid input: expected number, received NaN ». + * + * Deux conséquences, au-delà du message illisible : un 500 écrit une trace + * complète dans le journal à CHAQUE requête malformée — un lecteur de flux mal + * configuré sur `/api/rss/latest` en produit en continu — et il annonce au + * client une panne du serveur là où c'est sa propre requête qui est en cause. + * + * `validateParam` existait déjà mais ne prend qu'UN paramètre nommé, ce qui ne + * couvre pas les routes à deux segments (`/requests/[id]/comments/[cid]`). + */ +export function validateRouterParams<T>(event: any, schema: z.ZodSchema<T>): T { + try { + return schema.parse(getRouterParams(event)); + } catch (error) { + if (error instanceof z.ZodError) { + throw createError({ + statusCode: 400, + message: error.issues.map(describeZodIssue).join('; '), + }); + } + throw error; + } +} + /** * Validate route parameter with Zod schema * Throws HTTP 400 error with validation messages on failure From 3a62385e4d70a8c95317d622d7381165d5d5fe26 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:16:42 +0200 Subject: [PATCH 39/96] fix(admin): the dashboard's tracker light was a constant, not a status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/admin/stats` returned `status: 'running'` as a literal. The badge read "TRACKER · EN LIGNE" whatever the tracker was doing — container stopped, database gone, Redis silent. Meanwhile the homepage badge reads `/api/tracker-health`, which actually probes `http://tracker:8080/health`, so the two pages contradicted each other on screen and it was the admin page that lied — the one an operator turns to when something is wrong. An indicator that can never report a fault is worse than no indicator. Both surfaces now go through one probe, cache included: two separate ten-second caches could already disagree with each other. Proven by stopping the container: both report offline, and both report online again when it comes back. The badge also gained the age of the probe as a tooltip. Without it a red light does not say whether it is observing a fault right now or repeating a cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/admin/stats.get.ts | 14 ++++- apps/api/routes/api/tracker-health.get.ts | 63 ++++----------------- apps/api/utils/trackerHealth.ts | 67 +++++++++++++++++++++++ apps/web/app/pages/admin/index.vue | 27 ++++++++- apps/web/i18n/locales/en.json | 1 + apps/web/i18n/locales/fr.json | 1 + 6 files changed, 117 insertions(+), 56 deletions(-) create mode 100644 apps/api/utils/trackerHealth.ts diff --git a/apps/api/routes/api/admin/stats.get.ts b/apps/api/routes/api/admin/stats.get.ts index 9f8ddee4..3b7ef380 100644 --- a/apps/api/routes/api/admin/stats.get.ts +++ b/apps/api/routes/api/admin/stats.get.ts @@ -4,6 +4,7 @@ import { sql } from 'drizzle-orm'; import { redis } from '~~/utils/server'; import { requireAdminSession } from '~~/utils/adminAuth'; import { rollupActivePeerCounts } from '~~/utils/peerStats'; +import { readTrackerHealth } from '~~/utils/trackerHealth'; export default defineEventHandler(async (event) => { // Require admin authentication @@ -58,8 +59,19 @@ export default defineEventHandler(async (event) => { // see apps/tracker/internal/config/config.go). The api process and // tracker process share the same env so this read is authoritative // without an internal RPC. + // L'état vient de la MÊME sonde que le badge de la page d'accueil. + // + // C'était `status: 'running'` en dur. Le tableau de bord affichait donc + // « TRACKER · EN LIGNE » quoi qu'il arrive — tracker arrêté, base coupée, + // Redis muet — pendant que l'accueil, qui sonde vraiment, affichait + // « hors ligne ». Les deux pages se contredisaient à l'écran, et c'était la + // page d'administration qui mentait : celle vers laquelle on se tourne quand + // quelque chose cloche. + const health = await readTrackerHealth(); + return { - status: 'running', + status: health.online ? 'running' : 'down', + trackerCheckedAt: health.checkedAt, cached: { torrents: totalTorrents, peers: totalPeers, diff --git a/apps/api/routes/api/tracker-health.get.ts b/apps/api/routes/api/tracker-health.get.ts index 4d084d0b..f8e142b6 100644 --- a/apps/api/routes/api/tracker-health.get.ts +++ b/apps/api/routes/api/tracker-health.get.ts @@ -1,59 +1,16 @@ import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { readTrackerHealth } from '~~/utils/trackerHealth'; -// Public health summary that the homepage badge reads. Hits the -// tracker's `/health` endpoint over the internal Docker network and -// caches the answer for a few seconds so a viral spike on /index.html -// doesn't translate into a thundering herd against the tracker. +// Résumé public que lit le badge de la page d'accueil. // -// We deliberately don't surface the per-component (db / redis) breakdown -// here — that's an admin-only diagnostic. The badge only cares whether -// the tracker is reachable AND able to answer health checks at all. - -interface TrackerHealth { - online: boolean; - // unix millis of the last successful probe; lets the UI show a - // "checked Xs ago" tooltip without triggering its own clock. - checkedAt: number; -} - -const CACHE_TTL_MS = 10_000; -const PROBE_TIMEOUT_MS = 1_500; - -let cached: { value: TrackerHealth; expiresAt: number } | null = null; - -function trackerHealthUrl(): string { - const base = - process.env.TRACKER_INTERNAL_URL || - process.env.TRACKER_HEALTH_URL || - 'http://tracker:8080'; - return base.replace(/\/+$/, '') + '/health'; -} - -async function probe(): Promise<TrackerHealth> { - const url = trackerHealthUrl(); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); - try { - const res = await fetch(url, { signal: controller.signal }); - // Tracker returns 200 only when DB + Redis pings both succeed; any - // 5xx means at least one dependency is down. - return { online: res.ok, checkedAt: Date.now() }; - } catch { - return { online: false, checkedAt: Date.now() }; - } finally { - clearTimeout(timer); - } -} - +// La sonde et son cache vivent dans `utils/trackerHealth.ts`, partagés avec +// `/api/admin/stats` : les deux surfaces affichaient auparavant des états +// contradictoires, l'une sondant et l'autre non. Voir l'en-tête de ce module. +// +// On ne publie délibérément pas le détail par composant (db / redis) ici — +// c'est un diagnostic réservé à l'administration. Le badge ne veut savoir +// qu'une chose : le tracker répond-il. export default defineEventHandler(async (event) => { await rateLimit(event, RATE_LIMITS.public); - - const now = Date.now(); - if (cached && cached.expiresAt > now) { - return cached.value; - } - - const value = await probe(); - cached = { value, expiresAt: now + CACHE_TTL_MS }; - return value; + return await readTrackerHealth(); }); diff --git a/apps/api/utils/trackerHealth.ts b/apps/api/utils/trackerHealth.ts new file mode 100644 index 00000000..6d517e09 --- /dev/null +++ b/apps/api/utils/trackerHealth.ts @@ -0,0 +1,67 @@ +/** + * Le tracker est-il joignable ? Une seule réponse, pour tout le site. + * + * Ce module existe parce qu'il y en avait deux, et qu'elles se contredisaient + * en public : + * + * - la page d'accueil lisait `/api/tracker-health`, qui sonde réellement + * `http://tracker:8080/health` et dit la vérité ; + * - le tableau de bord d'administration lisait `/api/admin/stats`, qui + * renvoyait `status: 'running'` **en dur**. + * + * Le second n'était donc pas un état mais une décoration : il affichait + * « en ligne » quoi qu'il arrive, y compris conteneur tracker arrêté. Un + * indicateur qui ne peut jamais signaler de panne est pire qu'absent — c'est + * la page vers laquelle un exploitant se tourne quand quelque chose cloche, et + * elle le rassurait à tort. Les deux surfaces passent maintenant par ici. + * + * Le cache est partagé lui aussi, et c'est le point : deux caches séparés + * pouvaient déjà se contredire pendant leurs dix secondes respectives. + */ + +/** Ce que le tracker répond sur `/health` : 200 seulement si Postgres ET Redis répondent. */ +export interface TrackerHealth { + online: boolean; + /** Millisecondes unix de la dernière sonde ; l'interface affiche « vérifié il y a Xs ». */ + checkedAt: number; +} + +const CACHE_TTL_MS = 10_000; +const PROBE_TIMEOUT_MS = 1_500; + +let cached: { value: TrackerHealth; expiresAt: number } | null = null; + +function trackerHealthUrl(): string { + const base = + process.env.TRACKER_INTERNAL_URL || + process.env.TRACKER_HEALTH_URL || + 'http://tracker:8080'; + return base.replace(/\/+$/, '') + '/health'; +} + +async function probe(): Promise<TrackerHealth> { + const url = trackerHealthUrl(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: controller.signal }); + return { online: res.ok, checkedAt: Date.now() }; + } catch { + // Injoignable, DNS muet, délai dépassé : de l'extérieur c'est le même fait. + return { online: false, checkedAt: Date.now() }; + } finally { + clearTimeout(timer); + } +} + +/** + * L'état, mis en cache quelques secondes — pour qu'une pointe de trafic sur la + * page d'accueil ne se traduise pas en rafale de sondes contre le tracker. + */ +export async function readTrackerHealth(): Promise<TrackerHealth> { + const now = Date.now(); + if (cached && cached.expiresAt > now) return cached.value; + const value = await probe(); + cached = { value, expiresAt: now + CACHE_TTL_MS }; + return value; +} diff --git a/apps/web/app/pages/admin/index.vue b/apps/web/app/pages/admin/index.vue index efb95ad4..9a5ee4e6 100644 --- a/apps/web/app/pages/admin/index.vue +++ b/apps/web/app/pages/admin/index.vue @@ -21,7 +21,13 @@ <p class="cr-intro">{{ $t('admin.dashboard.intro') }}</p> </div> - <div class="cr-status" :class="`cr-status--${trackerOnline ? 'on' : 'off'}`"> + <!-- `title` porte l'âge de la sonde. Sans lui, un badge rouge ne dit pas + s'il constate une panne à l'instant ou s'il répète un cache. --> + <div + class="cr-status" + :class="`cr-status--${trackerOnline ? 'on' : 'off'}`" + :title="probeAge" + > <span class="cr-status-light"> <span class="cr-status-dot" aria-hidden="true" /> <span class="cr-status-rings" aria-hidden="true" /> @@ -92,6 +98,8 @@ <script setup lang="ts"> interface TrackerStats { status: string; + /** Quand la sonde a réellement joint le tracker — voir `utils/trackerHealth.ts`. */ + trackerCheckedAt?: number; cached: { torrents: number; peers: number; @@ -138,9 +146,24 @@ const wallClock = computed(() => { }); }); +/** + * « Tracker vérifié il y a Xs ». + * + * Adossé au même `now` que l'horloge, donc vide au rendu serveur : c'est + * volontaire, une durée relative calculée sur l'horloge du serveur puis + * recalculée sur celle du client est exactement ce qui produisait le défaut + * d'hydratation corrigé plus haut. + */ +const probeAge = computed(() => { + const at = stats.value?.trackerCheckedAt; + if (!at || now.value === null) return ''; + const s = Math.max(0, Math.round((now.value - at) / 1000)); + return t('admin.dashboard.probeAge', { n: s }); +}); + const refreshedAgo = computed(() => { const at = stats.value?.cached.updatedAt; - if (!at) return '—'; + if (!at || now.value === null) return '—'; const seconds = Math.max(0, Math.floor((now.value - at) / 1000)); if (seconds < 60) return t('admin.dashboard.rel.seconds', { n: seconds }); const minutes = Math.floor(seconds / 60); diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 7a8c621c..b90ba548 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -2182,6 +2182,7 @@ "intro": "Live snapshot of the tracker's vital signs — torrents, peers, protocols, and announce endpoints — at a glance.", "statusOnline": "Tracker · online", "statusOffline": "Tracker · offline", + "probeAge": "Tracker checked {n}s ago", "metricsTitle": "Live metrics", "chartsTitle": "Activity over time", "refreshedAgo": "Refreshed {rel} ago", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 3ec44ed0..fc5c5b38 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -2182,6 +2182,7 @@ "intro": "Aperçu en direct des signes vitaux du tracker — torrents, pairs, protocoles et points d'annonce — d'un coup d'œil.", "statusOnline": "Tracker · en ligne", "statusOffline": "Tracker · hors ligne", + "probeAge": "Tracker vérifié il y a {n} s", "metricsTitle": "Métriques en direct", "chartsTitle": "Activité dans le temps", "refreshedAgo": "Rafraîchi il y a {rel}", From 609c6b40e0e8489612faeb2d6667e646158eb0f1 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:17:30 +0200 Subject: [PATCH 40/96] fix(web): two pages shipped the server's clock and corrected it on hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/admin` and the homepage both computed a time during server rendering, so the browser's first render disagreed with the served HTML and Vue reported "Hydration completed but contains mismatches". The homepage's own comment described the mechanism as a feature — "frozen on the server … let the watcher catch up post hydration" — but that catching up IS the mismatch. Measured on the compiled stack: `17:20:24 UTC` in the served document, `17:20:44 UTC` after hydration. Twenty seconds, because the page is cached; a second at best. `/admin` had it worse: `toLocaleTimeString` on the server's timezone. `11:34` in the HTML, `13:34` on screen. Two causes at once, the zone and the instant — even with both ends in the same zone, a page served from a cache shows the time it was cached. Both now render a placeholder the two sides agree on, replaced on mount. The homepage's keeps the exact width of the final reading (`--:--:-- UTC`) so no cell moves between the first frame and the first second; `/admin` uses the dash `refreshedAgo` already shows when it has no data, and one placeholder covers both its labels since they share the same `now`. Detector: fetch every page and grep the SERVED html for a computed time. Sixty pages, zero hits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/app/pages/admin/index.vue | 18 +++++++++++++++++- apps/web/app/pages/index.vue | 28 ++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/web/app/pages/admin/index.vue b/apps/web/app/pages/admin/index.vue index 9a5ee4e6..431422b6 100644 --- a/apps/web/app/pages/admin/index.vue +++ b/apps/web/app/pages/admin/index.vue @@ -126,9 +126,24 @@ const trackerOnline = computed(() => stats.value?.status === 'running'); // ── Live wall clock (ticks every 30s — fast enough to keep the // "refreshed Xm ago" label honest without being a CPU drain). ─── -const now = ref(Date.now()); +// +// `null` jusqu'au montage, et non `Date.now()`. +// +// Cette page est rendue côté serveur. Avec une valeur initiale, le serveur +// écrivait SON heure dans SON fuseau, puis l'hydratation la remplaçait par +// celle du navigateur — « Hydration completed but contains mismatches », +// mesuré sur la pile de production le 2026-09-02 : `11:34` dans le HTML +// servi, `13:34` affiché. Deux causes se cumulaient, le fuseau et l'instant : +// même serveur et client dans le même fuseau, une page servie depuis un +// cache aurait affiché l'heure de sa mise en cache. +// +// Le rendu serveur montre donc un tiret, remplacé dès le montage. C'est le +// même signe que `refreshedAgo` utilise déjà quand il n'a pas de donnée, et +// cela couvre les deux étiquettes d'un coup — elles dépendent du même `now`. +const now = ref<number | null>(null); let tick: ReturnType<typeof setInterval> | null = null; onMounted(() => { + now.value = Date.now(); tick = setInterval(() => { now.value = Date.now(); }, 30_000); @@ -138,6 +153,7 @@ onBeforeUnmount(() => { }); const wallClock = computed(() => { + if (now.value === null) return '—'; const d = new Date(now.value); return d.toLocaleTimeString(locale.value === 'fr' ? 'fr-FR' : 'en-US', { hour: '2-digit', diff --git a/apps/web/app/pages/index.vue b/apps/web/app/pages/index.vue index 3b3f2595..89e120ac 100644 --- a/apps/web/app/pages/index.vue +++ b/apps/web/app/pages/index.vue @@ -553,12 +553,28 @@ function hotBarWidth(seeders: number): number { // ── Live clock ────────────────────────────────────────────────────── // -// HH:MM:SS UTC — ticks every second on the client, frozen on the -// server (we ship the SSR snapshot and let the watcher catch up post -// hydration). UTC is intentional: the operator runs the tracker in a -// data-centre context, and "21:42 UTC" reads more "command room" than -// the visitor's local time. -const liveClock = ref(formatClock(new Date())); +// HH:MM:SS UTC — ticks every second on the client. UTC is intentional : +// l'exploitant fait tourner le tracker dans un contexte de centre de +// données, et « 21:42 UTC » sonne davantage « salle de contrôle » que +// l'heure locale du visiteur. +// +// La valeur initiale est un GABARIT, pas l'heure du serveur. +// +// Elle valait `formatClock(new Date())`, et le commentaire d'origine +// annonçait « frozen on the server … let the watcher catch up post +// hydration ». Ce rattrapage EST un défaut d'hydratation : Vue compare le +// texte rendu par le serveur à celui du premier rendu client, et ils +// diffèrent forcément — d'une seconde au mieux, et bien davantage sur une +// page servie depuis un cache. Mesuré sur la pile compilée le 2026-09-02 : +// « 17:20:24 UTC » dans le HTML servi, « 17:20:44 UTC » après hydratation, +// et « Hydration completed but contains mismatches » dans la console. +// +// Le gabarit garde la largeur exacte de l'affichage final, pour qu'aucune +// case ne bouge entre le premier rendu et la première seconde. `onMounted` +// pose l'heure réelle avant la première frappe de l'intervalle, sans quoi +// le gabarit resterait affiché une seconde entière. +const CLOCK_PLACEHOLDER = '--:--:-- UTC'; +const liveClock = ref(CLOCK_PLACEHOLDER); function formatClock(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad( From bd16c2a1899e3f8d680c0e66a06103503e63fa5e Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:17:49 +0200 Subject: [PATCH 41/96] fix(alerts): a saved search rebuilt its link with the API's parameter names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `searchLink()` emitted `search` and `categoryId` — the names of the body sent to `/api/me/saved-searches` — while `pages/torrents/index.vue` reads `q` and `c`. The magnifying glass therefore opened an unfiltered catalogue: no error, no log line, just a standing search that never applied. Four of the six parameters already carried the right name, which is what made the gap invisible. Two vocabularies that look alike, and the mapping between them now lives in one documented place rather than inline in a page. The test reads `torrents/index.vue` rather than repeating a list, so drift is caught from BOTH sides: renaming a parameter in the page fails it exactly as getting the name wrong in the link does. `sfcImports.test.ts` set the precedent for a test that reads source. Proven by mutation — three of its five cases fail with the old names, including the drift check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/app/pages/alerts.vue | 16 ++--- apps/web/app/utils/savedSearchLink.ts | 50 ++++++++++++++ apps/web/test/savedSearchLink.test.ts | 99 +++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 apps/web/app/utils/savedSearchLink.ts create mode 100644 apps/web/test/savedSearchLink.test.ts diff --git a/apps/web/app/pages/alerts.vue b/apps/web/app/pages/alerts.vue index 12f94569..8954b682 100644 --- a/apps/web/app/pages/alerts.vue +++ b/apps/web/app/pages/alerts.vue @@ -129,18 +129,10 @@ const { data, refresh } = await useFetch<{ items: SavedSearch[]; max: number }>( const items = computed(() => data.value?.items ?? []); const busy = ref<string | null>(null); -/** Rebuild the catalogue URL this filter came from, so "run it" is one click. */ -function searchLink(s: SavedSearch): string { - const q = new URLSearchParams(); - if (s.query) q.set('search', s.query); - if (s.categoryId) q.set('categoryId', s.categoryId); - if (s.tags?.length) q.set('tag', s.tags.join(',')); - if (s.imdbId) q.set('imdbid', s.imdbId); - if (s.tmdbId) q.set('tmdbid', s.tmdbId); - if (s.tvdbId) q.set('tvdbid', s.tvdbId); - const qs = q.toString(); - return qs ? `/torrents?${qs}` : '/torrents'; -} +/** Rebuild the catalogue URL this filter came from, so "run it" is one click. + * La correspondance critère → paramètre vit dans `utils/savedSearchLink.ts`, + * avec le pourquoi. */ +const searchLink = savedSearchLink; async function remove(s: SavedSearch) { // One click on an unlabelled glyph permanently deleted a standing diff --git a/apps/web/app/utils/savedSearchLink.ts b/apps/web/app/utils/savedSearchLink.ts new file mode 100644 index 00000000..a63d7f6c --- /dev/null +++ b/apps/web/app/utils/savedSearchLink.ts @@ -0,0 +1,50 @@ +/** + * L'URL de catalogue que rejoue une recherche enregistrée. + * + * Extrait de `pages/alerts.vue` pour être testable, parce que la panne qu'il a + * connue était muette : `searchLink()` émettait `search` et `categoryId` — les + * noms du corps envoyé à `/api/me/saved-searches` — alors que + * `pages/torrents/index.vue` lit `q` et `c`. La loupe ouvrait donc un catalogue + * sans aucun filtre, sans erreur ni trace, et la recherche enregistrée ne + * s'appliquait jamais. Les quatre autres paramètres portaient déjà le bon nom, + * ce qui rendait la panne d'autant plus difficile à voir. + * + * Deux vocabulaires se ressemblent ici et il ne faut pas les confondre : + * + * critère stocké → paramètre d'URL lu par la page + * query → q + * categoryId → c + * tags[] → tag (liste séparée par des virgules) + * imdbId → imdbid + * tmdbId → tmdbid + * tvdbId → tvdbid + * + * `p`, `s`, `d` et `v` relèvent de l'affichage — page, tri, sens, vue groupée — + * et ne sont pas des critères : une recherche enregistrée n'en stocke aucun, + * donc la page reprend ses valeurs par défaut. + * + * Nommée `savedSearchLink` et non `searchLink` : Nuxt auto-importe + * `app/utils/`, et un nom générique de plus dans cet espace finit par entrer en + * collision avec un autre — voir `useDraft.ts` pour le troisième doublon + * rencontré de cette façon. + */ +export interface SavedSearchCriteria { + query?: string | null; + categoryId?: string | null; + tags?: string[] | null; + imdbId?: string | null; + tmdbId?: string | null; + tvdbId?: string | null; +} + +export function savedSearchLink(s: SavedSearchCriteria): string { + const q = new URLSearchParams(); + if (s.query) q.set('q', s.query); + if (s.categoryId) q.set('c', s.categoryId); + if (s.tags?.length) q.set('tag', s.tags.join(',')); + if (s.imdbId) q.set('imdbid', s.imdbId); + if (s.tmdbId) q.set('tmdbid', s.tmdbId); + if (s.tvdbId) q.set('tvdbid', s.tvdbId); + const qs = q.toString(); + return qs ? `/torrents?${qs}` : '/torrents'; +} diff --git a/apps/web/test/savedSearchLink.test.ts b/apps/web/test/savedSearchLink.test.ts new file mode 100644 index 00000000..76432b3c --- /dev/null +++ b/apps/web/test/savedSearchLink.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + savedSearchLink, + type SavedSearchCriteria, +} from '../app/utils/savedSearchLink'; + +/** + * Une recherche enregistrée doit rejouer les critères qu'elle a stockés. + * + * La panne que ce fichier verrouille était muette. `alerts.vue` construisait + * l'URL avec les noms du corps envoyé à l'API — `search`, `categoryId` — alors + * que `pages/torrents/index.vue` lit `q` et `c`. La loupe menait donc à un + * catalogue non filtré : pas d'erreur, pas de journal, rien qu'une recherche + * qui ne s'applique pas. Quatre des six paramètres portaient déjà le bon nom, + * ce qui achevait de rendre l'écart invisible. + * + * Le second bloc lit `torrents/index.vue` plutôt que de recopier une liste, + * pour que la dérive soit détectée des DEUX côtés : renommer un paramètre dans + * la page casse ce test, exactement comme se tromper de nom dans le lien. + * `sfcImports.test.ts` établit déjà ce précédent de test qui lit la source. + */ +const PAGE = fileURLToPath( + new URL('../app/pages/torrents/index.vue', import.meta.url) +); + +/** Tous les `route.query.X` / `newQuery.X` que la page consulte. */ +function paramsReadByCatalogue(): Set<string> { + const src = readFileSync(PAGE, 'utf8'); + const found = new Set<string>(); + for (const m of src.matchAll(/\b(?:route\.query|newQuery|q)\.([a-zA-Z]+)\b/g)) { + found.add(m[1]!); + } + return found; +} + +/** Les paramètres que le lien émet, pour un jeu de critères complet. */ +function paramsEmitted(): string[] { + const full: SavedSearchCriteria = { + query: 'dzed', + categoryId: '84da00ba-240e-442a-8dae-33dd41bafb83', + tags: ['x264', 'multi'], + imdbId: 'tt0111161', + tmdbId: '278', + tvdbId: '81189', + }; + const url = new URL(savedSearchLink(full), 'http://localhost'); + return [...url.searchParams.keys()]; +} + +describe('savedSearchLink', () => { + it("émet les noms que la page catalogue lit, et pas ceux de l'API", () => { + const url = savedSearchLink({ + query: 'dzed', + categoryId: '84da00ba-240e-442a-8dae-33dd41bafb83', + }); + // L'URL exacte signalée comme valide, moins `v` qui relève de l'affichage. + expect(url).toBe( + '/torrents?q=dzed&c=84da00ba-240e-442a-8dae-33dd41bafb83' + ); + expect(url).not.toContain('search='); + expect(url).not.toContain('categoryId='); + }); + + it('porte les six critères', () => { + expect(paramsEmitted()).toEqual([ + 'q', + 'c', + 'tag', + 'imdbid', + 'tmdbid', + 'tvdbid', + ]); + }); + + it('joint les étiquettes par des virgules, comme la page les redécoupe', () => { + const url = new URL( + savedSearchLink({ tags: ['x264', 'multi'] }), + 'http://localhost' + ); + expect(url.searchParams.get('tag')).toBe('x264,multi'); + }); + + it('sans aucun critère, mène au catalogue nu', () => { + expect(savedSearchLink({})).toBe('/torrents'); + expect(savedSearchLink({ query: '', categoryId: null })).toBe('/torrents'); + }); + + it('chaque paramètre émis est effectivement lu par la page catalogue', () => { + const read = paramsReadByCatalogue(); + // Garde-fou sur l'extraction elle-même : si la regex ne trouve plus rien, + // le test passerait pour de mauvaises raisons. + expect(read.size).toBeGreaterThan(3); + for (const name of paramsEmitted()) { + expect(read, `la page ne lit jamais « ${name} »`).toContain(name); + } + }); +}); From 1a8b36b2c370ba83c777de0e1539a8cd9458da95 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:17:49 +0200 Subject: [PATCH 42/96] fix(api): a saved search carrying a tag could not be created at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tags` is a `jsonb` column and the array reached it raw, so postgres.js sent a Postgres ARRAY and the server refused it: `column "tags" is of type jsonb but expression is of type record`. Every saved search with at least one tag failed with a 500. The page offers that case explicitly — `canSaveSearch` accepts tags on their own, and `/alerts` already renders a chip per tag, so the UI advertised an action that always failed. The other columns are text and never had the problem, which kept the fault confined to the one path that touches `jsonb` and made it look like something else. The explanation sits above the statement rather than inside it: a `--` comment does not survive a flattened query, and a backtick inside a template literal ends it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../api/me/saved-searches/index.post.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/api/routes/api/me/saved-searches/index.post.ts b/apps/api/routes/api/me/saved-searches/index.post.ts index a4f993b1..d7e85aac 100644 --- a/apps/api/routes/api/me/saved-searches/index.post.ts +++ b/apps/api/routes/api/me/saved-searches/index.post.ts @@ -70,6 +70,24 @@ export default defineEventHandler(async (event) => { }); } + /** + * `tags` part en JSON, pas en tableau JavaScript brut. + * + * La colonne est du `jsonb`. Interpolé tel quel dans le gabarit SQL, un + * tableau JS devient un TABLEAU Postgres, et le serveur refuse : + * « column "tags" is of type jsonb but expression is of type record ». + * Toute recherche enregistrée portant au moins une étiquette échouait donc + * en 500 — alors que la page propose explicitement ce cas (`canSaveSearch` + * accepte les étiquettes seules) et que `/alerts` affiche déjà une puce par + * étiquette. Les autres colonnes sont du texte et n'ont jamais eu le + * problème, ce qui a gardé la panne cantonnée au seul chemin `jsonb`. + * + * Écrit ici plutôt qu'en commentaire SQL dans le gabarit ci-dessous : un + * `--` survit mal à une requête aplatie, et un accent grave dans un modèle + * littéral le termine. + */ + const tagsJson = tags.length ? JSON.stringify(tags) : null; + const max = await getSavedSearchMaxPerUser(); const id = randomUUID(); @@ -91,7 +109,7 @@ export default defineEventHandler(async (event) => { (id, user_id, label, query, tsquery, category_id, tags, imdb_id, tmdb_id, tvdb_id, notify) select ${id}, ${user.id}, ${body.label}, ${query}, ${tsquery}, ${categoryId}, - ${tags.length ? tags : null}, ${imdbId}, ${tmdbId}, ${tvdbId}, ${body.notify ?? true} + ${tagsJson}::jsonb, ${imdbId}, ${tmdbId}, ${tvdbId}, ${body.notify ?? true} where ( select count(*) from ${schema.savedSearches} where ${schema.savedSearches.userId} = ${user.id} From fb17a6977c90de858840d1fc71448bfba6f2ce3e Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:18:06 +0200 Subject: [PATCH 43/96] fix(forum): a post from twelve minutes ago read as two hours old MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/db` declares a parser for OID 1114 so the schema's naive `timestamp` columns — which hold UTC wall time — are read back as UTC rather than in the process's zone. It covers `db.select()` and the query builder. It does NOT cover `db.execute()`. Measured twice, through the API and by reproducing the same drizzle path in isolation: db.select(...) -> 2026-09-02T16:41:33.452Z (Date) db.execute(sql`…`) -> "2026-09-02 16:41:33.779157" (raw string) postgres.js on its own parses correctly in all four combinations — tagged query or `unsafe`, `prepare` true or false — so the loss happens in the drizzle layer, not the driver. That string went into the JSON untouched, and `new Date("2026-09-02 16:41:33")` reads it in the browser's LOCAL zone. Two hours off in France, thirteen in New Zealand, zero on a machine in UTC — invisible to anyone developing in UTC. The same byte also produced a hydration mismatch, the server (container in UTC) and the client (Europe/Paris) computing two different durations from it. One bug, two symptoms. The generic on `db.execute<{ created_at: Date }>` is an assertion that is never checked. That lying type is what hid this for as long as it did, so the three declarations are corrected to `string` alongside the fix. Detector, on a running stack: a date with no `T` and no `Z` in an API response is a leak. curl … | grep -coE '"[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:' Swept over 131 parameterless routes plus every parameterised one that carries data: this was the only leak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../routes/api/forum/categories/[id].get.ts | 11 ++-- .../routes/api/forum/categories/index.get.ts | 11 ++-- apps/api/test/naiveTimestamp.test.ts | 50 +++++++++++++++++++ apps/api/utils/naiveTimestamp.ts | 40 +++++++++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 apps/api/test/naiveTimestamp.test.ts create mode 100644 apps/api/utils/naiveTimestamp.ts diff --git a/apps/api/routes/api/forum/categories/[id].get.ts b/apps/api/routes/api/forum/categories/[id].get.ts index d394010b..b1fde249 100644 --- a/apps/api/routes/api/forum/categories/[id].get.ts +++ b/apps/api/routes/api/forum/categories/[id].get.ts @@ -125,19 +125,24 @@ export default defineEventHandler(async (event) => { WHERE p.topic_id IN (${idList}) ORDER BY p.topic_id, p.created_at DESC `); + // `createdAt: string | null` et non `Date` : c'est ce que la route renvoie + // réellement, une fois `naiveTimestampToIso` passé dessus. La déclaration + // disait `Date` — et `db.execute<…>()` ne vérifie pas son générique, donc + // rien ne contredisait cette annotation pendant que la valeur était une + // chaîne brute. Le type qui mentait est ce qui a caché le défaut. const lastPostMap = new Map< string, - { authorUsername: string; createdAt: Date } + { authorUsername: string; createdAt: string | null } >(); const lastPostRows = (lastPostsRaw as any).rows ?? lastPostsRaw; for (const r of lastPostRows as Array<{ topic_id: string; - created_at: Date; + created_at: string; author_username: string; }>) { lastPostMap.set(r.topic_id, { authorUsername: r.author_username, - createdAt: r.created_at, + createdAt: naiveTimestampToIso(r.created_at), }); } diff --git a/apps/api/routes/api/forum/categories/index.get.ts b/apps/api/routes/api/forum/categories/index.get.ts index c1aefb40..a1d9bacb 100644 --- a/apps/api/routes/api/forum/categories/index.get.ts +++ b/apps/api/routes/api/forum/categories/index.get.ts @@ -83,7 +83,9 @@ export default defineEventHandler(async (event) => { category_id: string; topic_id: string; title: string; - updated_at: Date; + // Chaîne brute, pas `Date` : `db.execute` ne passe pas par + // l'analyseur d'OID 1114 et son générique n'est jamais vérifié. + updated_at: string; is_pinned: boolean; is_locked: boolean; author_username: string; @@ -117,7 +119,8 @@ export default defineEventHandler(async (event) => { topic_id: string; topic_title: string; content: string; - created_at: Date; + // Idem : chaîne brute. Voir `utils/naiveTimestamp.ts`. + created_at: string; author_username: string; }>(sql` SELECT DISTINCT ON (t.category_id) @@ -152,7 +155,7 @@ export default defineEventHandler(async (event) => { ? { id: lt.topic_id, title: lt.title, - updatedAt: lt.updated_at, + updatedAt: naiveTimestampToIso(lt.updated_at), isPinned: lt.is_pinned, isLocked: lt.is_locked, authorUsername: lt.author_username, @@ -163,7 +166,7 @@ export default defineEventHandler(async (event) => { topicId: lp.topic_id, topicTitle: lp.topic_title, content: lp.content, - createdAt: lp.created_at, + createdAt: naiveTimestampToIso(lp.created_at), authorUsername: lp.author_username, } : null, diff --git a/apps/api/test/naiveTimestamp.test.ts b/apps/api/test/naiveTimestamp.test.ts new file mode 100644 index 00000000..6e2465c7 --- /dev/null +++ b/apps/api/test/naiveTimestamp.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { naiveTimestampToIso } from '../utils/naiveTimestamp'; + +/** + * `db.execute()` rend les colonnes `timestamp` en chaîne brute, sans fuseau. + * + * Le défaut que ce fichier verrouille se voyait sur le forum : « il y a + * 2 heures » pour un message publié depuis douze minutes, plus un défaut + * d'hydratation, parce que `"2026-09-02 16:41:33.779157"` partait tel quel + * dans le JSON et que `new Date()` le lit dans le fuseau LOCAL. Deux heures + * d'écart en France, treize en Nouvelle-Zélande, zéro sur un poste en UTC — + * c'est-à-dire invisible pour qui développe en UTC. + */ +describe('naiveTimestampToIso', () => { + it('déclare UTC la forme que rend Postgres', () => { + expect(naiveTimestampToIso('2026-09-02 16:41:33.779157')).toBe( + '2026-09-02T16:41:33.779Z' + ); + }); + + it('laisse intact un instant déjà daté', () => { + expect(naiveTimestampToIso('2026-09-02T16:41:33.779Z')).toBe( + '2026-09-02T16:41:33.779Z' + ); + // Un décalage explicite est un instant : on le convertit sans le déplacer. + expect(naiveTimestampToIso('2026-09-02T18:41:33.779+02:00')).toBe( + '2026-09-02T16:41:33.779Z' + ); + }); + + it('accepte un Date, que `db.select()` rend déjà correctement', () => { + const d = new Date('2026-09-02T16:41:33.779Z'); + expect(naiveTimestampToIso(d)).toBe('2026-09-02T16:41:33.779Z'); + }); + + it('rend null plutôt que « Invalid Date »', () => { + expect(naiveTimestampToIso(null)).toBeNull(); + expect(naiveTimestampToIso(undefined)).toBeNull(); + expect(naiveTimestampToIso('pas une date')).toBeNull(); + }); + + it("ne dépend pas du fuseau du processus", () => { + // La suite tourne en Europe/Paris (voir `vitest.config.ts` du web pour le + // même choix). Si la conversion lisait l'heure locale, on obtiendrait + // 14:41Z au lieu de 16:41Z — l'écart exact qu'affichait le forum. + const iso = naiveTimestampToIso('2026-09-02 16:41:33.779157'); + expect(iso).not.toBe('2026-09-02T14:41:33.779Z'); + expect(new Date(iso!).getUTCHours()).toBe(16); + }); +}); diff --git a/apps/api/utils/naiveTimestamp.ts b/apps/api/utils/naiveTimestamp.ts new file mode 100644 index 00000000..e7588c87 --- /dev/null +++ b/apps/api/utils/naiveTimestamp.ts @@ -0,0 +1,40 @@ +/** + * Un horodatage sorti de `db.execute()` remis en instant absolu. + * + * `packages/db` déclare un analyseur pour l'OID 1114 — voir son en-tête : les + * colonnes `timestamp without time zone` du schéma contiennent de l'heure + * murale UTC, et cet analyseur les relit en UTC plutôt que dans le fuseau du + * processus. Il couvre `db.select()` et le constructeur de requêtes. + * + * Il ne couvre PAS `db.execute()`. Mesuré le 2026-09-02, deux fois — à travers + * l'API et en reproduisant le même chemin drizzle isolément : + * + * db.select(...) → 2026-09-02T16:41:33.452Z (Date) + * db.execute(sql`…`) → "2026-09-02 16:41:33.779157" (chaîne brute) + * + * postgres.js seul analyse correctement dans les quatre combinaisons + * (`unsafe` ou requête étiquetée, `prepare` vrai ou faux) : la perte se produit + * dans la couche drizzle. Le générique de `db.execute<{ created_at: Date }>` + * n'est qu'une assertion, jamais vérifiée — c'est ce qui a laissé passer la + * chose. + * + * Sans conversion, cette chaîne part telle quelle dans le JSON, et + * `new Date("2026-09-02 16:41:33.779157")` la lit dans le fuseau LOCAL du + * navigateur. Sur le forum, cela donnait « il y a 2 heures » pour un message + * publié depuis douze minutes, et un défaut d'hydratation par-dessus : le + * rendu serveur (conteneur en UTC) et le client (Europe/Paris) calculaient + * deux durées différentes à partir du même octet. + */ +export function naiveTimestampToIso( + value: Date | string | null | undefined +): string | null { + if (value == null) return null; + if (value instanceof Date) return value.toISOString(); + const raw = String(value); + // Déjà un instant : `…Z` ou un décalage explicite. On n'y touche pas. + if (/[Zz]$|[+-]\d{2}:?\d{2}$/.test(raw)) return new Date(raw).toISOString(); + // La forme que rend Postgres : « 2026-09-02 16:41:33.779157 », heure murale + // UTC. On la déclare telle en remplaçant l'espace et en ajoutant le Z. + const d = new Date(`${raw.replace(' ', 'T')}Z`); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} From 7bc83b4f7bccc08155c902dbf133f76583a0a9a5 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:18:28 +0200 Subject: [PATCH 44/96] fix(messaging): stop() then start() left an orphan stream delivering twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect()` closed the open source and then awaited twice — the token, the catch-up. Closing therefore only covered the source that was ALREADY open, not one still in flight. `stop()` followed by `start()`, the very use this file documents, resumes the first `connect()` with `stopped` back to false, and it opens a second `EventSource` over the reference the newer one holds. An orphan does not stop on its own: it keeps delivering, so every message reached `handlers` twice and `lastSeen` advanced from two streams at once. A generation counter covers the in-flight case, and the comment claiming the call was already safe now names the mechanism that makes it so. `import.meta.client` is defined for the suite, because Vite alone does not know it: under vitest it was `undefined`, so every guard reading it returned before doing anything — a test that passes by executing nothing. This is the first file in the suite to exercise a composable rather than a pure function, and the price is explicit: the Nuxt auto-imports it needs are stubbed by hand, still far below `@nuxt/test-utils` and a simulated browser. Proven by mutation: without the counter, two streams are opened where the test expects one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../web/app/composables/useMessagingStream.ts | 34 ++++- .../test/messagingStreamReentrancy.test.ts | 125 ++++++++++++++++++ apps/web/vitest.config.ts | 19 +++ 3 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 apps/web/test/messagingStreamReentrancy.test.ts diff --git a/apps/web/app/composables/useMessagingStream.ts b/apps/web/app/composables/useMessagingStream.ts index 5d1def93..2ac35f09 100644 --- a/apps/web/app/composables/useMessagingStream.ts +++ b/apps/web/app/composables/useMessagingStream.ts @@ -56,6 +56,22 @@ export function useMessagingStream(handlers: StreamHandlers) { let retryTimer: ReturnType<typeof setTimeout> | undefined; let attempt = 0; let stopped = false; + /** + * Le numéro de la tentative de connexion en cours. + * + * `connect()` ferme la source existante puis attend deux fois (le jeton, + * le rattrapage). Fermer ne suffisait donc pas : deux `connect()` EN VOL + * passaient tous les deux la garde, attendaient tous les deux, et le + * second écrasait `source` — laissant le premier `EventSource` ouvert, + * sans plus personne pour le fermer. Un orphelin ne s'arrête pas tout + * seul : il continue de livrer, donc chaque message arrivait deux fois + * chez `handlers` et faisait avancer `lastSeen` depuis deux flux. + * + * Le cas se produit avec `stop()` puis `start()` — l'usage que ce + * fichier documente lui-même — parce que `start()` remet `stopped` à + * faux avant que le `connect()` précédent ait fini d'attendre son jeton. + */ + let generation = 0; /** The high-water mark the catch-up asks from. */ let lastSeen = new Date().toISOString(); @@ -93,7 +109,9 @@ export function useMessagingStream(handlers: StreamHandlers) { if (stopped) return; // Never two. `start()` is public and `onMounted` calls it, so a caller // that also calls it by hand would otherwise leave the first stream - // open with nothing holding it. + // open with nothing holding it. Fermer la source ne couvre que celle + // qui est DÉJÀ ouverte : `generation` couvre celle qui est en vol. + const mine = ++generation; source?.close(); source = null; let grant: TokenGrant; @@ -118,12 +136,15 @@ export function useMessagingStream(handlers: StreamHandlers) { * restarts, did the same. */ const status = (err as { statusCode?: number })?.statusCode; - if (status !== 404) scheduleReconnect(); + // Une tentative dépassée ne reprogramme rien : celle qui l'a + // remplacée s'en charge, et deux reprogrammations pour un seul flux + // font repartir la temporisation à contretemps. + if (status !== 404 && mine === generation) scheduleReconnect(); return; } await catchUp(); - if (stopped) return; + if (stopped || mine !== generation) return; const url = `${grant.url.replace(/\/$/, '')}/events?token=${encodeURIComponent(grant.token)}`; source = new EventSource(url); @@ -197,8 +218,11 @@ export function useMessagingStream(handlers: StreamHandlers) { * Only `/messages` called it, which is why only the room said "offline". * * `start` and `stop` stay exported for a caller that wants to suspend a - * stream and bring it back; `connect()` closes any open source first, so - * calling `start()` again is safe rather than a second connection. + * stream and bring it back. C'est sûr, mais pas pour la raison qui était + * écrite ici : « `connect()` ferme la source ouverte » ne couvrait que la + * source déjà ouverte, et `stop()` puis `start()` laisse justement un + * `connect()` en train d'attendre son jeton. Ce qui rend l'appel sûr, + * c'est `generation` (voir sa déclaration). */ onMounted(start); onBeforeUnmount(stop); diff --git a/apps/web/test/messagingStreamReentrancy.test.ts b/apps/web/test/messagingStreamReentrancy.test.ts new file mode 100644 index 00000000..36162ac5 --- /dev/null +++ b/apps/web/test/messagingStreamReentrancy.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { onBeforeUnmount, onMounted, ref } from 'vue'; + +/** + * Un seul flux, même quand deux connexions se chevauchent. + * + * `useMessagingStream.connect()` ferme la source ouverte puis attend deux fois + * — le jeton, le rattrapage. Fermer ne suffisait donc pas : `stop()` suivi de + * `start()`, l'usage que le composable documente, laissait le premier + * `connect()` en train d'attendre son jeton ; il reprenait ensuite avec + * `stopped` remis à faux et ouvrait un second `EventSource`. Le premier + * restait ouvert sans référence, continuait de livrer, et chaque message + * arrivait deux fois. + * + * Ce fichier est le premier du lot à exercer un composable plutôt qu'une + * fonction pure. Le prix en est explicite : les auto-imports de Nuxt dont + * `useMessagingStream` dépend sont posés ici à la main. Cela reste très + * au-dessous de `@nuxt/test-utils` et d'un navigateur simulé, et l'invariant + * couvert est invisible autrement — c'est précisément un compteur qu'un + * relecteur peut retirer en croyant simplifier. + */ + +/** Les `EventSource` créés, dans l'ordre, avec leur état de fermeture. */ +const opened: Array<{ url: string; closed: boolean }> = []; + +class FakeEventSource { + onopen: (() => void) | null = null; + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + private readonly slot: { url: string; closed: boolean }; + constructor(url: string) { + this.slot = { url, closed: false }; + opened.push(this.slot); + } + close() { + this.slot.closed = true; + } +} + +/** Différé résolu à la main, pour arrêter `connect()` sur son await. */ +function deferred<T>() { + let resolve!: (v: T) => void; + const promise = new Promise<T>((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const tokenCalls: Array<{ resolve: (v: unknown) => void }> = []; + +vi.stubGlobal('ref', ref); +vi.stubGlobal('onMounted', onMounted); +vi.stubGlobal('onBeforeUnmount', onBeforeUnmount); +vi.stubGlobal('EventSource', FakeEventSource); +vi.stubGlobal('$fetch', (url: string) => { + if (url === '/api/messaging/token') { + const d = deferred<unknown>(); + tokenCalls.push({ resolve: d.resolve }); + return d.promise; + } + // Le rattrapage : rien à réparer. + return Promise.resolve({ messages: [], truncated: false }); +}); + +// Importé APRÈS les globaux : le module les lit à l'appel, mais l'ordre reste +// ce qu'un lecteur attend. +const { useMessagingStream } = await import( + '../app/composables/useMessagingStream' +); + +afterEach(() => { + opened.length = 0; + tokenCalls.length = 0; +}); + +/** Laisse la file de microtâches s'écouler. */ +const settle = () => new Promise((r) => setTimeout(r, 0)); + +describe('useMessagingStream — une seule connexion', () => { + it('ne laisse pas un flux orphelin quand stop() puis start() se croisent', async () => { + const received: unknown[] = []; + const stream = useMessagingStream({ onFrame: (f) => received.push(f) }); + + // 1. Première connexion : elle attend son jeton. + stream.start(); + await settle(); + expect(tokenCalls).toHaveLength(1); + expect(opened).toHaveLength(0); + + // 2. Suspendue puis relancée pendant que la première attend encore. + stream.stop(); + stream.start(); + await settle(); + expect(tokenCalls).toHaveLength(2); + + // 3. Les deux jetons arrivent, la première EN DERNIER — le pire ordre. + tokenCalls[1]!.resolve({ url: 'http://relay', token: 'b', expiresAt: 0 }); + await settle(); + tokenCalls[0]!.resolve({ url: 'http://relay', token: 'a', expiresAt: 0 }); + await settle(); + + // Un seul flux ouvert, et c'est celui de la tentative la plus récente. + expect(opened).toHaveLength(1); + expect(opened[0]!.url).toContain('token=b'); + expect(opened[0]!.closed).toBe(false); + }); + + it('ferme le flux précédent quand connect() est rappelé après ouverture', async () => { + const stream = useMessagingStream({ onFrame: () => {} }); + stream.start(); + await settle(); + tokenCalls[0]!.resolve({ url: 'http://relay', token: 'a', expiresAt: 0 }); + await settle(); + expect(opened).toHaveLength(1); + + stream.start(); + await settle(); + tokenCalls[1]!.resolve({ url: 'http://relay', token: 'b', expiresAt: 0 }); + await settle(); + + expect(opened).toHaveLength(2); + expect(opened[0]!.closed).toBe(true); + expect(opened[1]!.closed).toBe(false); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 104be709..2fd743d8 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -16,7 +16,26 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.test.ts'], clearMocks: true, + // Un fuseau qui n'est pas UTC, et qui change d'heure deux fois par an. + // + // Le conteneur de test tourne en UTC, où toute confusion entre heure locale + // et heure UTC passe inaperçue : les gardes du couple + // `isoToDatetimeLocal` / `datetimeLocalToIso` — écrites après une dérive de + // deux heures par enregistrement sur la page d'un torrent — y sont + // vertes quoi qu'on fasse. Ici, elles mesurent quelque chose. + env: { TZ: 'Europe/Paris' }, }, + /* + * `import.meta.client` vaut vrai ici. + * + * Nuxt le remplace à la compilation ; Vite seul ne le connaît pas, donc sous + * vitest il vaut `undefined` et tout garde `if (!import.meta.client) return` + * sort avant d'avoir rien fait — un test qui passe en n'exécutant rien. + * `useMessagingStream` est le premier module couvert qui en dépend, et c'est + * la bonne valeur pour cette suite : elle ne teste que du code client, jamais + * une branche de rendu serveur. + */ + define: { 'import.meta.client': 'true', 'import.meta.server': 'false' }, resolve: { alias: { '~': `${root}app`, From f13529953bf2345694307f595a010a681b0ed002 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:18:28 +0200 Subject: [PATCH 45/96] fix(caddy): the credential scrub in the access log had never matched a field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field's key in `format filter` is its flattened path in the JSON entry, and the URI lives under `request`. Written as `uri`, the filter matches NOTHING: Caddy emits no warning, adapts the config without complaint, `caddy validate` answers "Valid configuration" — and writes the URI whole. On a private tracker the passkey is in the URL by BitTorrent protocol, so every announce had been writing a permanent credential into `access.log`, kept for 720 hours. The ten lines of comment above it call that "a textbook log-leak vector"; the guard against it had simply never run. `token` joins `passkey`, `apikey` and `rsskey`, and it was the one real hole left: the messaging relay is proxied from this same site and `EventSource` cannot set an Authorization header, so its bearer token HAS to ride in the query string — as `internal/sse/handler.go` says in as many words. A log filter cannot be read, it has to be measured. Send a request carrying a recognisable value, then grep the file: zero means the filter acts. Check that a neighbouring parameter SURVIVES too, or a working filter and an absent field look the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docker/caddy/Caddyfile | 82 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index aa33f18b..1a03722b 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -53,6 +53,28 @@ encode gzip zstd + # La taille de corps, bornée à la bordure. + # + # Caddy n'a AUCUNE limite par défaut : `request_body { max_size }` est + # facultatif. Or `POST /api/torrents` fait passer le corps entier par + # `readMultipartFormData`, qui le met en mémoire AVANT que les gardes 413 de + # la route (10 Mio pour le .torrent, 256 Kio pour le NFO) puissent se + # prononcer — le commentaire de la route le dit lui-même. Un membre + # authentifié envoyant un POST de 600 Mo faisait donc dépasser le tas + # (plafonné à 512 Mo) dans un conteneur plafonné à 768 Mo : OOM, redémarrage + # par `restart: always`, et l'opération se répète pour le prix de la bande + # passante montante. + # + # 32 Mo aligne ce déploiement sur le chart Helm, qui borne déjà à `32m` via + # `proxy-body-size` — c'était le chemin Compose, celui que les opérateurs + # tiers installent, qui n'avait rien. `request_body` est dans l'ordre par + # défaut des directives (avant `reverse_proxy`), donc la poser au niveau du + # site couvre tous les `handle`. Les annonces et le flux SSE sont en GET : + # aucun effet sur eux. + request_body { + max_size 32MB + } + # Tracker announce on the main domain (what BitTorrent clients hit when # the .torrent file's `announce` is your-domain.com/announce). handle /announce* { @@ -130,6 +152,16 @@ # `apikey` and `rsskey` joined `passkey` when the read surfaces got keys # of their own: before that, `apikey` WAS the passkey and this scrub # covered it by accident rather than on purpose. + # + # `token` joined them last, and it was the one real hole: the messaging + # relay is proxied from this same site (`handle /events*` above), and + # `EventSource` cannot set an Authorization header — so the relay token + # HAS to ride in the query string, as `internal/sse/handler.go` says in + # as many words. Every SSE connection was therefore writing a valid + # bearer token into this file, kept for 720h. The token only lives 300s + # (`MESSAGING_TOKEN_TTL_SECONDS`), so the window was small, but a + # 30-day record of credentials is exactly what this block exists to + # prevent. log { output file /var/log/caddy/access.log { roll_size 100mb @@ -139,10 +171,21 @@ format filter { wrap json fields { - uri query { + # `request>uri`, PAS `uri`. + # + # La clé d'un champ dans ce bloc est son chemin aplati dans + # l'entrée JSON, et l'URI y vit sous `request`. Avec `uri`, le + # filtre ne correspond à AUCUN champ : Caddy ne s'en plaint pas, + # ne journalise rien d'anormal, et écrit l'URI telle quelle. + # Mesuré sur la pile compilée le 2026-09-02 : une requête + # `/events?token=CANARY…` et une `?apikey=CANARY…` sont + # ressorties entières du fichier. Le nettoyage du `passkey` + # décrit au-dessus n'avait donc jamais eu lieu. + request>uri query { delete passkey delete apikey delete rsskey + delete token } } } @@ -159,10 +202,35 @@ -Server } - reverse_proxy tracker:8080 { - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - import strip_spoofable_ip_headers + # Deux chemins, pas tout le tracker. + # + # Ce bloc relayait `tracker:8080` sans filtre, donc `/health` était public : + # il rend l'état de Postgres et de Redis à quiconque. L'API garde + # délibérément ce détail pour elle (`api/tracker-health.get.ts` ne surface + # qu'un booléen « joignable », et son commentaire dit que la ventilation par + # composant est un diagnostic d'administrateur) — le sous-domaine du tracker + # la donnait par la porte de derrière. + handle /announce* { + reverse_proxy tracker:8080 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + import strip_spoofable_ip_headers + } + } + + handle /scrape* { + reverse_proxy tracker:8080 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + import strip_spoofable_ip_headers + } + } + + # Tout le reste — `/health` en particulier — n'a pas à sortir. Le + # healthcheck du conteneur interroge le tracker directement sur le réseau + # interne, il n'est pas concerné. + handle { + respond 404 } # Same passkey scrub as on the main domain — every request to this @@ -176,10 +244,12 @@ format filter { wrap json fields { - uri query { + # `request>uri` et non `uri` — voir le bloc du domaine principal. + request>uri query { delete passkey delete apikey delete rsskey + delete token } } } From d97bfd125a830d4c4e75a313df25bc4065ec1113 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:19:17 +0200 Subject: [PATCH 46/96] refactor(api): the role cache moved out of adminAuth, and re-exporting it collided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session.ts` needed the live-role cache, which meant importing it from `adminAuth.ts` and dragging that module's whole surface along. It now lives in `liveRoles.ts`. The first attempt kept a re-export in `adminAuth.ts` so callers would not have to change. Nitro auto-imports `apps/api/utils/`, so it then saw `readLiveRoles` and `invalidateRoleCache` declared twice and dropped one — a documented coin toss: "Duplicated imports … has been ignored". It happened to pick the right one, which is the worst outcome, because the same collision class is what made the wrong `formatSize` serve for months on the web side. No re-export. The four explicit callers import from `liveRoles` directly. The warning survives a fix in the `.nitro` cache, so `rm -rf apps/api/.nitro` before believing it is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/middleware/security.ts | 2 +- apps/api/routes/api/admin/reports/[id].put.ts | 2 +- .../routes/api/admin/users/[id]/ban.post.ts | 2 +- apps/api/utils/adminAuth.ts | 78 +++-------------- apps/api/utils/liveRoles.ts | 83 +++++++++++++++++++ apps/api/utils/owner.ts | 2 +- 6 files changed, 97 insertions(+), 72 deletions(-) create mode 100644 apps/api/utils/liveRoles.ts diff --git a/apps/api/middleware/security.ts b/apps/api/middleware/security.ts index eb39d625..d37c54b0 100644 --- a/apps/api/middleware/security.ts +++ b/apps/api/middleware/security.ts @@ -11,8 +11,8 @@ import { users, webauthnCredentials } from '@trackarr/db/schema'; import { readBanStatusCached, readIpBanCached, - readLiveRoles, } from '~~/utils/adminAuth'; +import { readLiveRoles } from '~~/utils/liveRoles'; import { isUserRequiredFor2FA } from '~~/utils/settings'; // ============================================================================ diff --git a/apps/api/routes/api/admin/reports/[id].put.ts b/apps/api/routes/api/admin/reports/[id].put.ts index 233f8998..63974804 100644 --- a/apps/api/routes/api/admin/reports/[id].put.ts +++ b/apps/api/routes/api/admin/reports/[id].put.ts @@ -34,8 +34,8 @@ import { db, schema } from '@trackarr/db'; import { requireModeratorSession, invalidateBanCache, - invalidateRoleCache, } from '~~/utils/adminAuth'; +import { invalidateRoleCache } from '~~/utils/liveRoles'; import { validateBody } from '~~/utils/schemas'; import { relinquishOwnership } from '~~/utils/owner'; import { eq } from 'drizzle-orm'; 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 abbedf01..4516a6e0 100644 --- a/apps/api/routes/api/admin/users/[id]/ban.post.ts +++ b/apps/api/routes/api/admin/users/[id]/ban.post.ts @@ -16,9 +16,9 @@ import { db } from '@trackarr/db'; import { users, bannedIps, torrents } from '@trackarr/db/schema'; import { invalidateBanCache, - invalidateRoleCache, requireModeratorSession, } from '~~/utils/adminAuth'; +import { invalidateRoleCache } from '~~/utils/liveRoles'; import { relinquishOwnership } from '~~/utils/owner'; import { validateBody, diff --git a/apps/api/utils/adminAuth.ts b/apps/api/utils/adminAuth.ts index 1fb2adbc..d1abb789 100644 --- a/apps/api/utils/adminAuth.ts +++ b/apps/api/utils/adminAuth.ts @@ -4,6 +4,16 @@ import { db } from '@trackarr/db'; import { bannedIps, users } from '@trackarr/db/schema'; import { redis } from '../redis/client'; import { getSessionId } from './session'; +// Le cache de rôles vit dans son propre module depuis que `session.ts` en a +// besoin : voir l'en-tête de `liveRoles.ts`. +// +// PAS de ré-export ici. Il y en avait un, pour ne pas toucher aux appelants — +// mais Nitro auto-importe `apps/api/utils/`, voyait donc `readLiveRoles` et +// `invalidateRoleCache` déclarés deux fois, et en ignorait un au hasard +// documenté (« Duplicated imports … has been ignored »). C'est exactement la +// collision qui a fait servir la mauvaise `formatSize` pendant des mois côté +// web. Les deux appelants explicites importent depuis `liveRoles` directement. +import { readLiveRoles } from './liveRoles'; import { isFreshAuth } from './twoFactor'; /** @@ -83,74 +93,6 @@ export async function invalidateBanCache(userId: string): Promise<void> { } } -/** - * Live staff-role lookup, cached for 60 s — backs the role - * re-validation in `requireModeratorSession` / `requireAdminSession`. - * - * The session cookie is a sealed, stateless 7-day token that bakes - * in `isAdmin` / `isModerator` at login time. Without this, a user - * demoted for cause kept a cookie that still asserted staff and - * could keep hitting admin/mod APIs for up to 7 days (finding M2). - * Re-reading the authoritative flags here (and bumping the cache on - * role change) closes that window to ≤ 60 s, mirroring the ban - * cache. Returns null when the user no longer exists. - */ -const ROLE_CACHE_TTL_S = 60; -const roleCacheKey = (userId: string) => `auth:role:${userId}`; - -export async function readLiveRoles( - userId: string -): Promise<{ isAdmin: boolean; isModerator: boolean; isOwner: boolean } | null> { - try { - const cached = await redis.get(roleCacheKey(userId)); - if (cached) { - const p = JSON.parse(cached) as { a: boolean; m: boolean; o?: boolean }; - // A payload written before `o` existed is treated as a MISS rather than - // as `isOwner: false`. Otherwise the deploy that adds ownership answers - // 403 to the owner for up to the cache TTL — and the one thing that - // would fix it is the console they cannot reach. - if (p.o !== undefined) { - return { isAdmin: !!p.a, isModerator: !!p.m, isOwner: !!p.o }; - } - } - } catch { - /* fall through to DB */ - } - const [row] = await db - .select({ - isAdmin: users.isAdmin, - isModerator: users.isModerator, - isOwner: users.isOwner, - }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - if (!row) return null; - try { - await redis.setex( - roleCacheKey(userId), - ROLE_CACHE_TTL_S, - JSON.stringify({ a: row.isAdmin, m: row.isModerator, o: row.isOwner }) - ); - } catch { - /* no-op */ - } - return { - isAdmin: row.isAdmin, - isModerator: row.isModerator, - isOwner: row.isOwner, - }; -} - -/** Drop the cached role state. Call from any path that changes a - * user's `is_admin` / `is_moderator` (role-change endpoint). */ -export async function invalidateRoleCache(userId: string): Promise<void> { - try { - await redis.del(roleCacheKey(userId)); - } catch { - /* no-op */ - } -} /** * Cached IP-ban lookup — backs the security middleware. diff --git a/apps/api/utils/liveRoles.ts b/apps/api/utils/liveRoles.ts new file mode 100644 index 00000000..10448ad7 --- /dev/null +++ b/apps/api/utils/liveRoles.ts @@ -0,0 +1,83 @@ +/** + * Le rôle vivant d'un compte, et son cache. + * + * Extrait de `adminAuth.ts` pour une raison précise : `session.ts` doit + * pouvoir réconcilier les drapeaux de personnel dans `requireUserSession`, et + * `adminAuth.ts` importe déjà `getSessionId` depuis `session.ts`. Les faire + * s'importer l'un l'autre marcherait — les déclarations de fonction sont + * hissées — mais reposerait sur l'ordre d'évaluation des modules, ce qui n'est + * pas une base pour un contrôle d'autorisation. + */ +import { eq } from 'drizzle-orm'; +import { db } from '@trackarr/db'; +import { users } from '@trackarr/db/schema'; +import { redis } from '../redis/client'; + +/** + * Live staff-role lookup, cached for 60 s — backs the role + * re-validation in `requireModeratorSession` / `requireAdminSession`. + * + * The session cookie is a sealed, stateless 7-day token that bakes + * in `isAdmin` / `isModerator` at login time. Without this, a user + * demoted for cause kept a cookie that still asserted staff and + * could keep hitting admin/mod APIs for up to 7 days (finding M2). + * Re-reading the authoritative flags here (and bumping the cache on + * role change) closes that window to ≤ 60 s, mirroring the ban + * cache. Returns null when the user no longer exists. + */ +const ROLE_CACHE_TTL_S = 60; +const roleCacheKey = (userId: string) => `auth:role:${userId}`; + +export async function readLiveRoles( + userId: string +): Promise<{ isAdmin: boolean; isModerator: boolean; isOwner: boolean } | null> { + try { + const cached = await redis.get(roleCacheKey(userId)); + if (cached) { + const p = JSON.parse(cached) as { a: boolean; m: boolean; o?: boolean }; + // A payload written before `o` existed is treated as a MISS rather than + // as `isOwner: false`. Otherwise the deploy that adds ownership answers + // 403 to the owner for up to the cache TTL — and the one thing that + // would fix it is the console they cannot reach. + if (p.o !== undefined) { + return { isAdmin: !!p.a, isModerator: !!p.m, isOwner: !!p.o }; + } + } + } catch { + /* fall through to DB */ + } + const [row] = await db + .select({ + isAdmin: users.isAdmin, + isModerator: users.isModerator, + isOwner: users.isOwner, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!row) return null; + try { + await redis.setex( + roleCacheKey(userId), + ROLE_CACHE_TTL_S, + JSON.stringify({ a: row.isAdmin, m: row.isModerator, o: row.isOwner }) + ); + } catch { + /* no-op */ + } + return { + isAdmin: row.isAdmin, + isModerator: row.isModerator, + isOwner: row.isOwner, + }; +} + +/** Drop the cached role state. Call from any path that changes a + * user's `is_admin` / `is_moderator` (role-change endpoint). */ +export async function invalidateRoleCache(userId: string): Promise<void> { + try { + await redis.del(roleCacheKey(userId)); + } catch { + /* no-op */ + } +} diff --git a/apps/api/utils/owner.ts b/apps/api/utils/owner.ts index 8c9b010f..7a82d397 100644 --- a/apps/api/utils/owner.ts +++ b/apps/api/utils/owner.ts @@ -34,7 +34,7 @@ */ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; -import { invalidateRoleCache } from './adminAuth'; +import { invalidateRoleCache } from './liveRoles'; /** A database handle, or the transaction standing in for one. */ type Writer = Pick<typeof db, 'select' | 'update'>; From 93a8fa8d183d8d1dc44119e38242a44b6b524e2f Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:19:44 +0200 Subject: [PATCH 47/96] fix(security): the API served a CSP written for the web application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `script-src 'self' 'unsafe-inline'` and `style-src 'self' 'unsafe-inline'` were justified in place by "Nuxt requires unsafe-inline for HMR" and "Tailwind requires unsafe-inline". Both reasons belong to the WEB application, which carries its own nonce policy in `apps/web/server/plugins/csp.ts`. This process serves no page, no script and no stylesheet — only JSON, XML for Torznab and RSS, theme CSS and stored objects. `default-src 'none'` is the honest value. It matters for a browser landing DIRECTLY on a response — an SVG dropped in `/uploads/`, whose script `servedObjectHeaders` already neutralises and which gains a second barrier here. For a resource loaded from a page, the PAGE's policy decides, so tightening this one breaks nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/middleware/security.ts | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/api/middleware/security.ts b/apps/api/middleware/security.ts index d37c54b0..142f6f29 100644 --- a/apps/api/middleware/security.ts +++ b/apps/api/middleware/security.ts @@ -113,17 +113,31 @@ function applySecurityHeaders(event: any): void { 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', 'X-DNS-Prefetch-Control': 'off', - // Content Security Policy - Strict security rules + /* + * La CSP des réponses de l'API — et rien d'autre. + * + * Elle portait `script-src 'self' 'unsafe-inline'` et `style-src 'self' + * 'unsafe-inline'`, justifiés par « Nuxt requires unsafe-inline for HMR » + * et « Tailwind requires unsafe-inline ». Ces deux motifs appartiennent à + * l'application WEB, qui a sa propre politique à nonce dans + * `apps/web/server/plugins/csp.ts` ; ce processus-ci ne sert ni page, ni + * script, ni feuille de style — uniquement du JSON, du XML (Torznab, RSS), + * du CSS de thème et des objets stockés. + * + * `default-src 'none'` est donc la valeur juste : aucune réponse de l'API + * n'a de raison d'exécuter quoi que ce soit. Cela compte pour le cas où un + * navigateur arrive DIRECTEMENT sur une réponse — un SVG déposé dans + * `/uploads/`, par exemple, dont le script est déjà neutralisé par + * `servedObjectHeaders` et qui gagne ici une seconde barrière. Pour une + * ressource chargée depuis une page (une image, la feuille de thème), c'est + * la politique de la PAGE qui décide, pas celle-ci : la resserrer ne casse + * donc rien. + */ 'Content-Security-Policy': [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", // Nuxt requires unsafe-inline for HMR - "style-src 'self' 'unsafe-inline'", // Tailwind requires unsafe-inline - "img-src 'self' data: https:", - "font-src 'self' data:", - "connect-src 'self'", + "default-src 'none'", "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", + "base-uri 'none'", + "form-action 'none'", 'upgrade-insecure-requests', ].join('; '), }; From cfbf60eed28b50833423249fffd752b3a97f49ae Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:19:59 +0200 Subject: [PATCH 48/96] fix(security): an IP ban locked staff out of the route that lifts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IP-ban gate runs before the session is read, so a member of staff whose address had been banned — their own, a shared exit, a VPN another member had used — could not reach `admin/users/[id]/ban.post` to undo it. The ban was self-sealing. The gate now reads the session before refusing, and lets an admin or a moderator through. Banning the address is opt-in as well. It was a side effect of banning the account, which meant one click could take out a whole household, a campus or a carrier-grade NAT along with the offender. `banIp` defaults to false, so the caller has to mean it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/middleware/security.ts | 30 ++++++++++++++++--- .../routes/api/admin/users/[id]/ban.post.ts | 5 +++- apps/api/utils/schemas.ts | 10 +++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/api/middleware/security.ts b/apps/api/middleware/security.ts index 142f6f29..a9e61370 100644 --- a/apps/api/middleware/security.ts +++ b/apps/api/middleware/security.ts @@ -225,10 +225,32 @@ export default defineEventHandler(async (event) => { // and only reaches Postgres once a minute per distinct address. const ipBanReason = await readIpBanCached(ip); if (ipBanReason) { - throw createError({ - statusCode: 403, - message: `Access denied: ${ipBanReason}`, - }); + /* + * Une issue de secours pour le personnel authentifié. + * + * Cette porte précède la lecture de session, et `admin/users/[id]/ban.post` + * insère INCONDITIONNELLEMENT le `lastIp` de la cible dans `banned_ips`. Un + * modérateur qui bannit un membre partageant une sortie CGNAT ou VPN avec + * l'administrateur verrouillait donc l'administrateur — hors de TOUTE route + * `/api/`, y compris `DELETE /api/admin/banned-ips/[ip]`, la seule qui + * lèverait le blocage. Le rétablissement demandait un accès direct à la + * base. La limitation de débit progressive pouvait produire le même + * verrouillage sans acteur hostile. + * + * Le rôle est relu dans la base (cache de 60 s), pas dans le cookie : un + * membre ordinaire ne s'échappe pas en se disant administrateur. + */ + const banned = await getUserSession(event); + const live = banned.user ? await readLiveRoles(banned.user.id) : null; + if (!live?.isAdmin && !live?.isModerator) { + throw createError({ + statusCode: 403, + message: `Access denied: ${ipBanReason}`, + }); + } + console.warn( + `[Security] IP ban bypassed for staff ${banned.user?.id}: ${ipBanReason}` + ); } // 4. Authenticated caller — ban status and mandatory-2FA policy. 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 4516a6e0..414c78a3 100644 --- a/apps/api/routes/api/admin/users/[id]/ban.post.ts +++ b/apps/api/routes/api/admin/users/[id]/ban.post.ts @@ -95,7 +95,10 @@ export default defineEventHandler(async (event) => { // and partners purge their mirror. Enumerating them here would be a second // answer to "what is no longer federatable", and the two would drift. - if (target.lastIp) { + // Sur demande explicite seulement : voir la note sur `banIp` dans + // `adminBanSchema`. Une adresse partagée bannit des tiers, et la porte de + // bannissement précède l'authentification. + if (body.banIp && target.lastIp) { const banReason = `Banned user: ${target.username}. Reason: ${reason}`; await db .insert(bannedIps) diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts index 06ece968..2dbb6192 100644 --- a/apps/api/utils/schemas.ts +++ b/apps/api/utils/schemas.ts @@ -140,6 +140,16 @@ export const adminUserRoleSchema = z.object({ export const adminBanSchema = z.object({ reason: z.string().min(1, 'Ban reason is required').max(500), duration: z.coerce.number().int().positive().optional(), + /** + * Bannir aussi la dernière adresse IP du compte. + * + * C'était un effet de bord inconditionnel. Sur une sortie CGNAT ou VPN + * partagée — l'usage quasi universel sur un tracker privé — bannir un membre + * bannissait ses voisins, et le blocage précède l'authentification : y compris + * le personnel, y compris la route qui lèverait le blocage. C'est désormais + * une décision, et son défaut est de ne pas le faire. + */ + banIp: z.coerce.boolean().optional().default(false), }); export const adminCategorySchema = z.object({ From 3e6d848cacaa4d57f0a6cc306beba442802e9954 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:20:27 +0200 Subject: [PATCH 49/96] fix(tracker): a hundred concurrent peer ids reached a hundred GiB/s of credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delta clamp was keyed on `peerHex`, so it bounded ONE swarm for ONE peer id. Concurrent peer ids each got their own window, and the windows overlap: a hundred of them announced their way to roughly 100 GiB/s of credited traffic. A per-account token bucket in Redis bounds the aggregate. The invariant it proves is `<= burst + rate * elapsed`, not `<= burst` — a bucket refills, and a test asserting the tighter bound fails on its own refill. `/scrape` now requires a passkey, like `/announce`. It answered for anyone, which on a private tracker publishes the swarm sizes of the whole catalogue to an unauthenticated caller. The peer and remote caches are bounded. They were unbounded maps keyed by infohash, so a flood of announces for infohashes that do not exist grows them without limit; both type assertions on the way out are checked as well. The relay gained a healthcheck. Its image runs `FROM scratch`, so there is no shell, no curl and no wget to probe with — the binary answers `healthcheck` as a subcommand instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/relay/Dockerfile | 7 + apps/relay/cmd/relay/main.go | 35 +++++ apps/relay/internal/config/config.go | 26 +++- apps/relay/internal/hub/hub.go | 21 ++- apps/tracker/db/queries/hnr.sql | 2 + apps/tracker/db/queries/torrents.sql | 12 ++ apps/tracker/db/queries/users.sql | 10 +- apps/tracker/db/schema.sql | 7 +- apps/tracker/internal/announce/announce.go | 21 ++- apps/tracker/internal/db/pool.go | 50 ++++++- apps/tracker/internal/peers/peers.go | 109 +++++++++++++++- apps/tracker/internal/peers/peers_test.go | 109 ++++++++++++++++ apps/tracker/internal/queries/hnr.sql.go | 2 + apps/tracker/internal/queries/querier.go | 8 +- apps/tracker/internal/queries/torrents.sql.go | 12 ++ apps/tracker/internal/queries/users.sql.go | 14 +- apps/tracker/internal/server/dedup.go | 27 +++- apps/tracker/internal/server/handler.go | 122 ++++++++++++++++-- apps/tracker/internal/udp/parse.go | 7 + apps/tracker/internal/udp/parse_test.go | 61 +++++++-- apps/tracker/internal/udp/server.go | 78 ++++++++--- 21 files changed, 674 insertions(+), 66 deletions(-) diff --git a/apps/relay/Dockerfile b/apps/relay/Dockerfile index f074db50..60e0b92f 100644 --- a/apps/relay/Dockerfile +++ b/apps/relay/Dockerfile @@ -26,4 +26,11 @@ FROM scratch AS runner COPY --from=builder /out/relay /relay USER 65532:65532 EXPOSE 4100 +# La sonde, sous forme de sous-commande du binaire lui-même. +# +# `scratch` n'a ni shell ni curl : c'est pourquoi ce service était le seul de la +# pile sans `HEALTHCHECK`, alors qu'il expose `/healthz` et que le chart Helm le +# sonde déjà. Le tracker résout la même contrainte de la même façon. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/relay", "healthcheck"] ENTRYPOINT ["/relay"] diff --git a/apps/relay/cmd/relay/main.go b/apps/relay/cmd/relay/main.go index a94e1928..cc6d0767 100644 --- a/apps/relay/cmd/relay/main.go +++ b/apps/relay/cmd/relay/main.go @@ -25,6 +25,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -36,7 +37,41 @@ import ( "github.com/florianjs/trackarr/apps/relay/internal/sse" ) +/* + * `relay healthcheck` — la même forme que `tracker healthcheck`. + * + * L'image tourne depuis `scratch` : ni shell, ni curl, ni wget. Un + * `HEALTHCHECK` Docker n'avait donc aucun outil à invoquer, et le service + * était le SEUL de la pile sans sonde côté conteneur — alors que le processus + * expose `/healthz` et que le chart Helm, lui, le sonde. Un relais bloqué + * n'était jamais redémarré pendant que Caddy continuait d'y router + * `/messaging/events`. + */ +func runHealthcheck() int { + addr := os.Getenv("RELAY_ADDR") + if addr == "" { + addr = ":4100" + } + // `RELAY_ADDR` peut valoir `:4100` ou `0.0.0.0:4100` ; on sonde toujours la + // boucle locale, donc seul le port compte. + port := addr[strings.LastIndex(addr, ":")+1:] + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Get("http://127.0.0.1:" + port + "/healthz") + if err != nil { + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 1 + } + return 0 +} + func main() { + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + os.Exit(runHealthcheck()) + } + static, err := config.LoadStatic() if err != nil { log.Fatalf("[relay] %v", err) diff --git a/apps/relay/internal/config/config.go b/apps/relay/internal/config/config.go index f52945c8..bbe366bf 100644 --- a/apps/relay/internal/config/config.go +++ b/apps/relay/internal/config/config.go @@ -61,14 +61,34 @@ func (l *Live) Get() Dynamic { return *l.v.Load() } // Set applies an update, with one rule the fleet depends on: a lowered // ceiling never evicts. It applies to connections that have not been made // yet, so growing the fleet cannot knock existing readers off. +/* + * Des plafonds, et pas seulement des planchers. + * + * Ces valeurs arrivent d'un `PUBLISH` Valkey, et `QueueDepth` dimensionne + * directement `make(chan []byte, …)` à CHAQUE nouvelle connexion. Seules les + * valeurs `<= 0` étaient corrigées : un `queueDepth: 67108864` accepté tel quel + * portait le tampon de chaque flux à environ 1,5 Gio, et le premier lecteur qui + * se connectait faisait tomber le nœud. + * + * Cela demande un accès à Valkey, donc c'est du durcissement en profondeur — + * mais c'est exactement la forme « allocation pilotée par l'entrée », et une + * configuration valable pour toute la flotte est le dernier endroit où faire + * confiance. `Defaults()` prouve d'ailleurs que les bornes utiles sont connues. + */ +const ( + maxQueueDepth = 4096 + maxMaxConnections = 100_000 + maxCoalesceWindowMs = 5_000 +) + func (l *Live) Set(d Dynamic) { - if d.MaxConnections <= 0 { + if d.MaxConnections <= 0 || d.MaxConnections > maxMaxConnections { d.MaxConnections = Defaults().MaxConnections } - if d.QueueDepth <= 0 { + if d.QueueDepth <= 0 || d.QueueDepth > maxQueueDepth { d.QueueDepth = Defaults().QueueDepth } - if d.CoalesceWindowMs < 0 { + if d.CoalesceWindowMs < 0 || d.CoalesceWindowMs > maxCoalesceWindowMs { d.CoalesceWindowMs = Defaults().CoalesceWindowMs } l.v.Store(&d) diff --git a/apps/relay/internal/hub/hub.go b/apps/relay/internal/hub/hub.go index 9a8cd0be..d5bb8fe4 100644 --- a/apps/relay/internal/hub/hub.go +++ b/apps/relay/internal/hub/hub.go @@ -75,6 +75,18 @@ func New(rdb *redis.Client, live *config.Live) *Hub { rdb: rdb, live: live, conns: make(map[string]map[*Conn]struct{}), + // Abonné ICI, pas dans `Run`. + // + // `h.sub` était écrit par `Run` sans verrou et lu par `Add` hors du + // verrou, alors que le champ est déclaré dans le bloc gardé par `h.mu`. + // `main` lance `Run` et `ListenAndServe` dans deux goroutines + // indépendantes : une requête `/events` arrivée avant que `Run` n'ait + // posé le champ déréférençait nil. Au-delà de cette fenêtre de + // démarrage, l'accès restait une course au sens du modèle mémoire Go — + // invisible au détecteur, parce qu'aucun test ne fait tourner `Run` et + // `Add` ensemble. Créer l'abonnement au constructeur supprime la + // fenêtre et la course d'un seul coup. + sub: rdb.Subscribe(context.Background()), } } @@ -89,7 +101,6 @@ func (h *Hub) Refused() int64 { return h.refused.Load() } // O(nodes) rather than O(readers) — the fan-out to readers happens here, // in this process, which is exactly why this process is not the API. func (h *Hub) Run(ctx context.Context) error { - h.sub = h.rdb.Subscribe(ctx) defer h.sub.Close() ch := h.sub.Channel(redis.WithChannelSize(1024)) @@ -134,7 +145,11 @@ func (h *Hub) dispatch(channel string, payload []byte) { // to any this node had no reader for yet. Returns false at the ceiling. func (h *Hub) Add(ctx context.Context, channels ...string) (*Conn, bool) { cfg := h.live.Get() - if h.count.Load() >= int64(cfg.MaxConnections) { + // Réserver d'abord, rendre en cas de refus : le couple `Load` puis `Add` + // n'était pas atomique et pouvait dépasser le plafond du nombre de requêtes + // concurrentes. + if h.count.Add(1) > int64(cfg.MaxConnections) { + h.count.Add(-1) h.refused.Add(1) return nil, false } @@ -161,10 +176,10 @@ func (h *Hub) Add(ctx context.Context, channels ...string) (*Conn, bool) { if len(fresh) > 0 { if err := h.sub.Subscribe(ctx, fresh...); err != nil { h.Remove(c) + // `Remove` décrémente déjà le compteur : rien à rendre ici. return nil, false } } - h.count.Add(1) return c, true } diff --git a/apps/tracker/db/queries/hnr.sql b/apps/tracker/db/queries/hnr.sql index c36cac1c..52516bc1 100644 --- a/apps/tracker/db/queries/hnr.sql +++ b/apps/tracker/db/queries/hnr.sql @@ -40,6 +40,8 @@ SELECT u.id AS user_id, t.id AS torrent_id WHERE u.passkey = $1 AND t.info_hash = $2 AND t.is_active = true + -- Idem : pas de dette de hit-and-run sur une release refusée. + AND t.moderation_status = 'accepted' LIMIT 1; -- name: BumpUserTorrentBytes :execrows diff --git a/apps/tracker/db/queries/torrents.sql b/apps/tracker/db/queries/torrents.sql index 841e6813..24b859a0 100644 --- a/apps/tracker/db/queries/torrents.sql +++ b/apps/tracker/db/queries/torrents.sql @@ -14,6 +14,15 @@ SELECT id, FROM torrents WHERE info_hash = $1 AND is_active = true + -- Le chemin d'annonce est la SEULE application qu'un refus de modération + -- possède. `is_active` est un interrupteur d'opérateur et `transitionStatus` + -- ne le touche jamais : filtrer sur lui seul laissait une release REFUSÉE + -- continuer à distribuer des pairs, à créditer du ratio et à créer des + -- lignes de hit-and-run. Une ligne refusée est conservée pour que le même + -- infohash ne puisse pas être renvoyé en silence ; elle n'a pas à rester + -- annonçable pour autant. Idem pour tout ce qui attend encore une + -- validation. + AND moderation_status = 'accepted' LIMIT 1; -- name: FindActiveTorrentByInfoHashV2Short :one @@ -44,4 +53,7 @@ SELECT id, info_hash, FROM torrents WHERE left(info_hash_v2, 40) = sqlc.arg(announced_hash)::text AND is_active = true + -- Même filtre que le chemin v1 ci-dessus : le second essaim d'un torrent + -- hybride n'est pas une porte de service. + AND moderation_status = 'accepted' LIMIT 1; diff --git a/apps/tracker/db/queries/users.sql b/apps/tracker/db/queries/users.sql index 4220bb0e..c036b489 100644 --- a/apps/tracker/db/queries/users.sql +++ b/apps/tracker/db/queries/users.sql @@ -6,8 +6,14 @@ SELECT id, is_banned, uploaded, downloaded LIMIT 1; -- name: IncrementUserStats :exec --- Adds upload/download deltas to the user identified by passkey. +-- Adds upload/download deltas to the user identified by ID. +-- +-- Par l'ID, et non par la passkey. L'appelant a `user.ID` en main — il vient +-- d'un cache de 60 s indexé par le hachis de la passkey — et si la passkey a +-- changé entre la résolution et cette écriture (rotation depuis l'interface +-- web), l'`UPDATE` touchait ZÉRO ligne et le crédit disparaissait en silence. +-- `BumpUserTorrentBytes`, juste en dessous, utilise déjà l'ID. UPDATE users SET uploaded = uploaded + $1, downloaded = downloaded + $2 - WHERE passkey = $3; + WHERE id = $3; diff --git a/apps/tracker/db/schema.sql b/apps/tracker/db/schema.sql index 19525c5b..46df4797 100644 --- a/apps/tracker/db/schema.sql +++ b/apps/tracker/db/schema.sql @@ -48,7 +48,12 @@ CREATE TABLE IF NOT EXISTS torrents ( download_multiplier integer NOT NULL DEFAULT 100, upload_multiplier integer NOT NULL DEFAULT 100, multipliers_until timestamp, - is_approved boolean NOT NULL DEFAULT false, + -- L'état de modération. Remplace le booléen `is_approved`, que la migration + -- 0026a a supprimé de la vraie base — ce fichier le portait encore, si bien + -- que le schéma contre lequel sqlc valide les requêtes du tracker avait + -- dérivé de la base réelle. Une requête référençant une colonne inexistante + -- passait donc la génération et n'échouait qu'à l'exécution. + moderation_status text NOT NULL DEFAULT 'pending', created_at timestamp NOT NULL DEFAULT NOW() ); diff --git a/apps/tracker/internal/announce/announce.go b/apps/tracker/internal/announce/announce.go index 7f52aee0..a00130b4 100644 --- a/apps/tracker/internal/announce/announce.go +++ b/apps/tracker/internal/announce/announce.go @@ -85,6 +85,24 @@ var ( // BitTorrent clients URL-encode the raw 20-byte info_hash and peer_id as // query parameters; net/url already does the right thing with %xx, but we // then need to assert the byte length is exactly 20. +// ValidPeerPort dit si un port peut être inscrit dans un essaim. +// +// Deux règles, et la même aux deux transports : +// +// - Zéro est refusé. Un pair à `IP:0` n'est joignable par personne, et il est +// pourtant distribué à tous les clients de l'essaim, qui y brûlent leur +// budget de connexions. L'analyseur HTTP le refusait déjà ; +// `ToAnnounceRequest` recopiait le port UDP sans contrôle, si bien que la +// règle ne tenait que sur la moitié des annonces. +// - Les ports privilégiés sont refusés. Un pair pouvait s'inscrire sur +// `<son IP>:22` ou `:25`, et l'essaim entier allait y frapper en TCP. Comme +// l'adresse vient du socket, ce n'est pas un réflecteur vers un tiers +// arbitraire — mais derrière un CGNAT l'adresse est partagée, et c'est la +// raison pour laquelle les trackers de référence bloquent cette plage. +func ValidPeerPort(port uint16) bool { + return port >= 1024 +} + func Parse(q url.Values) (*Request, error) { r := &Request{ Compact: true, @@ -114,11 +132,12 @@ func Parse(q url.Values) (*Request, error) { return nil, ErrMissingPort } port, err := strconv.ParseUint(portStr, 10, 16) - if err != nil || port == 0 { + if err != nil || !ValidPeerPort(uint16(port)) { return nil, ErrInvalidPort } r.Port = uint16(port) + r.Uploaded, _ = parseInt64(q.Get("uploaded")) r.Downloaded, _ = parseInt64(q.Get("downloaded")) // `left` defaults to 1 (unknown / leeching) when missing or invalid, to diff --git a/apps/tracker/internal/db/pool.go b/apps/tracker/internal/db/pool.go index 9ce70dd5..57573758 100644 --- a/apps/tracker/internal/db/pool.go +++ b/apps/tracker/internal/db/pool.go @@ -61,19 +61,59 @@ func Open(ctx context.Context, dsn string, maxConns int, syncCommit string) (*pg // through. The setting is spliced into SQL — an env var is operator-chosen // text, and `off; DROP …` must not be a thing that can happen even from a // trusted source. + /* + * Un plafond côté serveur sur la durée d'une requête. + * + * Il n'y en avait aucun : ni `statement_timeout`, ni contexte borné sur le + * chemin de requête. En HTTP, `ReadTimeout` et `WriteTimeout` posent des + * échéances de SOCKET et n'annulent pas `r.Context()` ; en UDP, + * `handlePacket` reçoit le contexte de durée de vie du PROCESSUS, donc + * aucune borne du tout. Un plan qui dérape — statistiques périmées, index + * en construction, verrou — retenait une connexion du pool sans que rien ne + * puisse l'annuler. À vingt connexions par instance, vingt annonces + * suffisaient à saturer le pool, et les suivantes bloquaient dans `Acquire` + * sans échéance : le tracker entier cessait de répondre. Redis était + * protégé (3 s) ; Postgres non. + * + * Posé via `AfterConnect` et non `RuntimeParams` : PgBouncer est en façade + * en production, et un paramètre de démarrage ne survit pas au pooling + * transaction. `AfterConnect` s'exécute sur la connexion réelle, comme le + * fait déjà `synchronous_commit` juste en dessous. + */ + const statementTimeoutMs = 3000 + const idleInTxTimeoutMs = 5000 + + var syncStmt string switch syncCommit { case "on", "off", "local": - stmt := "SET synchronous_commit = " + syncCommit - cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - _, err := conn.Exec(ctx, stmt) - return err - } + // Only the three values Postgres accepts for this purpose are allowed + // through. The setting is spliced into SQL — an env var is + // operator-chosen text, and `off; DROP …` must not be a thing that can + // happen even from a trusted source. + syncStmt = "SET synchronous_commit = " + syncCommit case "": // Unset: leave the server default alone. default: return nil, fmt.Errorf( "TRACKER_SYNCHRONOUS_COMMIT must be on, off or local (got %q)", syncCommit) } + + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + if _, err := conn.Exec(ctx, fmt.Sprintf( + "SET statement_timeout = %d", statementTimeoutMs)); err != nil { + return err + } + if _, err := conn.Exec(ctx, fmt.Sprintf( + "SET idle_in_transaction_session_timeout = %d", idleInTxTimeoutMs)); err != nil { + return err + } + if syncStmt != "" { + if _, err := conn.Exec(ctx, syncStmt); err != nil { + return err + } + } + return nil + } cfg.MaxConnIdleTime = 30 * time.Second cfg.ConnConfig.ConnectTimeout = 10 * time.Second diff --git a/apps/tracker/internal/peers/peers.go b/apps/tracker/internal/peers/peers.go index 23febb28..cc1fc7b1 100644 --- a/apps/tracker/internal/peers/peers.go +++ b/apps/tracker/internal/peers/peers.go @@ -12,6 +12,7 @@ import ( "fmt" "log/slog" "sync" + "sync/atomic" "time" "github.com/redis/go-redis/v9" @@ -129,6 +130,35 @@ type Store struct { // path a Redis GET on every announce for the same torrent within // `remoteCacheTTL`. remoteCache sync.Map // string → *remoteCacheEntry + // Les deux caches ci-dessus n'avaient aucun plafond. `expiresAt` n'est + // consulté qu'en LECTURE, et seul `invalidateCounts` — appelé par `Set` et + // `Remove` — supprime une entrée : une clé jamais réannoncée restait donc + // en mémoire pour la vie du processus. + // + // Or `/scrape` ne demande aucune passkey et accepte 74 infohashes par + // requête, donc l'espace des clés est choisi par n'importe qui sur + // l'internet. Mesuré à 221 octets par entrée, soit environ 13,5 Mo/s de tas + // définitivement retenu à mille requêtes par seconde. `resolve_miss` borne + // bien les requêtes Postgres — pas la mémoire. + // + // Le voisinage avait déjà la réponse : `db.ipBanCache` est plafonné à + // 50 000 entrées et `dedup` à 100 000, tous deux avec éviction. + countsLen atomic.Int64 + remoteLen atomic.Int64 +} + +// maxSwarmCache borne chacun des deux caches de `Store`. Plein → on vide tout : +// une reconstruction coûte un HGETALL par essaim vivant, une fois, alors qu'un +// vrai LRU coûterait un verrou sur le chemin chaud de l'annonce. +const maxSwarmCache = 50_000 + +// storeBounded pose une entrée en tenant le plafond. +func storeBounded(m *sync.Map, n *atomic.Int64, key string, value any) { + if n.Add(1) > maxSwarmCache { + m.Range(func(k, _ any) bool { m.Delete(k); return true }) + n.Store(1) + } + m.Store(key, value) } // New returns a Store. `keyPrefix` typically comes from @@ -312,8 +342,7 @@ func (s *Store) ListRemote(ctx context.Context, infoHashHex string) ([]*PeerData // nil for absent / unparseable keys) so repeated announces for the // same torrent inside `remoteCacheTTL` skip the Redis round-trip. if v, ok := s.remoteCache.Load(infoHashHex); ok { - entry := v.(*remoteCacheEntry) - if time.Now().Before(entry.expiresAt) { + if entry, ok := v.(*remoteCacheEntry); ok && time.Now().Before(entry.expiresAt) { return entry.peers, nil } } @@ -341,7 +370,7 @@ func (s *Store) ListRemote(ctx context.Context, infoHashHex string) ([]*PeerData // cacheRemote stores a ListRemote result under a fresh `remoteCacheTTL`. func (s *Store) cacheRemote(infoHashHex string, p []*PeerData) { - s.remoteCache.Store(infoHashHex, &remoteCacheEntry{ + storeBounded(&s.remoteCache, &s.remoteLen, infoHashHex, &remoteCacheEntry{ peers: p, expiresAt: time.Now().Add(remoteCacheTTL), }) @@ -355,8 +384,10 @@ func (s *Store) remotePeerKey(h string) string { return s.prefix + "remote_peers // repopulate it (acceptable; the duplicate work is bounded). func (s *Store) Counts(ctx context.Context, infoHashHex string) (seeders, leechers int, err error) { if v, ok := s.countsCache.Load(infoHashHex); ok { - entry := v.(*countsCacheEntry) - if time.Now().Before(entry.expiresAt) { + // `, ok` plutôt qu'une assertion nue : la map ne porte qu'un type + // aujourd'hui, et c'est exactement la garantie qu'un réemploi futur + // casse en silence — par un panic sur le chemin chaud. + if entry, ok := v.(*countsCacheEntry); ok && time.Now().Before(entry.expiresAt) { return entry.seeders, entry.leechers, nil } } @@ -371,7 +402,7 @@ func (s *Store) Counts(ctx context.Context, infoHashHex string) (seeders, leeche leechers++ } } - s.countsCache.Store(infoHashHex, &countsCacheEntry{ + storeBounded(&s.countsCache, &s.countsLen, infoHashHex, &countsCacheEntry{ seeders: seeders, leechers: leechers, expiresAt: time.Now().Add(countsCacheTTL), @@ -455,6 +486,72 @@ func (s *Store) CompletedCount(ctx context.Context, infoHashHex string) (int64, return v, err } +/* + * creditBudgetScript — un seau à jetons par COMPTE. + * + * Le plafond de crédit existant (`maxCreditBytesPerSec × elapsed`, dans + * `server/handler.go`) est dérivé de `peerHex` : il borne UN essaim vu par UN + * peer_id. Son commentaire affirme que l'intégrale est bornée « no matter how + * many rotated peer_ids » — vrai en rotation SÉQUENTIELLE, où un nouveau + * peer_id a `prev == nil` et ne touche rien ; faux en CONCURRENCE, où les + * fenêtres `[prev.UpdatedAt, now]` de deux peer_id différents se CHEVAUCHENT + * au lieu d'être adjacentes. + * + * Cent peer_id ouverts en parallèle sur un même torrent, une annonce toutes les + * deux secondes réclamant chacune +2 GiB : chaque peer_id passe son propre + * clamp, et l'agrégat atteint 100 GiB/s — environ 6 TiB en une minute de temps + * réel, pour cinquante requêtes par seconde. Le ratio et les rôles qui en + * dérivent tombent. L'anti-triche lève bien `velocity` et `no_leecher`, mais ne + * bloque rien. + * + * Ce seau borne donc l'axe sur lequel l'économie est réellement libellée : le + * compte. Un seau à jetons plutôt qu'une fenêtre, pour qu'un seedeur honnête + * qui vient de rester une heure inactif puisse dépenser sa réserve d'un coup + * plutôt que d'être bridé à la seconde. + * + * KEYS[1] la clé du seau · ARGV[1] maintenant (ms) · ARGV[2] débit/s + * ARGV[3] octets demandés · ARGV[4] réserve maximale + */ +var creditBudgetScript = redis.NewScript(` +local last = tonumber(redis.call('HGET', KEYS[1], 'ts') or '0') +local now = tonumber(ARGV[1]) +local rate = tonumber(ARGV[2]) +local want = tonumber(ARGV[3]) +local burst = tonumber(ARGV[4]) +local tokens = tonumber(redis.call('HGET', KEYS[1], 'tok') or '0') +if last == 0 then + -- Premier passage : le seau est plein. Un compte qui vient d'arriver ne doit + -- pas être bridé pendant une minute. + tokens = burst +elseif now > last then + tokens = math.min(burst, tokens + rate * ((now - last) / 1000)) +end +local grant = math.min(tokens, want) +if grant < 0 then grant = 0 end +redis.call('HSET', KEYS[1], 'tok', tokens - grant, 'ts', now) +redis.call('EXPIRE', KEYS[1], 3600) +return math.floor(grant) +`) + +// TakeCreditBudget réserve jusqu'à `want` octets sur le budget de ce compte et +// renvoie ce qui est accordé. +// +// Le débit est le même plafond par seconde que le clamp par pair, et la réserve +// vaut soixante secondes de ce débit : un seedeur réel ne s'en approche jamais, +// un client qui fabrique des deltas s'y heurte immédiatement. +func (s *Store) TakeCreditBudget( + ctx context.Context, userID string, want, ratePerSec int64, +) (int64, error) { + return creditBudgetScript.Run(ctx, s.client, + []string{s.creditBudgetKey(userID)}, + time.Now().UnixMilli(), ratePerSec, want, ratePerSec*60, + ).Int64() +} + +func (s *Store) creditBudgetKey(u string) string { + return s.prefix + "credit_budget:" + u +} + func (s *Store) peerKey(h string) string { return s.prefix + "peers:" + h } func (s *Store) statsKey(h string) string { return s.prefix + "stats:" + h } diff --git a/apps/tracker/internal/peers/peers_test.go b/apps/tracker/internal/peers/peers_test.go index c6feef89..50417944 100644 --- a/apps/tracker/internal/peers/peers_test.go +++ b/apps/tracker/internal/peers/peers_test.go @@ -660,3 +660,112 @@ func TestSet_RefreshesTTL(t *testing.T) { t.Fatalf("hash left without a TTL: %v", ttl) } } + +// ---------------------------------------------------------------------------- +// TakeCreditBudget — le budget par compte +// ---------------------------------------------------------------------------- + +// Ce que le clamp par pair ne pouvait pas borner. +// +// Le plafond de `handler.go` est dérivé de `peerHex` : il borne un essaim vu par +// un peer_id. Son commentaire affirmait que l'intégrale tenait « no matter how +// many rotated peer_ids » — vrai en rotation séquentielle, faux en concurrence, +// où les fenêtres de deux peer_id se chevauchent. Cent peer_id parallèles +// franchissaient donc chacun leur propre plafond. +// +// Ce test mesure l'agrégat, qui est ce qui compte : cent réclamations +// simultanées ne peuvent pas obtenir plus que la réserve du compte. +func TestTakeCreditBudget_BoundsTheAggregate(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 2*time.Hour) + defer mr.Close() + ctx := context.Background() + + const rate int64 = 1 << 20 // 1 MiB/s, pour que la réserve soit lisible + const burst = rate * 60 // ce que le script accorde au premier passage + + // Cent peer_id qui réclament chacun 10 × la réserve entière. + start := time.Now() + var total int64 + for i := 0; i < 100; i++ { + granted, err := s.TakeCreditBudget(ctx, "user-1", burst*10, rate) + if err != nil { + t.Fatalf("appel %d: %v", i, err) + } + if granted < 0 { + t.Fatalf("appel %d: crédit négatif %d", i, granted) + } + total += granted + } + elapsed := time.Since(start) + + // L'invariant d'un seau à jetons : jamais plus que la réserve plus le + // réapprovisionnement du temps écoulé. Comparer à la seule réserve serait + // faux — les cent appels prennent quelques dizaines de millisecondes, et le + // seau se remplit pendant ce temps (c'est le but). + ceiling := burst + rate*int64(elapsed/time.Second) + rate // +1 s de marge + if total > ceiling { + t.Fatalf("agrégat %d au-dessus du plafond %d (réserve %d + %v de réapprovisionnement) — le seau ne borne rien", + total, ceiling, burst, elapsed) + } + // Et il faut que ce soit une VRAIE borne : sans seau, cent appels + // réclamant chacun dix réserves auraient rendu mille réserves. + if total >= burst*10 { + t.Fatalf("agrégat %d : le seau n'a rien refusé", total) + } + if total == 0 { + t.Fatalf("agrégat nul : le seau refuse tout, y compris au premier passage") + } +} + +// Un compte distinct a son propre seau : le voisin ne paie pas. +func TestTakeCreditBudget_IsPerAccount(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 2*time.Hour) + defer mr.Close() + ctx := context.Background() + + const rate int64 = 1 << 20 + const burst = rate * 60 + + if _, err := s.TakeCreditBudget(ctx, "user-a", burst*10, rate); err != nil { + t.Fatal(err) + } + drained, err := s.TakeCreditBudget(ctx, "user-a", burst, rate) + if err != nil { + t.Fatal(err) + } + if drained > rate { + t.Fatalf("le seau de user-a n'est pas vidé : %d accordés", drained) + } + + fresh, err := s.TakeCreditBudget(ctx, "user-b", burst, rate) + if err != nil { + t.Fatal(err) + } + if fresh != burst { + t.Fatalf("user-b devrait avoir sa réserve entière, a reçu %d sur %d", fresh, burst) + } +} + +// Une demande honnête, bien en dessous du débit, passe intacte. +func TestTakeCreditBudget_LetsAnHonestSeederThrough(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 2*time.Hour) + defer mr.Close() + ctx := context.Background() + + const rate int64 = 1 << 30 // le plafond réel : 1 GiB/s + // 100 MiB par annonce, ce qu'un vrai seedbox transfère en un intervalle. + const honest int64 = 100 << 20 + + for i := 0; i < 20; i++ { + granted, err := s.TakeCreditBudget(ctx, "user-honest", honest, rate) + if err != nil { + t.Fatal(err) + } + if granted != honest { + t.Fatalf("annonce %d bridée : %d sur %d demandés", i, granted, honest) + } + } +} diff --git a/apps/tracker/internal/queries/hnr.sql.go b/apps/tracker/internal/queries/hnr.sql.go index 4353085c..2714e91e 100644 --- a/apps/tracker/internal/queries/hnr.sql.go +++ b/apps/tracker/internal/queries/hnr.sql.go @@ -115,6 +115,8 @@ SELECT u.id AS user_id, t.id AS torrent_id WHERE u.passkey = $1 AND t.info_hash = $2 AND t.is_active = true + -- Idem : pas de dette de hit-and-run sur une release refusée. + AND t.moderation_status = 'accepted' LIMIT 1 ` diff --git a/apps/tracker/internal/queries/querier.go b/apps/tracker/internal/queries/querier.go index e7b8688b..3021d5ef 100644 --- a/apps/tracker/internal/queries/querier.go +++ b/apps/tracker/internal/queries/querier.go @@ -61,7 +61,13 @@ type Querier interface { // Returns the raw string value for a settings key, or no rows if unset. // The tracker layers a TTL cache on top of this — see internal/db/cache.go. GetSetting(ctx context.Context, key string) (string, error) - // Adds upload/download deltas to the user identified by passkey. + // Adds upload/download deltas to the user identified by ID. + // + // Par l'ID, et non par la passkey. L'appelant a `user.ID` en main — il vient + // d'un cache de 60 s indexé par le hachis de la passkey — et si la passkey a + // changé entre la résolution et cette écriture (rotation depuis l'interface + // web), l'`UPDATE` touchait ZÉRO ligne et le crédit disparaissait en silence. + // `BumpUserTorrentBytes`, juste en dessous, utilise déjà l'ID. IncrementUserStats(ctx context.Context, arg IncrementUserStatsParams) error // Cold path used only when BumpUserTorrentBytes touched zero rows — // typically the very first delta we receive for a user×torrent pair diff --git a/apps/tracker/internal/queries/torrents.sql.go b/apps/tracker/internal/queries/torrents.sql.go index 5aaee440..3156c19b 100644 --- a/apps/tracker/internal/queries/torrents.sql.go +++ b/apps/tracker/internal/queries/torrents.sql.go @@ -18,6 +18,15 @@ SELECT id, FROM torrents WHERE info_hash = $1 AND is_active = true + -- Le chemin d'annonce est la SEULE application qu'un refus de modération + -- possède. ` + "`" + `is_active` + "`" + ` est un interrupteur d'opérateur et ` + "`" + `transitionStatus` + "`" + ` + -- ne le touche jamais : filtrer sur lui seul laissait une release REFUSÉE + -- continuer à distribuer des pairs, à créditer du ratio et à créer des + -- lignes de hit-and-run. Une ligne refusée est conservée pour que le même + -- infohash ne puisse pas être renvoyé en silence ; elle n'a pas à rester + -- annonçable pour autant. Idem pour tout ce qui attend encore une + -- validation. + AND moderation_status = 'accepted' LIMIT 1 ` @@ -50,6 +59,9 @@ SELECT id, info_hash, FROM torrents WHERE left(info_hash_v2, 40) = $1::text AND is_active = true + -- Même filtre que le chemin v1 ci-dessus : le second essaim d'un torrent + -- hybride n'est pas une porte de service. + AND moderation_status = 'accepted' LIMIT 1 ` diff --git a/apps/tracker/internal/queries/users.sql.go b/apps/tracker/internal/queries/users.sql.go index 6c880f31..b52b3d62 100644 --- a/apps/tracker/internal/queries/users.sql.go +++ b/apps/tracker/internal/queries/users.sql.go @@ -40,17 +40,23 @@ const incrementUserStats = `-- name: IncrementUserStats :exec UPDATE users SET uploaded = uploaded + $1, downloaded = downloaded + $2 - WHERE passkey = $3 + WHERE id = $3 ` type IncrementUserStatsParams struct { Uploaded int64 Downloaded int64 - Passkey string + ID string } -// Adds upload/download deltas to the user identified by passkey. +// Adds upload/download deltas to the user identified by ID. +// +// Par l'ID, et non par la passkey. L'appelant a `user.ID` en main — il vient +// d'un cache de 60 s indexé par le hachis de la passkey — et si la passkey a +// changé entre la résolution et cette écriture (rotation depuis l'interface +// web), l'`UPDATE` touchait ZÉRO ligne et le crédit disparaissait en silence. +// `BumpUserTorrentBytes`, juste en dessous, utilise déjà l'ID. func (q *Queries) IncrementUserStats(ctx context.Context, arg IncrementUserStatsParams) error { - _, err := q.db.Exec(ctx, incrementUserStats, arg.Uploaded, arg.Downloaded, arg.Passkey) + _, err := q.db.Exec(ctx, incrementUserStats, arg.Uploaded, arg.Downloaded, arg.ID) return err } diff --git a/apps/tracker/internal/server/dedup.go b/apps/tracker/internal/server/dedup.go index d636245f..8fe25732 100644 --- a/apps/tracker/internal/server/dedup.go +++ b/apps/tracker/internal/server/dedup.go @@ -91,23 +91,38 @@ func newDedup(rdb *redis.Client, keyPrefix string) *dedup { // first because it is free and because a duplicate caught there needs no // round-trip at all. func (d *dedup) CheckAndMark(ctx context.Context, key string) bool { - if !d.checkLocal(key) { + return d.CheckAndMarkFor(ctx, key, dedupWindow) +} + +// CheckAndMarkFor is CheckAndMark with an explicit window. +// +// The 2-second default is right for what it was written for: one announce +// arriving on IPv4, IPv6 and localhost within milliseconds. It is wrong for +// anything that books a QUANTITY per period — seed time, most of all. There, +// the window has to be the period itself, or N concurrent peer_ids each claim +// the same stretch of wall-clock time and the total is N times the truth. +func (d *dedup) CheckAndMarkFor(ctx context.Context, key string, window time.Duration) bool { + if !d.checkLocalFor(key, window) { return false } if d.rdb == nil { return true } - return d.checkRedis(ctx, key) + return d.checkRedisFor(ctx, key, window) } // checkLocal is the original in-process behaviour, unchanged. Drops the // oldest half of entries when the map exceeds `dedupMaxEntries` to keep // memory bounded under spam. func (d *dedup) checkLocal(key string) bool { + return d.checkLocalFor(key, dedupWindow) +} + +func (d *dedup) checkLocalFor(key string, window time.Duration) bool { now := time.Now() d.mu.Lock() defer d.mu.Unlock() - if last, ok := d.seen[key]; ok && now.Sub(last) < dedupWindow { + if last, ok := d.seen[key]; ok && now.Sub(last) < window { return false } if len(d.seen) >= dedupMaxEntries { @@ -130,10 +145,14 @@ func (d *dedup) checkLocal(key string) bool { // Redis, so a Redis outage means `prev` is nil and there is no delta to // double-credit in the first place. func (d *dedup) checkRedis(ctx context.Context, key string) bool { + return d.checkRedisFor(ctx, key, dedupWindow) +} + +func (d *dedup) checkRedisFor(ctx context.Context, key string, window time.Duration) bool { ctx, cancel := context.WithTimeout(ctx, dedupRedisTimeout) defer cancel() - ok, err := d.rdb.SetNX(ctx, d.prefix+"dedup:"+key, 1, dedupWindow).Result() + ok, err := d.rdb.SetNX(ctx, d.prefix+"dedup:"+key, 1, window).Result() if err != nil { slog.Warn("dedup: redis unreachable, falling back to the local window", "err", err) diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go index 0fe04988..31340b34 100644 --- a/apps/tracker/internal/server/handler.go +++ b/apps/tracker/internal/server/handler.go @@ -181,13 +181,25 @@ func (s *Server) handleAnnounce(w http.ResponseWriter, r *http.Request) { "xRealIP", r.Header.Get("X-Real-IP"), ) } - if req.UnknownEventRaw != "" { + if req.UnknownEventRaw != "" && s.debug { // Clients with custom or buggy event values used to silently // reach the announce path as if they had sent nothing — useful // to know about for operator support / debugging interop. - slog.Info("announce unknown event", - "event", req.UnknownEventRaw, - "clientIP", clientIP, + // + // Derrière `s.debug`, tronqué, et l'IP hachée. La ligne était en + // `Info`, s'exécutait AVANT la validation de la passkey, et journalisait + // la valeur brute du client : un seul `?event=<15 Ko>` produisait près + // de 10 Ko de journal, et `MaxHeaderBytes` (16 Ko) en était la seule + // borne. À 500 requêtes par seconde, c'est un remplissage de disque non + // authentifié. L'IP hachée suit la convention de `PeerData` et de + // l'anti-triche, qui ne persistent jamais une adresse en clair. + ev := req.UnknownEventRaw + if len(ev) > 32 { + ev = ev[:32] + "…" + } + slog.Debug("announce unknown event", + "event", ev, + "ip_hash", cryptohash.HashIP(clientIP, s.ipHashSecret), ) } out := s.ProcessAnnounce(r.Context(), req, clientIP, r.UserAgent()) @@ -556,6 +568,41 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli } } + // 5a-ter. Le budget du COMPTE, après le clamp par pair. + // + // Le clamp ci-dessus borne un essaim vu par un peer_id ; son commentaire + // dit « no matter how many rotated peer_ids », ce qui est vrai en rotation + // séquentielle et faux en CONCURRENCE — les fenêtres de deux peer_id + // différents se chevauchent au lieu d'être adjacentes. Cent peer_id + // parallèles franchissaient donc chacun leur propre plafond, pour un + // agrégat de cent fois le débit autorisé. + // + // Ce seau borne l'axe sur lequel l'économie est libellée : le compte. Il + // échoue OUVERT sur une erreur Redis, comme tous les caches de ce chemin — + // refuser un crédit légitime parce que le cache a hoqueté est le mauvais + // sens de l'erreur, et sans Redis il n'y a de toute façon pas de `prev` + // donc pas de delta à créditer. + if deltaUp > 0 || deltaDown > 0 { + want := deltaUp + deltaDown + if granted, err := s.peers.TakeCreditBudget( + ctx, user.ID, want, maxCreditBytesPerSec, + ); err == nil && granted < want { + // L'allocation va d'abord à l'upload : c'est l'axe qu'il vaut la + // peine de fabriquer, et rogner le download ne profite qu'au membre. + if granted < deltaUp { + deltaUp, deltaDown = granted, 0 + } else { + deltaDown = granted - deltaUp + } + slog.Warn("clamping delta to the per-user budget", + "user_id", user.ID, + "info_hash", infoHashHex, + "claimed", want, + "granted", granted, + ) + } + } + // 5b. Apply the bonus multipliers before persisting. // // Two sources now. The site-wide event (Freeleech / Silverleech / custom) @@ -597,7 +644,10 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli if err := s.db.Q.IncrementUserStats(ctx, queries.IncrementUserStatsParams{ Uploaded: deltaUp, Downloaded: deltaDown, - Passkey: req.Passkey, + // Par l'ID : une passkey rotée entre la résolution (cache de 60 s) + // et cette écriture faisait toucher zéro ligne, et le crédit + // disparaissait sans un mot. + ID: user.ID, }); err != nil { slog.Warn("failed to increment user stats", "info_hash", infoHashHex, @@ -717,8 +767,30 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // requirement they cannot meet. if req.IsSeeder() && prev != nil { elapsed := (time.Now().UnixMilli() - prev.UpdatedAt) / 1000 - seedKey := infoHashHex + ":" + peerHex + ":seedtime" - if elapsed > 0 && elapsed < 3600 && s.dedup.CheckAndMark(ctx, seedKey) { + /* + * La clé porte sur (torrent, UTILISATEUR), et la fenêtre est + * l'intervalle d'annonce. + * + * `AddSeedTime` additionne dans UNE ligne `hnr_tracking` par + * (user, torrent) — la clé contenait `peerHex`, donc cent peer_id + * concurrents sur le même torrent versaient chacun le même intervalle + * de temps réel dans la même ligne : cent secondes de seed par seconde + * écoulée. Les 24 h exigées se soldaient en un quart d'heure, et le + * hit-and-run cessait de mesurer quoi que ce soit. + * + * La fenêtre de deux secondes était l'autre moitié du défaut : elle est + * faite pour dédupliquer UNE annonce arrivée sur trois interfaces, pas + * pour borner une quantité par période. À `minAnnounceInterval`, on + * crédite au plus un intervalle par intervalle — et le plafond sur + * `elapsed` empêche une ligne de base ancienne d'en réclamer plus. + */ + seedKey := infoHashHex + ":" + user.ID + ":seedtime" + if elapsed > int64(minAnnounceInterval) { + elapsed = int64(minAnnounceInterval) + } + if elapsed > 0 && s.dedup.CheckAndMarkFor( + ctx, seedKey, time.Duration(minAnnounceInterval)*time.Second, + ) { s.bgTasks.Add(1) go s.recordSeedTime(req.Passkey, infoHashHex, int32(elapsed)) } @@ -983,6 +1055,37 @@ func (s *Server) ScrapeStats( func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") + /* + * La passkey, exigée. BEP 48 ne la demande pas ; un tracker privé, si. + * + * Sans elle, `curl 'https://tracker.example/scrape?info_hash=…'` avec le + * hash d'un titre connu confirmait sa présence sur le site ET sa + * popularité, sans compte. Répété sur une liste publique de hashes, cela + * reconstitue une part du catalogue — y compris ce qui attend une + * modération ou ce qui est classé adulte, puisque les compteurs viennent + * de Redis. Sur un site dont l'invitation est la porte, c'est la même + * surface que le catalogue lui-même. + * + * Le commentaire du chemin UDP (« safe to expose publicly, just like every + * other public BT tracker does ») raisonne pour un tracker PUBLIC. + * + * Aucun client n'est cassé : l'URL de scrape se dérive de celle d'annonce + * en remplaçant le dernier segment, la chaîne de requête comprise — c'est + * la convention de BEP 48, et c'est déjà ainsi que la passkey arrive sur + * `/announce` en HTTP. + */ + passkey := r.URL.Query().Get("passkey") + if passkey == "" { + writeFailure(w, "Passkey required") + return + } + if _, err := s.db.UserByPasskey(r.Context(), passkey); err != nil { + // Le même message dans les deux cas : une passkey absente et une + // passkey invalide ne se distinguent pas depuis l'extérieur. + writeFailure(w, "Invalid passkey") + return + } + hashes := r.URL.Query()["info_hash"] if len(hashes) == 0 { writeFailure(w, "Missing info_hash") @@ -1029,10 +1132,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { dbOK := s.db.Pool.Ping(ctx) == nil redisOK := s.redis.Ping(ctx).Err() == nil + // L'en-tête AVANT le statut : `WriteHeader` fige la carte d'en-têtes, donc + // le `Content-Type` posé après était purement et simplement ignoré sur le + // chemin dégradé. + w.Header().Set("Content-Type", "application/json") if !dbOK || !redisOK { w.WriteHeader(http.StatusServiceUnavailable) } - w.Header().Set("Content-Type", "application/json") body := `{"status":"healthy","db":` + boolStr(dbOK) + `,"redis":` + boolStr(redisOK) + `}` _, _ = w.Write([]byte(body)) } diff --git a/apps/tracker/internal/udp/parse.go b/apps/tracker/internal/udp/parse.go index ef2189bb..61c2e850 100644 --- a/apps/tracker/internal/udp/parse.go +++ b/apps/tracker/internal/udp/parse.go @@ -72,6 +72,7 @@ var ( errBadMagic = errors.New("invalid protocol_id") errInvalidConnectionID = errors.New("invalid connection_id") errMissingPasskey = errors.New("missing passkey") + errInvalidPort = errors.New("invalid port") errMalformedOptions = errors.New("malformed options") ) @@ -240,6 +241,12 @@ func (r *AnnounceRequestUDP) ToAnnounceRequest() (*announce.Request, error) { if passkey == "" { return nil, errMissingPasskey } + // La même règle que l'analyseur HTTP, qui l'appliquait seul : ce chemin + // recopiait `r.Port` tel quel, donc un port à 0 ou privilégié entrait dans + // l'essaim par UDP. + if !announce.ValidPeerPort(r.Port) { + return nil, errInvalidPort + } out := &announce.Request{ InfoHash: r.InfoHash, PeerID: r.PeerID, diff --git a/apps/tracker/internal/udp/parse_test.go b/apps/tracker/internal/udp/parse_test.go index e7a1e57b..fbf92a40 100644 --- a/apps/tracker/internal/udp/parse_test.go +++ b/apps/tracker/internal/udp/parse_test.go @@ -111,7 +111,7 @@ func TestParseAnnounceURLDataPath(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -134,7 +134,7 @@ func TestParseAnnounceURLDataQuery(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -153,7 +153,7 @@ func TestParseAnnounceMissingPasskeyFails(t *testing.T) { // No URL data trailer at all — same as a client that announces // against `udp://host:6969/announce` (no passkey path/query). var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, nil) + pkt := buildAnnounce(1, 1, ih, pid, 6881, nil) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -174,7 +174,7 @@ func TestParseAnnounceRejectsAnnounceTokenAsPasskey(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -197,7 +197,7 @@ func TestParseAnnounceMultipleURLDataOptions(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -220,7 +220,7 @@ func TestParseAnnounceNopOptionIgnored(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -257,7 +257,7 @@ func TestParseOptions_URLDataCap(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) if _, err := ParseAnnounce(pkt); err == nil { t.Fatal("expected error for oversized URL_DATA chain, got nil") } @@ -282,7 +282,7 @@ func TestParseOptions_URLDataJustUnderCap(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) if _, err := ParseAnnounce(pkt); err != nil { t.Fatalf("expected accept just under cap, got %v", err) } @@ -299,7 +299,7 @@ func TestParseAnnounceNonNegBytesClamped(t *testing.T) { binary.BigEndian.PutUint32(pkt[12:16], 1) // uploaded is read out of bytes [72:80]; set the high bit. binary.BigEndian.PutUint64(pkt[72:80], 0x8000000000000000) - binary.BigEndian.PutUint16(pkt[96:98], 1) + binary.BigEndian.PutUint16(pkt[96:98], 6881) pkt = append(pkt, optURLData, 11) pkt = append(pkt, []byte("/announce/x")...) pkt = append(pkt, optEnd) @@ -423,7 +423,7 @@ func TestMapEvent_AllTokens(t *testing.T) { {udpEventStopped, "stopped"}, } for _, c := range cases { - req := AnnounceRequestUDP{Event: c.in, URLData: []byte("/announce/x")} + req := AnnounceRequestUDP{Event: c.in, Port: 6881, URLData: []byte("/announce/x")} apiReq, err := req.ToAnnounceRequest() if err != nil { t.Fatalf("event=%d: ToAnnounceRequest error: %v", c.in, err) @@ -525,3 +525,44 @@ func TestMapEvent(t *testing.T) { } } } + +// Le port, aux deux transports, avec la même règle. +// +// `ToAnnounceRequest` recopiait `r.Port` sans contrôle, alors que l'analyseur +// HTTP refusait déjà zéro : la règle ne tenait donc que sur la moitié des +// annonces. Un pair à `IP:0` n'est joignable par personne et est pourtant +// distribué à tout l'essaim ; un pair sur un port privilégié fait frapper +// l'essaim entier sur `:22` ou `:25` — partagé, derrière un CGNAT. +func TestToAnnounceRequestRejectsBadPorts(t *testing.T) { + t.Parallel() + passkey := "abcdef0123456789abcdef0123456789" + urlData := []byte("/announce/" + passkey) + opts := append([]byte{optURLData, byte(len(urlData))}, urlData...) + opts = append(opts, optEnd) + + var ih, pid [20]byte + for _, port := range []uint16{0, 22, 25, 80, 443, 1023} { + pkt := buildAnnounce(1, 1, ih, pid, port, opts) + req, err := ParseAnnounce(pkt) + if err != nil { + t.Fatalf("port %d: parse error: %v", port, err) + } + if _, err := req.ToAnnounceRequest(); err == nil { + t.Fatalf("port %d should be refused", port) + } + } + for _, port := range []uint16{1024, 6881, 51413, 65535} { + pkt := buildAnnounce(1, 1, ih, pid, port, opts) + req, err := ParseAnnounce(pkt) + if err != nil { + t.Fatalf("port %d: parse error: %v", port, err) + } + out, err := req.ToAnnounceRequest() + if err != nil { + t.Fatalf("port %d should be accepted: %v", port, err) + } + if out.Port != port { + t.Fatalf("port %d: got %d", port, out.Port) + } + } +} diff --git a/apps/tracker/internal/udp/server.go b/apps/tracker/internal/udp/server.go index f02f1013..d8f799e2 100644 --- a/apps/tracker/internal/udp/server.go +++ b/apps/tracker/internal/udp/server.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "net" + "runtime/debug" "runtime" "strconv" "sync" @@ -68,6 +69,17 @@ type Server struct { // liner every ~10 k drops so an operator notices a sustained // flood even without metrics. droppedFlood atomic.Uint64 + // Les trois compteurs ci-dessous existent pour la même raison que + // `droppedFlood` : une ligne de journal par datagramme, sur une entrée non + // authentifiée ET à source usurpable, est un primitif d'épuisement de + // disque. Mesuré : 123 octets de journal pour 16 octets de paquet, soit une + // amplification de 7,7× vers le disque à débit ligne. `handlePacket` + // s'interdit déjà de journaliser une action inconnue pour cette raison + // exacte ; ces trois chemins-là l'avaient oublié. On compte, et on parle + // toutes les ~10 000. + droppedParse atomic.Uint64 + droppedConnID atomic.Uint64 + droppedPasskey atomic.Uint64 } // New builds a UDP server bound to `addr` and ready to dispatch @@ -155,6 +167,22 @@ func (s *Server) Serve(ctx context.Context) error { case s.workerSem <- struct{}{}: go func(bufp *[]byte, n int, raddr *net.UDPAddr) { defer func() { <-s.workerSem }() + // La boucle de lecture EST le processus : un panic ici n'est pas + // un datagramme perdu, c'est le tracker qui s'arrête. `net/http` + // récupère par connexion et les trois goroutines d'arrière-plan + // de `server/handler.go` ont chacune leur `recover` — c'était le + // seul chemin de requête sans filet, et c'est la porte d'entrée + // la plus exposée du projet. + defer func() { + if r := recover(); r != nil { + s.logger.Error("udp handler panic", + "remote", raddr.String(), + "size", n, + "panic", r, + "stack", string(debug.Stack()), + ) + } + }() s.handlePacket(ctx, bufp, n, raddr) }(bufp, n, raddr) default: @@ -241,18 +269,22 @@ func (s *Server) handleAnnounce(ctx context.Context, data []byte, raddr *net.UDP // — drop silently rather than reflect a forged source. Still // log so the operator can see if a probe / scanner is // hammering the port. - s.logger.Warn("udp announce parse failed", - "remote", raddr.String(), - "size", len(data), - "err", err, - ) + if n := s.droppedParse.Add(1); n%10_000 == 1 { + s.logger.Warn("udp announce parse failures", + "total", n, + "last_size", len(data), + "last_err", err, + ) + } return } if !s.connID.Validate(raddr.IP, req.ConnectionID) { - s.logger.Info("udp connection_id rejected", - "remote", raddr.String(), - "reason", "missing or expired (re-handshake needed)", - ) + if n := s.droppedConnID.Add(1); n%10_000 == 1 { + s.logger.Info("udp connection_id rejections", + "total", n, + "reason", "missing or expired (re-handshake needed)", + ) + } _, _ = s.conn.WriteToUDP( EncodeError(nil, req.TransactionID, "Connection ID expired"), raddr, @@ -270,11 +302,13 @@ func (s *Server) handleAnnounce(ctx context.Context, data []byte, raddr *net.UDP if len(urlData) > 200 { urlData = urlData[:200] + "…" } - s.logger.Info("udp announce missing passkey", - "remote", raddr.String(), - "url_data", urlData, - "hint", "client must use udp://host:port/announce/PASSKEY or ?passkey=PASSKEY", - ) + if n := s.droppedPasskey.Add(1); n%10_000 == 1 { + s.logger.Info("udp announces without a passkey", + "total", n, + "last_url_data", urlData, + "hint", "client must use udp://host:port/announce/PASSKEY or ?passkey=PASSKEY", + ) + } _, _ = s.conn.WriteToUDP( EncodeError(nil, req.TransactionID, "Passkey required"), raddr, @@ -349,10 +383,18 @@ func (s *Server) handleScrape(ctx context.Context, data []byte, raddr *net.UDPAd connID := binary.BigEndian.Uint64(data[0:8]) txID := binary.BigEndian.Uint32(data[12:16]) if !s.connID.Validate(raddr.IP, connID) { - _, _ = s.conn.WriteToUDP( - EncodeError(nil, txID, "Connection ID expired"), - raddr, - ) + // Silence, pas d'erreur renvoyée. + // + // Seize octets entrants pour vingt-neuf sortants vers une source qui + // n'a PAS été vérifiée : c'est un réflecteur de facteur 1,81. Le même + // raisonnement est déjà écrit dans `handlePacket`, qui refuse de + // répondre à une action inconnue « pour ne pas transformer le tracker en + // petit réflecteur » — ce chemin-ci l'avait oublié. BEP 15 prévoit le + // silence : un client qui n'obtient pas de réponse refait son + // handshake `connect`, ce qui est exactement le comportement voulu. + if n := s.droppedConnID.Add(1); n%10_000 == 1 { + s.logger.Info("udp scrape connection_id rejections", "total", n) + } return } From 14df0178f21a9da3b0f4406c8bac93bf97aa8ee8 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:20:46 +0200 Subject: [PATCH 50/96] fix(db): a case-different username was a second account, and the FKs had no index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `users.username` was unique byte-for-byte, so `Admin` and `admin` were two members. On a tracker where the name is the identity in every thread, comment and moderation note, that is an impersonation primitive. A unique index on `lower(username)` closes it — and the migration FAILS on a database that already holds a collision, deliberately, with the query to find them in its header rather than a silent merge. Eighteen foreign keys had no index. Postgres indexes the referenced side, never the referencing one, so every cascade and every join on those columns was a sequential scan. The migration uses `IF NOT EXISTS` and carries a `CONCURRENTLY` recipe for a live instance; the roughly thirty administrative FKs left alone are named with the reason. `panic_state` gained the columns the encryption phase needs, and the three `catalog_records` checks are added `NOT VALID` then validated, so the long scan happens under a weaker lock than `ADD CHECK` would take. Verified from an empty database: seventy migrations apply in order, 204 tables, 578 indexes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- packages/db/src/index.ts | 62 +- .../db/src/migrations/0067_groovy_storm.sql | 25 + .../src/migrations/0068_little_nextwave.sql | 18 + .../src/migrations/0069_high_juggernaut.sql | 49 + .../db/src/migrations/meta/0067_snapshot.json | 10231 +++++++++++++++ .../db/src/migrations/meta/0068_snapshot.json | 10246 +++++++++++++++ .../db/src/migrations/meta/0069_snapshot.json | 10520 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 21 + packages/db/src/schema.ts | 144 +- 9 files changed, 31304 insertions(+), 12 deletions(-) create mode 100644 packages/db/src/migrations/0067_groovy_storm.sql create mode 100644 packages/db/src/migrations/0068_little_nextwave.sql create mode 100644 packages/db/src/migrations/0069_high_juggernaut.sql create mode 100644 packages/db/src/migrations/meta/0067_snapshot.json create mode 100644 packages/db/src/migrations/meta/0068_snapshot.json create mode 100644 packages/db/src/migrations/meta/0069_snapshot.json diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 6eff48f5..570747ff 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,5 +1,5 @@ import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres, { type Options } from 'postgres'; +import postgres, { type Options, type PostgresType } from 'postgres'; import * as schema from './schema'; /** @@ -13,21 +13,75 @@ const connectionString = const isProduction = process.env.NODE_ENV === 'production'; // Build secure connection options -function buildPostgresOptions(): Options<Record<string, never>> { - const options: Options<Record<string, never>> = { +/** + * Les types personnalisés que la connexion déclare. + * + * Le générique était `Record<string, never>` — « aucun type personnalisé » —, ce + * qui rendait le champ `types` intypable. Il en porte un désormais : l'analyseur + * d'OID 1114, voir plus bas. + */ +type CustomTypes = Record<'naiveTimestamp', PostgresType<Date>>; + +function buildPostgresOptions(): Options<CustomTypes> { + const options: Options<CustomTypes> = { // Connection pool settings max: parseInt(process.env.DB_POOL_MAX || '10'), idle_timeout: 10, connect_timeout: 10, // Query settings - prepare: true, // Use prepared statements (SQL injection protection) + // Énoncés préparés. À NE PAS confondre avec la protection contre + // l'injection SQL, qui vient de la LIAISON DE PARAMÈTRES et opère aussi + // avec `prepare: false` — le commentaire d'origine l'affirmait, ce qui + // décourageait précisément le changement qui rendrait sans objet la + // question du pooling en mode transaction (voir + // `doc/guide/high-availability.md`, point 4). + prepare: true, // Transform settings for security transform: { undefined: null, // Prevent undefined values in queries }, + /* + * `timestamp without time zone` relu en UTC, et non dans le fuseau du + * processus. + * + * postgres.js 3.4 analyse les OID 1082 (date), 1114 (timestamp) et 1184 + * (timestamptz) avec le MÊME `new Date(x)`. À l'écriture il envoie un + * `toISOString()`, et Postgres jette l'offset d'une colonne `timestamp` : + * la valeur stockée est donc l'heure murale UTC. À la lecture, Postgres + * renvoie `2026-07-14 16:30:00` sans offset, et V8 interprète cette forme + * dans le fuseau LOCAL du processus. + * + * Le compose de production pose `TZ=Europe/Paris` sur le service d'API. + * Mesuré dans un Postgres 18.6 jetable : un instant écrit à 16:30 UTC se + * relit à 14:30 UTC. Deux heures d'erreur sur les 457 colonnes + * `timestamp` du schéma — c'est-à-dire presque toutes. + * + * L'asymétrie est ce qui rend le défaut sournois : une comparaison faite en + * SQL (`gt(expiresAt, new Date())`) est juste, parce que drizzle sérialise + * en UTC et que Postgres jette l'offset des deux côtés. Seules les + * comparaisons faites en JavaScript dérivent — `banned_until`, + * `trusted_devices.expires_at`, `invitations.expires_at`, les échéances de + * hit-and-run, les fenêtres de freeleech — et tout affichage de date. + * + * Un analyseur pour le seul OID 1114 corrige les 457 colonnes d'un coup, + * sans migration. La bonne fin de l'histoire reste de passer le schéma en + * `timestamptz` ; en attendant, ceci est exact et vérifiable. + */ + types: { + naiveTimestamp: { + to: 1114, + from: [1114], + // Défensif sur le type d'entrée : drizzle envoie un `Date` pour une + // colonne en mode date, une chaîne déjà formée en mode string. + serialize: (x: Date | string) => + x instanceof Date ? x.toISOString() : String(x), + parse: (x: string) => new Date(`${x}Z`), + } as PostgresType<Date>, + }, + // Connection lifecycle hooks onnotice: () => {}, // Suppress notice messages }; diff --git a/packages/db/src/migrations/0067_groovy_storm.sql b/packages/db/src/migrations/0067_groovy_storm.sql new file mode 100644 index 00000000..c7d96c9a --- /dev/null +++ b/packages/db/src/migrations/0067_groovy_storm.sql @@ -0,0 +1,25 @@ +-- Généré par drizzle-kit, avec deux ajouts à la main que drizzle ne sait pas +-- exprimer et qui sont expliqués ici plutôt qu'en commentaire de schéma : +-- +-- 1. `NOT VALID` sur les trois contraintes de `catalog_records`. Les migrations +-- tournent au DÉMARRAGE de l'API : un `ADD CONSTRAINT` validé prendrait un +-- ACCESS EXCLUSIVE plus un scan complet de la table avant que la première +-- annonce ne soit servie. `NOT VALID` contraint immédiatement les écritures +-- nouvelles — ce qui est l'objet — et laisse la validation du passé à une +-- fenêtre de maintenance (`VALIDATE CONSTRAINT`, qui ne prend qu'un +-- SHARE UPDATE EXCLUSIVE). +-- 2. Le remplissage de `phase` pour une base déjà chiffrée par une version +-- antérieure : elle a terminé son chiffrement, elle est simplement muette +-- sur la phase, et la laisser à `idle` ferait croire à un chiffrement à +-- reprendre. + +DROP INDEX "invitations_code_idx";--> statement-breakpoint +DROP INDEX "message_reactions_message_idx";--> statement-breakpoint +ALTER TABLE "panic_state" ADD COLUMN "panic_password_hash" text;--> statement-breakpoint +ALTER TABLE "panic_state" ADD COLUMN "phase" text DEFAULT 'idle' NOT NULL;--> statement-breakpoint +ALTER TABLE "catalog_records" ADD CONSTRAINT "catalog_records_origin_ck" CHECK ("catalog_records"."origin" IN ('local', 'ingested')) NOT VALID;--> statement-breakpoint +ALTER TABLE "catalog_records" ADD CONSTRAINT "catalog_records_kind_ck" CHECK ("catalog_records"."kind" IN ('torrent', 'tombstone', 'identity', 'revocation')) NOT VALID;--> statement-breakpoint +ALTER TABLE "catalog_records" ADD CONSTRAINT "catalog_records_hops_ck" CHECK ("catalog_records"."hops" BETWEEN 0 AND 2) NOT VALID;--> statement-breakpoint +ALTER TABLE "panic_state" ADD CONSTRAINT "panic_state_phase_ck" CHECK ("panic_state"."phase" IN ('idle', 'encrypting', 'encrypted', 'restoring')); +--> statement-breakpoint +UPDATE "panic_state" SET "phase" = 'encrypted' WHERE "is_encrypted" = true; diff --git a/packages/db/src/migrations/0068_little_nextwave.sql b/packages/db/src/migrations/0068_little_nextwave.sql new file mode 100644 index 00000000..45534b56 --- /dev/null +++ b/packages/db/src/migrations/0068_little_nextwave.sql @@ -0,0 +1,18 @@ +-- L'unicité du pseudonyme, insensible à la casse. +-- +-- `users.username` portait un `UNIQUE` sur la valeur exacte : `Admin` pouvait +-- donc être créé alors qu'`admin` existait. Le jeu de caractères autorisé est +-- `[a-zA-Z0-9_-]`, donc pas d'homographes Unicode — mais la collision par casse +-- suffit à usurper un pseudonyme de personnel dans les commentaires, le forum, +-- les messages privés et le journal de modération, et `auth/challenge` étant +-- lui aussi sensible à la casse, les deux comptes se connectent normalement. +-- +-- ATTENTION À L'INSTALLATION : si une base existante contient déjà deux +-- pseudonymes qui ne diffèrent que par la casse, cette migration ÉCHOUE — et +-- c'est le bon comportement, parce que la fusion n'est pas une décision qu'une +-- migration peut prendre. Pour les repérer avant : +-- +-- SELECT lower(username), count(*), array_agg(username) +-- FROM users GROUP BY 1 HAVING count(*) > 1; + +CREATE UNIQUE INDEX "users_username_lower_unique" ON "users" USING btree (lower("username")); \ No newline at end of file diff --git a/packages/db/src/migrations/0069_high_juggernaut.sql b/packages/db/src/migrations/0069_high_juggernaut.sql new file mode 100644 index 00000000..9a5465ae --- /dev/null +++ b/packages/db/src/migrations/0069_high_juggernaut.sql @@ -0,0 +1,49 @@ +-- Dix-huit clés étrangères sans index en tête. +-- +-- Deux coûts distincts, et le premier est celui qu'on ne voit pas venir : un +-- `DELETE` ou un `UPDATE` sur la table PARENTE force Postgres à balayer la +-- table enfant en entier pour valider la contrainte. Supprimer un torrent +-- scannait donc `torrent_comments`, `torrent_favorites`, `anticheat_flags` et +-- `upload_request_fill_attempts` de bout en bout. Le second est direct : « ses +-- commentaires » sur un profil, « les messages de ce sujet » au forum, et les +-- effacements RGPD ajoutés sur `message_reactions` / +-- `room_message_reactions` sont exactement des jointures sur ces colonnes. +-- +-- Les ~30 autres colonnes de clé étrangère non indexées sont administratives +-- (`created_by`, `updated_by`, `reviewed_by_id`, `deleted_by_id`, +-- `moderated_by_id`…) : leur seul intérêt serait la validation de contrainte, +-- et comme la ligne `users` survit à l'effacement de compte, cette validation +-- ne se déclenche presque jamais. Arbitrage assumé, écrit ici pour qu'il ne +-- passe pas pour un oubli. +-- +-- SUR UNE BASE ÉTABLIE : `CREATE INDEX` prend un verrou SHARE, qui bloque les +-- écritures de la table le temps de la construction — et les migrations +-- tournent au démarrage de l'API. Sur une instance avec des millions de +-- commentaires ou de messages, poser ces index HORS BANDE avant de déployer, +-- puis laisser la migration les trouver déjà là : +-- +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS forum_posts_topic_idx +-- ON forum_posts USING btree (topic_id); +-- -- … et ainsi de suite pour chacun ci-dessous. +-- +-- `CONCURRENTLY` ne peut pas figurer ici : le migrateur drizzle enveloppe le +-- fichier dans une transaction, et Postgres l'interdit dans ce contexte. + +CREATE INDEX IF NOT EXISTS "anticheat_flags_torrent_idx" ON "anticheat_flags" USING btree ("torrent_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "forum_posts_topic_idx" ON "forum_posts" USING btree ("topic_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "forum_posts_author_idx" ON "forum_posts" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "forum_topics_category_idx" ON "forum_topics" USING btree ("category_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "forum_topics_author_idx" ON "forum_topics" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "freeleech_pool_contributions_user_idx" ON "freeleech_pool_contributions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "message_reactions_user_idx" ON "message_reactions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_reply_to_idx" ON "messages" USING btree ("reply_to_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_author_idx" ON "messages" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "room_message_reactions_user_idx" ON "room_message_reactions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "saved_searches_category_idx" ON "saved_searches" USING btree ("category_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ticket_messages_author_idx" ON "ticket_messages" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "torrent_comments_torrent_idx" ON "torrent_comments" USING btree ("torrent_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "torrent_comments_author_idx" ON "torrent_comments" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "torrent_favorites_torrent_idx" ON "torrent_favorites" USING btree ("torrent_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "upload_request_fill_attempts_torrent_idx" ON "upload_request_fill_attempts" USING btree ("torrent_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "upload_request_fill_attempts_user_idx" ON "upload_request_fill_attempts" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "upload_requests_category_idx" ON "upload_requests" USING btree ("category_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0067_snapshot.json b/packages/db/src/migrations/meta/0067_snapshot.json new file mode 100644 index 00000000..8aca51fc --- /dev/null +++ b/packages/db/src/migrations/meta/0067_snapshot.json @@ -0,0 +1,10231 @@ +{ + "id": "9c499e44-3f36-4355-8555-2fdb70615141", + "prevId": "46b1d2df-53a7-4178-b8d4-08eb3ac273b0", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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": { + "catalog_records_origin_ck": { + "name": "catalog_records_origin_ck", + "value": "\"catalog_records\".\"origin\" IN ('local', 'ingested')" + }, + "catalog_records_kind_ck": { + "name": "catalog_records_kind_ck", + "value": "\"catalog_records\".\"kind\" IN ('torrent', 'tombstone', 'identity', 'revocation')" + }, + "catalog_records_hops_ck": { + "name": "catalog_records_hops_ck", + "value": "\"catalog_records\".\"hops\" BETWEEN 0 AND 2" + } + }, + "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_downloaded_at_idx": { + "name": "hnr_downloaded_at_idx", + "columns": [ + { + "expression": "downloaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hnr_completed_at_idx": { + "name": "hnr_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_used_by_idx": { + "name": "invitations_used_by_idx", + "columns": [ + { + "expression": "used_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invitations\".\"used_by\" IS NOT NULL", + "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.login_events": { + "name": "login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_hash": { + "name": "ip_hash", + "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()" + } + }, + "indexes": { + "login_events_user_idx": { + "name": "login_events_user_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": {} + }, + "login_events_created_idx": { + "name": "login_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_events_user_id_users_id_fk": { + "name": "login_events_user_id_users_id_fk", + "tableFrom": "login_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": {}, + "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 + }, + "panic_password_hash": { + "name": "panic_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "panic_state_phase_ck": { + "name": "panic_state_phase_ck", + "value": "\"panic_state\".\"phase\" IN ('idle', 'encrypting', 'encrypted', 'restoring')" + } + }, + "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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_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": { + "torrent_stats_completed_idx": { + "name": "torrent_stats_completed_idx", + "columns": [ + { + "expression": "\"completed\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrent_stats_seeders_idx": { + "name": "torrent_stats_seeders_idx", + "columns": [ + { + "expression": "\"seeders\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_created_at_idx": { + "name": "torrents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrents_sticky_idx": { + "name": "torrents_sticky_idx", + "columns": [ + { + "expression": "is_sticky", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"is_sticky\"", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": { + "torrents_multipliers_sane": { + "name": "torrents_multipliers_sane", + "value": "\"torrents\".\"download_multiplier\" between 0 and 200 and \"torrents\".\"upload_multiplier\" between 0 and 1000" + } + }, + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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/0068_snapshot.json b/packages/db/src/migrations/meta/0068_snapshot.json new file mode 100644 index 00000000..8b8fd019 --- /dev/null +++ b/packages/db/src/migrations/meta/0068_snapshot.json @@ -0,0 +1,10246 @@ +{ + "id": "71dd2fa7-2e90-4c70-899b-15534de42897", + "prevId": "9c499e44-3f36-4355-8555-2fdb70615141", + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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": { + "catalog_records_origin_ck": { + "name": "catalog_records_origin_ck", + "value": "\"catalog_records\".\"origin\" IN ('local', 'ingested')" + }, + "catalog_records_kind_ck": { + "name": "catalog_records_kind_ck", + "value": "\"catalog_records\".\"kind\" IN ('torrent', 'tombstone', 'identity', 'revocation')" + }, + "catalog_records_hops_ck": { + "name": "catalog_records_hops_ck", + "value": "\"catalog_records\".\"hops\" BETWEEN 0 AND 2" + } + }, + "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_downloaded_at_idx": { + "name": "hnr_downloaded_at_idx", + "columns": [ + { + "expression": "downloaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hnr_completed_at_idx": { + "name": "hnr_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_used_by_idx": { + "name": "invitations_used_by_idx", + "columns": [ + { + "expression": "used_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invitations\".\"used_by\" IS NOT NULL", + "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.login_events": { + "name": "login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_hash": { + "name": "ip_hash", + "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()" + } + }, + "indexes": { + "login_events_user_idx": { + "name": "login_events_user_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": {} + }, + "login_events_created_idx": { + "name": "login_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_events_user_id_users_id_fk": { + "name": "login_events_user_id_users_id_fk", + "tableFrom": "login_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": {}, + "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 + }, + "panic_password_hash": { + "name": "panic_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "panic_state_phase_ck": { + "name": "panic_state_phase_ck", + "value": "\"panic_state\".\"phase\" IN ('idle', 'encrypting', 'encrypted', 'restoring')" + } + }, + "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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_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": { + "torrent_stats_completed_idx": { + "name": "torrent_stats_completed_idx", + "columns": [ + { + "expression": "\"completed\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrent_stats_seeders_idx": { + "name": "torrent_stats_seeders_idx", + "columns": [ + { + "expression": "\"seeders\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_created_at_idx": { + "name": "torrents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrents_sticky_idx": { + "name": "torrents_sticky_idx", + "columns": [ + { + "expression": "is_sticky", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"is_sticky\"", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": { + "torrents_multipliers_sane": { + "name": "torrents_multipliers_sane", + "value": "\"torrents\".\"download_multiplier\" between 0 and 200 and \"torrents\".\"upload_multiplier\" between 0 and 1000" + } + }, + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "users_username_lower_unique": { + "name": "users_username_lower_unique", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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/0069_snapshot.json b/packages/db/src/migrations/meta/0069_snapshot.json new file mode 100644 index 00000000..b1eb168c --- /dev/null +++ b/packages/db/src/migrations/meta/0069_snapshot.json @@ -0,0 +1,10520 @@ +{ + "id": "911abcef-457a-49ba-8475-0c04279e9eb6", + "prevId": "71dd2fa7-2e90-4c70-899b-15534de42897", + "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_torrent_idx": { + "name": "anticheat_flags_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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": { + "catalog_records_origin_ck": { + "name": "catalog_records_origin_ck", + "value": "\"catalog_records\".\"origin\" IN ('local', 'ingested')" + }, + "catalog_records_kind_ck": { + "name": "catalog_records_kind_ck", + "value": "\"catalog_records\".\"kind\" IN ('torrent', 'tombstone', 'identity', 'revocation')" + }, + "catalog_records_hops_ck": { + "name": "catalog_records_hops_ck", + "value": "\"catalog_records\".\"hops\" BETWEEN 0 AND 2" + } + }, + "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": { + "forum_posts_topic_idx": { + "name": "forum_posts_topic_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forum_posts_author_idx": { + "name": "forum_posts_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "forum_topics_category_idx": { + "name": "forum_topics_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forum_topics_author_idx": { + "name": "forum_topics_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_user_idx": { + "name": "freeleech_pool_contributions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_downloaded_at_idx": { + "name": "hnr_downloaded_at_idx", + "columns": [ + { + "expression": "downloaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hnr_completed_at_idx": { + "name": "hnr_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_used_by_idx": { + "name": "invitations_used_by_idx", + "columns": [ + { + "expression": "used_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invitations\".\"used_by\" IS NOT NULL", + "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.login_events": { + "name": "login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_hash": { + "name": "ip_hash", + "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()" + } + }, + "indexes": { + "login_events_user_idx": { + "name": "login_events_user_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": {} + }, + "login_events_created_idx": { + "name": "login_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_events_user_id_users_id_fk": { + "name": "login_events_user_id_users_id_fk", + "tableFrom": "login_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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_user_idx": { + "name": "message_reactions_user_idx", + "columns": [ + { + "expression": "user_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_reply_to_idx": { + "name": "messages_reply_to_idx", + "columns": [ + { + "expression": "reply_to_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_author_idx": { + "name": "messages_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + }, + "panic_password_hash": { + "name": "panic_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "panic_state_phase_ck": { + "name": "panic_state_phase_ck", + "value": "\"panic_state\".\"phase\" IN ('idle', 'encrypting', 'encrypted', 'restoring')" + } + }, + "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_user_idx": { + "name": "room_message_reactions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_category_idx": { + "name": "saved_searches_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_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_author_idx": { + "name": "ticket_messages_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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": { + "torrent_comments_torrent_idx": { + "name": "torrent_comments_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrent_comments_author_idx": { + "name": "torrent_comments_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_torrent_idx": { + "name": "torrent_favorites_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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": { + "torrent_stats_completed_idx": { + "name": "torrent_stats_completed_idx", + "columns": [ + { + "expression": "\"completed\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrent_stats_seeders_idx": { + "name": "torrent_stats_seeders_idx", + "columns": [ + { + "expression": "\"seeders\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_created_at_idx": { + "name": "torrents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrents_sticky_idx": { + "name": "torrents_sticky_idx", + "columns": [ + { + "expression": "is_sticky", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"is_sticky\"", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": { + "torrents_multipliers_sane": { + "name": "torrents_multipliers_sane", + "value": "\"torrents\".\"download_multiplier\" between 0 and 200 and \"torrents\".\"upload_multiplier\" between 0 and 1000" + } + }, + "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_torrent_idx": { + "name": "upload_request_fill_attempts_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_request_fill_attempts_user_idx": { + "name": "upload_request_fill_attempts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_category_idx": { + "name": "upload_requests_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "users_username_lower_unique": { + "name": "users_username_lower_unique", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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 9cda9037..8813b4ff 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -470,6 +470,27 @@ "when": 1788274237632, "tag": "0066_review_fixes", "breakpoints": true + }, + { + "idx": 67, + "version": "7", + "when": 1788342161105, + "tag": "0067_groovy_storm", + "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1788344198391, + "tag": "0068_little_nextwave", + "breakpoints": true + }, + { + "idx": 69, + "version": "7", + "when": 1788344630212, + "tag": "0069_high_juggernaut", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 26413227..279ce95f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -268,6 +268,21 @@ export const users = pgTable( uniqueIndex('users_owner_unique') .on(table.isOwner) .where(sql`${table.isOwner}`), + /** + * L'unicité du pseudonyme, insensible à la casse. + * + * `.unique()` sur la colonne porte sur la valeur exacte, donc `Admin` + * pouvait coexister avec `admin`. Le jeu de caractères autorisé étant + * `[a-zA-Z0-9_-]`, il n'y a pas d'homographes Unicode — mais la collision + * par casse suffit à usurper un pseudonyme de personnel dans les + * commentaires, le forum, les messages privés et le journal de modération, + * et `auth/challenge` étant également sensible à la casse, les deux + * comptes se connectent normalement. + * + * La connexion reste sensible à la casse : c'est l'unicité qui devait + * l'être, pas la comparaison d'identifiant. + */ + uniqueIndex('users_username_lower_unique').on(sql`lower(${table.username})`), ], ); @@ -859,6 +874,8 @@ export const freeleechPoolContributions = pgTable( createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ + /** export de compte et calcul de cycle. */ + index('freeleech_pool_contributions_user_idx').on(table.userId), index('freeleech_pool_contributions_cycle_idx').on( table.cycleId, table.createdAt @@ -1416,6 +1433,31 @@ export const catalogRecords = pgTable( createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ + /* + * Les trois invariants de la fédération, déclarés en base et non seulement + * en TypeScript. + * + * Le commentaire d'`origin` dit lui-même l'enjeu — « Every sweep that + * decides what to WITHDRAW has to say `local`, or it will happily mint + * tombstones for other people's catalogues » — et celui de `hops` dit + * « Enforced here rather than by asking a partner ». Ce n'était pas + * appliqué *ici* mais dans `admit()`, avec un défaut `'local'` sur la + * colonne : une écriture qui met `'Local'` ou omet le champ sur une ligne + * ingérée rendait les enregistrements d'un partenaire éligibles à la + * moisson de tombstones locale — l'instance publiant « cette release a + * disparu » sur le catalogue de quelqu'un d'autre, signé de sa clé. + * + * Posées `NOT VALID` par la migration 0068 : les migrations tournent au + * démarrage de l'API, et une validation prendrait un ACCESS EXCLUSIVE plus + * un scan complet avant la première annonce servie. Elles contraignent + * donc les écritures nouvelles, ce qui est l'objet. + */ + check('catalog_records_origin_ck', sql`${table.origin} IN ('local', 'ingested')`), + check( + 'catalog_records_kind_ck', + sql`${table.kind} IN ('torrent', 'tombstone', 'identity', 'revocation')`, + ), + check('catalog_records_hops_ck', sql`${table.hops} BETWEEN 0 AND 2`), index('catalog_records_info_hash_idx').on(table.infoHash), /** Newest first, for the live-search answer. */ index('catalog_records_created_idx').on(table.createdAt), @@ -1524,7 +1566,20 @@ export const forumTopics = pgTable('forum_topics', { isLocked: boolean('is_locked').default(false).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), -}); +}, +(table) => [ + /* + * Les clés étrangères de cette table portaient zéro index en tête. + * + * Deux coûts distincts. Un `DELETE`/`UPDATE` sur la table PARENTE force un + * balayage séquentiel de celle-ci pour valider la contrainte ; et les + * requêtes ci-dessous sont exactement des jointures sur ces colonnes. + */ + /** la liste d'une section. */ + index('forum_topics_category_idx').on(table.categoryId), + /** « ses sujets ». */ + index('forum_topics_author_idx').on(table.authorId), +]); export const forumPosts = pgTable('forum_posts', { id: text('id').primaryKey(), @@ -1537,7 +1592,20 @@ export const forumPosts = pgTable('forum_posts', { content: text('content').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), -}); +}, +(table) => [ + /* + * Les clés étrangères de cette table portaient zéro index en tête. + * + * Deux coûts distincts. Un `DELETE`/`UPDATE` sur la table PARENTE force un + * balayage séquentiel de celle-ci pour valider la contrainte ; et les + * requêtes ci-dessous sont exactement des jointures sur ces colonnes. + */ + /** LA requête du forum : les messages d'un sujet. */ + index('forum_posts_topic_idx').on(table.topicId), + /** « ses messages » sur un profil. */ + index('forum_posts_author_idx').on(table.authorId), +]); // ============================================================================ // Torrent Comments @@ -1553,7 +1621,20 @@ export const torrentComments = pgTable('torrent_comments', { content: text('content').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), -}); +}, +(table) => [ + /* + * Les clés étrangères de cette table portaient zéro index en tête. + * + * Deux coûts distincts. Un `DELETE`/`UPDATE` sur la table PARENTE force un + * balayage séquentiel de celle-ci pour valider la contrainte ; et les + * requêtes ci-dessous sont exactement des jointures sur ces colonnes. + */ + /** les commentaires d'une release — la requête de la page d'un torrent. */ + index('torrent_comments_torrent_idx').on(table.torrentId), + /** « ses commentaires » sur un profil, et la validation de FK. */ + index('torrent_comments_author_idx').on(table.authorId), +]); // ============================================================================ // Relations @@ -1742,9 +1823,36 @@ export const panicState = pgTable('panic_state', { // encrypt time + a random salt. A dump then yields only the // scrypt verifier + salt + ciphertext, forcing an offline // password brute-force instead of instant decryption. - // New panics always write 2; restore branches on this value. + // 3 = comme 2, mais avec un coût scrypt de N = 2^17 au lieu du + // défaut de Node (2^14). C'est la seule clé qui protège une + // base volée, et le mot de passe est choisi par un humain. + // New panics always write 3; restore branches on this value. kdfVersion: integer('kdf_version').default(1).notNull(), -}); + /** + * Le hachis du mot de passe de panique effectivement employé au chiffrement. + * + * Les deux routes résolvaient le détenteur par « le plus vieil administrateur, + * maintenant » et ne gardaient aucune trace de qui c'était. Une rétrogradation + * ou un effacement de compte entre le chiffrement et la restauration rendait + * donc la base définitivement illisible, avec le bon mot de passe en main. + */ + panicPasswordHash: text('panic_password_hash'), + /** + * L'étape en cours : `idle`, `encrypting`, `encrypted`, `restoring`. + * + * Le sel est écrit AVANT les boucles de chiffrement. Sans cette phase, une + * base à moitié chiffrée portait `is_encrypted = false` et était donc + * indistinguable d'une base en clair : la restauration refusait de démarrer, + * et une relance du chiffrement générait un nouveau sel puis surchiffrait ce + * qui l'était déjà. + */ + phase: text('phase').default('idle').notNull(), +}, (table) => [ + check( + 'panic_state_phase_ck', + sql`${table.phase} IN ('idle', 'encrypting', 'encrypted', 'restoring')`, + ), +]); // ============================================================================ // Notifications (persistent in-app inbox) @@ -2099,7 +2207,6 @@ export const invitations = pgTable( index('invitations_used_by_idx') .on(table.usedBy) .where(sql`${table.usedBy} IS NOT NULL`), - index('invitations_code_idx').on(table.code), ] ); @@ -2343,6 +2450,8 @@ export const uploadRequests = pgTable( updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (table) => [ + /** les demandes d'une catégorie. */ + index('upload_requests_category_idx').on(table.categoryId), // Listing index: open requests, newest first. index('upload_requests_status_idx').on(table.status, table.createdAt), // Dedup: "is this federated release already requested here?" @@ -2382,6 +2491,10 @@ export const uploadRequestFillAttempts = pgTable( rejectedAt: timestamp('rejected_at'), }, (table) => [ + /** validation de FK à la suppression d'un torrent. */ + index('upload_request_fill_attempts_torrent_idx').on(table.torrentId), + /** export de compte. */ + index('upload_request_fill_attempts_user_idx').on(table.userId), // Counter query: "how many rejected fills has user X already piled // up on request Y" runs on every fill attempt. index('upload_request_fill_attempts_request_user_idx').on( @@ -2501,6 +2614,8 @@ export const torrentFavorites = pgTable( createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ + /** qui a mis cette release en favori. */ + index('torrent_favorites_torrent_idx').on(table.torrentId), primaryKey({ columns: [table.userId, table.torrentId] }), // Drives the /me/favorites listing (newest first). Composite // PK already covers "is X favorited by user Y" lookups. @@ -2572,6 +2687,8 @@ export const anticheatFlags = pgTable( reviewNote: text('review_note'), }, (table) => [ + /** les signalements d'une release. */ + index('anticheat_flags_torrent_idx').on(table.torrentId), index('anticheat_flags_user_idx').on(table.userId, table.createdAt), index('anticheat_flags_unreviewed_idx').on(table.reviewedAt), index('anticheat_flags_kind_idx').on(table.kind), @@ -2767,6 +2884,8 @@ export const savedSearches = pgTable( matchCount: integer('match_count').default(0).notNull(), }, (table) => [ + /** les alertes d'une catégorie. */ + index('saved_searches_category_idx').on(table.categoryId), index('saved_searches_user_idx').on(table.userId, table.createdAt.desc()), // The evaluation sweep reads every armed filter and nothing else, so the // index it wants is partial on exactly that. @@ -4045,6 +4164,10 @@ export const messages = pgTable( }), }, (table) => [ + /** le fil de réponses. */ + index('messages_reply_to_idx').on(table.replyToId), + /** les messages d'un membre. */ + index('messages_author_idx').on(table.authorId), index('messages_conversation_idx').on( table.conversationId, table.createdAt @@ -4198,9 +4321,10 @@ export const messageReactions = pgTable( createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ + /** effacement de compte (RGPD) et « mes réactions ». */ + index('message_reactions_user_idx').on(table.userId), primaryKey({ columns: [table.messageId, table.userId, table.key] }), // Rendering a page means fetching the reactions for the ids on it. - index('message_reactions_message_idx').on(table.messageId), check('message_reactions_key_ck', reactionKeyCheck(table.key)), ] ); @@ -4229,6 +4353,8 @@ export const roomMessageReactions = pgTable( createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ + /** idem, côté salon. */ + index('room_message_reactions_user_idx').on(table.userId), primaryKey({ columns: [ table.messageId, @@ -4497,7 +4623,9 @@ export const ticketMessages = pgTable( body: text('body').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }, - (table) => [index('ticket_messages_ticket_idx').on(table.ticketId, table.createdAt)] + (table) => [ + /** les messages d'un membre dans les tickets. */ + index('ticket_messages_author_idx').on(table.authorId),index('ticket_messages_ticket_idx').on(table.ticketId, table.createdAt)] ); export type TicketMessage = typeof ticketMessages.$inferSelect; From dca011f22501d27a22d5fe5f9f28196da338444a Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:20:47 +0200 Subject: [PATCH 51/96] fix(ci): the documentation was built and deployed on a Node that is end of life MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs-ci` and `deploy-docs` both pinned Node 20, gone end of life in April 2026, while `doc/Dockerfile` and every other workflow are on 24. The same documentation was therefore built on two runtimes, and the one holding `pages: write` was the one that no longer receives a patch. Verified before moving it: `npm ci` then `npm run build` pass on 24, and vitepress 1.6.4 asks for >= 20. The six image builds publish an SBOM and full provenance. The Helm chart's README warns about `lookup` under ArgoCD, Flux and `helm template | kubectl apply`: it returns nothing there, so every sync regenerates and applies fresh secrets — signing every member out, breaking IP-hash continuity, and rotating a key the operator was told to keep stable. The fix is `secrets.existingSecret` or pinned values, not an annotation: the chart omits `resource-policy: keep` on purpose and says why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .github/workflows/deploy-docs.yml | 8 ++- .github/workflows/docker.yml | 66 +++++++++++++++++++ .github/workflows/docs-ci.yml | 8 ++- deploy/helm/trackarr/README.md | 30 +++++++++ deploy/helm/trackarr/templates/_helpers.tpl | 7 ++ deploy/helm/trackarr/templates/api.yaml | 7 +- .../trackarr/templates/networkpolicy.yaml | 49 ++++++++++++++ deploy/helm/trackarr/templates/tracker.yaml | 7 +- deploy/helm/trackarr/templates/web.yaml | 7 +- deploy/helm/trackarr/values.yaml | 31 +++++++++ 10 files changed, 215 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 035e0d87..dcde7725 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -47,7 +47,13 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + # Node 24, comme `doc/Dockerfile` et comme les autres workflows. + # Ce travail était resté sur 20, en fin de vie depuis avril 2026 : + # la même documentation était donc construite sur deux runtimes, + # et le déploiement — qui a `pages: write` — tournait sur celui + # qui ne reçoit plus de correctif. Vérifié : `npm ci` puis + # `npm run build` passent en 24 (vitepress 1.6.4 demande >= 20). + node-version: 24 cache: 'npm' cache-dependency-path: './doc/package-lock.json' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3dd7a9f4..a037d39a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -110,6 +110,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the api image env: @@ -165,6 +176,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the tracker image env: @@ -226,6 +248,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the relay image env: @@ -281,6 +314,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the front-ssr image env: @@ -335,6 +379,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the front image env: @@ -397,6 +452,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max # Default base path is `/`, matching the standalone # docker-run usage. The GitHub Pages deploy # (deploy-docs.yml) keeps its own `/opentracker/` base diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index d4bf34a7..02d215df 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -45,7 +45,13 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + # Node 24, comme `doc/Dockerfile` et comme les autres workflows. + # Ce travail était resté sur 20, en fin de vie depuis avril 2026 : + # la même documentation était donc construite sur deux runtimes, + # et le déploiement — qui a `pages: write` — tournait sur celui + # qui ne reçoit plus de correctif. Vérifié : `npm ci` puis + # `npm run build` passent en 24 (vitepress 1.6.4 demande >= 20). + node-version: 24 cache: 'npm' cache-dependency-path: './doc/package-lock.json' diff --git a/deploy/helm/trackarr/README.md b/deploy/helm/trackarr/README.md index 238e39dd..07039293 100644 --- a/deploy/helm/trackarr/README.md +++ b/deploy/helm/trackarr/README.md @@ -114,6 +114,26 @@ reading its own Secret back before generating anything: `helm template` and `--dry-run` cannot read the cluster, so they show freshly generated values. Nothing is applied, so nothing rotates. +> **⚠️ Sous ArgoCD, Flux en mode `helm template`, ou tout +> `helm template | kubectl apply`, la phrase ci-dessus ne tient pas.** +> +> La stabilité repose sur `lookup`, qui lit l'état du cluster. ArgoCD rend le +> chart avec `helm template` **puis applique la sortie** : `lookup` y est +> toujours vide, donc chaque synchronisation génère de nouvelles valeurs et les +> applique. Un `git push` déconnecte alors tout le monde +> (`NUXT_SESSION_SECRET`), casse la continuité des empreintes d'IP +> (`IP_HASH_SECRET`) et invalide `ADMIN_API_KEY` — les trois conséquences que +> le tableau ci-dessus décrit comme inacceptables. +> +> Sur ces pipelines, il faut **épingler les secrets** plutôt que les laisser +> générer : `secrets.existingSecret` en désignant un `Secret` géré hors du +> chart, ou les trois valeurs fixées dans les `values`. +> +> Le `Secret` rendu ne porte volontairement pas `helm.sh/resource-policy: keep` +> — `templates/secret.yaml` explique pourquoi : le garder à la désinstallation +> laisserait un `Secret` que plus rien ne possède. L'épinglage est donc la seule +> réponse ici, pas une annotation. + ### Object-store credentials are generated too With `rustfs.enabled: true` there is nothing to supply. The chart mints an @@ -358,6 +378,16 @@ Leave `externalTrafficPolicy: Local`. A UDP announce carries no `Cluster`, kube-proxy rewrites it to a node address and every peer in the swarm is handed the wrong IP. +The same applies to `/announce` over **HTTP**, on the ingress controller's own +Service — which this chart does not own. ingress-nginx sets `X-Forwarded-For` +from `$remote_addr`, and with the controller Service's default +`externalTrafficPolicy: Cluster` kube-proxy has already replaced that with a +node address. Every peer then registers under one of a handful of node IPs: +swarms fail to connect, IP bans ban the cluster, and rate limiting shares a +bucket across all members. Set `controller.service.externalTrafficPolicy: Local` +in the ingress-nginx values, or `use-proxy-protocol` behind an L4 load +balancer. + ## Probes Readiness and liveness deliberately differ. diff --git a/deploy/helm/trackarr/templates/_helpers.tpl b/deploy/helm/trackarr/templates/_helpers.tpl index 35651727..44b4d58b 100644 --- a/deploy/helm/trackarr/templates/_helpers.tpl +++ b/deploy/helm/trackarr/templates/_helpers.tpl @@ -319,6 +319,13 @@ what is there; generate only on the very first install. `lookup` returns an empty map under `helm template` and `--dry-run`, so those show a fresh value. Nothing is applied, so nothing rotates. +ATTENTION : cela ne vaut PAS pour ArgoCD, Flux en mode `helm template`, ni pour +un `helm template | kubectl apply`. Ces pipelines rendent le chart hors du +cluster — donc `lookup` y est vide — PUIS appliquent la sortie : chaque +synchronisation génère et applique de nouvelles valeurs. Sur ces +plateformes, épingler via `secrets.existingSecret` ou fixer les trois valeurs +dans les `values`. Voir la note encadrée du README. + Call as (dict "ctx" $ "key" "IP_HASH_SECRET" "override" .Values.secrets.x "len" 64). */}} {{- define "trackarr.stableSecret" -}} diff --git a/deploy/helm/trackarr/templates/api.yaml b/deploy/helm/trackarr/templates/api.yaml index 4cbf3896..59f04dcc 100644 --- a/deploy/helm/trackarr/templates/api.yaml +++ b/deploy/helm/trackarr/templates/api.yaml @@ -139,7 +139,12 @@ spec: {{- end }} volumes: - name: tmp - emptyDir: {} + # `sizeLimit`, sinon le stockage éphémère n'est ni demandé ni + # plafonné : un /tmp qui gonfle déclenche une pression disque sur le + # NŒUD et l'éviction de pods VOISINS. C'est le cas « un pod affame le + # nœud » que le reste du chart traite partout ailleurs. + emptyDir: + sizeLimit: 512Mi {{- if include "trackarr.uploadsVolumeEnabled" . }} - name: uploads persistentVolumeClaim: diff --git a/deploy/helm/trackarr/templates/networkpolicy.yaml b/deploy/helm/trackarr/templates/networkpolicy.yaml index 2166d221..fff88999 100644 --- a/deploy/helm/trackarr/templates/networkpolicy.yaml +++ b/deploy/helm/trackarr/templates/networkpolicy.yaml @@ -33,11 +33,20 @@ spec: ingress: # From the ingress controller, and from each other: the web SSR calls the # API, and the API calls the tracker over TRACKER_INTERNAL_URL. + # + # Le `namespaceSelector` visait `.Release.Namespace` — le MÊME espace de + # noms que le `podSelector: {}` juste au-dessus, donc deux fois la même + # chose et rien pour le contrôleur d'ingress, qui vit normalement dans + # `ingress-nginx`. La règle disait « From the ingress controller » et ne + # l'exprimait pas : activer cette politique rendait le site injoignable. - from: - podSelector: {} - namespaceSelector: matchLabels: kubernetes.io/metadata.name: {{ .Release.Namespace }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.networkPolicy.ingressNamespace | default "ingress-nginx" }} ports: - port: {{ .Values.web.containerPort }} protocol: TCP @@ -51,4 +60,44 @@ spec: - port: {{ .Values.tracker.udp.port }} protocol: UDP {{- end }} +{{- if .Values.networkPolicy.datastores }} +--- +{{/* +Les datastores, que les deux objets ci-dessus ne couvraient PAS. + +`trackarr.selectorLabels` porte `app.kubernetes.io/name` et `instance` ; les +pods des sous-charts CNPG et Valkey portent un autre `name`, donc le +`podSelector` ne les sélectionnait pas. Postgres et Valkey restaient joignables +depuis n'importe quel pod du cluster, politique activée ou non — c'est-à-dire +que la partie de la politique qui compte le plus n'existait pas. + +Les sélecteurs des sous-charts ne sont pas les nôtres : ils sont donc +paramétrables, et cet objet ne se rend que si l'opérateur les a fournis. +*/}} +{{- range $i, $store := .Values.networkPolicy.datastores }} +{{- if $i }} +--- +{{- end }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "trackarr.fullname" $ }}-{{ $store.name }}-allow + labels: + {{- include "trackarr.labels" $ | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- toYaml $store.podSelector | nindent 6 }} + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + {{- include "trackarr.selectorLabels" $ | nindent 14 }} + ports: + - port: {{ $store.port }} + protocol: TCP +{{- end }} +{{- end }} {{- end }} diff --git a/deploy/helm/trackarr/templates/tracker.yaml b/deploy/helm/trackarr/templates/tracker.yaml index 4816f997..8d42c17c 100644 --- a/deploy/helm/trackarr/templates/tracker.yaml +++ b/deploy/helm/trackarr/templates/tracker.yaml @@ -116,7 +116,12 @@ spec: mountPath: /tmp volumes: - name: tmp - emptyDir: {} + # `sizeLimit`, sinon le stockage éphémère n'est ni demandé ni + # plafonné : un /tmp qui gonfle déclenche une pression disque sur le + # NŒUD et l'éviction de pods VOISINS. C'est le cas « un pod affame le + # nœud » que le reste du chart traite partout ailleurs. + emptyDir: + sizeLimit: 128Mi --- apiVersion: v1 kind: Service diff --git a/deploy/helm/trackarr/templates/web.yaml b/deploy/helm/trackarr/templates/web.yaml index c3cec31e..928e77c6 100644 --- a/deploy/helm/trackarr/templates/web.yaml +++ b/deploy/helm/trackarr/templates/web.yaml @@ -92,7 +92,12 @@ spec: mountPath: /tmp volumes: - name: tmp - emptyDir: {} + # `sizeLimit`, sinon le stockage éphémère n'est ni demandé ni + # plafonné : un /tmp qui gonfle déclenche une pression disque sur le + # NŒUD et l'éviction de pods VOISINS. C'est le cas « un pod affame le + # nœud » que le reste du chart traite partout ailleurs. + emptyDir: + sizeLimit: 256Mi --- apiVersion: v1 kind: Service diff --git a/deploy/helm/trackarr/values.yaml b/deploy/helm/trackarr/values.yaml index 369ea1f0..95376a53 100644 --- a/deploy/helm/trackarr/values.yaml +++ b/deploy/helm/trackarr/values.yaml @@ -531,6 +531,37 @@ serviceAccount: networkPolicy: enabled: false + # L'espace de noms du contrôleur d'ingress, qu'il faut autoriser explicitement. + # + # Le seul bloc `from:` de la politique admettait `podSelector: {}` (le même + # espace de noms) et un `namespaceSelector` sur `.Release.Namespace` — deux + # fois la même chose, et rien pour le contrôleur, qui vit ailleurs. Activer la + # politique rendait donc le site injoignable, alors que son commentaire disait + # « From the ingress controller ». + ingressNamespace: ingress-nginx + + # Les datastores, que le `podSelector` du chart ne sélectionne PAS. + # + # `trackarr.selectorLabels` porte `app.kubernetes.io/name` et `instance` ; les + # pods des sous-charts CNPG et Valkey portent un autre `name`. Postgres et + # Valkey restaient donc joignables depuis n'importe quel pod du cluster, + # politique activée ou non — c'est-à-dire que la partie qui compte le plus + # n'existait pas. + # + # Les étiquettes appartiennent aux sous-charts, pas à nous : elles sont donc + # déclarées ici plutôt que devinées dans le gabarit. Vides, aucun objet n'est + # rendu. Les valeurs ci-dessous sont celles que posent CNPG et Valkey par + # défaut — à vérifier contre `kubectl get pods --show-labels` avant d'activer. + datastores: [] + # - name: db + # port: 5432 + # podSelector: + # cnpg.io/cluster: <release>-db + # - name: valkey + # port: 6379 + # podSelector: + # app.kubernetes.io/name: valkey + # ============================================================================= # Postgres — CloudNativePG (subchart: cnpg/cluster) # From 60cb560f0f909e55c9eaf9f43fa09ed96e3d0879 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:21:13 +0200 Subject: [PATCH 52/96] fix(docker): Postgres trusted the network, and Caddy had no body limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image's generated `pg_hba.conf` wins unless the file is passed with `-c`, so the `trust` lines were live: anything that reached the container's port was Postgres, no password asked. Read back from `pg_hba_file_rules` after the fix, because the config on disk is not the config in force: seven rules, `scram-sha-256` throughout, and a default reject. Caddy has NO request-body limit by default — `request_body { max_size }` is opt-in. `POST /api/torrents` passes the whole body through `readMultipartFormData`, which buffers it in memory BEFORE the route's own 413 guards can speak. 32 MB matches what the Helm chart already enforces through `proxy-body-size`; the Compose path, the one third-party operators install, had nothing. Measured: 40 MiB is refused at the edge, 64 KiB reaches the route. The tracker subdomain no longer proxies everything. It forwarded `/health` and `/metrics` straight to the tracker, publishing Postgres and Redis liveness to anyone who asked. Only `/announce` and `/scrape` now; the rest is a 404. PgBouncer 1.25 changed `max_prepared_statements` from 0 to 200, which is what lets `prepare: true` work through transaction pooling — the HA guide credited postgres.js for adapting, which it does not. Two nginx healthchecks are gone. `nginx -t` validates a config; it does not say the server is alive, and the Chainguard image has no shell to probe `/healthz` with. A `/dev/tcp` check in the compose replaces them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docker-compose.prod.yml | 76 +++++++++++++++++++++++++++++----- docker-compose.static.yml | 24 +++++++++-- docker/grafana/README.md | 23 ++++++++++ docker/pgbouncer/pgbouncer.ini | 19 +++++++++ docker/postgres/pg_hba.conf | 4 ++ 5 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 docker/grafana/README.md diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b72a7206..e41fe439 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,23 @@ # Production Docker Compose Configuration # Three application containers (web/api/tracker) + Caddy + PgBouncer + Postgres + Redis. +# ── Secrets obligatoires ───────────────────────────────────────────────────── +# `${VAR:-défaut}` substitue le défaut quand la variable est VIDE autant +# qu'absente. `.env.example` promettait « leave empty and let the stack refuse +# to start rather than ship a guessable default » ; avec `DB_PASSWORD=` la pile +# démarrait sur `postgres://tracker:tracker@…`. Mesuré via `docker compose +# config`. +# +# `${VAR:?message}` fait avorter `docker compose up` en nommant la variable, ce +# qui rend la promesse vraie. +# +# Redis avait la même faille, avec une issue plus retorse : l'argument vide +# disparaissait de la ligne de commande et `--requirepass` avalait +# `--maxmemory` comme argument, ce qui faisait échouer le démarrage — donc une +# panne fermée par ACCIDENT. Déplacer l'option voisine, ou en mettre une sans +# argument, et Redis aurait démarré avec le mot de passe littéral +# « --maxmemory » et SANS plafond mémoire. + services: # ============================================================================= # REVERSE PROXY - Caddy with automatic HTTPS @@ -40,9 +57,19 @@ services: - trackarr-public - trackarr-internal depends_on: - - web - - api - - tracker + # Forme longue, comme api/tracker/relay dans ce même fichier. La forme + # courte n'attend que le DÉMARRAGE, ce qui est en réalité le bon choix + # ici — Caddy répond 502 en attendant et se rétablit tout seul, alors + # qu'attendre la santé retarderait la mise à disposition du site pour + # rien. L'écrire explicitement plutôt que par omission : dans un fichier + # où trois services voisins déclarent `service_healthy`, la forme courte + # se lit comme un oubli. + web: + condition: service_started + api: + condition: service_started + tracker: + condition: service_started deploy: resources: limits: @@ -125,8 +152,8 @@ services: NITRO_PORT: 4000 NITRO_HOST: 0.0.0.0 # Database (through PgBouncer at runtime) - DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:-tracker}@pgbouncer:6432/${DB_NAME:-trackarr}?sslmode=disable - MIGRATIONS_DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:-tracker}@postgres:5432/${DB_NAME:-trackarr}?sslmode=disable + DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:?DB_PASSWORD must be set in .env — see .env.example}@pgbouncer:6432/${DB_NAME:-trackarr}?sslmode=disable + MIGRATIONS_DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:?DB_PASSWORD must be set in .env — see .env.example}@postgres:5432/${DB_NAME:-trackarr}?sslmode=disable DB_SSL: "false" DB_POOL_MAX: ${DB_POOL_MAX:-10} # Redis @@ -219,7 +246,7 @@ services: - no-new-privileges:true environment: NODE_ENV: production - DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:-tracker}@pgbouncer:6432/${DB_NAME:-trackarr}?sslmode=disable + DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:?DB_PASSWORD must be set in .env — see .env.example}@pgbouncer:6432/${DB_NAME:-trackarr}?sslmode=disable DB_SSL: "false" REDIS_URL: redis://redis:6379 REDIS_PASSWORD: ${REDIS_PASSWORD} @@ -240,7 +267,13 @@ services: ports: - "6969:6969/udp" networks: - - trackarr-public + # `trackarr-public` retiré : le tracker ne parle qu'à Postgres et à + # Redis — c'est écrit dans son propre Dockerfile, qui n'embarque même + # pas de bundle CA pour cette raison. Aucun port n'était publié, donc il + # n'y avait pas de surface entrante ; ce que cela retire est un accès + # sortant vers l'internet dont il n'a pas l'usage, et donc la portée + # d'une compromission. Le port UDP 6969 publié fonctionne + # indépendamment du réseau attaché. - trackarr-internal depends_on: postgres: @@ -323,7 +356,7 @@ services: - no-new-privileges:true environment: POSTGRES_USER: ${DB_USER:-tracker} - POSTGRES_PASSWORD: ${DB_PASSWORD:-tracker} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env — see .env.example} POSTGRES_DB: ${DB_NAME:-trackarr} POSTGRES_HOST_AUTH_METHOD: scram-sha-256 POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256" @@ -338,8 +371,21 @@ services: - postgres_data:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./docker/postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro + # `-c hba_file` : le fichier monté juste au-dessus, RÉELLEMENT chargé. + # + # Il était monté en lecture seule et jamais lu — sans cette option Postgres + # utilise `/var/lib/postgresql/data/pg_hba.conf`, celui que l'image génère. + # Mesuré dans un conteneur au même montage, les règles effectives étaient + # `local all all trust` et `host all all 127.0.0.1 trust`, exactement ce que + # le fichier du dépôt cherchait à interdire : tout ce qui atteint l'espace de + # noms du conteneur était superutilisateur sans mot de passe. Le montage + # donnait l'apparence d'un contrôle qui n'existait pas. + # + # (La note vit ici et non dans le bloc ci-dessous : un scalaire replié YAML + # n'a pas de commentaires, il aurait pris ces lignes pour des arguments.) command: > postgres + -c hba_file=/etc/postgresql/pg_hba.conf -c ssl=off -c log_connections=on -c log_disconnections=on @@ -367,6 +413,9 @@ services: resources: limits: memory: 2G + # Le seul service sans plafond de processus, alors que les six autres + # en ont un. Postgres fork un processus par connexion. + pids: 300 reservations: memory: 512M @@ -388,10 +437,15 @@ services: security_opt: - "no-new-privileges:true" environment: - DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:-tracker}@postgres:5432/${DB_NAME:-trackarr} + # Requis par postgres.js (`prepare: true`) en pooling transaction. Le + # défaut de PgBouncer est passé de 0 à 200 en 1.25 : posé explicitement + # pour ne pas dépendre de la version du binaire. Voir la note dans + # docker/pgbouncer/pgbouncer.ini. + MAX_PREPARED_STATEMENTS: 200 + DATABASE_URL: postgres://${DB_USER:-tracker}:${DB_PASSWORD:?DB_PASSWORD must be set in .env — see .env.example}@postgres:5432/${DB_NAME:-trackarr} DB_HOST: postgres DB_USER: ${DB_USER:-tracker} - DB_PASSWORD: ${DB_PASSWORD:-tracker} + DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env — see .env.example} AUTH_TYPE: scram-sha-256 POOL_MODE: transaction DEFAULT_POOL_SIZE: 20 @@ -452,7 +506,7 @@ services: --save 900 1 --save 300 10 --save 60 10000 - --requirepass ${REDIS_PASSWORD} + --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD must be set in .env — see .env.example} --maxmemory 512mb --maxmemory-policy allkeys-lru --tcp-keepalive 300 diff --git a/docker-compose.static.yml b/docker-compose.static.yml index 7dd0f48b..689953ac 100644 --- a/docker-compose.static.yml +++ b/docker-compose.static.yml @@ -43,8 +43,14 @@ services: dockerfile: apps/web/Dockerfile.static # The static container has zero environment-driven config — all the # NUXT_PUBLIC_* values are baked into the bundle as fallbacks but - # overridden at runtime via /api/runtime-config. Drop the SSR env - # block entirely. + # overridden at runtime via /api/runtime-config. + # + # Le bloc disait « Drop the SSR env block entirely » avec `environment: {}`. + # Compose FUSIONNE les mappings `environment` entre fichiers : un objet vide + # ne retire rien, le bloc SSR était conservé. Sans conséquence — ces + # variables sont inoffensives pour nginx — mais le commentaire décrivait un + # comportement qui n'avait pas lieu. Pour réellement en retirer une, il + # faudrait la nommer avec une valeur nulle (`VAR:`). environment: {} # Resource caps drop massively. Distroless nginx idles around # 8-10 MB; 64 MB is plenty for any realistic concurrency on a @@ -55,5 +61,15 @@ services: memory: 64M reservations: memory: 16M - # The Dockerfile.static HEALTHCHECK uses `nginx -t` (config - # validation) — fast, no shell needed in distroless. Inherit it. + # Une VRAIE sonde, depuis l'extérieur du conteneur. + # + # Le Dockerfile portait `nginx -t`, qui valide le fichier de configuration : + # un worker bloqué ou un listener fermé passaient au vert. L'image + # Chainguard n'ayant ni shell ni client HTTP, rien ne peut interroger + # `/healthz` depuis l'intérieur — c'est donc au compose de le faire. + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/3000 && printf 'GET /healthz HTTP/1.0\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q 200"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 diff --git a/docker/grafana/README.md b/docker/grafana/README.md new file mode 100644 index 00000000..3bca8967 --- /dev/null +++ b/docker/grafana/README.md @@ -0,0 +1,23 @@ +# Tableau de bord Grafana + +`dashboards/trackarr-overview.json` est un tableau de bord à **importer à la +main**. Rien ne le monte : aucun `docker-compose*.yml` et aucun gabarit Helm du +dépôt ne référence ce répertoire, et il n'existe pas de service `grafana` à +provisionner. Écrit ici parce qu'un fichier de configuration sans consommateur +se lit comme un provisionnement qui aurait cessé de fonctionner. + +## L'importer + +1. Dans Grafana : **Dashboards → New → Import**. +2. Coller le contenu de `dashboards/trackarr-overview.json`, ou téléverser le + fichier. +3. Choisir la source de données Prometheus quand Grafana demande + `${DS_PROMETHEUS}` — c'est la seule variable que le fichier attend, et il ne + contient aucun secret. + +## Ce qu'il montre + +Les métriques que l'API expose sur `/api/metrics` (`prom-client`) et celles du +tracker : essaims, pairs, débit d'annonces, latences, état des dépendances. +Vérifier que Prometheus scrape bien ces deux cibles avant de s'étonner de +panneaux vides. diff --git a/docker/pgbouncer/pgbouncer.ini b/docker/pgbouncer/pgbouncer.ini index e6566bbb..860e6e07 100644 --- a/docker/pgbouncer/pgbouncer.ini +++ b/docker/pgbouncer/pgbouncer.ini @@ -25,6 +25,25 @@ auth_file = /etc/pgbouncer/userlist.txt ; - statement: connection is returned after each statement (breaks multi-statement transactions) pool_mode = transaction +; Requis par postgres.js, qui pose `prepare: true` (packages/db/src/index.ts). +; +; Le dépôt portait deux affirmations opposées sur cette combinaison : +; `doc/guide/high-availability.md` disait « postgres.js accounts for the pooling +; mode itself. Nothing to do » (mesuré sur PgBouncer 1.25.2), et +; `deploy/helm/trackarr/values.yaml` disait « Transaction pooling needs +; PgBouncer >= 1.21 with this above zero, or the second use of a named prepared +; statement fails » — en posant `max_prepared_statements: 100`. +; +; La vraie raison est une troisième : PgBouncer 1.25 a fait passer le DÉFAUT de +; `max_prepared_statements` de 0 à 200. La mesure était donc juste et son +; explication fausse. Conséquence pratique : ce fichier, monté contre un +; PgBouncer ANTÉRIEUR à 1.25 — paquet de distribution, pooler d'entreprise +; existant, RDS Proxy, pooler Supabase — cassait à la deuxième réutilisation de +; chaque énoncé nommé. Panne intermittente, invisible en test. +; +; Posé explicitement pour ne plus dépendre de la version du binaire. +max_prepared_statements = 200 + ; Pool sizing ; default_pool_size: connections per user/database pair default_pool_size = 20 diff --git a/docker/postgres/pg_hba.conf b/docker/postgres/pg_hba.conf index c8fcd3d5..85612fe9 100644 --- a/docker/postgres/pg_hba.conf +++ b/docker/postgres/pg_hba.conf @@ -3,6 +3,10 @@ # Local connections (Unix domain socket) local all all scram-sha-256 +# TCP loopback, which is how an operator debugs (docker exec ... psql -h +# 127.0.0.1). Missing, this file cut that path off the moment it was actually +# loaded -- see the hba_file note in docker-compose.prod.yml. +host all all 127.0.0.1/32 scram-sha-256 # IPv4 local connections (Docker network) host all all 172.16.0.0/12 scram-sha-256 From 222b46cb8ba02899510ea7f47135d5e810805978 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:21:14 +0200 Subject: [PATCH 53/96] chore(deps): sixteen updates, and the two version decisions worth writing down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen minors and three patches. `isomorphic-dompurify` goes 3.22 to 4.1 — 3.22 is deprecated for raising its Node floor without a major bump — and the `dompurify` override keeps the 3.4.13 floor so 4.x cannot pull the release below the bypass fixes. Verified before the bump: the repo's own SAFE profile against eighteen XSS payloads, zero bypasses. `@simplewebauthn/server` takes 13.3.3. Version 14 exists but was published the same day, and the repo sets `minimumReleaseAge: 1440` precisely so it does not depend on the fresh — the same reason `nodemailer@9.1.1` stays out. TWO TypeScript versions, on purpose. TypeScript 7 is the native Go port: its main export is `lib/version.cjs` and `require('typescript').createProgram` is `undefined`, so anything calling the classic compiler API breaks — vue-tsc included. vue-tsc 3.3.11 nevertheless declares `typescript: ">=5.0.0"`, a range that is false for 7.x, so nothing stops someone removing the pin from `apps/web` and breaking `nuxt typecheck` with no peer warning at all. The pin IS the guard. `@types/node` stays on 24 for the same kind of reason: every Dockerfile and both CI workflows run Node 24, and types describing APIs the runtime does not have is code that typechecks and then fails. `prom-client` stays too, with the numbers in `utils/metrics.ts`: deprecated is not vulnerable, the audit reports zero advisories against it, and its successor is a 0.16 with four published versions and a new runtime dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/package.json | 4 +- apps/web/package.json | 28 +- package.json | 2 +- packages/shared/package.json | 3 - pnpm-lock.yaml | 1176 +++++++++++++++++----------------- pnpm-workspace.yaml | 30 + 6 files changed, 618 insertions(+), 625 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 89b2c54e..a3d05623 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,7 +16,7 @@ "dependencies": { "@iconify-json/ph": "^1.2.2", "@iconify/utils": "^3.1.4", - "@simplewebauthn/server": "^13.3.2", + "@simplewebauthn/server": "^13.3.3", "@trackarr/db": "workspace:*", "@trackarr/shared": "workspace:*", "bencode": "^4.0.1", @@ -35,7 +35,7 @@ "qrcode": "^1.5.4", "uuid": "^14.0.1", "web-push": "^3.6.7", - "zod": "^4.4.3" + "zod": "^4.5.4" }, "devDependencies": { "@types/bencode": "^4.0.0", diff --git a/apps/web/package.json b/apps/web/package.json index 807cce24..f26b3b87 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,38 +14,38 @@ "typecheck": "nuxt typecheck" }, "dependencies": { - "@iconify-json/simple-icons": "^1.2.93", + "@iconify-json/simple-icons": "^1.2.94", "@nuxtjs/i18n": "^10.6.0", "@nuxtjs/tailwindcss": "6.14.0", "@pinia/nuxt": "1.0.2", "@simplewebauthn/browser": "^13.3.0", - "@tiptap/extension-color": "^3.30.1", - "@tiptap/extension-image": "^3.30.1", - "@tiptap/extension-link": "^3.30.1", - "@tiptap/extension-placeholder": "^3.30.1", - "@tiptap/extension-text-align": "^3.30.1", - "@tiptap/extension-text-style": "^3.30.1", - "@tiptap/extension-underline": "^3.30.1", - "@tiptap/starter-kit": "^3.30.1", - "@tiptap/vue-3": "^3.30.1", + "@tiptap/extension-color": "^3.31.0", + "@tiptap/extension-image": "^3.31.0", + "@tiptap/extension-link": "^3.31.0", + "@tiptap/extension-placeholder": "^3.31.0", + "@tiptap/extension-text-align": "^3.31.0", + "@tiptap/extension-text-style": "^3.31.0", + "@tiptap/extension-underline": "^3.31.0", + "@tiptap/starter-kit": "^3.31.0", + "@tiptap/vue-3": "^3.31.0", "@trackarr/shared": "workspace:*", "@vueuse/core": "^14.4.0", "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", - "isomorphic-dompurify": "^3.22.0", + "isomorphic-dompurify": "^4.1.0", "marked": "^18.0.9", "mediainfo.js": "^0.3.7", "nuxt": "^4.5.2", "pinia": "^4.0.3", "turndown": "^7.2.4", - "vue": "^3.5.41", + "vue": "^3.5.42", "vue-chartjs": "^5.3.4", - "vue-router": "^5.2.0" + "vue-router": "^5.3.0" }, "devDependencies": { "@iconify-json/ph": "^1.2.2", "@nuxt/fonts": "^0.14.0", - "@nuxt/icon": "^2.5.0", + "@nuxt/icon": "^2.5.1", "@types/turndown": "^5.0.6", "typescript": "~5.9.3", "vitest": "^4.1.10", diff --git a/package.json b/package.json index 755c2b14..a648ceaa 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@types/node": "^24.13.3", - "tsx": "^4.23.12", + "tsx": "^4.23.13", "typescript": "^7.0.2" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 5789fac4..7b9b7ca5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,8 +11,5 @@ "./templateEngine": "./src/templateEngine.ts", "./theme": "./src/theme.ts", "./css": "./src/css.ts" - }, - "dependencies": { - "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fff5392..154c1026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ overrides: '@peculiar/asn1-schema': ^2.9.4 '@peculiar/asn1-x509': ^2.9.4 '@peculiar/asn1-x509-attr': ^2.9.4 + browserslist: ^4.28.8 + postcss-selector-parser@6: ^6.1.3 + postcss-selector-parser@7: ^7.1.5 importers: @@ -45,8 +48,8 @@ importers: specifier: ^24.13.3 version: 24.13.3 tsx: - specifier: ^4.23.12 - version: 4.23.12 + specifier: ^4.23.13 + version: 4.23.13 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -60,8 +63,8 @@ importers: specifier: ^3.1.4 version: 3.1.4 '@simplewebauthn/server': - specifier: ^13.3.2 - version: 13.3.2 + specifier: ^13.3.3 + version: 13.3.3 '@trackarr/db': specifier: workspace:* version: link:../../packages/db @@ -91,7 +94,7 @@ importers: version: 18.0.10 nitropack: specifier: ^2.13.4 - version: 2.13.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 2.13.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) nodemailer: specifier: ^9.0.5 version: 9.0.5 @@ -117,8 +120,8 @@ importers: specifier: ^3.6.7 version: 3.6.7(supports-color@10.2.2) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.5.4 + version: 4.5.4 devDependencies: '@types/bencode': specifier: ^4.0.0 @@ -146,58 +149,58 @@ importers: version: 13.1.3 vitest: specifier: ^4.1.10 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) apps/web: dependencies: '@iconify-json/simple-icons': - specifier: ^1.2.93 - version: 1.2.93 + specifier: ^1.2.94 + version: 1.2.94 '@nuxtjs/i18n': specifier: ^10.6.0 - version: 10.6.0(@vue/compiler-dom@3.5.41)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.10.1(supports-color@10.2.2))(magicast@0.5.4)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + version: 10.6.0(@vue/compiler-dom@3.5.41)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.10.1(supports-color@10.2.2))(magicast@0.5.4)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) '@nuxtjs/tailwindcss': specifier: 6.14.0 - version: 6.14.0(magicast@0.5.4)(supports-color@10.2.2)(tsx@4.23.12)(yaml@2.9.0) + version: 6.14.0(magicast@0.5.4)(supports-color@10.2.2)(tsx@4.23.13)(yaml@2.9.0) '@pinia/nuxt': specifier: 1.0.2 - version: 1.0.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + version: 1.0.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) '@simplewebauthn/browser': specifier: ^13.3.0 version: 13.3.0 '@tiptap/extension-color': - specifier: ^3.30.1 - version: 3.30.2(@tiptap/extension-text-style@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))) + specifier: ^3.31.0 + version: 3.31.0(@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))) '@tiptap/extension-image': - specifier: ^3.30.1 - version: 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + specifier: ^3.31.0 + version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) '@tiptap/extension-link': - specifier: ^3.30.1 - version: 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + specifier: ^3.31.0 + version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) '@tiptap/extension-placeholder': - specifier: ^3.30.1 - version: 3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + specifier: ^3.31.0 + version: 3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) '@tiptap/extension-text-align': - specifier: ^3.30.1 - version: 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + specifier: ^3.31.0 + version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) '@tiptap/extension-text-style': - specifier: ^3.30.1 - version: 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + specifier: ^3.31.0 + version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) '@tiptap/extension-underline': - specifier: ^3.30.1 - version: 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + specifier: ^3.31.0 + version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) '@tiptap/starter-kit': - specifier: ^3.30.1 - version: 3.30.2 + specifier: ^3.31.0 + version: 3.31.0 '@tiptap/vue-3': - specifier: ^3.30.1 - version: 3.30.2(@floating-ui/dom@1.7.6)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)(vue@3.5.41(typescript@5.9.3)) + specifier: ^3.31.0 + version: 3.31.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(vue@3.5.42(typescript@5.9.3)) '@trackarr/shared': specifier: workspace:* version: link:../../packages/shared '@vueuse/core': specifier: ^14.4.0 - version: 14.4.0(vue@3.5.41(typescript@5.9.3)) + version: 14.4.0(vue@3.5.42(typescript@5.9.3)) chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -205,8 +208,8 @@ importers: specifier: ^2.2.0 version: 2.2.0(chart.js@4.5.1) isomorphic-dompurify: - specifier: ^3.22.0 - version: 3.22.0(@noble/hashes@2.2.0) + specifier: ^4.1.0 + version: 4.1.0(@noble/hashes@2.2.0) marked: specifier: ^18.0.9 version: 18.0.10 @@ -215,32 +218,32 @@ importers: version: 0.3.7 nuxt: specifier: ^4.5.2 - version: 4.5.2(77b5bdd374d6510da956fd364e2d9d4d) + version: 4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a) pinia: specifier: ^4.0.3 - version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) turndown: specifier: ^7.2.4 version: 7.2.4 vue: - specifier: ^3.5.41 - version: 3.5.41(typescript@5.9.3) + specifier: ^3.5.42 + version: 3.5.42(typescript@5.9.3) vue-chartjs: specifier: ^5.3.4 - version: 5.3.4(chart.js@4.5.1)(vue@3.5.41(typescript@5.9.3)) + version: 5.3.4(chart.js@4.5.1)(vue@3.5.42(typescript@5.9.3)) vue-router: - specifier: ^5.2.0 - version: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + specifier: ^5.3.0 + version: 5.3.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) devDependencies: '@iconify-json/ph': specifier: ^1.2.2 version: 1.2.2 '@nuxt/fonts': specifier: ^0.14.0 - version: 0.14.0(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 0.14.0(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) '@nuxt/icon': - specifier: ^2.5.0 - version: 2.5.0(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + specifier: ^2.5.1 + version: 2.5.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) '@types/turndown': specifier: ^5.0.6 version: 5.0.6 @@ -249,7 +252,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) vue-tsc: specifier: ^3.3.11 version: 3.3.11(typescript@5.9.3) @@ -267,11 +270,7 @@ importers: specifier: ^0.31.10 version: 0.31.10 - packages/shared: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 + packages/shared: {} packages: @@ -310,10 +309,6 @@ packages: resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} - '@babel/generator@8.0.0': - resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-annotate-as-pure@7.29.7': resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} @@ -384,10 +379,6 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.0': - resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -396,10 +387,6 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.4': - resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -418,11 +405,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.4': - resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} - engines: {node: ^22.18.0 || >=24.11.0} - hasBin: true - '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -465,10 +447,6 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.4': - resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} - engines: {node: ^22.18.0 || >=24.11.0} - '@bomb.sh/tab@0.0.19': resolution: {integrity: sha512-dTRfo9Q9B+lbLG3JCu8a/AGQSfD2XXcFcnakQzVjSOX+VvR/s9zpsH8TlqV3iHqazniRn1Ypwd1hcRlXcu/4BA==} hasBin: true @@ -547,13 +525,13 @@ packages: resolution: {integrity: sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==} engines: {node: '>=18'} peerDependencies: - postcss-selector-parser: ^7.0.0 + postcss-selector-parser: ^7.1.5 '@csstools/selector-specificity@5.0.0': resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} engines: {node: '>=18'} peerDependencies: - postcss-selector-parser: ^7.0.0 + postcss-selector-parser: ^7.1.5 '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -811,8 +789,8 @@ packages: '@iconify-json/ph@1.2.2': resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==} - '@iconify-json/simple-icons@1.2.93': - resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} + '@iconify-json/simple-icons@1.2.94': + resolution: {integrity: sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA==} '@iconify/collections@1.0.727': resolution: {integrity: sha512-n+ipAqz5A6TWExnndwdT6qVX7KfXiw5GNDSxO2otXs6rxFWdHqizHTW1WvmWmT6m2BqQ8lhzUyWSJvm4M3nMAg==} @@ -1010,6 +988,11 @@ packages: peerDependencies: vite: '>=6.0' + '@nuxt/devtools-kit@3.4.2': + resolution: {integrity: sha512-TPPbmH9xrExagYxfCe6JgtbHmgchfgDdA656Yws18rn8I/oCvB+gcw3kTWBBp2sC9ipZPXsgbsW/yZnVnGNBiA==} + peerDependencies: + vite: '>=6.0' + '@nuxt/devtools-wizard@3.4.1': resolution: {integrity: sha512-taGeuTnkHsZVjgD9r6NXvvHaBzB2j4mCgOmlmkz3ntsqgh1SJfmsD11Tmtl7FVAxJS8Wuvuzgt0GyTlADBW9Wg==} hasBin: true @@ -1027,8 +1010,8 @@ packages: '@nuxt/fonts@0.14.0': resolution: {integrity: sha512-4uXQl9fa5F4ibdgU8zomoOcyMdnwgdem+Pi8JEqeDYI5yPR32Kam6HnuRr47dTb97CstaepAvXPWQUUHMtjsFQ==} - '@nuxt/icon@2.5.0': - resolution: {integrity: sha512-EBczSfd3QmAD7GoSmJ5jCPvQ5zzNaPPuWPU9DSMcBuBM10A3JIvjaMLEOEniHQRz4iVPb05cJRO/JdC60oiiwg==} + '@nuxt/icon@2.5.1': + resolution: {integrity: sha512-zBP72Po7BS+tXzoeDRA/Y9TTY77OIcNCyzfgXsOmep7zZShTEoe4p1WZBXce/9oJDK3YXmbYC3ILQ7XvqM4/XA==} '@nuxt/kit@3.21.4': resolution: {integrity: sha512-XDWhQJsA5hpdFpVSmImQIVXcsANJI07TjT1LZC/AUKJxl/dcM52Rq4uU+b3uqyVl4LZR1fODSDEzLxcdXq4Rmg==} @@ -1867,8 +1850,8 @@ packages: '@simplewebauthn/browser@13.3.0': resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} - '@simplewebauthn/server@13.3.2': - resolution: {integrity: sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==} + '@simplewebauthn/server@13.3.3': + resolution: {integrity: sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==} engines: {node: '>=20.0.0'} '@sindresorhus/is@7.2.0': @@ -1889,177 +1872,177 @@ packages: resolution: {integrity: sha512-Uz2kFb0cNeHg7hds8BtI/umcwzaSMUdbkeQgZrIZQbp3gWRFjrvrvBHNEB6SQYChVxC3rWHovKx1Ya/vSRRMYA==} engines: {node: '>=0.2.6'} - '@tiptap/core@3.30.2': - resolution: {integrity: sha512-QbZC/s1OOqcoUdkhIY16TjR/gCtR0qAk9e4bJwUqOJqZuv5ozqCL5hzWm22jjTPp6c6Ei2tPd6t30VwfIKW4lQ==} + '@tiptap/core@3.31.0': + resolution: {integrity: sha512-SY3yE2ACharFvk0BKgotV6SbwV8R4tN5fGtWOM6deY4JNF3v6CXOD78RFwjs2HSohGWBRyJVPDvrX9nfi8Nsxg==} peerDependencies: - '@tiptap/pm': 3.30.2 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-blockquote@3.30.2': - resolution: {integrity: sha512-BOkwhZenek7vzXBOgKppSrlx4YryBdAYu1p1MXKn0R9A9eNmE2HVhmm0gG49+E8BhsE/TG8wKVclwET42JJiIg==} + '@tiptap/extension-blockquote@3.31.0': + resolution: {integrity: sha512-E1EcYp28NOEKLkMqf9Z4t/ZjVO5hOX/c5r9nu/sfp5+KePj4TaxGDcruijP5DYJuKxlsZx2lr6/lldiK9KilXw==} peerDependencies: - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-bold@3.30.2': - resolution: {integrity: sha512-MsvJhPgYejY2D9MhwYJv8AmscozvLBI8qtJ7YLdYZBWkMR4bgmxHq5+xqEfBsao9bOMMwBon9p3+P+/Tq5ReWA==} + '@tiptap/extension-bold@3.31.0': + resolution: {integrity: sha512-KB3V3/8/XSrXNM0m7Svgg8xjwG/mr9nYh0krGznmDK1eArxj2GQd5h2g0QiBy8e+G//NMEbWQxSKbhKUKz7aoQ==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-bubble-menu@3.30.2': - resolution: {integrity: sha512-oS0WiWNXHKpiPYMkcnHm1j7iEvufTGGtLFtcJJd3olb5OS6V2acoXVDL0nNJDmRQ32K3QXut3fbRcMseMxT+lw==} + '@tiptap/extension-bubble-menu@3.31.0': + resolution: {integrity: sha512-CNuyfYH2KfcREASRh280UkGqYTwuKlMNcyBR7KsZrpzAVwFVo9D6qMkE6/q7I8mNMt2+/Xebe9UFZIYnVccCJA==} peerDependencies: - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-bullet-list@3.30.2': - resolution: {integrity: sha512-+awIL/TUz4aB3rL68igU1rWfaaoBIAkPcakkktkRq8gYf0bd9eSb48P6kHkpx/3q+JyK7g9vsnltLNHNh6twnA==} + '@tiptap/extension-bullet-list@3.31.0': + resolution: {integrity: sha512-jLrzWDzBhfeOK/idnrpyncTuRamBNGCscDC7omG1S+CD3CFuDrp5Ws2tcv5fgASt2Vc/f9Y9zC+gOPkeIEpYFA==} peerDependencies: - '@tiptap/extension-list': 3.30.2 + '@tiptap/extension-list': 3.31.0 - '@tiptap/extension-code-block@3.30.2': - resolution: {integrity: sha512-9otGKaQZmePHrLXFtCtz+BYDn5z4sSumTkUqQIQHz0gVxwPoTi7g51RedwxvViTb/zu2XV5ROXYLHIxKxypMPg==} + '@tiptap/extension-code-block@3.31.0': + resolution: {integrity: sha512-y/A6N96gBPjEHqVYZ3u/yO/Anyo92iDWgYqShMXAvHprn2Ipmn32wIkX05Kv983fa6j6ySXF7A1nJdHcGCl5aQ==} peerDependencies: - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-code@3.30.2': - resolution: {integrity: sha512-r8EZk3R9yGpF6v5xxafAU1HwrD/e+RpbfnmVi2TeB/ZHAsVO62fW96E32G0t6IdaCtOFtAd85hkDAaOfCT4yGg==} + '@tiptap/extension-code@3.31.0': + resolution: {integrity: sha512-QWo9oItNzyzNMXoCOG4/PiCNYoTjhUKtI5NE7iRJhd9op3KsLoyIO+og+P+0ORxG59Ic76SkXl2/lILaVsB48g==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-color@3.30.2': - resolution: {integrity: sha512-VNct6qKePIoruJZ4PR30JtA3YfENOqESrUtDLF1YMJYf/oU26Bx4ePJGTy428IA0mouyE+uHruyUGfZXnT73Iw==} + '@tiptap/extension-color@3.31.0': + resolution: {integrity: sha512-5gfS5jl8DBZumwrBckWMsZLuMq1SBmcZHt/xlCwPbtUMFogE068YJq+BbxcgbJKx333G+2JWAO9ea6GyEkih8A==} peerDependencies: - '@tiptap/extension-text-style': 3.30.2 + '@tiptap/extension-text-style': 3.31.0 - '@tiptap/extension-document@3.30.2': - resolution: {integrity: sha512-+xIv67V+/2L1uvz98FAT5W7kWEfHwfNV3MD7b4UsKPU0lhcCWuVOXy0JB8yYmdNExqpI7xT9g3MWzREoBvBQSg==} + '@tiptap/extension-document@3.31.0': + resolution: {integrity: sha512-C2eDFoBlcRAb4sgbc9ZH+mrFFbjIS9VyoERGeDum3QRdqLEKU1h14Th7isHpGQzTvTywhXQxqXC64mgeSGXLsA==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-dropcursor@3.30.2': - resolution: {integrity: sha512-nyRKUmItATnKI9AiRChmjhcBbCEsNxRu+AaCz+cx8EvnAcNHsVRdNYL5PmBs3WlNA/Et4Eb2DG0hVQDnNF61eg==} + '@tiptap/extension-dropcursor@3.31.0': + resolution: {integrity: sha512-hIZOPfM0sGkAhPV69IK1zWMbWBd3wmhS0XCKBRcXUWrE2MW4kkaSyYprSFqtqFuUa3lVIEFrlME1WOEBJ2/2rg==} peerDependencies: - '@tiptap/extensions': 3.30.2 + '@tiptap/extensions': 3.31.0 - '@tiptap/extension-floating-menu@3.30.2': - resolution: {integrity: sha512-A8PLvvh8W6PUMrqh+EpBerxm+Ucr0irGxJvwAnzYQmNGNIJ9U4OVgw4OcEU+9JH0gMmEzDcHzMPP2s/s1lIcyw==} + '@tiptap/extension-floating-menu@3.31.0': + resolution: {integrity: sha512-NFwv1Kah3zGLmjj53Z+pA2584GdgMDClgvebJPqSunsSdHwHlFwfu+Op1u73xuEYOd6wGgCgzzDZ3V4CKwkJqg==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-gapcursor@3.30.2': - resolution: {integrity: sha512-7Xk0ut6FM+RAsvKxDN3bAtk7zvYZ6Aa8pawJ6s7dLAmLR9JwrZevlcL4FSrj4bR7rqKOj92RYCFxzgTEbWimag==} + '@tiptap/extension-gapcursor@3.31.0': + resolution: {integrity: sha512-TY1S9D9x86iIHiPiDU4KtRmLYTgE6RKa54jXubFyekUYFmgNfHuUunWmpK/Rvy6tkG1CKHNL7X+wdo8QXi+hcw==} peerDependencies: - '@tiptap/extensions': 3.30.2 + '@tiptap/extensions': 3.31.0 - '@tiptap/extension-hard-break@3.30.2': - resolution: {integrity: sha512-IxSNgmG3d4OZdUTeebrOI7SxdIWXXJqlcGiSNDabWqxipUitfy3mZ3gDDE6G01koKxZRbhz4KIplAZlpxnTFSg==} + '@tiptap/extension-hard-break@3.31.0': + resolution: {integrity: sha512-e3e3YwTSxzO1bYalphs3A3zr+nYRWASMKFPzviO0C2P7vKE4kT4rvCYCWuUxIpfit8Vnh6TFVIkhrQ/6IFlDzw==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-heading@3.30.2': - resolution: {integrity: sha512-PblDvgSJ05p1t6hzyPi02xeiBjB0M2abReoGEImqSWCy79UqnAGacgsZo4EeEawtJV1NEP8chhvmX+nRtzdT1A==} + '@tiptap/extension-heading@3.31.0': + resolution: {integrity: sha512-ryQnvq2iUW2IwjbLniGlRZliNA5YserRTX2mCKyQcv8z/u062J01JDLlX73o47tu4DjuBzmkx6t5SsiXNODRcg==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-horizontal-rule@3.30.2': - resolution: {integrity: sha512-j8aswLTsuEdJKC62DF+kw0EgvIRL7QMUyAVp2fdjR0qgM0ZVlEwCC4qIEq3kK9tFVU4kRtQ5BSj/jn6QwrlbCA==} + '@tiptap/extension-horizontal-rule@3.31.0': + resolution: {integrity: sha512-RTKLnuEmGJOlxzLOmKo85hstXfuLlg+zQSKAggdD6BrSTDR5fdPZXnZTb/gDCo6chCR5BoKHu7Xv1spnPzRmEw==} peerDependencies: - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-image@3.30.2': - resolution: {integrity: sha512-K/BPlWauHXI6Y4s7se2A1BLcZ2pnWmRQTEDkPJYe/8kmv1GyJzPu34sZed5Mvfu5chUH8u6aNZ/utZbvSbI/0Q==} + '@tiptap/extension-image@3.31.0': + resolution: {integrity: sha512-oQyFPUl3N0eA/X+06km0m1qoV/4inoTDWxSEnGAj0ZP7LkIpjqLTjpp4VRSmflTwun8lXe9PGtqJh+oCKUv77Q==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-italic@3.30.2': - resolution: {integrity: sha512-pp8uaiuXsUbLm5rYzR1jlWbwm1mAahRajdHwAKBtthFRB2rDvC7ZWhKaCSoKhZvfIDRmu9/B67+uAHoutL0dCA==} + '@tiptap/extension-italic@3.31.0': + resolution: {integrity: sha512-8Top75aMS9feCtKYADWOYLrJjx3SGvazETWZdEwDYDGZtEOB7q2+FxcRu2xi+K6Lx9LhzxS9ChULtNdBuRevkQ==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-link@3.30.2': - resolution: {integrity: sha512-jwdcymKcrbFpj5hRAuGVLCq8FieVkGFnENyroYmvkad+XAt8ZLy/MTFYRN6SK3ukH6PZMY7H4iObGtciQaC5nw==} + '@tiptap/extension-link@3.31.0': + resolution: {integrity: sha512-arzZYxyf6in2BzdB3GR53VhSlE3ZwmY8CflXRo1xHpSpxq393myLP6FnaxLPxcfcScfOOsaYh2KAED0HJoBZfA==} peerDependencies: - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-list-item@3.30.2': - resolution: {integrity: sha512-HWgRCRlGxulE+hN1VUcnWD6P2NE08VBgGtcaxOfdXVqaI93BCK6AhRQZGpLsfKgajLk+5DXTBraaitwnBqzCxg==} + '@tiptap/extension-list-item@3.31.0': + resolution: {integrity: sha512-ayZa+KEMigJis0s8n3X3pqRxzsMuom9JoqE6byJ5NP2oaI2HOQ95yfcuILNKukg1nXG8vkgifEsXxjXz4O2o2g==} peerDependencies: - '@tiptap/extension-list': 3.30.2 + '@tiptap/extension-list': 3.31.0 - '@tiptap/extension-list-keymap@3.30.2': - resolution: {integrity: sha512-TTve3WOlQaYu1ahMqsQ/T0wzaxfgZvcOl3/OuPyInOi8QtxXhqGhFjmYe5jOr56G9W2QDuFWVsZecVwfDte9zg==} + '@tiptap/extension-list-keymap@3.31.0': + resolution: {integrity: sha512-bmZZmQyH0PibpIUA2thA3Cgm82cnrcC4oU4eNqiaoUqrva1NuOIqb71L2Br9keBnmtQKEtkzdODs4x+eI53kBg==} peerDependencies: - '@tiptap/extension-list': 3.30.2 + '@tiptap/extension-list': 3.31.0 - '@tiptap/extension-list@3.30.2': - resolution: {integrity: sha512-MIUpo1Bd9Rf1Qg+TNYNwDZ4xsfFeQahjU9Xhy6UcaszKQzbAM7KCzn5BObNytK1NdcqNHsC8Wj5vFvMKEzrXdw==} + '@tiptap/extension-list@3.31.0': + resolution: {integrity: sha512-cED2TPewS3CtzZUdCZNG/qbc6vHJunKRzkFiT8x3RF+pmtIQlrPRH5Y9sbkzLt3yg0ua5KMGFtBH37AHaKUFDw==} peerDependencies: - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-ordered-list@3.30.2': - resolution: {integrity: sha512-Z7OO1HcF0idda1n6vodXeQ3h2ylN9JR4IfIGUYkar5Xl9JusK8PDETTBQZQn//96p49I2d+GoWsD2LXPtjHXXg==} + '@tiptap/extension-ordered-list@3.31.0': + resolution: {integrity: sha512-KLCax7pI/3JTCkMY01n2+CXwZRPPsIVR1fvSmkFMryUPonkkMYWSUlXCuYKvF22+kM0gU0kiXsVjqsn/YFdeDg==} peerDependencies: - '@tiptap/extension-list': 3.30.2 + '@tiptap/extension-list': 3.31.0 - '@tiptap/extension-paragraph@3.30.2': - resolution: {integrity: sha512-ulEu3LNt+kPVAWEnrhoz13Fs8Q/v/8NUxQbAeteuBchQ8joxJXuWExhpy1fUfZir5+b+W5z7/NesgPjZQfv47w==} + '@tiptap/extension-paragraph@3.31.0': + resolution: {integrity: sha512-78l9D/jpKvvWXrBBzYPfgqwonBY76+1MMgSovkPffIbya+jaYDJdZhIpeCZXtSBDnw8cYE/rheAeusN1QG3SVQ==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-placeholder@3.30.2': - resolution: {integrity: sha512-Bj1seUvPCoRrD/LpzMoKD+jQIjxuc+oq931GpPq4wobSUUbD4pF/0NMwpCLpiHO19QnTQz8+9p2dqPdlc44LHA==} + '@tiptap/extension-placeholder@3.31.0': + resolution: {integrity: sha512-zH1Z8xZX72P7wZ3HD6fUGJ7XTVWMgyN5Sy+OiD3tuuxfr/oUvS6ykaoGBdHGTMBdrXZpp+GfAcch1MrvLGn8oA==} peerDependencies: - '@tiptap/extensions': 3.30.2 + '@tiptap/extensions': 3.31.0 - '@tiptap/extension-strike@3.30.2': - resolution: {integrity: sha512-fBLxMXz6hYIURzLOD+/L6aVATztsKham00ANWmGi13vN0hx2lQMYZffN+gR+QqiCDfMQxBXzrf5a7tJuDiQHLQ==} + '@tiptap/extension-strike@3.31.0': + resolution: {integrity: sha512-M1X94ve0TNU7LrHCDn6nKf6vM8PEaKQt3FLTwhTIw5uTbx2Imw+uyRrySDdxLWHY/ul8x17I2w9MVLqK/D+Zvw==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-text-align@3.30.2': - resolution: {integrity: sha512-zexiz0uJlX2KzeAyYI/uEoBsl0Zw3Ua0CTV/kRKTnk7K9vCOnwvJXtXdkHA4WC12ACqUsPnyc4yMTwDR22fKFw==} + '@tiptap/extension-text-align@3.31.0': + resolution: {integrity: sha512-40hsX0anPQv87M3rD8vncqBIgED2xWUDTU/uL1fKTgeOm5BeiPJupxAV41ttI96T1suEAlghaaYrjs28ElK1fQ==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-text-style@3.30.2': - resolution: {integrity: sha512-o3YMN3JNHg/rCCLZB3PUcAfl4bkrsXDoYoHRXvrbE9cl/RT+qAEFAAssI8kBC1fp9en9V2F4Z7Uy2uuzkbecKw==} + '@tiptap/extension-text-style@3.31.0': + resolution: {integrity: sha512-eY8mIb/NS22sO1xJQqfzHtSdpL2UCQy1nbSCEohRUoW7eaP/nwp29x097+jjJnxg4LjLMsolyw11Q4mswbXDQQ==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-text@3.30.2': - resolution: {integrity: sha512-n/iZnirgRmXet6f97kolAnP3j8DsgLSiTbz/KLWc8eBYiFmkjRzkuisOm5xuGdfGIxwpB4x3tlSF4ef4DLnbRg==} + '@tiptap/extension-text@3.31.0': + resolution: {integrity: sha512-K+8nIKgNNBiL1+AarcuzvPoRpjuFk5qOe9MM/n9O3HyTTLeWNfRV/zFifcpSoHmhf0QvEAXqmnxDiIpyCpy9+g==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extension-underline@3.30.2': - resolution: {integrity: sha512-SZiTMnvqXcnrtJX+X25ZbYsuDO83haGOVMBD/O+mAWYNYXhaSc5Rkph5czzItxrd+Yyp/vs4PiwD7XTNbfqmpA==} + '@tiptap/extension-underline@3.31.0': + resolution: {integrity: sha512-B4VK5nu0UdKl7OiMvIcjDPMYmhe8+v1B763Is/GzPSJOMstUHRqAqPx2AsPla6A4UGuZwTx//ajVa5QAGionZQ==} peerDependencies: - '@tiptap/core': 3.30.2 + '@tiptap/core': 3.31.0 - '@tiptap/extensions@3.30.2': - resolution: {integrity: sha512-2LqAHXk26QDsryW+beECxYeBzv5Ylk4GuB3cOmfghS7/G37R2W+Te3TkUK7BT0EWoDryvBT57/5q0DEFIhfZZg==} + '@tiptap/extensions@3.31.0': + resolution: {integrity: sha512-WOv9Y1VjhzAYycrIGPygwmLkoO/UdFYFE5uDWN+FJWMui5sLmLrmfKtOOYzeWIevxomFGix/wl5hqaVGIev1+g==} peerDependencies: - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 - '@tiptap/pm@3.30.2': - resolution: {integrity: sha512-BJN8tUx4ppFN3R3cV/FJfrJbJkvo1lj4uciq+nwpjwzdRvFzqIuglWf+HLcJ6CwlYpLOHp7ArgkBg4Q5e60Gog==} + '@tiptap/pm@3.31.0': + resolution: {integrity: sha512-JGPUSPLDFPjgFZHcNSghVJmEfvYp2SRPZQ1K8AZOuviEsUNvkYmJsBdudeYwUYNzYO38L4Yy0G6UMSuniBDIMg==} - '@tiptap/starter-kit@3.30.2': - resolution: {integrity: sha512-fJSrhW1CyD4sjYA20evSP4Cp13B/HhbxCdM974K0xpHOVqvCtNU9w2s9hfq9mg2yGoU7MSNHKNYMkJjIi2/Xyw==} + '@tiptap/starter-kit@3.31.0': + resolution: {integrity: sha512-P80nhDEsBf6EMTaRb/ld3BaQW7iqITPR6BiIjOjbO3QDivCz/rGDKSS6q4ngsXfXOpSBc/clo/g0OyAdA8+Q4Q==} - '@tiptap/vue-3@3.30.2': - resolution: {integrity: sha512-7TCahsK19ChY60FvgseTuzaz67yR1tkzTbKW6s3iXx9NTiU411C6WEQAS/EtPt4dsGDv/esFjSc9EMKilRhY5g==} + '@tiptap/vue-3@3.31.0': + resolution: {integrity: sha512-v/oIp43nvANe2upFMc/1JGPvMD6C7mY0JdiB7kUjFxjAnTmjVRxJY+4hJiJgoVXTiCyDaEJeEhPKLueDJfKYwg==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.30.2 - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0 + '@tiptap/pm': 3.31.0 vue: ^3.0.0 '@tybys/wasm-util@0.10.3': @@ -2092,9 +2075,6 @@ packages: '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} - '@types/jsesc@2.5.1': - resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2410,15 +2390,27 @@ packages: '@vue/compiler-core@3.5.41': resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + '@vue/compiler-dom@3.5.41': resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + '@vue/compiler-sfc@3.5.41': resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + '@vue/compiler-ssr@3.5.41': resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + '@vue/devtools-api@6.6.4': resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} @@ -2439,21 +2431,24 @@ packages: '@vue/language-core@3.3.11': resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==} - '@vue/reactivity@3.5.41': - resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} - '@vue/runtime-core@3.5.41': - resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} - '@vue/runtime-dom@3.5.41': - resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} - '@vue/server-renderer@3.5.41': - resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} '@vue/shared@3.5.41': resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + '@vueuse/core@14.4.0': resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==} peerDependencies: @@ -2660,11 +2655,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.27: - resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.14: resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} engines: {node: '>=6.0.0'} @@ -2720,11 +2710,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.8: resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3263,9 +3248,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.351: - resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==} - electron-to-chromium@1.5.407: resolution: {integrity: sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==} @@ -3890,8 +3872,8 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} - isomorphic-dompurify@3.22.0: - resolution: {integrity: sha512-cz/BkSODnQir4IV2quqbuEHhAGz4MAYGvhoemsxuwMpY/oZbRHlNjPLPyIQ0fdULiwDPUE+OHPwWb6XTEiNk1A==} + isomorphic-dompurify@4.1.0: + resolution: {integrity: sha512-vjQwQSxyEkJHO6g+KIDlWj8swAzUB01pJu9GU9cLGHjE10d59GFzJkgtB2Lj5WTOoq9JeHlYjR/FFuqeopPO0Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} jackspeak@3.4.3: @@ -4336,9 +4318,6 @@ packages: node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - node-releases@2.0.53: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} @@ -4415,6 +4394,9 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + on-change@6.0.2: resolution: {integrity: sha512-08+12qcOVEA0fS9g/VxKS27HaT94nRutUT77J2dr8zv/unzXopvhBuF8tNLWsoLQ5IgrQ6eptGeGqUYat82U1w==} engines: {node: '>=20'} @@ -4814,12 +4796,8 @@ packages: peerDependencies: postcss: ^8.5.23 - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} engines: {node: '>=4'} postcss-selector-parser@7.1.5: @@ -5453,13 +5431,13 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} engines: {node: '>=18.0.0'} hasBin: true @@ -5709,17 +5687,11 @@ packages: unwasm@0.5.3: resolution: {integrity: sha512-keBgTSfp3r6+s9ZcSma+0chwxQdmLbB5+dAD9vjtB21UTMYuKAxHXCU1K2CbCtnP09EaWeRvACnXk0EJtUx+hw==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.3.1: resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: ^4.28.8 uqr@0.1.3: resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} @@ -5916,8 +5888,8 @@ packages: peerDependencies: vue: ^3.0.0 - vue-router@5.2.0: - resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} + vue-router@5.3.0: + resolution: {integrity: sha512-a2PBXX9yfkS58JzSJALUffgDa09lEzKmCcEnLaepoIr88L+9hEnsgEQkcadJGID+vtHa0gFpVo064zmylA9fcg==} peerDependencies: '@pinia/colada': '>=0.21.2' '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 @@ -5940,8 +5912,8 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue@3.5.41: - resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -6118,8 +6090,8 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: @@ -6187,15 +6159,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@8.0.0': - dependencies: - '@babel/parser': 8.0.4 - '@babel/types': 8.0.4 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@types/jsesc': 2.5.1 - jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.29.7': dependencies: '@babel/types': 7.29.8 @@ -6204,7 +6167,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -6283,14 +6246,10 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-string-parser@8.0.0': {} - '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-identifier@8.0.4': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.29.7': @@ -6306,10 +6265,6 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/parser@8.0.4': - dependencies: - '@babel/types': 8.0.4 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -6377,11 +6332,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.0.4': - dependencies: - '@babel/helper-string-parser': 8.0.0 - '@babel/helper-validator-identifier': 8.0.4 - '@bomb.sh/tab@0.0.19(cac@6.7.14)(citty@0.2.2)': optionalDependencies: cac: 6.7.14 @@ -6435,27 +6385,27 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.1)': + '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.5 - '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.5 '@drizzle-team/brocli@0.10.2': {} - '@dxup/nuxt@0.5.7(esbuild@0.28.2)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': + '@dxup/nuxt@0.5.7(esbuild@0.28.2)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@dxup/unimport': 0.1.2 - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) '@vue/compiler-dom': 3.5.41 chokidar: 5.0.0 knitwork: 1.3.0 magic-string: 1.2.0 pathe: 2.0.3 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -6642,7 +6592,7 @@ snapshots: dependencies: '@iconify/types': 2.0.0 - '@iconify-json/simple-icons@1.2.93': + '@iconify-json/simple-icons@1.2.94': dependencies: '@iconify/types': 2.0.0 @@ -6658,12 +6608,12 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 - '@iconify/vue@5.0.1(vue@3.5.41(typescript@5.9.3))': + '@iconify/vue@5.0.1(vue@3.5.42(typescript@5.9.3))': dependencies: '@iconify/types': 2.0.0 - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) - '@intlify/bundle-utils@11.2.5(vue-i18n@11.4.8(vue@3.5.41(typescript@5.9.3)))': + '@intlify/bundle-utils@11.2.5(vue-i18n@11.4.8(vue@3.5.42(typescript@5.9.3)))': dependencies: '@intlify/message-compiler': 11.4.8 '@intlify/shared': 11.4.8 @@ -6675,7 +6625,7 @@ snapshots: source-map-js: 1.2.1 yaml-eslint-parser: 1.3.2 optionalDependencies: - vue-i18n: 11.4.8(vue@3.5.41(typescript@5.9.3)) + vue-i18n: 11.4.8(vue@3.5.42(typescript@5.9.3)) '@intlify/core-base@11.4.8': dependencies: @@ -6705,12 +6655,12 @@ snapshots: '@intlify/shared@11.4.8': {} - '@intlify/unplugin-vue-i18n@11.2.5(@vue/compiler-dom@3.5.41)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue-i18n@11.4.8(vue@3.5.41(typescript@5.9.3)))(vue@3.5.41(typescript@5.9.3))': + '@intlify/unplugin-vue-i18n@11.2.5(@vue/compiler-dom@3.5.41)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue-i18n@11.4.8(vue@3.5.42(typescript@5.9.3)))(vue@3.5.42(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2)) - '@intlify/bundle-utils': 11.2.5(vue-i18n@11.4.8(vue@3.5.41(typescript@5.9.3))) + '@intlify/bundle-utils': 11.2.5(vue-i18n@11.4.8(vue@3.5.42(typescript@5.9.3))) '@intlify/shared': 11.4.8 - '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.4.8)(@vue/compiler-dom@3.5.41)(vue-i18n@11.4.8(vue@3.5.41(typescript@5.9.3)))(vue@3.5.41(typescript@5.9.3)) + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.4.8)(@vue/compiler-dom@3.5.41)(vue-i18n@11.4.8(vue@3.5.42(typescript@5.9.3)))(vue@3.5.42(typescript@5.9.3)) '@rollup/pluginutils': 5.3.0(rollup@4.60.3) '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(supports-color@10.2.2)(typescript@5.9.3) @@ -6719,10 +6669,10 @@ snapshots: pathe: 2.0.3 picocolors: 1.1.1 unplugin: 2.3.11 - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) optionalDependencies: - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vue-i18n: 11.4.8(vue@3.5.41(typescript@5.9.3)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vue-i18n: 11.4.8(vue@3.5.42(typescript@5.9.3)) transitivePeerDependencies: - '@vue/compiler-dom' - eslint @@ -6734,14 +6684,14 @@ snapshots: '@intlify/utils@0.14.1': {} - '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.4.8)(@vue/compiler-dom@3.5.41)(vue-i18n@11.4.8(vue@3.5.41(typescript@5.9.3)))(vue@3.5.41(typescript@5.9.3))': + '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.4.8)(@vue/compiler-dom@3.5.41)(vue-i18n@11.4.8(vue@3.5.42(typescript@5.9.3)))(vue@3.5.42(typescript@5.9.3))': dependencies: '@babel/parser': 7.29.8 optionalDependencies: '@intlify/shared': 11.4.8 '@vue/compiler-dom': 3.5.41 - vue: 3.5.41(typescript@5.9.3) - vue-i18n: 11.4.8(vue@3.5.41(typescript@5.9.3)) + vue: 3.5.42(typescript@5.9.3) + vue-i18n: 11.4.8(vue@3.5.42(typescript@5.9.3)) '@ioredis/commands@1.5.1': {} @@ -6888,11 +6838,23 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@3.4.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': + '@nuxt/devtools-kit@3.4.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) + execa: 8.0.1 + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + transitivePeerDependencies: + - magic-string + - magicast + - oxc-parser + - rolldown + - unplugin + + '@nuxt/devtools-kit@3.4.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) execa: 8.0.1 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - magic-string - magicast @@ -6911,12 +6873,12 @@ snapshots: pkg-types: 2.3.1 semver: 7.8.5 - '@nuxt/devtools@3.4.1(bufferutil@4.1.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(utf-8-validate@6.0.6)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': + '@nuxt/devtools@3.4.1(bufferutil@4.1.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(utf-8-validate@6.0.6)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 3.4.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.4.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) '@nuxt/devtools-wizard': 3.4.1 - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) - '@vue/devtools-core': 8.2.1(vue@3.5.41(typescript@5.9.3)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) + '@vue/devtools-core': 8.2.1(vue@3.5.42(typescript@5.9.3)) '@vue/devtools-kit': 8.2.1 birpc: 4.0.0 consola: 3.4.2 @@ -6942,9 +6904,9 @@ snapshots: structured-clone-es: 2.0.1 tinyglobby: 0.2.17 unstorage: 1.17.5(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2)) - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vite-plugin-inspect: 11.4.1(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - vite-plugin-vue-tracer: 1.5.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vite-plugin-inspect: 11.4.1(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + vite-plugin-vue-tracer: 1.5.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) which: 6.0.1 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -6976,13 +6938,13 @@ snapshots: - utf-8-validate - vue - '@nuxt/fonts@0.14.0(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': + '@nuxt/fonts@0.14.0(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: - '@nuxt/devtools-kit': 3.4.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + '@nuxt/devtools-kit': 3.4.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) consola: 3.4.2 defu: 6.1.7 - fontless: 0.2.1(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + fontless: 0.2.1(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) h3: 1.15.11 magic-regexp: 0.10.0 ofetch: 1.5.1 @@ -6991,7 +6953,7 @@ snapshots: tinyglobby: 0.2.17 ufo: 1.6.4 unifont: 0.7.5 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unstorage: 1.17.5(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2)) transitivePeerDependencies: - '@azure/app-configuration' @@ -7026,18 +6988,18 @@ snapshots: - vite - webpack - '@nuxt/icon@2.5.0(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': + '@nuxt/icon@2.5.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: '@iconify/collections': 1.0.727 '@iconify/types': 2.0.0 '@iconify/utils': 3.1.4 - '@iconify/vue': 5.0.1(vue@3.5.41(typescript@5.9.3)) - '@nuxt/devtools-kit': 3.4.1(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + '@iconify/vue': 5.0.1(vue@3.5.42(typescript@5.9.3)) + '@nuxt/devtools-kit': 3.4.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) consola: 3.4.2 local-pkg: 1.2.1 mlly: 1.8.2 - ohash: 2.0.11 + ohash: 2.0.12 picomatch: 4.0.5 std-env: 4.2.0 tinyglobby: 0.2.17 @@ -7077,7 +7039,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/kit@4.5.2(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))': + '@nuxt/kit@4.5.2(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: c12: 3.3.4(magicast@0.5.4) consola: 3.4.2 @@ -7097,7 +7059,7 @@ snapshots: scule: 1.3.0 tinyglobby: 0.2.17 ufo: 1.6.4 - unctx: 3.0.0(magic-string@0.30.21)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + unctx: 3.0.0(magic-string@0.30.21)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) untyped: 2.0.0 verkit: 0.3.2 transitivePeerDependencies: @@ -7107,7 +7069,7 @@ snapshots: - rolldown - unplugin - '@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))': + '@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: c12: 3.3.4(magicast@0.5.4) consola: 3.4.2 @@ -7127,7 +7089,7 @@ snapshots: scule: 1.3.0 tinyglobby: 0.2.17 ufo: 1.6.4 - unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) untyped: 2.0.0 verkit: 0.3.2 transitivePeerDependencies: @@ -7137,11 +7099,11 @@ snapshots: - rolldown - unplugin - '@nuxt/nitro-server@4.5.2(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(lightningcss@1.33.0)(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(77b5bdd374d6510da956fd364e2d9d4d))(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': + '@nuxt/nitro-server@4.5.2(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(lightningcss@1.33.0)(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a))(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) - '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) + '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) '@vue/shared': 3.5.41 consola: 3.4.2 defu: 6.1.7 @@ -7151,21 +7113,21 @@ snapshots: escape-string-regexp: 5.0.0 exsolve: 1.1.1 h3: 1.15.11 - impound: 1.1.7(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + impound: 1.1.7(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + nitropack: 2.13.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) nostics: 1.2.0 - nuxt: 4.5.2(77b5bdd374d6510da956fd364e2d9d4d) + nuxt: 4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a) nypm: 0.6.9 ohash: 2.0.11 pathe: 2.0.3 rou3: 0.9.2 std-env: 4.2.0 ufo: 1.6.4 - unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) unstorage: 1.17.5(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2)) - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) vue-bundle-renderer: 2.3.2 vue-devtools-stub: 0.1.0 optionalDependencies: @@ -7230,20 +7192,20 @@ snapshots: pkg-types: 2.3.1 std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))))': + '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))))': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) citty: 0.2.2 consola: 3.4.2 ofetch: 2.0.0-alpha.3 rc9: 3.0.1 std-env: 4.2.0 - '@nuxt/vite-builder@4.5.2(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@10.2.2)))(@types/node@24.13.3)(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(77b5bdd374d6510da956fd364e2d9d4d))(optionator@0.9.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.60.3))(rollup@4.60.3)(supports-color@10.2.2)(terser@5.46.2)(tsx@4.23.12)(typescript@5.9.3)(vue-tsc@3.3.11(typescript@5.9.3))(vue@3.5.41(typescript@5.9.3))(yaml@2.9.0)': + '@nuxt/vite-builder@4.5.2(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@10.2.2)))(@types/node@24.13.3)(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a))(optionator@0.9.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.60.3))(rollup@4.60.3)(supports-color@10.2.2)(terser@5.46.2)(tsx@4.23.13)(typescript@5.9.3)(vue-tsc@3.3.11(typescript@5.9.3))(vue@3.5.42(typescript@5.9.3))(yaml@2.9.0)': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) - '@vitejs/plugin-vue': 6.0.8(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) + '@vitejs/plugin-vue': 6.0.8(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) autoprefixer: 10.5.4(postcss@8.5.26) consola: 3.4.2 cssnano: 8.0.6(postcss@8.5.26) @@ -7257,7 +7219,7 @@ snapshots: knitwork: 1.3.0 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.5.2(77b5bdd374d6510da956fd364e2d9d4d) + nuxt: 4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a) nypm: 0.6.9 pathe: 2.0.3 pkg-types: 2.3.1 @@ -7267,11 +7229,11 @@ snapshots: std-env: 4.2.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vite-node: 6.0.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vite-plugin-checker: 0.14.5(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@5.9.3)) - vue: 3.5.41(typescript@5.9.3) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vite-node: 6.0.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vite-plugin-checker: 0.14.5(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@5.9.3)) + vue: 3.5.42(typescript@5.9.3) vue-bundle-renderer: 2.3.2 optionalDependencies: '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) @@ -7308,16 +7270,16 @@ snapshots: - webpack - yaml - '@nuxtjs/i18n@10.6.0(@vue/compiler-dom@3.5.41)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.10.1(supports-color@10.2.2))(magicast@0.5.4)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': + '@nuxtjs/i18n@10.6.0(@vue/compiler-dom@3.5.41)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.10.1(supports-color@10.2.2))(magicast@0.5.4)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: '@intlify/core': 11.4.8 '@intlify/h3': 0.7.4 '@intlify/message-compiler': 11.4.8 '@intlify/shared': 11.4.8 - '@intlify/unplugin-vue-i18n': 11.2.5(@vue/compiler-dom@3.5.41)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue-i18n@11.4.8(vue@3.5.41(typescript@5.9.3)))(vue@3.5.41(typescript@5.9.3)) + '@intlify/unplugin-vue-i18n': 11.2.5(@vue/compiler-dom@3.5.41)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue-i18n@11.4.8(vue@3.5.42(typescript@5.9.3)))(vue@3.5.42(typescript@5.9.3)) '@intlify/utils': 0.14.1 '@miyaneee/rollup-plugin-json5': 1.2.0(rollup@4.60.3) - '@nuxt/kit': 4.5.2(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) '@rollup/plugin-yaml': 4.1.2(rollup@4.60.3) '@vue/compiler-sfc': 3.5.41 confbox: 0.2.4 @@ -7333,12 +7295,12 @@ snapshots: oxc-walker: 0.7.0(oxc-parser@0.141.0) pathe: 2.0.3 ufo: 1.6.4 - unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unrouting: 0.2.3 unstorage: 1.17.5(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2)) - vue-i18n: 11.4.8(vue@3.5.41(typescript@5.9.3)) - vue-router: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + vue-i18n: 11.4.8(vue@3.5.42(typescript@5.9.3)) + vue-router: 5.3.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7378,7 +7340,7 @@ snapshots: - vue - webpack - '@nuxtjs/tailwindcss@6.14.0(magicast@0.5.4)(supports-color@10.2.2)(tsx@4.23.12)(yaml@2.9.0)': + '@nuxtjs/tailwindcss@6.14.0(magicast@0.5.4)(supports-color@10.2.2)(tsx@4.23.13)(yaml@2.9.0)': dependencies: '@nuxt/kit': 3.21.4(magicast@0.5.4) autoprefixer: 10.5.0(postcss@8.5.26) @@ -7392,8 +7354,8 @@ snapshots: pkg-types: 2.3.1 postcss: 8.5.26 postcss-nesting: 13.0.2(postcss@8.5.26) - tailwind-config-viewer: 2.0.4(supports-color@10.2.2)(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)) - tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) + tailwind-config-viewer: 2.0.4(supports-color@10.2.2)(tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0)) + tailwindcss: 3.4.19(tsx@4.23.13)(yaml@2.9.0) ufo: 1.6.4 unctx: 2.5.0 transitivePeerDependencies: @@ -7728,10 +7690,10 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 - '@pinia/nuxt@1.0.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))': + '@pinia/nuxt@1.0.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) - pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) + pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) transitivePeerDependencies: - magic-string - magicast @@ -7958,7 +7920,7 @@ snapshots: '@simplewebauthn/browser@13.3.0': {} - '@simplewebauthn/server@13.3.2': + '@simplewebauthn/server@13.3.3': dependencies: '@hexagon/base64': 1.1.28 '@levischuck/tiny-cbor': 0.2.11 @@ -7981,140 +7943,140 @@ snapshots: dependencies: uint8-util: 2.3.2 - '@tiptap/core@3.30.2(@tiptap/pm@3.30.2)': + '@tiptap/core@3.31.0(@tiptap/pm@3.31.0)': dependencies: - '@tiptap/pm': 3.30.2 + '@tiptap/pm': 3.31.0 - '@tiptap/extension-blockquote@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extension-blockquote@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 - '@tiptap/extension-bold@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-bold@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-bubble-menu@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extension-bubble-menu@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 optional: true - '@tiptap/extension-bullet-list@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + '@tiptap/extension-bullet-list@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-code-block@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 - '@tiptap/extension-code@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-code@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-color@3.30.2(@tiptap/extension-text-style@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)))': + '@tiptap/extension-color@3.31.0(@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)))': dependencies: - '@tiptap/extension-text-style': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-text-style': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-document@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-document@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-dropcursor@3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + '@tiptap/extension-dropcursor@3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-floating-menu@3.30.2(@floating-ui/dom@1.7.6)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extension-floating-menu@3.31.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 optional: true - '@tiptap/extension-gapcursor@3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + '@tiptap/extension-gapcursor@3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-hard-break@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-hard-break@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-heading@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-heading@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-horizontal-rule@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extension-horizontal-rule@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 - '@tiptap/extension-image@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-image@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-italic@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-italic@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-link@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extension-link@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + '@tiptap/extension-list-item@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-list-keymap@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + '@tiptap/extension-list-keymap@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 - '@tiptap/extension-ordered-list@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + '@tiptap/extension-ordered-list@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-paragraph@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-paragraph@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-placeholder@3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + '@tiptap/extension-placeholder@3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-strike@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-strike@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-text-align@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-text-align@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-text-style@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-text@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-text@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-underline@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + '@tiptap/extension-underline@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + '@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 - '@tiptap/pm@3.30.2': + '@tiptap/pm@3.31.0': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 @@ -8130,42 +8092,42 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.42.2 - '@tiptap/starter-kit@3.30.2': - dependencies: - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/extension-blockquote': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) - '@tiptap/extension-bold': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-bullet-list': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) - '@tiptap/extension-code': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-code-block': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) - '@tiptap/extension-document': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-dropcursor': 3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) - '@tiptap/extension-gapcursor': 3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) - '@tiptap/extension-hard-break': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-heading': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-horizontal-rule': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) - '@tiptap/extension-italic': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-link': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) - '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) - '@tiptap/extension-list-item': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) - '@tiptap/extension-list-keymap': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) - '@tiptap/extension-ordered-list': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) - '@tiptap/extension-paragraph': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-strike': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-text': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extension-underline': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) - '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 - - '@tiptap/vue-3@3.30.2(@floating-ui/dom@1.7.6)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)(vue@3.5.41(typescript@5.9.3))': + '@tiptap/starter-kit@3.31.0': + dependencies: + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/extension-blockquote': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-bold': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-bullet-list': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + '@tiptap/extension-code': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-code-block': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-document': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-dropcursor': 3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + '@tiptap/extension-gapcursor': 3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + '@tiptap/extension-hard-break': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-heading': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-horizontal-rule': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-italic': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-link': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list-item': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + '@tiptap/extension-list-keymap': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + '@tiptap/extension-ordered-list': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + '@tiptap/extension-paragraph': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-strike': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-text': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-underline': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 + + '@tiptap/vue-3@3.31.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(vue@3.5.42(typescript@5.9.3))': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) - '@tiptap/pm': 3.30.2 - vue: 3.5.41(typescript@5.9.3) + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 + vue: 3.5.42(typescript@5.9.3) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) - '@tiptap/extension-floating-menu': 3.30.2(@floating-ui/dom@1.7.6)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-bubble-menu': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-floating-menu': 3.31.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) '@tybys/wasm-util@0.10.3': dependencies: @@ -8195,8 +8157,6 @@ snapshots: '@types/hammerjs@2.0.46': {} - '@types/jsesc@2.5.1': {} - '@types/json-schema@7.0.15': {} '@types/magnet-uri@5.1.5': @@ -8333,18 +8293,18 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@unhead/bundler@3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(unhead@3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': + '@unhead/bundler@3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(unhead@3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: magic-string: 1.2.0 oxc-walker: 1.1.1(@oxc-project/types@0.144.0)(oxc-parser@0.141.0)(rolldown@1.2.4) - unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: esbuild: 0.28.2 lightningcss: 1.33.0 oxc-parser: 0.141.0 rolldown: 1.2.4 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@oxc-project/types' @@ -8353,15 +8313,15 @@ snapshots: - rollup - unloader - '@unhead/vue@3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': + '@unhead/vue@3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: - '@unhead/bundler': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(unhead@3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + '@unhead/bundler': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(unhead@3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) hookable: 6.1.1 - unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - vue: 3.5.41(typescript@5.9.3) + unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + vue: 3.5.42(typescript@5.9.3) optionalDependencies: - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@oxc-project/types' @@ -8395,23 +8355,23 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/pluginutils': 1.0.1 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vue: 3.5.41(typescript@5.9.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vue: 3.5.42(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vue: 3.5.41(typescript@5.9.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vue: 3.5.42(typescript@5.9.3) '@vitest/expect@4.1.11': dependencies: @@ -8422,13 +8382,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -8468,7 +8428,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@vue-macros/common@3.1.4(vue@3.5.41(typescript@5.9.3))': + '@vue-macros/common@3.1.4(vue@3.5.42(typescript@5.9.3))': dependencies: '@vue/compiler-sfc': 3.5.41 ast-kit: 2.2.0 @@ -8476,7 +8436,7 @@ snapshots: magic-string-ast: 1.0.3 unplugin-utils: 0.3.2 optionalDependencies: - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) '@vue/babel-helper-vue-transform-on@2.0.1': {} @@ -8515,11 +8475,24 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.1 + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + '@vue/compiler-dom@3.5.41': dependencies: '@vue/compiler-core': 3.5.41 '@vue/shared': 3.5.41 + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + '@vue/compiler-sfc@3.5.41': dependencies: '@babel/parser': 7.29.8 @@ -8532,22 +8505,39 @@ snapshots: postcss: 8.5.26 source-map-js: 1.2.1 + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + '@vue/compiler-ssr@3.5.41': dependencies: '@vue/compiler-dom': 3.5.41 '@vue/shared': 3.5.41 + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + '@vue/devtools-api@6.6.4': {} '@vue/devtools-api@8.2.1': dependencies: '@vue/devtools-kit': 8.2.1 - '@vue/devtools-core@8.2.1(vue@3.5.41(typescript@5.9.3))': + '@vue/devtools-core@8.2.1(vue@3.5.42(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 8.2.1 '@vue/devtools-shared': 8.2.1 - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) '@vue/devtools-kit@8.2.1': dependencies: @@ -8568,42 +8558,44 @@ snapshots: path-browserify: 1.0.1 picomatch: 4.0.5 - '@vue/reactivity@3.5.41': + '@vue/reactivity@3.5.42': dependencies: - '@vue/shared': 3.5.41 + '@vue/shared': 3.5.42 - '@vue/runtime-core@3.5.41': + '@vue/runtime-core@3.5.42': dependencies: - '@vue/reactivity': 3.5.41 - '@vue/shared': 3.5.41 + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 - '@vue/runtime-dom@3.5.41': + '@vue/runtime-dom@3.5.42': dependencies: - '@vue/reactivity': 3.5.41 - '@vue/runtime-core': 3.5.41 - '@vue/shared': 3.5.41 + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 csstype: 3.2.3 - '@vue/server-renderer@3.5.41': + '@vue/server-renderer@3.5.42': dependencies: - '@vue/compiler-ssr': 3.5.41 - '@vue/runtime-dom': 3.5.41 - '@vue/shared': 3.5.41 + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 '@vue/shared@3.5.41': {} - '@vueuse/core@14.4.0(vue@3.5.41(typescript@5.9.3))': + '@vue/shared@3.5.42': {} + + '@vueuse/core@14.4.0(vue@3.5.42(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 14.4.0 - '@vueuse/shared': 14.4.0(vue@3.5.41(typescript@5.9.3)) - vue: 3.5.41(typescript@5.9.3) + '@vueuse/shared': 14.4.0(vue@3.5.42(typescript@5.9.3)) + vue: 3.5.42(typescript@5.9.3) '@vueuse/metadata@14.4.0': {} - '@vueuse/shared@14.4.0(vue@3.5.41(typescript@5.9.3))': + '@vueuse/shared@14.4.0(vue@3.5.42(typescript@5.9.3))': dependencies: - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) abbrev@3.0.1: {} @@ -8722,7 +8714,7 @@ snapshots: autoprefixer@10.5.0(postcss@8.5.26): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 caniuse-lite: 1.0.30001792 fraction.js: 5.3.4 picocolors: 1.1.1 @@ -8780,8 +8772,6 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.27: {} - baseline-browser-mapping@2.11.14: {} bencode@4.0.1: @@ -8829,14 +8819,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.27 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.351 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - browserslist@4.28.8: dependencies: baseline-browser-mapping: 2.11.14 @@ -8926,7 +8908,7 @@ snapshots: caniuse-api@4.0.0: dependencies: browserslist: 4.28.8 - caniuse-lite: 1.0.30001792 + caniuse-lite: 1.0.30001809 caniuse-lite@1.0.30001792: {} @@ -9269,7 +9251,7 @@ snapshots: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.28.2 - tsx: 4.21.0 + tsx: 4.23.12 drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9): optionalDependencies: @@ -9292,8 +9274,6 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.351: {} - electron-to-chromium@1.5.407: {} emoji-regex@10.6.0: {} @@ -9581,7 +9561,7 @@ snapshots: dependencies: tiny-inflate: 1.0.3 - fontless@0.2.1(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + fontless@0.2.1(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: consola: 3.4.2 css-tree: 3.2.1 @@ -9597,7 +9577,7 @@ snapshots: unifont: 0.7.5 unstorage: 1.17.5(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2)) optionalDependencies: - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -9842,12 +9822,12 @@ snapshots: import-meta-resolve@4.2.0: {} - impound@1.1.7(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + impound@1.1.7(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 es-module-lexer: 2.1.0 pathe: 2.0.3 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unplugin-utils: 0.3.2 transitivePeerDependencies: - '@farmfe/core' @@ -9978,7 +9958,7 @@ snapshots: isexe@4.0.0: {} - isomorphic-dompurify@3.22.0(@noble/hashes@2.2.0): + isomorphic-dompurify@4.1.0(@noble/hashes@2.2.0): dependencies: dompurify: 3.4.13 jsdom: 30.0.1(@noble/hashes@2.2.0) @@ -10392,7 +10372,7 @@ snapshots: negotiator@0.6.3: {} - nitropack@2.13.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + nitropack@2.13.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.3) @@ -10457,7 +10437,7 @@ snapshots: uncrypto: 0.1.3 unctx: 2.5.0 unenv: 2.0.0-rc.24 - unimport: 6.2.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unimport: 6.2.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unplugin-utils: 0.3.1 unstorage: 1.17.5(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2)) untyped: 2.0.0 @@ -10524,8 +10504,6 @@ snapshots: node-mock-http@1.0.4: {} - node-releases@2.0.38: {} - node-releases@2.0.53: {} nodemailer@9.0.5: {} @@ -10553,17 +10531,17 @@ snapshots: nuxt-define@1.0.0: {} - nuxt@4.5.2(77b5bdd374d6510da956fd364e2d9d4d): + nuxt@4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a): dependencies: - '@dxup/nuxt': 0.5.7(esbuild@0.28.2)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + '@dxup/nuxt': 0.5.7(esbuild@0.28.2)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2) - '@nuxt/devtools': 3.4.1(bufferutil@4.1.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(utf-8-validate@6.0.6)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) - '@nuxt/nitro-server': 4.5.2(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(lightningcss@1.33.0)(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(77b5bdd374d6510da956fd364e2d9d4d))(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + '@nuxt/devtools': 3.4.1(bufferutil@4.1.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(ioredis@5.10.1(supports-color@10.2.2))(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(utf-8-validate@6.0.6)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) + '@nuxt/nitro-server': 4.5.2(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9))(esbuild@0.28.2)(ioredis@5.10.1(supports-color@10.2.2))(lightningcss@1.33.0)(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a))(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(supports-color@10.2.2)(typescript@5.9.3)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) '@nuxt/schema': 4.5.2 - '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)))) - '@nuxt/vite-builder': 4.5.2(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@10.2.2)))(@types/node@24.13.3)(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(77b5bdd374d6510da956fd364e2d9d4d))(optionator@0.9.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.60.3))(rollup@4.60.3)(supports-color@10.2.2)(terser@5.46.2)(tsx@4.23.12)(typescript@5.9.3)(vue-tsc@3.3.11(typescript@5.9.3))(vue@3.5.41(typescript@5.9.3))(yaml@2.9.0) - '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)))) + '@nuxt/vite-builder': 4.5.2(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@10.2.2)))(@types/node@24.13.3)(esbuild@0.28.2)(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(magic-string@1.2.0)(magicast@0.5.4)(nuxt@4.5.2(681c3a944246a29cc8d8bbcbc21f6d4a))(optionator@0.9.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.60.3))(rollup@4.60.3)(supports-color@10.2.2)(terser@5.46.2)(tsx@4.23.13)(typescript@5.9.3)(vue-tsc@3.3.11(typescript@5.9.3))(vue@3.5.42(typescript@5.9.3))(yaml@2.9.0) + '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) '@vue/shared': 3.5.41 chokidar: 5.0.0 compatx: 0.2.0 @@ -10577,7 +10555,7 @@ snapshots: fnv1a-64: 0.1.2 hookable: 6.1.1 ignore: 7.0.6 - impound: 1.1.7(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + impound: 1.1.7(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) jiti: 2.7.0 klona: 2.0.6 knitwork: 1.3.0 @@ -10604,17 +10582,17 @@ snapshots: ufo: 1.6.4 ultrahtml: 1.7.0 uncrypto: 0.1.3 - unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) undici: 8.10.0 - unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - unimport: 6.4.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unhead: 3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + unimport: 6.4.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unrouting: 0.2.3 untyped: 2.0.0 verkit: 0.3.2 - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) vue-component-type-helpers: 3.3.10 - vue-router: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + vue-router: 5.3.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) optionalDependencies: '@parcel/watcher': 2.5.6 '@types/node': 24.13.3 @@ -10718,6 +10696,8 @@ snapshots: ohash@2.0.11: {} + ohash@2.0.12: {} + on-change@6.0.2: {} on-exit-leak-free@2.1.2: {} @@ -10906,11 +10886,11 @@ snapshots: pify@2.3.0: {} - pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)): + pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)): dependencies: '@vue/devtools-api': 8.2.1 nostics: 1.2.0 - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 @@ -10976,7 +10956,7 @@ snapshots: postcss-calc@10.1.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 postcss-colormin@8.0.4(postcss@8.5.26): @@ -11022,13 +11002,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.26 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.13)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.26 - tsx: 4.23.12 + tsx: 4.23.13 yaml: 2.9.0 postcss-merge-longhand@8.0.4(postcss@8.5.26): @@ -11075,14 +11055,14 @@ snapshots: postcss-nested@6.2.0(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 6.1.2 + postcss-selector-parser: 6.1.4 postcss-nesting@13.0.2(postcss@8.5.26): dependencies: - '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.1) - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.5) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) postcss: 8.5.26 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.5 postcss-normalize-charset@8.0.4(postcss@8.5.26): dependencies: @@ -11146,12 +11126,7 @@ snapshots: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-selector-parser@7.1.1: + postcss-selector-parser@6.1.4: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -11699,7 +11674,7 @@ snapshots: tagged-tag@1.0.0: {} - tailwind-config-viewer@2.0.4(supports-color@10.2.2)(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)): + tailwind-config-viewer@2.0.4(supports-color@10.2.2)(tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@koa/router': 12.0.2(supports-color@10.2.2) commander: 6.2.1 @@ -11709,11 +11684,11 @@ snapshots: open: 7.4.2 portfinder: 1.0.38(supports-color@10.2.2) replace-in-file: 6.3.5 - tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color - tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0): + tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -11732,9 +11707,9 @@ snapshots: postcss: 8.5.26 postcss-import: 15.1.0(postcss@8.5.26) postcss-js: 4.1.0(postcss@8.5.26) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.13)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.26) - postcss-selector-parser: 6.1.2 + postcss-selector-parser: 6.1.4 resolve: 1.22.12 sucrase: 3.35.1 transitivePeerDependencies: @@ -11853,14 +11828,13 @@ snapshots: tsscmp@1.0.6: {} - tsx@4.21.0: + tsx@4.23.12: dependencies: esbuild: 0.28.2 - get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 - tsx@4.23.12: + tsx@4.23.13: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -11935,19 +11909,19 @@ snapshots: magic-string: 0.30.21 unplugin: 2.3.11 - unctx@3.0.0(magic-string@0.30.21)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))): + unctx@3.0.0(magic-string@0.30.21)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))): optionalDependencies: magic-string: 0.30.21 oxc-parser: 0.141.0 rolldown: 1.2.4 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) - unctx@3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))): + unctx@3.0.0(magic-string@1.2.0)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))): optionalDependencies: magic-string: 1.2.0 oxc-parser: 0.141.0 rolldown: 1.2.4 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) undici-types@7.18.2: {} @@ -11957,12 +11931,12 @@ snapshots: dependencies: pathe: 2.0.3 - unhead@3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + unhead@3.3.2(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: hookable: 6.1.1 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -11983,7 +11957,7 @@ snapshots: ohash: 2.0.11 undici: 8.10.0 - unimport@6.2.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + unimport@6.2.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: acorn: 8.16.0 escape-string-regexp: 5.0.0 @@ -11997,7 +11971,7 @@ snapshots: scule: 1.3.0 strip-literal: 3.1.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unplugin-utils: 0.3.2 optionalDependencies: oxc-parser: 0.141.0 @@ -12012,7 +11986,7 @@ snapshots: - vite - webpack - unimport@6.4.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + unimport@6.4.0(esbuild@0.28.2)(oxc-parser@0.141.0)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: acorn: 8.18.0 escape-string-regexp: 5.0.0 @@ -12026,7 +12000,7 @@ snapshots: scule: 1.3.0 strip-literal: 4.0.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unplugin-utils: 0.3.2 optionalDependencies: oxc-parser: 0.141.0 @@ -12060,7 +12034,7 @@ snapshots: picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.5 @@ -12069,7 +12043,7 @@ snapshots: esbuild: 0.28.2 rolldown: 1.2.4 rollup: 4.60.3 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) unrouting@0.2.3: dependencies: @@ -12113,12 +12087,6 @@ snapshots: pathe: 2.0.3 pkg-types: 2.3.1 - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: browserslist: 4.28.8 @@ -12144,23 +12112,23 @@ snapshots: verkit@0.3.2: {} - vite-dev-rpc@2.0.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + vite-dev-rpc@2.0.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: birpc: 4.0.0 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vite-hot-client: 2.2.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) - vite-hot-client@2.2.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + vite-hot-client@2.2.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) - vite-node@6.0.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0): + vite-node@6.0.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: cac: 7.0.0 es-module-lexer: 2.1.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -12175,7 +12143,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.14.5(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@5.9.3)): + vite-plugin-checker@0.14.5(eslint@10.3.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@5.9.3)): dependencies: '@babel/code-frame': 7.29.0 chokidar: 5.0.0 @@ -12184,14 +12152,14 @@ snapshots: picomatch: 4.0.5 proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) optionalDependencies: eslint: 10.3.0(jiti@2.7.0)(supports-color@10.2.2) optionator: 0.9.4 typescript: 5.9.3 vue-tsc: 3.3.11(typescript@5.9.3) - vite-plugin-inspect@11.4.1(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + vite-plugin-inspect@11.4.1(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: ansis: 4.3.1 error-stack-parser-es: 1.0.5 @@ -12201,22 +12169,22 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.2 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vite-dev-rpc: 2.0.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vite-dev-rpc: 2.0.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: - '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))) - vite-plugin-vue-tracer@1.5.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)): + vite-plugin-vue-tracer@1.5.0(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.1.1 magic-string: 1.2.0 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vue: 3.5.41(typescript@5.9.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) + vue: 3.5.42(typescript@5.9.3) - vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0): + vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -12229,13 +12197,13 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 terser: 5.46.2 - tsx: 4.23.12 + tsx: 4.23.13 yaml: 2.9.0 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -12252,7 +12220,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -12267,31 +12235,30 @@ snapshots: dependencies: ufo: 1.6.4 - vue-chartjs@5.3.4(chart.js@4.5.1)(vue@3.5.41(typescript@5.9.3)): + vue-chartjs@5.3.4(chart.js@4.5.1)(vue@3.5.42(typescript@5.9.3)): dependencies: chart.js: 4.5.1 - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) vue-component-type-helpers@3.3.10: {} vue-devtools-stub@0.1.0: {} - vue-i18n@11.4.8(vue@3.5.41(typescript@5.9.3)): + vue-i18n@11.4.8(vue@3.5.42(typescript@5.9.3)): dependencies: '@intlify/core-base': 11.4.8 '@intlify/devtools-types': 11.4.8 '@intlify/shared': 11.4.8 '@vue/devtools-api': 6.6.4 - vue: 3.5.41(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) - vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)): + vue-router@5.3.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)): dependencies: - '@babel/generator': 8.0.0 - '@vue-macros/common': 3.1.4(vue@3.5.41(typescript@5.9.3)) + '@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3)) '@vue/devtools-api': 8.2.1 ast-walker-scope: 0.9.0 chokidar: 5.0.0 - json5: 2.2.3 + confbox: 0.2.4 local-pkg: 1.2.1 magic-string: 0.30.21 mlly: 1.8.2 @@ -12301,14 +12268,13 @@ snapshots: picomatch: 4.0.5 scule: 1.3.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.60.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0)) unplugin-utils: 0.3.2 - vue: 3.5.41(typescript@5.9.3) - yaml: 2.9.0 + vue: 3.5.42(typescript@5.9.3) optionalDependencies: '@vue/compiler-sfc': 3.5.41 - pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -12325,13 +12291,13 @@ snapshots: '@vue/language-core': 3.3.11 typescript: 5.9.3 - vue@3.5.41(typescript@5.9.3): + vue@3.5.42(typescript@5.9.3): dependencies: - '@vue/compiler-dom': 3.5.41 - '@vue/compiler-sfc': 3.5.41 - '@vue/runtime-dom': 3.5.41 - '@vue/server-renderer': 3.5.41 - '@vue/shared': 3.5.41 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 optionalDependencies: typescript: 5.9.3 @@ -12519,4 +12485,4 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod@4.4.3: {} + zod@4.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2760bb72..fb432312 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,23 @@ packages: # Hoist dependencies that have peer-dep issues with auto-install-peers shamefullyHoist: false +# DEUX versions de TypeScript, volontairement. Ne pas « harmoniser ». +# +# racine : ^7.0.2 — vérifie l'API (`tsc -p apps/api/.nitro/…`) +# apps/web : ~5.9.3 — vérifie le front (`nuxt typecheck`, donc vue-tsc) +# +# TypeScript 7 est le portage natif en Go. Son export principal ne contient +# plus le compilateur : `exports["."]` pointe sur `lib/version.cjs`, et +# `require('typescript').createProgram` vaut `undefined` (mesuré sur 7.0.2). +# Seules des entrées `./unstable/*` exposent une API, encore instable. +# +# vue-tsc 3.3.11 déclare pourtant `peerDependencies: { typescript: ">=5.0.0" }`. +# Cette plage est fausse pour 7.x : rien n'empêche donc quelqu'un de retirer le +# pin de `apps/web`, de laisser pnpm résoudre le TypeScript de la racine, et de +# casser `nuxt typecheck` sans qu'aucun avertissement de pair ne le signale. +# Le pin EST le garde-fou. Il tombera quand volar saura parler à l'API +# `unstable` — pas avant. + # pnpm 11 asks before removing node_modules when the lockfile has moved enough # to invalidate it, and refuses when there is no TTY: # ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY @@ -55,6 +72,8 @@ overrides: "@thaunknown/simple-peer": "npm:empty-npm-package@1.0.0" "esbuild": "^0.28.2" "shell-quote": "^1.9.0" + # `isomorphic-dompurify` 4.x tire `dompurify ^3.4.12` ; cette contrainte garde + # le plancher au-dessus des contournements corrigés en 3.4.13. "dompurify": "^3.4.13" "launch-editor": "^2.14.1" "devalue": "^5.8.1" @@ -81,6 +100,17 @@ overrides: "@peculiar/asn1-schema": "^2.9.4" "@peculiar/asn1-x509": "^2.9.4" "@peculiar/asn1-x509-attr": "^2.9.4" + # Deux avis GHSA sur des dépendances de CONSTRUCTION, remontés le 2026-09-02. + # Ni l'un ni l'autre n'est atteignable à l'exécution — browserslist résout des + # cibles de navigateur au build, postcss-selector-parser analyse nos propres + # sélecteurs — mais le verrou portait les deux versions, la corrigée et la + # vulnérable, et une contrainte coûte moins qu'une ligne d'exception dans un + # rapport d'audit. + # browserslist GHSA-c83g-rgw3-j3cx, GHSA-73wf-gq98-2v4g (<=4.28.6) + # postcss-selector-parser GHSA-w9m9-85wc-3x92 (<6.1.3 et 7.0-7.1.2) + "browserslist": "^4.28.8" + "postcss-selector-parser@6": "^6.1.3" + "postcss-selector-parser@7": "^7.1.5" # Was `onlyBuiltDependencies`. pnpm 11 merges onlyBuiltDependencies, # neverBuiltDependencies and ignoredBuiltDependencies into one map, so a From 947aa84c1f17ca28e05c875b02c7ca66d4610e27 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:21:34 +0200 Subject: [PATCH 54/96] test(e2e): the forum and the ticket queue were empty on a kept stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `demo.mjs` builds accounts, roles and both messaging surfaces; it never touched the forum or the tickets, and two empty surfaces cannot be tried. Everything goes over HTTP like the seeder — no SQL shortcut, so what you look at afterwards is what the application produces, first post of a topic and reply counter included. Three categories, six topics, sixteen posts, one pinned and one locked. Five tickets covering the five categories and the three visible states: open with nobody on it, in progress with an assignee and a thread, closed resolved and closed rejected. Re-runnable: a topic or a ticket whose subject is already there is skipped rather than duplicated. Both guards took a second try. The first read `/api/forum/stats`, whose `latest` is capped at five, so the sixth topic slipped past it every run; the second read only the open queue, so the three tickets the script closes itself came back on every pass. Members answer their OWN tickets. The route answers 404 for anyone else, which is correct, and rotating members the way the forum does left two threads without their follow-up. Listed beside `demo` in the runner so it does not trip the "scenario nobody ran" warning: it asserts nothing and is not a scenario. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- tests/e2e/README.md | 11 ++ tests/e2e/forumTickets.mjs | 392 +++++++++++++++++++++++++++++++++++++ tests/e2e/run.sh | 4 +- 3 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/forumTickets.mjs diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 78af1fd8..c51be2ae 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -26,6 +26,17 @@ Useful flags: - `--keep` — leave the stack running afterwards, for poking at <http://localhost:53000> (web) and <http://localhost:54000> (api) + +Two scripts fill a kept stack with something worth looking at. Neither asserts +anything, so neither is a scenario, and `run.sh` does not call them: + +```bash +node tests/e2e/demo.mjs # roles, both messaging surfaces, a room +node tests/e2e/forumTickets.mjs # three forum categories, six topics, five tickets +``` + +Both go through HTTP like the seeder, and both are re-runnable: a topic or a +ticket whose subject is already there is skipped rather than duplicated. - `--no-build` — reuse the images from the last run - `--only themes` — run one scenario file diff --git a/tests/e2e/forumTickets.mjs b/tests/e2e/forumTickets.mjs new file mode 100644 index 00000000..0a22eab6 --- /dev/null +++ b/tests/e2e/forumTickets.mjs @@ -0,0 +1,392 @@ +/** + * Remplir le forum et la file de tickets d'une pile gardée. + * + * `demo.mjs` fabrique les comptes, les rôles et la messagerie ; il ne touche ni + * au forum ni aux tickets, qui restaient donc vides — et deux surfaces vides ne + * se testent pas. Ce script les peuple. + * + * node tests/e2e/forumTickets.mjs + * + * Tout passe par HTTP, comme `seed.mjs` et `demo.mjs` : pas un raccourci SQL, + * donc ce que l'on regarde ensuite est bien ce que l'application produit — + * y compris le premier message d'un sujet, que la route crée dans la même + * transaction, et le compteur de réponses qu'elle tient à jour. + * + * Ce qu'il construit : + * + * forum trois catégories, six sujets, des réponses de plusieurs membres, + * un sujet épinglé et un sujet verrouillé + * tickets les cinq catégories représentées, dans les trois états visibles — + * ouvert sans réponse, en cours avec des allers-retours et un + * assigné, clos résolu et clos rejeté + * + * Rejouable : les catégories déjà présentes sont réutilisées, et une seconde + * exécution ajoute une nouvelle salve de sujets et de tickets plutôt que + * d'échouer. + */ +import { API, caller, resetRateLimits, sessions, sleep } from './lib.mjs'; + +const S = sessions(['founder', 'donator', 'plainuser']); +const req = caller(S); + +// Les comptes que `demo.mjs` ajoute. Absents si on n'a lancé que `seed.mjs`, +// auquel cas on se rabat sur les trois du seed — le script reste utile seul. +const HAS = (who) => !!S[who]?.cookie; +const MEMBERS = ['plainuser', 'donator', 'seedersam', 'lurkerlou'].filter(HAS); +const STAFF = ['founder', 'adminalex', 'modmaria'].filter(HAS); +const say = (s) => console.log(`\n\x1b[1m▸ ${s}\x1b[0m`); +const line = (ok, what, detail = '') => + console.log(` ${ok ? '·' : '✗'} ${what}${detail ? ' ' + detail : ''}`); + +/** Une salve d'écritures dépasse le seau `mutation` ; on souffle entre les phases. */ +async function phase(name, fn) { + await resetRateLimits(); + say(name); + await fn(); +} + +// ── Forum ──────────────────────────────────────────────────────────── + +const CATEGORIES = [ + { + name: 'Annonces', + description: "Ce que le staff publie et que personne n'a le droit d'ignorer.", + color: '#c2410c', + icon: 'ph:megaphone', + order: 0, + }, + { + name: 'Entraide', + description: 'Un client qui ne veut pas seeder, un ratio qui ne monte pas.', + color: '#0e7490', + icon: 'ph:lifebuoy', + order: 1, + }, + { + name: 'Discussions', + description: 'Le reste : ce qu’on regarde, ce qu’on cherche, ce qu’on aime.', + color: '#4d7c0f', + icon: 'ph:chats-circle', + order: 2, + }, +]; + +/** Les sujets, par nom de catégorie. `by` tourne sur les comptes disponibles. */ +const TOPICS = { + Annonces: [ + { + title: 'Règlement : les trois choses qui font bannir', + content: + "Le ratio n'en fait pas partie. Ce qui fait bannir : truquer les annonces, " + + 'partager son passkey, et revendre une invitation. Le reste se discute.', + by: 'staff', + pin: true, + replies: [ + ['member', 'Merci pour la clarté. Une question sur le partage de passkey en famille ?'], + ['staff', 'Même foyer, même IP : toléré. Deux villes différentes : non.'], + ], + }, + { + title: 'Maintenance de la base — samedi 06h UTC', + content: + "Une heure d'indisponibilité prévue. Les annonces continueront d'être " + + 'acceptées, seul le site sera coupé.', + by: 'staff', + lock: true, + replies: [], + }, + ], + Entraide: [ + { + title: 'Mon client annonce mais le tracker me voit hors ligne', + content: + "Transmission 4.0, port 6881 ouvert, et pourtant je n'apparais pas dans le " + + "swarm. J'ai vérifié le passkey deux fois.", + by: 'member', + replies: [ + ['member', "Vérifie que tu n'as pas deux clients sur le même torrent avec deux peer_id."], + ['staff', "Ton passkey a été régénéré il y a deux jours — reprends le .torrent."], + ['member', "C'était ça. Merci, je remets le fichier à jour partout."], + ], + }, + { + title: 'Comment lire les colonnes S / L / C du catalogue ?', + content: + 'Seeders, leechers, et la troisième ? Je devine « completed » mais je préfère demander.', + by: 'member', + replies: [['staff', "C'est bien ça : le nombre de téléchargements achevés depuis toujours."]], + }, + ], + Discussions: [ + { + title: 'Ce que vous avez découvert ici et que vous auriez raté ailleurs', + content: + "Un tracker privé, c'est aussi une bibliothèque. Racontez la trouvaille que " + + 'vous ne cherchiez pas.', + by: 'member', + replies: [ + ['member', "Un documentaire de 1974 sur les barrages, en 4K, remasterisé par un membre."], + ['member', 'La discographie complète d’un label suisse dont je ne connaissais rien.'], + ['staff', 'Ce fil mérite d’être épinglé un jour. On y pense.'], + ], + }, + { + title: 'Vos réglages de seedbox, sans les chiffres marketing', + content: + 'Combien de torrents en parallèle avant que ça se dégrade ? Chez moi, 400.', + by: 'member', + replies: [['member', 'Deux cents, mais mon disque est mécanique.']], + }, + ], +}; + +let mi = 0; +let si = 0; +/** Tourne sur les membres / le staff pour que les fils ne soient pas monologues. */ +const nextMember = () => MEMBERS[mi++ % MEMBERS.length]; +const nextStaff = () => STAFF[si++ % STAFF.length]; +const who = (kind) => (kind === 'staff' ? nextStaff() : nextMember()); + +async function seedForum() { + const existing = await req('founder', '/api/forum/categories'); + const byName = new Map( + (Array.isArray(existing.body) ? existing.body : (existing.body?.data ?? [])).map( + (c) => [c.name, c.id], + ), + ); + + for (const cat of CATEGORIES) { + if (byName.has(cat.name)) { + line(true, `catégorie réutilisée`, cat.name); + continue; + } + const r = await req('founder', '/api/forum/categories', { method: 'POST', body: cat }); + const id = r.body?.id ?? r.body?.data?.id; + if (r.status >= 300 || !id) { + line(false, `catégorie ${cat.name}`, `${r.status} ${JSON.stringify(r.body).slice(0, 120)}`); + continue; + } + byName.set(cat.name, id); + line(true, 'catégorie créée', cat.name); + } + + // Les titres déjà en ligne, pour qu'une seconde exécution n'ajoute pas six + // sujets identiques. + // + // Lus catégorie par catégorie, et non depuis `/api/forum/stats` : `latest` y + // est plafonné à cinq entrées, si bien que le sixième sujet passait au + // travers du garde et se dupliquait à chaque relance. Un plafond invisible + // dans une liste « les derniers » est exactement le genre de détail qu'on ne + // remarque qu'après coup. + const seen = new Set(); + for (const id of byName.values()) { + const detail = await req('founder', `/api/forum/categories/${id}`); + for (const t of detail.body?.topics ?? []) { + if (t?.title) seen.add(t.title); + } + } + + for (const [catName, topics] of Object.entries(TOPICS)) { + const categoryId = byName.get(catName); + if (!categoryId) { + line(false, `sujets de ${catName}`, 'catégorie absente'); + continue; + } + for (const t of topics) { + if (seen.has(t.title)) { + line(true, 'sujet déjà présent', t.title.slice(0, 50)); + continue; + } + const author = who(t.by); + const r = await req(author, '/api/forum/topics', { + method: 'POST', + body: { title: t.title, content: t.content, categoryId }, + }); + const topicId = r.body?.id ?? r.body?.data?.id ?? r.body?.topic?.id; + if (r.status >= 300 || !topicId) { + line(false, t.title.slice(0, 44), `${r.status} ${JSON.stringify(r.body).slice(0, 120)}`); + continue; + } + line(true, `${author} ouvre`, t.title.slice(0, 52)); + + for (const [kind, content] of t.replies) { + const replier = who(kind); + const p = await req(replier, '/api/forum/posts', { + method: 'POST', + body: { topicId, content }, + }); + line(p.status < 300, ` ${replier} répond`, p.status < 300 ? '' : String(p.status)); + await sleep(60); + } + + if (t.pin) { + const r2 = await req('founder', `/api/forum/topics/${topicId}/pin`, { + method: 'PUT', + body: { isPinned: true }, + }); + line(r2.status < 300, ' épinglé', r2.status < 300 ? '' : String(r2.status)); + } + if (t.lock) { + const r3 = await req('founder', `/api/forum/topics/${topicId}/lock`, { + method: 'PUT', + body: { isLocked: true }, + }); + line(r3.status < 300, ' verrouillé', r3.status < 300 ? '' : String(r3.status)); + } + await sleep(80); + } + } +} + +// ── Tickets ────────────────────────────────────────────────────────── + +const TICKETS = [ + { + category: 'appeal', + subject: 'Contestation : bannissement pour hit-and-run', + body: + "J'ai été banni pour trois hit-and-run. Deux sont dus à une coupure de fibre de " + + 'quatre jours chez moi, dont j’ai la trace. Je peux fournir le relevé.', + thread: [ + ['staff', 'Envoyez le relevé, on regarde. Le compte reste suspendu en attendant.'], + ['member', 'Voici les dates de coupure : du 4 au 8. Le troisième, je le reconnais.'], + ], + close: { reason: 'resolved', note: 'Deux H&R annulés, le troisième maintenu. Compte rouvert.' }, + }, + { + category: 'upload', + subject: 'Mon upload est refusé sans motif visible', + body: + "J'ai proposé un remux 1080p hier, la fiche disparaît de mes envois et je ne " + + 'trouve aucun message. Est-ce un doublon ?', + thread: [ + ['staff', "C'est bien un doublon : la même source est en ligne depuis mars, en meilleure qualité."], + ], + assign: true, + }, + { + category: 'bug', + subject: 'Les recherches enregistrées ne se rejouent pas', + body: + "Je clique sur la loupe d'une recherche enregistrée et j'atterris sur le " + + 'catalogue entier, sans aucun filtre appliqué. Testé sur deux navigateurs.', + thread: [ + ['staff', 'Reproduit. Le lien construit ne portait pas les bons paramètres — corrigé.'], + ['member', 'Confirmé de mon côté, merci.'], + ], + close: { reason: 'resolved', note: 'Noms de paramètres corrigés côté client.' }, + }, + { + category: 'account', + subject: 'Changement d’adresse de courriel', + body: + 'Mon adresse actuelle ne fonctionne plus, je voudrais la remplacer. Je peux ' + + 'prouver que le compte est bien le mien.', + thread: [], + }, + { + category: 'other', + subject: 'Proposition : un canal IRC pour les annonces', + body: + 'Beaucoup de membres suivent les nouveautés via IRC ailleurs. Est-ce envisagé ' + + 'ici ? Je peux aider à la configuration.', + thread: [['staff', 'Le module existe déjà côté administration. On ouvrira quand il sera réglé.']], + close: { reason: 'rejected', note: 'Pas de refus sur le fond — sujet déjà couvert ailleurs.' }, + }, +]; + +async function seedTickets() { + // La file est derrière un réglage, comme la messagerie pour `demo.mjs`. + const s = await req('founder', '/api/admin/settings', { + method: 'PUT', + body: { ticketsMode: 'on' }, + }); + line(s.status < 300, 'ticketsMode = on', s.status < 300 ? '' : `${s.status} ${JSON.stringify(s.body).slice(0, 140)}`); + if (s.status >= 300) return; + + // Les deux états, pas seulement la file ouverte. + // + // `/api/mod/tickets` répond `status = 'open'` par défaut et ne bascule sur + // les clos qu'avec `?closed=true`. Ne lire que le premier faisait recréer à + // chaque relance les trois tickets que le script clôt lui-même — un garde + // d'idempotence aveugle à la moitié de ce qu'il doit reconnaître. + const known = new Set(); + for (const qs of ['', '?closed=true']) { + const q = await req(STAFF[0], `/api/mod/tickets${qs}`); + for (const x of q.body?.tickets ?? []) { + if (x?.subject) known.add(x.subject); + } + } + + for (const t of TICKETS) { + if (known.has(t.subject)) { + line(true, 'ticket déjà là', t.subject.slice(0, 52)); + continue; + } + const author = nextMember(); + const r = await req(author, '/api/tickets', { + method: 'POST', + body: { subject: t.subject, category: t.category, body: t.body }, + }); + const id = r.body?.id ?? r.body?.data?.id ?? r.body?.ticket?.id; + if (r.status >= 300 || !id) { + line(false, `[${t.category}] ${t.subject.slice(0, 40)}`, `${r.status} ${JSON.stringify(r.body).slice(0, 140)}`); + continue; + } + line(true, `${author} ouvre [${t.category}]`, t.subject.slice(0, 46)); + + for (const [kind, body] of t.thread) { + // Un membre ne voit et ne commente que SES tickets — la route répond 404 + // pour tout autre, ce qui est le bon comportement. Faire tourner les + // membres comme sur le forum produisait donc deux fils amputés de leur + // relance. Côté membre, c'est l'auteur qui parle ; côté staff, n'importe + // qui du personnel. + const speaker = kind === 'staff' ? nextStaff() : author; + const m = await req(speaker, `/api/tickets/${id}/messages`, { + method: 'POST', + body: { body }, + }); + line(m.status < 300, ` ${speaker} répond`, m.status < 300 ? '' : String(m.status)); + await sleep(60); + } + + if (t.assign) { + const a = await req(STAFF[0], `/api/mod/tickets/${id}/assign`, { + method: 'POST', + body: { take: true }, + }); + line(a.status < 300, ` assigné à ${STAFF[0]}`, a.status < 300 ? '' : String(a.status)); + } + if (t.close) { + const c = await req(STAFF[0], `/api/mod/tickets/${id}/close`, { + method: 'POST', + body: t.close, + }); + line(c.status < 300, ` clos (${t.close.reason})`, c.status < 300 ? '' : String(c.status)); + } + await sleep(80); + } +} + +// ── Compte rendu ───────────────────────────────────────────────────── + +async function summary() { + const cats = await req('founder', '/api/forum/categories'); + const stats = await req('founder', '/api/forum/stats'); + const mine = await req(MEMBERS[0], '/api/tickets'); + const queue = await req(STAFF[0], '/api/mod/tickets'); + const count = (b) => + Array.isArray(b) + ? b.length + : (b?.tickets?.length ?? b?.items?.length ?? b?.data?.length ?? '?'); + console.log(''); + console.log(` catégories de forum : ${count(cats.body)}`); + console.log(` statistiques forum : ${JSON.stringify(stats.body).slice(0, 160)}`); + console.log(` tickets vus par un membre : ${count(mine.body)}`); + console.log(` file de modération : ${count(queue.body)}`); + console.log(`\n à regarder : ${API.replace('54000', '53000')}/forum et /mod/tickets`); +} + +await phase('forum', seedForum); +await phase('tickets', seedTickets); +await phase('compte rendu', summary); diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 2628374b..94db16d4 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -131,7 +131,9 @@ done # A scenario file nobody listed is a scenario nobody runs. for scenario in "$HERE"/*.mjs; do name="$(basename "$scenario" .mjs)" - case "$name" in seed|crypto|lib|demo) continue ;; esac + # `demo` et `forumTickets` remplissent une pile gardée pour l'essayer à la + # main ; ce ne sont pas des scénarios et ils n'assertent rien. + case "$name" in seed|crypto|lib|demo|forumTickets) continue ;; esac case " ${SCENARIOS[*]} " in *" $name "*) continue ;; esac echo "WARNING: $name.mjs is not in SCENARIOS and was not run" >&2 done From 9788a172b1bce6b2977af17190d2a13ceb81b3d2 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:21:34 +0200 Subject: [PATCH 55/96] test(api): the panic suite timed out on its own key-derivation cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 3 of the KDF costs about 490 ms per derivation against roughly 50 ms for versions 1 and 2 — N = 2^17, which is what OWASP asks for a human-chosen password and where it counts most, since panic mode assumes the attacker already holds the salt and the ciphertext. One test derives THREE keys, so vitest's default five seconds no longer fit. An explicit timeout, and a note pointing at `KDF_COST` so the next person to hit it lengthens the timeout rather than lowering N. Not a denial-of-service surface: the restore route sits behind `RATE_LIMITS.auth`, five attempts per five minutes per address with a progressive lockout, so the cost an unauthenticated caller can impose is about 2.4 seconds of CPU per address per five minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/test/panic.test.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/api/test/panic.test.ts b/apps/api/test/panic.test.ts index 774068de..8dd72973 100644 --- a/apps/api/test/panic.test.ts +++ b/apps/api/test/panic.test.ts @@ -37,14 +37,24 @@ describe('deriveKey / generateSalt', () => { expect(k.length).toBe(32); }); - it('is deterministic for a constant salt, and diverges otherwise', async () => { - const salt = Buffer.from(generateSalt(), 'base64'); - const a = await deriveKey(PASSWORD, salt); - const b = await deriveKey(PASSWORD, salt); - const c = await deriveKey(PASSWORD, Buffer.from(generateSalt(), 'base64')); - expect(a.equals(b)).toBe(true); - expect(a.equals(c)).toBe(false); - }); + // Délai explicite, et non le défaut de 5 s de vitest : ce test dérive TROIS + // clés, et la version 3 du KDF coûte ~490 ms par dérivation (N = 2^17, contre + // ~50 ms pour les versions 1 et 2). Le coût est le but — voir `KDF_COST` dans + // `utils/panic.ts` : le mode panique suppose la base déjà entre les mains de + // l'attaquant. Si ce test dépasse à nouveau, allonger le délai ; ne PAS + // baisser N. + it( + 'is deterministic for a constant salt, and diverges otherwise', + async () => { + const salt = Buffer.from(generateSalt(), 'base64'); + const a = await deriveKey(PASSWORD, salt); + const b = await deriveKey(PASSWORD, salt); + const c = await deriveKey(PASSWORD, Buffer.from(generateSalt(), 'base64')); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }, + 20_000 + ); it('produces a 32-byte salt, different on every call', () => { const s1 = Buffer.from(generateSalt(), 'base64'); From 387df95cd5c9ad28a3dfc16fc881346e2b925974 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:22:13 +0200 Subject: [PATCH 56/96] fix(panic): a twelve-character password was derived at Node's defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panic mode exists for the case where the database is already in someone else's hands, so the attacker holds the salt and the ciphertext and the only thing between them and the data is how long each guess takes. The cost was N = 2^14, Node's default, for a password a person chooses. Version 3 raises it to 2^17 — what OWASP asks — measured at ~490 ms per derivation against ~50 ms. Versioned rather than changed: a database encrypted by an older build has to stay restorable, exactly like the v1 to v2 branch already in place. The IV is 12 bytes. Sixteen is allowed but strictly worse — AES-GCM is built around a 96-bit nonce, and the extra bytes are folded into the tag derivation instead of being used as one. `restore` reads a schema instead of `readBody()`. Without one, `panicPassword` could arrive as an object, an array, or several megabytes of text, all of which reach scrypt — free CPU amplification for an unauthenticated caller, on the one route that cannot require a session because no session survives the encryption. `encrypt` now answers with the two lists that describe what it did and what it did not: private messages, tickets, TOTP secrets, the federation signing key and the audit log stay in cleartext. The guide said otherwise. Channel secrets accept a previous key, so rotating `CHANNEL_ENCRYPTION_KEY` stops meaning "every stored channel credential is now undecryptable". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../routes/api/admin/panic/encrypt.post.ts | 145 ++++++++++++++---- .../routes/api/admin/panic/restore.post.ts | 105 ++++++++++--- apps/api/utils/channelSecrets.ts | 57 ++++++- apps/api/utils/panic.ts | 80 +++++++++- 4 files changed, 335 insertions(+), 52 deletions(-) diff --git a/apps/api/routes/api/admin/panic/encrypt.post.ts b/apps/api/routes/api/admin/panic/encrypt.post.ts index f52c2e96..2b3a976a 100644 --- a/apps/api/routes/api/admin/panic/encrypt.post.ts +++ b/apps/api/routes/api/admin/panic/encrypt.post.ts @@ -1,4 +1,4 @@ -import { eq, and, asc } from 'drizzle-orm'; +import { eq, and, asc, isNull } from 'drizzle-orm'; import { db } from '@trackarr/db'; import { users, @@ -10,9 +10,12 @@ import { import { requireAdminSession } from '~~/utils/adminAuth'; import { auditDetail } from '~~/utils/audit'; import { + CURRENT_KDF_VERSION, deriveKey, generateSalt, - encryptField, + // `encryptFieldOnce` et non `encryptField` : une reprise doit reconnaître ce + // qui est déjà chiffré plutôt que le chiffrer une seconde fois. + encryptFieldOnce as encryptField, encrypt, } from '~~/utils/panic'; @@ -50,7 +53,6 @@ export default defineEventHandler(async (event) => { }); } - // Check if already encrypted const currentState = await db.query.panicState.findFirst(); if (currentState?.isEncrypted) { throw createError({ @@ -59,11 +61,32 @@ export default defineEventHandler(async (event) => { }); } - // Get first admin with panic password hash - const admin = await db.query.users.findFirst({ - where: and(eq(users.isAdmin, true)), - orderBy: asc(users.createdAt), - }); + /* + * Une reprise, et non un second chiffrement. + * + * Les quatre boucles ci-dessous tournent ligne par ligne, hors transaction. + * Une interruption au milieu — délai HTTP, mémoire épuisée, conteneur + * redémarré — laissait la moitié des lignes chiffrées sous une clé dérivée + * d'un sel qui n'existait que dans la mémoire du processus, `is_encrypted` + * restant à `false` : la donnée était définitivement perdue, et relancer la + * route générait un NOUVEAU sel puis surchiffrait les lignes déjà faites. + * + * Désormais : le sel, le hachis et la version sont écrits AVANT la première + * boucle avec `phase = 'encrypting'`. Une interruption laisse donc une base + * restaurable, et une relance reprend avec le MÊME sel — les champs déjà + * chiffrés sont reconnus et laissés tels quels par `encryptFieldOnce`. + */ + const resuming = currentState?.phase === 'encrypting' && !!currentState.encryptionSalt; + + // Le détenteur du mot de passe. En reprise, c'est celui qu'on a enregistré : + // le re-sélectionner est précisément ce qui perdait la base quand + // l'administrateur d'origine était rétrogradé ou effaçait son compte. + const admin = resuming + ? { panicPasswordHash: currentState!.panicPasswordHash } + : await db.query.users.findFirst({ + where: and(eq(users.isAdmin, true), isNull(users.deletedAt)), + orderBy: asc(users.createdAt), + }); if (!admin?.panicPasswordHash) { throw createError({ @@ -87,13 +110,44 @@ export default defineEventHandler(async (event) => { // `encrypt()` and prefixed into each ciphertext. The legacy IV // column on `panic_state` is left null on fresh panics; restore // still reads it as a fallback when decrypting old data. - const salt = generateSalt(); + const salt = resuming ? currentState!.encryptionSalt! : generateSalt(); + const kdfVersion = resuming + ? (currentState!.kdfVersion ?? CURRENT_KDF_VERSION) + : CURRENT_KDF_VERSION; // Derive the key from the RAW panic password (kdf_version 2). The // stored hash is NOT a key input — a DB dump then only yields the // scrypt verifier + salt + ciphertext, forcing an offline // brute-force rather than instant decryption (finding C1). - const key = await deriveKey(body.panicPassword, Buffer.from(salt, 'base64')); + const key = await deriveKey( + body.panicPassword, + Buffer.from(salt, 'base64'), + kdfVersion + ); + + // Le sel, le hachis employé et la phase — AVANT de toucher la moindre ligne. + // C'est ce qui rend une interruption récupérable au lieu de fatale. + await db + .insert(panicState) + .values({ + id: 'singleton', + isEncrypted: false, + encryptionSalt: salt, + encryptionIv: null, + kdfVersion, + panicPasswordHash: admin.panicPasswordHash, + phase: 'encrypting', + }) + .onConflictDoUpdate({ + target: panicState.id, + set: { + encryptionSalt: salt, + encryptionIv: null, + kdfVersion, + panicPasswordHash: admin.panicPasswordHash, + phase: 'encrypting', + }, + }); // ── Encrypt sensitive user data ────────────────────────────── const allUsers = await db.select().from(users); @@ -119,6 +173,18 @@ export default defineEventHandler(async (event) => { // ── Encrypt torrent data ───────────────────────────────────── const allTorrents = await db.select().from(torrents); for (const torrent of allTorrents) { + /* + * Une ligne déjà traitée se reconnaît à un marqueur exact, pas à une forme. + * + * Ce tour-ci n'est pas idempotent comme celui des utilisateurs : il écrase + * `size` par 0 et enveloppe la description dans `[PANIC_META:…]`. Repasser + * dessus enregistrerait donc `size: 0` comme taille « originale » — perdue + * pour de bon — et produirait un second niveau d'enveloppe que la + * restauration ne défait pas. Le préfixe est écrit par nous, il est donc + * une preuve et non une supposition. + */ + if (torrent.description?.startsWith('[PANIC_META:')) continue; + const originalMeta = JSON.stringify({ size: torrent.size, categoryId: torrent.categoryId, @@ -174,29 +240,56 @@ export default defineEventHandler(async (event) => { // The column stays in the schema for backward-compatible restore // of databases encrypted before this fix. await db - .insert(panicState) - .values({ - id: 'singleton', + .update(panicState) + .set({ isEncrypted: true, encryptedAt: new Date(), - encryptionSalt: salt, - encryptionIv: null, - kdfVersion: 2, + phase: 'encrypted', }) - .onConflictDoUpdate({ - target: panicState.id, - set: { - isEncrypted: true, - encryptedAt: new Date(), - encryptionSalt: salt, - encryptionIv: null, - kdfVersion: 2, - }, - }); + .where(eq(panicState.id, 'singleton')); return { success: true, message: 'Database encrypted. Use panic password to restore.', encryptedAt: new Date().toISOString(), + /* + * Ce que « encrypted » couvre, et ce qu'il ne couvre pas. + * + * La réponse disait « Database encrypted » sans réserve. La couverture + * réelle est : `users` (sel et vérificateur d'authentification, passkey, + * clés RSS et API, dernière IP), `torrents` (nom, description, octets du + * .torrent, taille, catégorie), `forum_posts.content` et + * `torrent_comments.content`. + * + * Restent EN CLAIR : `messages.body` et `room_messages.body` — soit tout le + * texte des conversations privées et du salon public, le chiffrement de + * bout en bout étant optionnel et par conversation —, les tickets et leurs + * messages, les titres de sujets de forum, la bio et le nom affiché, + * `totp_secret`, la configuration serveur des canaux, le journal de + * connexions (adresses, agents), les signalements de l'anti-triche, le + * journal d'audit, le miroir des torrents distants et la clé privée de + * fédération. + * + * Étendre la couverture ferait de ces boucles quelque chose de bien plus + * long, ce qui rend la reprise (voir `phase`) d'autant plus nécessaire. + * D'ici là, l'énoncé est ici plutôt que dans une promesse tacite : + * `doc/guide/messaging.md` fait exactement ce travail de précision pour le + * cadenas de bout en bout. + */ + covers: [ + 'users.auth_salt', 'users.auth_verifier', 'users.passkey', + 'users.rss_key', 'users.api_key', 'users.last_ip', + 'torrents.name', 'torrents.description', 'torrents.torrent_data', + 'torrents.size', 'torrents.category_id', + 'forum_posts.content', 'torrent_comments.content', + ], + leavesInCleartext: [ + 'messages.body', 'room_messages.body', + 'tickets.*', 'ticket_messages.*', + 'forum_topics.title', 'users.bio', 'users.display_name', + 'users.totp_secret', 'notification_channels.server_config', + 'login_events.*', 'anticheat_flags.ip', 'anticheat_flags.user_agent', + 'audit_log.*', 'remote_torrents.*', 'federation_config.private_key', + ], }; }); diff --git a/apps/api/routes/api/admin/panic/restore.post.ts b/apps/api/routes/api/admin/panic/restore.post.ts index 1f570cff..d1e85bbd 100644 --- a/apps/api/routes/api/admin/panic/restore.post.ts +++ b/apps/api/routes/api/admin/panic/restore.post.ts @@ -1,4 +1,4 @@ -import { eq, and, asc } from 'drizzle-orm'; +import { eq, and, asc, isNull } from 'drizzle-orm'; import { db } from '@trackarr/db'; import { users, @@ -94,13 +94,32 @@ export default defineEventHandler(async (event) => { }); } - // Get first admin to verify panic password - const admin = await db.query.users.findFirst({ - where: and(eq(users.isAdmin, true)), - orderBy: asc(users.createdAt), - }); + /* + * Le hachis contre lequel on vérifie : celui du chiffrement, pas celui de + * l'administrateur le plus ancien d'aujourd'hui. + * + * `encrypt` et cette route résolvaient toutes deux le détenteur par + * « `is_admin = true` trié par `created_at` », et rien n'enregistrait qui + * c'était. Trois façons de perdre la base avec le bon mot de passe en main : + * l'administrateur qui a chiffré est rétrogradé (on vérifiait alors contre le + * hachis d'un autre → 401), il efface son compte (`eraseAccount` vide + * `panic_password_hash` sans toucher `is_admin`, et `created_at` reste le plus + * ancien → 500), ou un administrateur plus récent était le seul à avoir + * configuré un mot de passe. + * + * `encrypt` persiste désormais le hachis employé. Le repli sur la + * re-sélection ne sert qu'aux bases chiffrées avant ce correctif. + */ + let expectedHash = currentState.panicPasswordHash ?? null; + if (!expectedHash) { + const admin = await db.query.users.findFirst({ + where: and(eq(users.isAdmin, true), isNull(users.deletedAt)), + orderBy: asc(users.createdAt), + }); + expectedHash = admin?.panicPasswordHash ?? null; + } - if (!admin?.panicPasswordHash) { + if (!expectedHash) { throw createError({ statusCode: 500, message: 'Admin panic password hash not found', @@ -108,7 +127,7 @@ export default defineEventHandler(async (event) => { } // Verify panic password matches stored hash - const isValid = await verifyPassword(admin.panicPasswordHash, body.panicPassword); + const isValid = await verifyPassword(expectedHash, body.panicPassword); if (!isValid) { throw createError({ statusCode: 401, @@ -123,17 +142,43 @@ export default defineEventHandler(async (event) => { // is embedded per-record (`iv:ct:tag`); `legacyIv` is forwarded for // databases encrypted before per-record IVs (single global IV). const kdfInput = - (currentState.kdfVersion ?? 1) >= 2 - ? body.panicPassword - : admin.panicPasswordHash; + (currentState.kdfVersion ?? 1) >= 2 ? body.panicPassword : expectedHash; const key = await deriveKey( kdfInput, - Buffer.from(currentState.encryptionSalt, 'base64') + Buffer.from(currentState.encryptionSalt, 'base64'), + currentState.kdfVersion ?? 1 ); const legacyIv = currentState.encryptionIv ? Buffer.from(currentState.encryptionIv, 'base64') : undefined; + /* + * Les échecs sont comptés, et ils décident de la suite. + * + * Chaque ligne était dans un `try { … } catch { console.error(…) }` qui + * continuait, puis la route effaçait inconditionnellement `encryption_salt` + * et répondait « Database restored successfully ». Une seule ligne `users` + * dont `auth_salt` ne déchiffrait pas — rot de bit, surchiffrement, ligne + * insérée pendant le chiffrement — et ce membre restait chiffré POUR + * TOUJOURS : le `try` englobe tout l'`update`, donc rien n'était écrit pour + * lui, et le sel disparaissait ensuite. Son mot de passe et son passkey + * d'annonce étaient morts, sans qu'aucun compteur ne le dise. + * + * Tant qu'il reste un échec, le sel et la phase sont conservés : la + * restauration reste possible après correction, et elle est reprenable + * puisque les lignes déjà déchiffrées ne déchiffrent plus. + */ + const failures: string[] = []; + const fail = (what: string, id: string, err: unknown) => { + failures.push(`${what}:${id}`); + console.error(`[panic] restore failed for ${what} ${id}:`, err); + }; + + await db + .update(panicState) + .set({ phase: 'restoring' }) + .where(eq(panicState.id, 'singleton')); + // ── Decrypt user data ──────────────────────────────────────── const allUsers = await db.select().from(users); for (const user of allUsers) { @@ -150,7 +195,7 @@ export default defineEventHandler(async (event) => { }) .where(eq(users.id, user.id)); } catch (err) { - console.error(`Failed to decrypt user ${user.id}:`, err); + fail('user', user.id, err); } } @@ -176,7 +221,10 @@ export default defineEventHandler(async (event) => { originalSize = meta.size ?? 0; originalCategoryId = meta.categoryId ?? null; } catch { - console.error(`Failed to decrypt metadata for torrent ${torrent.id}`); + // La taille et la catégorie sont dans ces métadonnées, et le + // chiffrement a mis `size` à 0 : les perdre en silence était pire + // que de refuser de terminer. + fail('torrent-meta', torrent.id, 'metadata'); } decryptedDesc = encryptedDescPart @@ -195,7 +243,7 @@ export default defineEventHandler(async (event) => { const decryptedBase64 = decrypt(encryptedStr, key, legacyIv); decryptedTorrentData = Buffer.from(decryptedBase64, 'base64'); } catch { - console.error(`Failed to decrypt torrentData for torrent ${torrent.id}`); + fail('torrent-data', torrent.id, 'torrentData'); } } @@ -210,7 +258,7 @@ export default defineEventHandler(async (event) => { }) .where(eq(torrents.id, torrent.id)); } catch (err) { - console.error(`Failed to decrypt torrent ${torrent.id}:`, err); + fail('torrent', torrent.id, err); } } @@ -225,7 +273,7 @@ export default defineEventHandler(async (event) => { }) .where(eq(forumPosts.id, post.id)); } catch (err) { - console.error(`Failed to decrypt post ${post.id}:`, err); + fail('post', post.id, err); } } @@ -240,13 +288,32 @@ export default defineEventHandler(async (event) => { }) .where(eq(torrentComments.id, comment.id)); } catch (err) { - console.error(`Failed to decrypt comment ${comment.id}:`, err); + fail('comment', comment.id, err); } } // ===================================================================== // Update panic state // ===================================================================== + if (failures.length > 0) { + // On garde tout ce qui permet de recommencer. Lever le drapeau ici + // signifierait « c'est fini » à une base qui ne l'est pas, et effacer le + // sel rendrait le reste illisible pour de bon. + await db + .update(panicState) + .set({ phase: 'restoring' }) + .where(eq(panicState.id, 'singleton')); + + throw createError({ + statusCode: 500, + message: + `Restore incomplete: ${failures.length} record(s) could not be decrypted. ` + + 'The encryption salt has been KEPT so the restore can be retried once the ' + + 'cause is fixed. Do not re-encrypt.', + data: { failed: failures.length, sample: failures.slice(0, 20) }, + }); + } + await db .update(panicState) .set({ @@ -254,6 +321,8 @@ export default defineEventHandler(async (event) => { encryptedAt: null, encryptionSalt: null, encryptionIv: null, + panicPasswordHash: null, + phase: 'idle', }) .where(eq(panicState.id, 'singleton')); diff --git a/apps/api/utils/channelSecrets.ts b/apps/api/utils/channelSecrets.ts index 38fe12a4..31a2a2b1 100644 --- a/apps/api/utils/channelSecrets.ts +++ b/apps/api/utils/channelSecrets.ts @@ -24,6 +24,7 @@ import { scryptSync } from 'crypto'; import { encrypt, decrypt } from './panic'; let cachedKey: Buffer | null = null; +let cachedKeys: { current: Buffer; previous: Buffer | null } | null = null; /** * Resolve and cache the encryption key. Lazy on purpose — `notify.ts` @@ -31,7 +32,33 @@ let cachedKey: Buffer | null = null; * want the import to fail just because the env var is read before the * Nitro runtime hands them through. */ -function getKey(): Buffer { +/** + * La clé courante et, si elle est déclarée, la précédente. + * + * `credentialSecrets.ts` gère `CREDENTIAL_ENCRYPTION_KEY_PREVIOUS` et + * ré-chiffre à la connexion prouvée ; ce module-ci n'avait aucun équivalent, et + * la clé retombe par défaut sur `NUXT_SESSION_SECRET`. Or faire tourner + * `NUXT_SESSION_SECRET` est une opération périodique banale qui, avant cette + * fonctionnalité, n'invalidait que des cookies : tous les + * `notification_channels.server_config` et `user_notification_channels.user_config` + * devenaient d'un coup indéchiffrables, `decryptJson` LEVANT une exception de + * tag AES-GCM sans message actionnable, et les notifications tombaient. + * + * Avec la clé précédente déclarée, la lecture retente avec elle : l'opérateur + * garde une fenêtre pour tourner sans casser. + */ +function getKeys(): { current: Buffer; previous: Buffer | null } { + if (cachedKeys) return cachedKeys; + const salt = process.env.CHANNEL_ENCRYPTION_SALT || 'trackarr:channels:v1'; + const prev = process.env.CHANNEL_ENCRYPTION_KEY_PREVIOUS; + cachedKeys = { + current: deriveCurrent(), + previous: prev && prev.length >= 32 ? (scryptSync(prev, salt, 32) as Buffer) : null, + }; + return cachedKeys; +} + +function deriveCurrent(): Buffer { if (cachedKey) return cachedKey; const raw = process.env.CHANNEL_ENCRYPTION_KEY || process.env.NUXT_SESSION_SECRET; @@ -70,7 +97,7 @@ export function encryptJson(value: unknown): string { if (value == null) return ''; const json = typeof value === 'string' ? value : JSON.stringify(value); if (json.length === 0) return ''; - return encrypt(json, getKey()); + return encrypt(json, getKeys().current); } /** @@ -83,8 +110,28 @@ export function decryptJson<T = Record<string, unknown>>( blob: string | null | undefined ): T | null { if (!blob) return null; - const json = decrypt(blob, getKey()); - return JSON.parse(json) as T; + const keys = getKeys(); + try { + return JSON.parse(decrypt(blob, keys.current)) as T; + } catch (err) { + // La clé précédente, quand l'opérateur en a déclaré une. Sans ce chemin, + // faire tourner `NUXT_SESSION_SECRET` rendait indéchiffrable la + // configuration de TOUS les canaux, avec pour seul symptôme une exception + // de tag AES-GCM. + if (keys.previous) { + try { + return JSON.parse(decrypt(blob, keys.previous)) as T; + } catch { + /* ni l'une ni l'autre : on relaie l'erreur d'origine ci-dessous */ + } + } + throw new Error( + '[channelSecrets] Could not decrypt a channel config. The key changed ' + + 'without CHANNEL_ENCRYPTION_KEY_PREVIOUS being set, or ' + + 'CHANNEL_ENCRYPTION_SALT was altered. ' + + `(${(err as Error).message})` + ); + } } /** @@ -93,5 +140,5 @@ export function decryptJson<T = Record<string, unknown>>( * the row so the UI shows the misconfig before garbage is written. */ export function assertChannelEncryptionReady(): void { - getKey(); + getKeys(); } diff --git a/apps/api/utils/panic.ts b/apps/api/utils/panic.ts index 852bc51c..1ef628b3 100644 --- a/apps/api/utils/panic.ts +++ b/apps/api/utils/panic.ts @@ -19,10 +19,20 @@ import { createDecipheriv, randomBytes, scrypt, + type ScryptOptions, } from 'crypto'; import { promisify } from 'util'; -const scryptAsync = promisify(scrypt); +/** + * `promisify` ne retient qu'une des surcharges de `scrypt`, celle sans options. + * On déclare donc la forme qu'on utilise réellement. + */ +const scryptAsync = promisify(scrypt) as unknown as ( + password: string, + salt: Buffer, + keylen: number, + options?: ScryptOptions +) => Promise<Buffer>; const ALGORITHM = 'aes-256-gcm'; const KEY_LENGTH = 32; // 12-byte IVs are the AES-GCM standard (96-bit nonce); previous code @@ -31,11 +41,41 @@ const KEY_LENGTH = 32; // around 96 bits. const IV_LENGTH = 12; +/** + * Le coût scrypt, par version de KDF. + * + * La version 1 et la version 2 utilisaient les défauts de Node — N = 2^14, + * r = 8, p = 1, soit environ 16 Mo. L'OWASP demande N = 2^17 pour un mot de + * passe humain, et c'est ici que cela compte le plus : le mode panique existe + * pour le cas « la base a fuité », donc l'attaquant détient le sel et le + * chiffré, et le mot de passe de panique est choisi par une personne + * (douze caractères au minimum). Un facteur huit sur le coût est un facteur + * huit sur la durée d'une attaque hors ligne. + * + * Versionné plutôt que changé : une base chiffrée avant ce correctif doit + * rester restaurable, exactement comme la branche v1 → v2 déjà en place. + */ +const KDF_COST: Record<number, { N: number; r: number; p: number }> = { + 1: { N: 16384, r: 8, p: 1 }, + 2: { N: 16384, r: 8, p: 1 }, + 3: { N: 131072, r: 8, p: 1 }, +}; + +/** La version qu'écrit un nouveau chiffrement. */ +export const CURRENT_KDF_VERSION = 3; + export async function deriveKey( password: string, - salt: Buffer + salt: Buffer, + kdfVersion: number = CURRENT_KDF_VERSION ): Promise<Buffer> { - return (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer; + const cost = KDF_COST[kdfVersion] ?? KDF_COST[1]!; + return (await scryptAsync(password, salt, KEY_LENGTH, { + ...cost, + // scrypt exige ~128 · N · r octets ; à N = 2^17 cela fait 128 Mo, au-delà + // du plafond par défaut de Node (32 Mo), qui refuserait sinon de dériver. + maxmem: 256 * 1024 * 1024, + })) as Buffer; } /** @@ -110,6 +150,40 @@ export function encryptField( return encrypt(value, key); } +/** + * Chiffrer une valeur qui l'est peut-être déjà. + * + * Ce dont une reprise a besoin. Le chiffrement de panique parcourt quatre + * tables ligne par ligne, hors transaction ; une interruption au milieu — délai + * HTTP, mémoire épuisée, conteneur redémarré — laissait la moitié des lignes + * chiffrées. Relancer la route repassait alors sur ces lignes et les + * SURCHIFFRAIT, ce que la restauration ne défait qu'une fois : la donnée + * devenait irrécupérable. + * + * La détection ne se fait PAS sur la forme. Un champ de texte libre peut + * ressembler à `a:b:c` par accident, et se tromper dans ce sens laisserait une + * donnée en clair dans une base annoncée comme chiffrée. On tente le + * déchiffrement sous la clé courante : s'il réussit, la valeur vient de ce même + * chiffrement et on n'y touche pas ; s'il échoue, c'est du clair. Une opération + * AES-GCM par champ, et la réponse est exacte plutôt que probable. + */ +export function encryptFieldOnce( + value: string | null | undefined, + key: Buffer, + legacyIv?: Buffer +): string | null { + if (value == null) return null; + if (value.split(':').length === 3) { + try { + decrypt(value, key, legacyIv); + return value; // déjà chiffré sous cette clé + } catch { + /* pas notre chiffré : c'est du clair qui contient des deux-points */ + } + } + return encrypt(value, key); +} + export function decryptField( value: string | null | undefined, key: Buffer, From 0ba6daa25f7875158a77338934888c8858621245 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:22:14 +0200 Subject: [PATCH 57/96] fix(auth): one proof of work could be spent twice, and a name differed by case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The challenge was read and deleted in two steps, so two solutions of the SAME challenge arriving together both passed. `GETDEL` makes it one. Registration compares `lower(username)`, matching the unique index that now backs it. Without that, `Admin` registered alongside `admin`. `authSalt` and `authVerifier` are bounded. They are opaque strings the server never interprets, which is exactly why nothing stopped a caller storing megabytes in them. Rotating a passkey purges the tracker's cache key for the old one. The tracker caches a passkey lookup for sixty seconds, so a key rotated because it leaked kept announcing for up to a minute after the member revoked it. `recoveryCodeEquals` is gone. It was dead, and it invited a comparison the atomic `UPDATE … WHERE code_hash = $1 AND used_at IS NULL RETURNING id` already does correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../api/auth/2fa/passkey-options.post.ts | 6 +++ apps/api/routes/api/auth/pow.get.ts | 31 ++++++++++++- apps/api/routes/api/auth/register.post.ts | 17 ++++++- apps/api/routes/api/me/passkey/reset.post.ts | 25 +++++++++++ apps/api/utils/pow.ts | 23 +++++++--- apps/api/utils/schemas.ts | 11 ++++- apps/api/utils/session.ts | 44 ++++++++++++++++++- apps/api/utils/twoFactor.ts | 21 ++++----- 8 files changed, 155 insertions(+), 23 deletions(-) diff --git a/apps/api/routes/api/auth/2fa/passkey-options.post.ts b/apps/api/routes/api/auth/2fa/passkey-options.post.ts index 1a88f9cf..9af557b8 100644 --- a/apps/api/routes/api/auth/2fa/passkey-options.post.ts +++ b/apps/api/routes/api/auth/2fa/passkey-options.post.ts @@ -19,12 +19,18 @@ import { z } from 'zod'; import { redis } from '../../../../utils/server'; import { rpID, setAuthChallenge } from '~~/utils/webauthn'; import { validateBody } from '~~/utils/schemas'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; const bodySchema = z.object({ challengeToken: z.string().min(16).max(128), }); export default defineEventHandler(async (event) => { + // Non authentifiée par nécessité — le second facteur se présente avant qu'une + // session existe — donc la limite est le seul plafond. Le jeton de défi vient + // déjà d'une preuve de mot de passe, mais chaque appel génère un défi WebAuthn + // et écrit une clé Redis : sans borne, c'est de la frappe de clés gratuite. + await rateLimit(event, RATE_LIMITS.auth); const body = await validateBody(event, bodySchema); // Peek at the challenge token without consuming it — we need the diff --git a/apps/api/routes/api/auth/pow.get.ts b/apps/api/routes/api/auth/pow.get.ts index 747d666b..48976efc 100644 --- a/apps/api/routes/api/auth/pow.get.ts +++ b/apps/api/routes/api/auth/pow.get.ts @@ -3,8 +3,37 @@ * Generate a Proof of Work challenge for registration */ import { generatePoWChallenge } from '~~/utils/pow'; +import { rateLimit } from '~~/utils/rateLimit'; -export default defineEventHandler(async () => { +/** + * Le même raisonnement que `challenge.get.ts`, appliqué à la même forme. + * + * Cette route était ouverte à l'internet, sans garde ET sans limite propre, et + * chaque appel écrit une clé Redis `pow:<64 hex>` avec un TTL de cinq minutes. + * Le seul plafond était `detectDDoS` — 100 requêtes par 10 s et par IP, soit + * environ 600 clés par minute et par adresse, et 3 000 clés vivantes en + * permanence. Un botnet, ou simplement une rotation dans un /64 IPv6, y + * maintient des millions de clés. + * + * Ce que cela coûte n'est pas la mémoire en soi : ce Redis porte aussi les + * sessions, les seaux de limitation, le cache de bannissement et le cache de + * rôles. Sous `allkeys-lru`, l'éviction commence par ces caches — la + * vérification de bannissement et les limites de débit se dégradent alors EN + * SILENCE en retombant sur Postgres, ce qui convertit une pression mémoire en + * amplification de base de données. + * + * Plus généreux que `RATE_LIMITS.auth` : un défi n'est pas une tentative, et un + * client légitime en demande un par écran d'inscription. + */ +const POW_LIMIT = { + windowSec: 300, + maxRequests: 20, + prefix: 'pow', + progressive: true, +} as const; + +export default defineEventHandler(async (event) => { + await rateLimit(event, POW_LIMIT); const challenge = await generatePoWChallenge(); return challenge; }); diff --git a/apps/api/routes/api/auth/register.post.ts b/apps/api/routes/api/auth/register.post.ts index 555b14b0..0d09584d 100644 --- a/apps/api/routes/api/auth/register.post.ts +++ b/apps/api/routes/api/auth/register.post.ts @@ -142,10 +142,25 @@ export default defineEventHandler(async (event) => { } // Check for existing username + /* + * Insensible à la casse. + * + * Le contrôle et l'index unique portaient tous deux sur la valeur exacte, + * donc `Admin` pouvait être créé alors qu'`admin` existait. Le jeu de + * caractères est `[a-zA-Z0-9_-]`, donc pas d'homographes Unicode — mais la + * collision par casse suffit à usurper un pseudonyme de personnel dans les + * commentaires, le forum, les messages privés et le journal de modération. + * Et `auth/challenge` étant lui aussi sensible à la casse, les deux comptes + * se connectent normalement. + * + * L'index unique fonctionnel de la migration est ce qui rend la garantie + * réelle : ce contrôle-ci répond 409 plutôt que de laisser la base lever une + * violation de contrainte. + */ const existingUsername = await db .select({ username: users.username }) .from(users) - .where(eq(users.username, body.username)) + .where(sql`lower(${users.username}) = ${body.username.toLowerCase()}`) .limit(1); if (existingUsername.length > 0) { diff --git a/apps/api/routes/api/me/passkey/reset.post.ts b/apps/api/routes/api/me/passkey/reset.post.ts index a40c96e5..be14c359 100644 --- a/apps/api/routes/api/me/passkey/reset.post.ts +++ b/apps/api/routes/api/me/passkey/reset.post.ts @@ -20,6 +20,8 @@ * of the tracker. */ import { eq } from 'drizzle-orm'; +import { redis } from '~~/utils/server'; +import { createHash } from 'node:crypto'; import { db, schema } from '@trackarr/db'; import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; import { generatePasskey } from '~~/utils/auth'; @@ -65,6 +67,29 @@ export default defineEventHandler(async (event) => { // counters behind would also leak the rotation. await retireTorznabPasskey(oldPasskey, carried); + /* + * Le cache du tracker, purgé tout de suite. + * + * Le tracker résout `(passkey → user)` par un cache Redis de 60 s indexé sur + * `sha256(passkey)[:16]`. Il l'invalide lui-même sur son chemin de déban + * paresseux, et laisse le TTL couvrir le reste — un contrat écrit pour les + * changements d'état de BANNISSEMENT, pas pour une RÉVOCATION. Résultat : + * une passkey régénérée ici continuait d'annoncer une minute durant, ce qui + * est exactement la fenêtre qu'une rotation existe pour fermer (« ma clé a + * fuité »). + * + * La même clé, calculée de la même façon, effacée depuis ce côté-ci : c'est + * une suppression Redis, pas un appel au tracker, donc il n'y a pas de + * couplage de service à ajouter. + */ + try { + const digest = createHash('sha256').update(oldPasskey).digest('hex').slice(0, 16); + await redis.del(`${process.env.REDIS_KEY_PREFIX ?? 'ot:'}trk:pk:${digest}`); + } catch { + // Le TTL de 60 s reste le repli : ne pas faire échouer une rotation + // parce que le cache a hoqueté. + } + // The session cookie carries the passkey, so it now holds a dead one. Write // the new value back rather than force a re-login. await setUserSession(event, { diff --git a/apps/api/utils/pow.ts b/apps/api/utils/pow.ts index 2505cc6d..08392623 100644 --- a/apps/api/utils/pow.ts +++ b/apps/api/utils/pow.ts @@ -43,8 +43,23 @@ export async function generatePoWChallenge(): Promise<PoWChallenge> { * Deletes challenge after verification to prevent reuse */ export async function verifyPoWSolution(solution: PoWSolution): Promise<boolean> { - // Check if challenge exists and hasn't been used - const exists = await redis.get(`pow:${solution.challenge}`); + /* + * `GETDEL` : la réclamation et la lecture en une opération. + * + * C'était un `GET` puis, tout en bas, un `DEL` — donc deux solutions + * concurrentes du MÊME défi passaient toutes les deux le `GET`, calculaient + * le même hachis valide, et étaient toutes deux acceptées. Le défi est + * censé être à usage unique ; la fenêtre entre les deux appels le rendait + * réutilisable autant de fois que le parallélisme le permettait. + * + * `twoFactor.ts` utilise déjà `getdel` pour exactement ce motif. + * + * Conséquence assumée du déplacement : un défi est consommé même si la + * solution se révèle fausse. C'est le bon sens de l'erreur — le client en + * demande un autre, et une tentative ratée ne doit pas offrir un essai + * gratuit. + */ + const exists = await redis.getdel(`pow:${solution.challenge}`); if (!exists) { return false; } @@ -64,9 +79,7 @@ export async function verifyPoWSolution(solution: PoWSolution): Promise<boolean> return false; } - // Delete challenge to prevent reuse (one-time use) - await redis.del(`pow:${solution.challenge}`); - + // Le défi a déjà été consommé par le `GETDEL` en tête : rien à supprimer ici. return true; } diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts index 2dbb6192..939b63f7 100644 --- a/apps/api/utils/schemas.ts +++ b/apps/api/utils/schemas.ts @@ -61,8 +61,15 @@ export const registerSchema = z.object({ 'Username can only contain letters, numbers, underscores, and hyphens' ), // ZKE fields - server never sees password - authSalt: z.string().min(40, 'Invalid salt'), - authVerifier: z.string().min(40, 'Invalid verifier'), + // Bornées en haut aussi. Les deux atterrissent dans des colonnes `text` non + // bornées, par un appelant NON authentifié (`POST /api/auth/register`), et + // un client honnête envoie 44 caractères de base64 pour 32 octets. Sans + // plafond, chaque inscription pouvait y planter la taille maximale d'un corps + // Nitro — puis `encryptSecretRequired` chiffrait tout cela à chaque écriture, + // et `login.post.ts` concaténait le vérificateur dans un SHA-256 à chaque + // tentative. + authSalt: z.string().min(40, 'Invalid salt').max(64, 'Invalid salt'), + authVerifier: z.string().min(40, 'Invalid verifier').max(64, 'Invalid verifier'), // Proof of Work powChallenge: z.string().length(64, 'Invalid PoW challenge'), powNonce: z.string().min(1, 'Invalid PoW nonce'), diff --git a/apps/api/utils/session.ts b/apps/api/utils/session.ts index 2a74e982..b47495d5 100644 --- a/apps/api/utils/session.ts +++ b/apps/api/utils/session.ts @@ -1,4 +1,5 @@ import type { H3Event } from 'h3'; +import { readLiveRoles } from './liveRoles'; import { useSession } from 'h3'; import { touchPresence } from './presence'; @@ -115,6 +116,31 @@ export async function getSessionId(event: H3Event): Promise<string> { return s.id; } +/** + * La porte d'authentification, et les deux choses qu'elle doit faire au passage. + * + * **Les drapeaux de personnel sont relus dans la base, pas dans le cookie.** + * Le cookie est un jeton scellé de sept jours : lus depuis lui, `isAdmin` et + * `isModerator` restaient vrais toute cette durée, si bien qu'un modérateur + * rétrogradé conservait ses pouvoirs sur douze routes mutantes qui n'élargissent + * les leurs que sur ces drapeaux — réécrire le message d'un autre membre, + * supprimer n'importe quel torrent, publier sans passer par la file de revue, + * désanonymiser un uploadeur. `invalidateRoleCache` était bien appelé à la + * rétrogradation, et son effet s'arrêtait aux portes `/api/admin/**` et + * `/api/mod/**` : depuis un client HTTP, le reste tenait. + * + * `reconcileStaffRoles` existait, avec ce défaut écrit mot pour mot dans son + * commentaire, et n'était câblé qu'à la messagerie et aux tickets. Le poser ICI + * plutôt que dans les douze routes est ce qui garantit qu'une treizième, écrite + * demain, en hérite : le coût est une lecture Redis mise en cache 60 s, que la + * chaîne d'authentification faisait déjà pour l'état de bannissement. + * + * **L'acteur du journal d'audit est posé ici aussi**, pour la même raison : + * `requireAuthSession` le posait et `requireUserSession` non, si bien que dix + * routes que `STAFF_REACH` déclare vouloir tracer n'écrivaient aucune ligne — + * ni succès, ni refus. Le choix entre les deux gardes, indistinguable vu de la + * route, décidait de la traçabilité. + */ export async function requireUserSession( event: H3Event ): Promise<UserSessionData & { user: SessionUser }> { @@ -125,5 +151,21 @@ export async function requireUserSession( message: 'Authentication required', }); } - return data as UserSessionData & { user: SessionUser }; + const session = data as UserSessionData & { user: SessionUser }; + + // Mémoïsé par requête : une route qui enchaîne `requireUserSession` puis + // `requireAuthSession` ne paie la lecture qu'une fois. + if (!event.context.rolesReconciled) { + const live = await readLiveRoles(session.user.id); + if (!live) { + throw createError({ statusCode: 403, message: 'Account no longer exists' }); + } + session.user.isAdmin = live.isAdmin; + session.user.isModerator = live.isModerator; + session.user.isOwner = live.isOwner; + event.context.rolesReconciled = true; + } + + event.context.auditActor = session.user; + return session; } diff --git a/apps/api/utils/twoFactor.ts b/apps/api/utils/twoFactor.ts index 5d581d4a..84ff8556 100644 --- a/apps/api/utils/twoFactor.ts +++ b/apps/api/utils/twoFactor.ts @@ -176,20 +176,15 @@ export function hashRecoveryCode(code: string): string { .digest('hex'); } -/** - * Constant-time-ish comparison: same length so timing leaks can't tell - * whether the prefix matched. (Strict constant time isn't critical for - * our threat model since attackers don't get to enumerate codes — but - * it costs nothing to be careful.) +/* + * `recoveryCodeEquals` a été SUPPRIMÉE : elle n'avait aucun appelant. + * + * La vérification réelle est dans `routes/api/auth/2fa/verify-totp.post.ts` : + * un `UPDATE … WHERE code_hash = $1 AND used_at IS NULL RETURNING id`, ce qui + * est meilleur — usage unique atomique, et égalité indexée sur un SHA-256 + * plutôt qu'une comparaison en mémoire. Garder une fonction de comparaison + * inutilisée suggérait une protection qui vivait ailleurs. */ -export function recoveryCodeEquals(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let mismatch = 0; - for (let i = 0; i < a.length; i++) { - mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return mismatch === 0; -} // ── Fresh-auth window ──────────────────────────────────────── // From 02c211cca43890b0731ceef3394d561751a68b81 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:22:36 +0200 Subject: [PATCH 58/96] fix(channels): a webhook reflected its target's response back to the member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `!res.ok` returned the upstream body verbatim, so a member pointing a webhook at an internal address got that service's error text back — a read primitive against anything the API container can reach, wrapped in a feature meant to send notifications out. It now reports the status and nothing else. `WEBHOOK_ALLOW_HOSTS` lets an operator name the hosts a member may target. Empty by default, so nothing changes for a deployment that does not set it. The note in `safeFetch` claimed the SSRF guard was theoretical because "we don't expose this on user-supplied URLs today". Webhooks are exactly that. The note now says so, and says why DNS pinning is not in place: it needs undici as a direct dependency, and Node's `fetch` dispatcher wants its internal instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../me/notification-channels/[type].delete.ts | 40 ++++++++------ .../me/notification-channels/[type].put.ts | 2 + .../routes/api/me/notification-routing.put.ts | 2 + apps/api/utils/channels/webhook.ts | 55 +++++++++++++++++-- apps/api/utils/safeFetch.ts | 23 +++++++- 5 files changed, 98 insertions(+), 24 deletions(-) diff --git a/apps/api/routes/api/me/notification-channels/[type].delete.ts b/apps/api/routes/api/me/notification-channels/[type].delete.ts index 1709415a..ebe733c9 100644 --- a/apps/api/routes/api/me/notification-channels/[type].delete.ts +++ b/apps/api/routes/api/me/notification-channels/[type].delete.ts @@ -7,30 +7,38 @@ * deliver to a row that no longer exists). */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { and, eq } from 'drizzle-orm'; export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const type = getRouterParam(event, 'type') ?? ''; if (!type) { throw createError({ statusCode: 400, statusMessage: 'Missing channel type' }); } - await db - .delete(schema.userNotificationRouting) - .where( - and( - eq(schema.userNotificationRouting.userId, user.id), - eq(schema.userNotificationRouting.channelType, type) - ) - ); - await db - .delete(schema.userNotificationChannels) - .where( - and( - eq(schema.userNotificationChannels.userId, user.id), - eq(schema.userNotificationChannels.channelType, type) - ) - ); + // Les deux suppressions dans UNE transaction : l'intention est de retirer un + // canal ET ses routes, et un échec entre les deux laissait la configuration à + // moitié détruite — des routes orphelines pointant sur un canal disparu, ce + // que ce fichier existe précisément pour éviter. + await db.transaction(async (tx) => { + await tx + .delete(schema.userNotificationRouting) + .where( + and( + eq(schema.userNotificationRouting.userId, user.id), + eq(schema.userNotificationRouting.channelType, type) + ) + ); + await tx + .delete(schema.userNotificationChannels) + .where( + and( + eq(schema.userNotificationChannels.userId, user.id), + eq(schema.userNotificationChannels.channelType, type) + ) + ); + }); return { ok: true }; }); diff --git a/apps/api/routes/api/me/notification-channels/[type].put.ts b/apps/api/routes/api/me/notification-channels/[type].put.ts index b88d6dab..dc0e2585 100644 --- a/apps/api/routes/api/me/notification-channels/[type].put.ts +++ b/apps/api/routes/api/me/notification-channels/[type].put.ts @@ -14,6 +14,7 @@ * land. We re-check the gate here rather than trusting the UI. */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { v4 as uuidv4 } from 'uuid'; @@ -31,6 +32,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const type = getRouterParam(event, 'type') ?? ''; const adapter = getAdapter(type); if (!adapter) { diff --git a/apps/api/routes/api/me/notification-routing.put.ts b/apps/api/routes/api/me/notification-routing.put.ts index 61f56987..7220952c 100644 --- a/apps/api/routes/api/me/notification-routing.put.ts +++ b/apps/api/routes/api/me/notification-routing.put.ts @@ -15,6 +15,7 @@ * actually configured (avoids dangling pointers). */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; @@ -31,6 +32,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const raw = await readBody(event); const parsed = bodySchema.safeParse(raw); if (!parsed.success) { diff --git a/apps/api/utils/channels/webhook.ts b/apps/api/utils/channels/webhook.ts index 2bbe1632..a5573374 100644 --- a/apps/api/utils/channels/webhook.ts +++ b/apps/api/utils/channels/webhook.ts @@ -87,6 +87,43 @@ async function sendWebhook( .digest('hex'); } + /* + * Une liste d'hôtes autorisés, quand l'opérateur en déclare une. + * + * `url` est un champ de MEMBRE : `me/notification-channels/[type].put.ts` le + * persiste sans revue. `safeFetch` écarte les plages privées, la boucle + * locale et le lien-local, et re-valide chaque redirection — mais il reste + * une course de réattachement DNS sous-milliseconde, et son commentaire + * l'avait classée sans suite en supposant qu'aucune URL de membre ne + * l'atteignait. + * + * Vide par défaut, donc rien ne change pour une installation existante : + * c'est un levier offert à l'opérateur, pas une restriction imposée. Le + * motif est celui que `channels/webpush.ts` applique déjà à ses points de + * terminaison. + */ + const allow = (process.env.WEBHOOK_ALLOW_HOSTS ?? '') + .split(',') + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + if (allow.length > 0) { + let host: string; + try { + host = new URL(user.url).hostname.toLowerCase(); + } catch { + return { ok: false, error: 'Invalid webhook URL' }; + } + const permitted = allow.some( + (a) => host === a || host.endsWith(`.${a}`), + ); + if (!permitted) { + return { + ok: false, + error: `Webhook host not permitted by WEBHOOK_ALLOW_HOSTS (${host})`, + }; + } + } + try { const res = await safeFetch(user.url, { method: 'POST', @@ -94,11 +131,19 @@ async function sendWebhook( body, }); if (!res.ok) { - const text = await res.text().catch(() => ''); - return { - ok: false, - error: `Webhook returned ${res.status}: ${text.slice(0, 200)}`, - }; + // Le statut, et rien d'autre. + // + // On renvoyait les 200 premiers caractères du corps AMONT au membre. Avec + // une URL qu'il choisit lui-même (`url` est un `userField`, cf. plus bas) + // et jusqu'à seize en-têtes arbitraires, cela lui donnait un primitif de + // requête forgée AVEC lecture de réponse — la seule barrière côté cible + // étant le filtre de plages de `safeFetch`, et c'est précisément ce + // filtre que la course DNS résiduelle contourne. + // + // `channels/webpush.ts` a déjà pris cette décision et l'écrit : + // « Don't reflect the upstream response body — only the status code ». + // Celui-ci ne l'avait pas suivie. + return { ok: false, error: `Webhook returned ${res.status}` }; } return { ok: true }; } catch (err) { diff --git a/apps/api/utils/safeFetch.ts b/apps/api/utils/safeFetch.ts index c061d3a8..2f3548ac 100644 --- a/apps/api/utils/safeFetch.ts +++ b/apps/api/utils/safeFetch.ts @@ -23,9 +23,26 @@ * DNS server can hand us a different answer. The race window is * sub-millisecond and shrinks the SSRF surface from "trivially * exploitable" to "needs a DNS server you control + cooperating - * timing". Acceptable for an operator-curated webhook target; if - * we ever expose this on user-supplied URLs without admin review - * (we don't today), revisit. + * timing". + * + * ATTENTION — cette limitation avait été classée sans suite « acceptable for an + * operator-curated webhook target; if we ever expose this on user-supplied URLs + * without admin review (we don't today), revisit ». La prémisse est FAUSSE : + * `channels/webhook.ts` déclare `url` comme un `userField`, et + * `routes/api/me/notification-channels/[type].put.ts` laisse tout membre le + * persister — sa validation ne contrôle que les clés connues, une longueur et + * le fait que ce soit une primitive, sans revue d'administrateur. Le + * pré-requis reste qu'un administrateur ait activé et testé le canal + * `webhook` générique. + * + * Ce qui a été fait plutôt que l'épinglage d'adresse : le canal ne réfléchit + * plus le corps amont (c'était le primitif de lecture), la route de + * persistance porte une limite de débit, et `WEBHOOK_ALLOW_HOSTS` permet à + * l'opérateur de restreindre les hôtes. L'épinglage lui-même demanderait + * `undici` en dépendance DIRECTE de l'API — le `dispatcher` du `fetch` de Node + * n'accepte qu'une instance de son undici interne — donc un ajout de + * dépendance pour une course sous-milliseconde ; à faire, mais délibérément et + * pas au détour d'un correctif. */ import { promises as dns } from 'node:dns'; import { isIP } from 'node:net'; From 347a9bb46867675e8c26a1e97b63ed0b17e268e4 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:22:37 +0200 Subject: [PATCH 59/96] fix(api): an announce could claim more bytes than a number can hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uploaded`, `downloaded` and `left` were unbounded integers, so a client could report past `Number.MAX_SAFE_INTEGER` and the arithmetic downstream stopped being arithmetic. `ip` is validated as v4 or v6 rather than accepted as a string. Blacklisting an address for an account's own rate-limit breach is gone. The penalty has to hit the subject that was counted; keying it on the address is precisely what let one member get their neighbours thrown off. `ADMIN_API_KEY` is documented for what it is. `requireAdmin` and `isAdmin` have no callers — the admin panel authenticates by session — while the env var is in `.env.example`, generated by the Helm chart on 48 bytes and kept stable across upgrades, and three guide pages presented it as mandatory. The gate stays: it is correct, constant-time, and answers 503 rather than passing through when unconfigured. What was harmful was the documentation making it look active. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/admin/favicon.delete.ts | 47 +++++++++ .../routes/api/admin/system/version.get.ts | 5 + apps/api/routes/api/categories/index.get.ts | 34 ++++++- apps/api/utils/account/eraseAccount.ts | 61 ++++++++++++ apps/api/utils/auth.ts | 22 +++++ apps/api/utils/federation/inbound.ts | 11 ++- apps/api/utils/federation/signing.ts | 18 +++- apps/api/utils/logger.ts | 32 ++++-- apps/api/utils/metrics.ts | 18 ++++ apps/api/utils/mixedGroups.ts | 19 ++++ apps/api/utils/rateLimit.ts | 97 ++++++++++++++++--- apps/api/utils/schemas.ts | 25 ++++- 12 files changed, 361 insertions(+), 28 deletions(-) create mode 100644 apps/api/routes/api/admin/favicon.delete.ts diff --git a/apps/api/routes/api/admin/favicon.delete.ts b/apps/api/routes/api/admin/favicon.delete.ts new file mode 100644 index 00000000..5863dbff --- /dev/null +++ b/apps/api/routes/api/admin/favicon.delete.ts @@ -0,0 +1,47 @@ +import { requireAdminSession } from '~~/utils/adminAuth'; +import { setSetting, SETTINGS_KEYS } from '~~/utils/server'; +import { getStorage } from '~~/utils/storage'; +import { resolveObjectKey } from '~~/utils/storage/keys'; + +/** + * DELETE /api/admin/favicon + * + * Removes the custom favicon and falls back to the built-in one. + * + * This route exists because the console already offered the action and nothing + * carried it out. The branding form has a remove button next to the favicon + * preview; it set a local field that no request ever sent — `settings.put.ts` + * has never known the word "favicon", the only writer being the sibling upload + * route. So an operator removed the favicon, the form counted one unsaved + * change, the save reported success, and the favicon was still being served. + * + * Symmetrical with the upload in both respects: the setting is cleared and the + * stored file is deleted, and both are the operator's own upload rather than a + * shipped asset — `resolveObjectKey` is what makes that an assertion rather + * than a hope. + */ +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + + const { getSiteFavicon } = await import('~~/utils/server'); + const current = await getSiteFavicon(); + + await setSetting(SETTINGS_KEYS.SITE_FAVICON, ''); + await setSetting(SETTINGS_KEYS.SITE_FAVICON_SIZE, ''); + + if (current && current.startsWith('/uploads/')) { + const key = resolveObjectKey(current.replace(/^\/uploads\//, '')); + if (key) { + try { + await getStorage().delete(key); + } catch (err) { + // The setting is already cleared, which is what the site reads. A file + // left behind is disk, not behaviour, and failing the request here + // would tell the operator the removal did not happen when it did. + console.warn('[Favicon Delete] could not remove the stored file:', err); + } + } + } + + return { success: true }; +}); diff --git a/apps/api/routes/api/admin/system/version.get.ts b/apps/api/routes/api/admin/system/version.get.ts index e9e8f0ca..4079fe9a 100644 --- a/apps/api/routes/api/admin/system/version.get.ts +++ b/apps/api/routes/api/admin/system/version.get.ts @@ -127,6 +127,11 @@ export default defineEventHandler(async (event) => { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'Trackarr-Admin', }, + // Le seul appel sortant du projet qui n'avait pas de délai + // d'expiration : les huit autres portent un `AbortSignal`. Une + // connexion GitHub qui pend retenait la requête d'administration + // jusqu'au délai par défaut de Node, soit indéfiniment. + signal: AbortSignal.timeout(8000), } ); diff --git a/apps/api/routes/api/categories/index.get.ts b/apps/api/routes/api/categories/index.get.ts index 10103e33..fb5d62a9 100644 --- a/apps/api/routes/api/categories/index.get.ts +++ b/apps/api/routes/api/categories/index.get.ts @@ -1,5 +1,5 @@ import { db, schema } from '@trackarr/db'; -import { asc, eq } from 'drizzle-orm'; +import { asc, count, eq } from 'drizzle-orm'; export default defineEventHandler(async (event) => { const { user: session } = await requireUserSession(event); @@ -38,11 +38,39 @@ export default defineEventHandler(async (event) => { // Filter both the root categories and the nested subcategories so a // user with the toggle off never even sees the XXX tree existed. const visible = allCategories.filter((c) => showAdult || !c.isAdult); + + // How many torrents sit in each category. Staff only, and only when asked + // for: it is one grouped scan, and the member-facing callers of this route + // (the upload form, the filter rail) have no use for it. + // + // The admin panel needs it because DELETE refuses a category that still has + // torrents — without the number, the operator learns that from a 400 after + // confirming a dialog that had promised the torrents would simply become + // unclassified. + let counts = new Map<string, number>(); + if (isStaff && query.withCounts === 'true') { + const rows = await db + .select({ categoryId: schema.torrents.categoryId, n: count() }) + .from(schema.torrents) + .groupBy(schema.torrents.categoryId); + counts = new Map( + rows + .filter((r): r is { categoryId: string; n: number } => !!r.categoryId) + .map((r) => [r.categoryId, Number(r.n)]), + ); + } + const withCount = <T extends { id: string }>(c: T) => + counts.size || (isStaff && query.withCounts === 'true') + ? { ...c, torrentCount: counts.get(c.id) ?? 0 } + : c; + const rootCategories = visible .filter((c) => c.parentId === null) .map((c) => ({ - ...c, - subcategories: c.subcategories.filter((sc) => showAdult || !sc.isAdult), + ...withCount(c), + subcategories: c.subcategories + .filter((sc) => showAdult || !sc.isAdult) + .map(withCount), })); return rootCategories; diff --git a/apps/api/utils/account/eraseAccount.ts b/apps/api/utils/account/eraseAccount.ts index 53e86f5d..b43cd2ef 100644 --- a/apps/api/utils/account/eraseAccount.ts +++ b/apps/api/utils/account/eraseAccount.ts @@ -60,6 +60,25 @@ * part of the economy's audit trail, and none of them holds a raw identifier * once step 2 has run. Stated here because an unstated retention is * indistinguishable from an oversight. + * + * Cinq de plus, énoncées ici parce qu'elles sont exportées par + * `exportAccount` et donc déclarées comme données du membre — la liste + * s'arrêtait aux six ci-dessus et ces cinq-là étaient exactement des + * rétentions non énoncées : + * + * `bonus_grants`, `shop_purchases`, `freeleech_pool_contributions` + * Le grand livre de l'économie. Les retirer ne rend pas un compte + * anonyme — la ligne `users` porte déjà des totaux — mais falsifie le + * solde du site et l'historique d'un pot commun auquel d'autres + * membres ont contribué. + * `upload_requests`, `upload_request_fill_attempts` + * Une demande est du contenu avec lequel d'autres ont interagi, comme + * un message de forum : conservée sous un auteur anonymisé, au même + * titre. + * + * Aucune ne porte d'identifiant brut après l'étape 2. Ce qui était PUREMENT + * personnel — suivis fédérés, réactions, modèles de fiche, crédits fédérés — + * est désormais supprimé ; voir l'étape 2. */ import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { randomBytes, randomUUID } from 'node:crypto'; @@ -168,6 +187,48 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { .where(eq(schema.savedSearches.userId, userId)); await tx.delete(schema.loginEvents).where(eq(schema.loginEvents.userId, userId)); await tx.delete(schema.torrentFavorites).where(eq(schema.torrentFavorites.userId, userId)); + + /* + * Cinq tables qui manquaient, trouvées en recoupant `exportAccount` avec + * ce fichier — le test décisif, puisque ce que l'article 15 déclare + * « vos données » ne peut pas survivre à l'article 17. + * + * Aucun `ON DELETE cascade` ne se déclenche jamais ici : la ligne `users` + * survit, c'est le choix acté, et toute clé étrangère vers elle se traite + * donc à la main. Ces cinq-là étaient restées attachées à la pierre + * tombale. + * + * `federated_follows` la liste des uploadeurs qu'un membre suit chez + * les partenaires, `remote_username` compris : + * un graphe social personnel. L'étape 1b efface + * `remote_identity_links` et l'étape 2 + * `federated_identities` ; celle-là avait été + * oubliée entre les deux. + * `message_reactions` qui a réagi à quel message, avec quelle clé. + * `room_message_reactions` idem, côté salon. + * `presentation_templates` les textes de fiche que le membre a écrits + * pour lui-même. Même classe que + * `saved_searches`, effacé juste au-dessus. + * `federation_credit_grants` le DID que l'effacement vient précisément + * de révoquer et d'annoncer comme retiré, gardé + * en local et joint au compte anonymisé — ce qui + * défait la révocation qu'on vient de publier. + */ + await tx + .delete(schema.federatedFollows) + .where(eq(schema.federatedFollows.localUserId, userId)); + await tx + .delete(schema.messageReactions) + .where(eq(schema.messageReactions.userId, userId)); + await tx + .delete(schema.roomMessageReactions) + .where(eq(schema.roomMessageReactions.userId, userId)); + await tx + .delete(schema.presentationTemplates) + .where(eq(schema.presentationTemplates.ownerId, userId)); + await tx + .delete(schema.federationCreditGrants) + .where(eq(schema.federationCreditGrants.localUserId, userId)); await tx .delete(schema.userFollows) .where( diff --git a/apps/api/utils/auth.ts b/apps/api/utils/auth.ts index 6c1be182..7feb4da0 100644 --- a/apps/api/utils/auth.ts +++ b/apps/api/utils/auth.ts @@ -1,6 +1,28 @@ // Admin API-key gate. The session-based path lives in adminAuth.ts; this // file is for header-based access only (X-Admin-Key or `Authorization: // Bearer …`). Constant-time comparison prevents key recovery via timing. +// +// AUCUNE ROUTE N'APPELLE `requireAdmin` NI `isAdmin` AUJOURD'HUI. +// +// Vérifié le 2026-09-02 : rien sous `routes/`, `middleware/` ni `plugins/` +// ne les invoque, et `x-admin-key` n'apparaît qu'ici et dans la liste de +// masquage de `logger.ts`. Le panneau d'administration s'authentifie par +// session (`requireAdminSession`, dans adminAuth.ts) — pas par cette clé. +// +// Cela mérite d'être écrit, parce que toute la plomberie autour donne +// l'impression du contraire : `ADMIN_API_KEY` figure dans `.env.example`, le +// chart Helm le génère sur 48 octets et le garde stable entre deux mises à +// jour, `docker-compose.prod.yml` le passe au conteneur, et trois pages du +// guide le présentaient comme obligatoire — dont une qui affirmait que +// l'application refuse de démarrer sans lui. C'est faux : +// `plugins/00.secrets.ts` n'exige que `NUXT_SESSION_SECRET` et +// `IP_HASH_SECRET`. Les trois pages ont été corrigées le même jour. +// +// La porte n'est pas supprimée pour autant : elle est correcte (comparaison +// à temps constant, 503 quand la clé n'est pas configurée plutôt qu'un +// passe-droit en développement), et son coût est nul tant qu'on ne l'appelle +// pas. Ce qui était nuisible, c'était la documentation qui la faisait passer +// pour une protection active. import { randomBytes } from 'crypto'; diff --git a/apps/api/utils/federation/inbound.ts b/apps/api/utils/federation/inbound.ts index 1ca98f90..901ad15f 100644 --- a/apps/api/utils/federation/inbound.ts +++ b/apps/api/utils/federation/inbound.ts @@ -201,7 +201,16 @@ export async function verifyInboundS2S( prefix: 'feds2s', }); - await assertNotReplayed(headers['x-trackarr-signature']); + // La signature RÉELLEMENT vérifiée, pas celle de l'en-tête v1. + // + // `verifySignedRequest` ne contrôle que la v2 dès qu'une v2 et une audience + // sont présentes ; cléer le nonce sur la v1 laissait donc un attaquant + // rejouer une requête capturée indéfiniment en changeant simplement des + // octets d'un en-tête que personne ne vérifiait. Le repli sur la v1 ne sert + // qu'aux pairs qui n'ont pas encore de v2. + await assertNotReplayed( + verdict.verifiedSignature ?? headers['x-trackarr-signature'], + ); return { peer, config: config!, rawBody }; } diff --git a/apps/api/utils/federation/signing.ts b/apps/api/utils/federation/signing.ts index 4d833933..660cfe7c 100644 --- a/apps/api/utils/federation/signing.ts +++ b/apps/api/utils/federation/signing.ts @@ -122,6 +122,20 @@ export interface VerifyResult { /** Sender instanceId from the header, present even on some failures * so the caller can log who tried. */ instanceId?: string; + /** + * La signature RÉELLEMENT vérifiée, sur laquelle la garde anti-rejeu doit + * s'indexer. + * + * L'appelant cléait son nonce sur `x-trackarr-signature` — la v1 — alors que + * dès qu'une v2 et une audience sont présentes, cette fonction ne vérifie QUE + * la v2 et retourne. La v1 n'était donc jamais contrôlée sur ce chemin : un + * attaquant qui capturait une requête signée la rejouait autant de fois qu'il + * voulait en remplaçant la v1 par des octets aléatoires. La v2 vérifiait + * toujours, la clé Redis différait à chaque essai, et le doublon n'était + * jamais vu. La protection annoncée était nulle pendant toute la fenêtre de + * ±5 minutes. + */ + verifiedSignature?: string; } /** @@ -172,7 +186,7 @@ export function verifySignedRequest(opts: { if (!valid) { return { ok: false, reason: 'bad signature (audience)', instanceId }; } - return { ok: true, instanceId }; + return { ok: true, instanceId, verifiedSignature: signatureV2 }; } if (REQUIRE_AUDIENCE) { @@ -189,7 +203,7 @@ export function verifySignedRequest(opts: { signature, ); if (!valid) return { ok: false, reason: 'bad signature', instanceId }; - return { ok: true, instanceId }; + return { ok: true, instanceId, verifiedSignature: signature }; } export interface SignedResponse { diff --git a/apps/api/utils/logger.ts b/apps/api/utils/logger.ts index f0e2cb76..5f032c36 100644 --- a/apps/api/utils/logger.ts +++ b/apps/api/utils/logger.ts @@ -1,4 +1,5 @@ import pino from 'pino'; +import { isIP } from 'net'; import { fingerprintIP } from './crypto'; const isProd = process.env.NODE_ENV === 'production'; @@ -85,12 +86,31 @@ function hashRequestIp(req: any): string { process.env.TRUST_PROXY === 'true' ? req.headers?.['x-forwarded-for'] : undefined; - // x-forwarded-for can be a comma-separated chain; only the leftmost - // entry is the original client. - const raw = - (typeof fwd === 'string' ? fwd.split(',')[0]?.trim() : undefined) || - req.socket?.remoteAddress || - null; + /* + * Le jeton le plus à DROITE, et validé comme une adresse. + * + * Le commentaire disait « only the leftmost entry is the original client ». + * C'est vrai d'un proxy qui REMPLACE l'en-tête, et faux de tous ceux qui y + * AJOUTENT — nginx `proxy_add_x_forwarded_for`, Traefik, HAProxy, la plupart + * des CDN. Derrière l'un de ceux-là, avec `TRUST_PROXY=true`, le client + * choisissait l'empreinte IP inscrite dans chaque ligne de journal de + * sécurité, et pouvait aussi en fabriquer à partir de chaînes qui ne sont pas + * des adresses. + * + * `rateLimit.ts` a reçu ce correctif (`firstValidIp`, droite→gauche + + * `net.isIP`) et le compte-rendu de sécurité le marque comme réglé ; ce + * fichier n'en faisait pas partie. Même parcours, même validation, ici aussi. + */ + const rightmostValid = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const parts = value.split(','); + for (let i = parts.length - 1; i >= 0; i--) { + const candidate = parts[i]!.trim(); + if (candidate && isIP(candidate)) return candidate; + } + return null; + }; + const raw = rightmostValid(fwd) || req.socket?.remoteAddress || null; if (!raw) return 'unknown'; try { return fingerprintIP(raw); diff --git a/apps/api/utils/metrics.ts b/apps/api/utils/metrics.ts index 0abcbaaa..fb8b6d1e 100644 --- a/apps/api/utils/metrics.ts +++ b/apps/api/utils/metrics.ts @@ -12,6 +12,24 @@ * typical scrape) so a Prometheus burst doesn't repeatedly walk the * keyspace. */ +/* + * `prom-client` est marqué déprécié (« replaced by @prometheus-io/client »). + * On y reste, délibérément, et voici les chiffres qui portent ce choix — pris + * le 2026-09-02 : + * + * - `prom-client@15.1.3` n'a AUCUNE alerte de sécurité (audit npm en masse + * sur les 1101 paquets du verrou : 0). Déprécié n'est pas vulnérable. + * - `@prometheus-io/client` en est à `0.16.1`, avec quatre versions publiées + * en tout, la première non-alpha le 2026-08-24. Le dépôt s'impose + * `minimumReleaseAge: 1440` justement pour ne pas dépendre du frais ; un + * 0.x de six jours est très en dessous de cette barre. + * - Il ajoute une dépendance d'exécution, `@opentelemetry/api`. + * + * La surface à migrer est pourtant minuscule — ce fichier est le seul + * appelant, et il n'utilise que les quatre symboles ci-dessous. Le jour où le + * successeur atteint une majeure stable, la bascule tient en un import. + * D'ici là, changer de bibliothèque de métriques ne corrigerait rien. + */ import { Registry, Gauge, diff --git a/apps/api/utils/mixedGroups.ts b/apps/api/utils/mixedGroups.ts index 7e717de9..6439bb6b 100644 --- a/apps/api/utils/mixedGroups.ts +++ b/apps/api/utils/mixedGroups.ts @@ -145,6 +145,19 @@ export interface MixedGroupRow { seedMax: number; leechMin: number; leechMax: number; + /** + * Les totaux du groupe, un par release distincte (`rn = 1`), c'est-à-dire + * sans compter deux fois une release qu'un partenaire a aussi. + * + * L'agrégat les calculait déjà — c'est sur eux que la liste se trie — mais + * la projection ne les recopiait pas, alors que le type côté navigateur les + * déclarait. La colonne TÉLÉCHARGEMENTS du catalogue groupé affichait donc + * `completedTotal ?? 0` pour toutes les lignes : une colonne triable de + * zéros. + */ + seedTotal: number; + leechTotal: number; + completedTotal: number; scopes: ScopeSummary[]; defaultScope: GroupScope; } @@ -184,6 +197,9 @@ type RawMixedGroup = RawScopeCounts & { seed_max: number | null; leech_min: number | null; leech_max: number | null; + seed_total: number | null; + leech_total: number | null; + completed_total: number | null; }; /** @@ -412,6 +428,9 @@ export async function listMixedGroups( seedMax: Number(r.seed_max ?? 0), leechMin: Number(r.leech_min ?? 0), leechMax: Number(r.leech_max ?? 0), + seedTotal: Number(r.seed_total ?? 0), + leechTotal: Number(r.leech_total ?? 0), + completedTotal: Number(r.completed_total ?? 0), scopes, defaultScope: pickDefault(scopes), }; diff --git a/apps/api/utils/rateLimit.ts b/apps/api/utils/rateLimit.ts index a3c8280a..98de07a7 100644 --- a/apps/api/utils/rateLimit.ts +++ b/apps/api/utils/rateLimit.ts @@ -165,7 +165,29 @@ function normalizeIP(ip: string): string { // Blacklist Management // ============================================================================ -const BLACKLIST_KEY = 'ddos:blacklist'; +/** + * Une clé par adresse, et non un hachis unique. + * + * C'était `HSET ddos:blacklist <ip>` avec un `EXPIRE` sur le hachis ENTIER. Un + * champ ne disparaissait qu'au moment où cette même adresse était réinterrogée + * et trouvée expirée — donc jamais, pour un attaquant qui ne revient pas — et + * chaque nouvelle mise en liste noire repoussait le TTL du hachis complet à + * 24 h, si bien que tant qu'un abus durait le hachis ne s'auto-nettoyait pas. + * À ~200 octets par champ, un million d'adresses distinctes retenaient 200 Mo + * pendant au moins 24 h, dans le Redis qui porte aussi les sessions et les + * seaux de limitation. + * + * Le repli en mémoire, lui, était plafonné à 100 000 entrées avec éviction — + * « bounded to prevent unbounded growth when Redis is down under abuse ». Le + * chemin Redis, celui qui sert en production, n'avait rien. Avec une clé par + * adresse, Redis fait l'expiration lui-même : le jeu ne peut plus croître sans + * borne et une mise en liste noire ne prolonge plus les autres. + */ +const blacklistKey = (ip: string) => `ddos:bl:${ip}`; + +/** L'ancien hachis, conservé le temps qu'une instance en cours d'exécution le + * laisse expirer de lui-même. Lu, plus jamais écrit. */ +const LEGACY_BLACKLIST_KEY = 'ddos:blacklist'; const BLACKLIST_DURATION_BASE = 300; // 5 minutes base const MAX_BLACKLIST_DURATION = 86400; // 24 hours max @@ -176,12 +198,14 @@ export async function isBlacklisted(ip: string): Promise<boolean> { const normalizedIP = normalizeIP(ip); try { - const data = await redis.hget(BLACKLIST_KEY, normalizedIP); - if (!data) return false; - - const entry: BlacklistEntry = JSON.parse(data); + if ((await redis.exists(blacklistKey(normalizedIP))) === 1) return true; + // Le hachis d'avant, pendant sa fenêtre d'expiration. Retiré au passage + // dès qu'il est périmé, pour que la transition se termine d'elle-même. + const legacy = await redis.hget(LEGACY_BLACKLIST_KEY, normalizedIP); + if (!legacy) return false; + const entry: BlacklistEntry = JSON.parse(legacy); if (entry.expiresAt < Date.now()) { - await redis.hdel(BLACKLIST_KEY, normalizedIP); + await redis.hdel(LEGACY_BLACKLIST_KEY, normalizedIP); return false; } return true; @@ -226,8 +250,12 @@ export async function blacklistIP( ); try { - await redis.hset(BLACKLIST_KEY, normalizedIP, JSON.stringify(entry)); - await redis.expire(BLACKLIST_KEY, MAX_BLACKLIST_DURATION); + await redis.set( + blacklistKey(normalizedIP), + JSON.stringify(entry), + 'EX', + Math.ceil(duration / 1000) + ); } catch { setBounded(memoryBlacklist, normalizedIP, entry); } @@ -240,7 +268,9 @@ async function getViolationCount(ip: string): Promise<number> { const normalizedIP = normalizeIP(ip); try { - const data = await redis.hget(BLACKLIST_KEY, normalizedIP); + const data = + (await redis.get(blacklistKey(normalizedIP))) ?? + (await redis.hget(LEGACY_BLACKLIST_KEY, normalizedIP)); if (!data) return 0; const entry: BlacklistEntry = JSON.parse(data); return entry.violations; @@ -390,15 +420,58 @@ export async function rateLimit( }); } - const key = `${prefix}:${normalizedIP}`; + /* + * Le sujet compté : le compte quand il y en a un, l'adresse sinon. + * + * C'était l'adresse, toujours. Trois conséquences, dont la première est un + * déni de service que n'importe quel membre pouvait déclencher : + * + * - **Dommage collatéral.** Sur un tracker privé, l'usage du VPN est + * quasi universel et les adresses de sortie sont massivement partagées. + * Tous les membres derrière une même sortie partageaient un seau de dix + * écritures par minute. + * - **Escalade en 403 pour tout le monde.** Les seaux `mutation`, `public`, + * `auth` et `tracker` portent `progressive: true` : au dépassement, l'IP + * ENTIÈRE part en liste noire pour 5 min, doublant jusqu'à 24 h — et + * `middleware/security.ts` évalue cette liste AVANT toute + * authentification, sans exemption. Onze étiquettes éditées en une minute + * expulsaient donc du site, pendant cinq minutes, tout le monde sur la + * même adresse, personnel compris. Déclenchable délibérément contre un + * voisin identifié. + * - **Évasion triviale.** Un /64 IPv6 résidentiel offre 2⁶⁴ adresses + * sources ; chaque rotation remettait le seau à zéro et la liste noire + * punissait l'adresse déjà abandonnée. La limite mordait le membre + * honnête à adresse stable, pas l'attaquant. + * + * La primitive correcte était déjà dans ce fichier — `rateLimitIdentity`, + * écrite pour la fédération avec exactement ce raisonnement — et n'avait pas + * été portée à la surface des membres. + * + * `getUserSession` plutôt que `event.context.auditActor` : plusieurs routes + * appellent `rateLimit` AVANT leur garde d'authentification, et faire + * dépendre la correction d'un ordre que chaque route peut se tromper à écrire + * est ce qui a produit ce défaut. La lecture est celle du cookie scellé, donc + * sans I/O. + */ + let actorId: string | undefined; + try { + actorId = (await getUserSession(event))?.user?.id; + } catch { + /* pas de session lisible : on compte l'adresse */ + } + const key = `${prefix}:${actorId ? `u:${actorId}` : normalizedIP}`; const result = useRedis ? await rateLimitRedis(key, windowSec, maxRequests) : rateLimitMemory(key, windowSec, maxRequests); if (result.blocked) { - // Progressive penalty: blacklist after multiple rate limit violations - if (progressive) { + // Progressive penalty: blacklist after multiple rate limit violations. + // + // Jamais pour un compte : la sanction doit viser le sujet compté. Mettre + // une adresse en liste noire parce qu'un compte a dépassé son seau est + // précisément ce qui permettait d'expulser ses voisins. + if (progressive && !actorId) { const violations = await getViolationCount(normalizedIP); if (violations >= 3) { await blacklistIP( diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts index 939b63f7..21e1ed2b 100644 --- a/apps/api/utils/schemas.ts +++ b/apps/api/utils/schemas.ts @@ -359,17 +359,34 @@ export const forumCategoryUpdateSchema = forumCategorySchema.partial(); // Tracker Schemas (for announce/scrape validation) // ============================================================================ +/** + * Le contrat d'annonce, publié dans l'OpenAPI et servi par personne. + * + * Ce schéma et `scrapeQuerySchema` n'ont aucun appelant : l'annonce et le + * scrape sont servis par le tracker Go, qui a sa propre validation + * (`apps/tracker/internal/announce`). Ils restent parce que + * `scripts/generate-openapi.mjs` les publie comme documentation du protocole. + * + * Deux bornes ajoutées pour que le contrat publié dise la vérité : les trois + * compteurs d'octets étaient `min(0)` sans MAXIMUM, et `ip` était une chaîne + * libre. Ce que le tracker applique réellement, lui, est plus strict — un + * `peer_id` de 20 octets, un port hors plage privilégiée, `left` borné — donc + * une documentation plus permissive que l'implémentation est une invitation à + * signaler un faux bug. + */ export const announceQuerySchema = z.object({ info_hash: infoHashSchema, peer_id: z.string().length(20, 'Peer ID must be 20 characters'), port: z.coerce.number().int().min(1).max(65535), - uploaded: z.coerce.number().int().min(0), - downloaded: z.coerce.number().int().min(0), - left: z.coerce.number().int().min(0), + uploaded: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + downloaded: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + left: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER), compact: z.coerce.number().int().optional(), no_peer_id: z.coerce.number().int().optional(), event: z.enum(['started', 'stopped', 'completed', '']).optional(), - ip: z.string().optional(), + // Fourni par le client et DÉLIBÉRÉMENT ignoré par le tracker : l'accepter + // ferait de lui un réflecteur (BEP 7 a fini par décourager ce champ). + ip: z.union([z.ipv4(), z.ipv6()]).optional(), numwant: z.coerce.number().int().min(0).max(200).optional(), key: z.string().optional(), trackerid: z.string().optional(), From d5d50b77e34868359d5745e0b1aae970bf4cd25f Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:22:50 +0200 Subject: [PATCH 60/96] fix(torrents): a wrong type in an edit reached Postgres as a 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PATCH /api/torrents/[hash]` read a bare body, so a field of the wrong shape travelled all the way to the driver and came back as a server error. A strict schema bounds the five media ids, the name, the description and a 256 KiB NFO, and answers 400. Deleting a torrent purges its `completed_once:` keys. They carry a six-month TTL and survived the row, so re-uploading the same infohash inherited the old completions — a member who had already been credited was credited again, and the snatch counter started from someone else's history. The forum and comment routes lost their duplicated ownership checks in favour of the shared guard, which is the one that also reads the live roles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../api/routes/api/forum/posts/[id].delete.ts | 2 + apps/api/routes/api/forum/posts/[id].patch.ts | 2 + apps/api/routes/api/forum/posts/index.post.ts | 2 + .../routes/api/forum/topics/[id].delete.ts | 2 + .../api/routes/api/forum/topics/index.post.ts | 2 + apps/api/routes/api/torrents/[hash].delete.ts | 28 +++++++ .../api/torrents/[hash]/comments.post.ts | 2 + .../routes/api/torrents/[hash]/index.patch.ts | 32 +++++++- .../[hash]/moderation/messages.post.ts | 2 + .../routes/api/torrents/[hash]/tags.put.ts | 50 ++++++++---- .../api/torrents/comments/[id].delete.ts | 2 + apps/api/routes/api/torrents/index.post.ts | 77 ++++++++++++++++++- 12 files changed, 185 insertions(+), 18 deletions(-) diff --git a/apps/api/routes/api/forum/posts/[id].delete.ts b/apps/api/routes/api/forum/posts/[id].delete.ts index b76f1d31..7183c70d 100644 --- a/apps/api/routes/api/forum/posts/[id].delete.ts +++ b/apps/api/routes/api/forum/posts/[id].delete.ts @@ -1,4 +1,5 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumPosts, forumTopics } from '@trackarr/db/schema'; import { eq, count } from 'drizzle-orm'; import { requireAuthSession } from '~~/utils/adminAuth'; @@ -6,6 +7,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const id = getRouterParam(event, 'id'); if (!id) { diff --git a/apps/api/routes/api/forum/posts/[id].patch.ts b/apps/api/routes/api/forum/posts/[id].patch.ts index e69d1e4b..c047d667 100644 --- a/apps/api/routes/api/forum/posts/[id].patch.ts +++ b/apps/api/routes/api/forum/posts/[id].patch.ts @@ -10,12 +10,14 @@ * without trusting client-supplied timestamps. */ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumPosts, forumTopics } from '@trackarr/db/schema'; import { validateBody, forumPostUpdateSchema } from '~~/utils/schemas'; import { eq } from 'drizzle-orm'; export default defineEventHandler(async (event) => { const session = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const id = getRouterParam(event, 'id'); if (!id) { diff --git a/apps/api/routes/api/forum/posts/index.post.ts b/apps/api/routes/api/forum/posts/index.post.ts index b2389f11..4124a95a 100644 --- a/apps/api/routes/api/forum/posts/index.post.ts +++ b/apps/api/routes/api/forum/posts/index.post.ts @@ -1,4 +1,5 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumPosts, forumTopics } from '@trackarr/db/schema'; import { eq, sql } from 'drizzle-orm'; import { v4 as uuidv4 } from 'uuid'; @@ -7,6 +8,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); // Validate request body with Zod const body = await validateBody(event, forumPostSchema); diff --git a/apps/api/routes/api/forum/topics/[id].delete.ts b/apps/api/routes/api/forum/topics/[id].delete.ts index 1063c82e..6b5113bc 100644 --- a/apps/api/routes/api/forum/topics/[id].delete.ts +++ b/apps/api/routes/api/forum/topics/[id].delete.ts @@ -1,10 +1,12 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumTopics } from '@trackarr/db/schema'; import { eq } from 'drizzle-orm'; import { requireAuthSession } from '~~/utils/adminAuth'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const id = getRouterParam(event, 'id'); if (!id) { diff --git a/apps/api/routes/api/forum/topics/index.post.ts b/apps/api/routes/api/forum/topics/index.post.ts index cfe97f3c..2acc16ea 100644 --- a/apps/api/routes/api/forum/topics/index.post.ts +++ b/apps/api/routes/api/forum/topics/index.post.ts @@ -1,10 +1,12 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumTopics, forumPosts } from '@trackarr/db/schema'; import { v4 as uuidv4 } from 'uuid'; import { validateBody, forumTopicSchema } from '~~/utils/schemas'; export default defineEventHandler(async (event) => { const session = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); // Validate request body with Zod const body = await validateBody(event, forumTopicSchema); diff --git a/apps/api/routes/api/torrents/[hash].delete.ts b/apps/api/routes/api/torrents/[hash].delete.ts index d0bcab89..74d0ce5c 100644 --- a/apps/api/routes/api/torrents/[hash].delete.ts +++ b/apps/api/routes/api/torrents/[hash].delete.ts @@ -61,9 +61,37 @@ export default defineEventHandler(async (event) => { // torrent it describes, which is what makes that possible. // Delete from Redis cache + // + // `completed_once:<torrentId>:*` s'ajoute aux deux : le tracker y pose une + // clé par (membre, torrent) avec un TTL de SIX MOIS, pour qu'un `completed` + // rejoué ne gonfle pas le compteur public. Une ligne supprimée laissait donc + // ces marques une demi-année, et un infohash réenvoyé après suppression — + // ce que la branche « déjà existant » du point d'envoi permet — héritait des + // complétions de l'ancienne ligne, donc d'un compteur qui refusait de + // repartir à zéro. + // + // `SCAN` plutôt que `KEYS` : le motif est étroit (un torrent, ses membres) + // mais `KEYS` bloque Redis le temps du parcours de l'espace ENTIER. try { await redis.del(`peers:${infoHash}`); await redis.del(`stats:${infoHash}`); + let cursor = '0'; + do { + const [next, keys] = await redis.scan( + cursor, + 'MATCH', + `${redis.options.keyPrefix ?? ''}completed_once:${existing.id}:*`, + 'COUNT', + 200 + ); + cursor = next; + if (keys.length > 0) { + // `scan` rend des clés PRÉFIXÉES, et `del` en rajoute un : on retire + // le préfixe avant de supprimer. + const prefix = redis.options.keyPrefix ?? ''; + await redis.del(...keys.map((k) => (prefix && k.startsWith(prefix) ? k.slice(prefix.length) : k))); + } + } while (cursor !== '0'); } catch { // Redis errors are non-fatal } diff --git a/apps/api/routes/api/torrents/[hash]/comments.post.ts b/apps/api/routes/api/torrents/[hash]/comments.post.ts index 6bafcf13..48f8bd21 100644 --- a/apps/api/routes/api/torrents/[hash]/comments.post.ts +++ b/apps/api/routes/api/torrents/[hash]/comments.post.ts @@ -1,4 +1,5 @@ import { eq } from 'drizzle-orm'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { db, schema } from '@trackarr/db'; import { torrents, torrentComments } from '@trackarr/db/schema'; import { canComment } from '~~/utils/commentPolicy'; @@ -13,6 +14,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); // Validate hash parameter const hash = validateParam(event, 'hash', infoHashSchema); diff --git a/apps/api/routes/api/torrents/[hash]/index.patch.ts b/apps/api/routes/api/torrents/[hash]/index.patch.ts index 12ee00ff..93bff5ac 100644 --- a/apps/api/routes/api/torrents/[hash]/index.patch.ts +++ b/apps/api/routes/api/torrents/[hash]/index.patch.ts @@ -17,6 +17,8 @@ * they edit — they're trusted to publish without re-review. */ import { eq } from 'drizzle-orm'; +import { validateBody } from '~~/utils/schemas'; +import { z } from 'zod'; import { db } from '@trackarr/db'; import { torrents, categories, torrentModerationMessages } from '@trackarr/db/schema'; import { randomUUID } from 'node:crypto'; @@ -85,8 +87,34 @@ export default defineEventHandler(async (event) => { // bypass flag. The result drives the auto-revert on save below. const canBypass = isStaff || (await userCanBypassModeration(user)); - // Read body - const body = await readBody(event); + /* + * Un schéma, pas `readBody()` nu. + * + * C'était l'une des cinq routes mutantes sans validation : `categoryId` + * partait tel quel dans `eq(categories.id, categoryId)`, donc un objet ou un + * nombre produisait une 500 de Postgres au lieu d'une 400 — pas d'injection + * (Drizzle paramètre), mais un plantage là où il fallait un refus. Même forme + * pour `description`, dont un objet atterrissait dans une colonne `text`. + * + * Les bornes reprennent celles que la route appliquait déjà à la main plus + * bas, et celles du point d'envoi : c'est le même contrat, exprimé une fois + * en entrée plutôt que dispersé dans le corps du handler. + */ + const patchSchema = z + .object({ + name: z.string().min(1).max(256).optional(), + description: z.string().max(10_000).nullish(), + categoryId: z.string().uuid().nullish().or(z.literal('')), + nfo: z.string().max(256 * 1024).nullish(), + imdbId: z.string().max(32).nullish(), + tmdbId: z.string().max(32).nullish(), + tvdbId: z.string().max(32).nullish(), + igdbId: z.string().max(32).nullish(), + openlibraryId: z.string().max(64).nullish(), + }) + .strict(); + + const body = await validateBody(event, patchSchema); const { name, description, diff --git a/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts b/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts index e9b4ebb2..a4238bf1 100644 --- a/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts +++ b/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts @@ -6,6 +6,7 @@ * actions. Either side (uploader or staff) can post. */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { eq } from 'drizzle-orm'; import { z } from 'zod/v4'; import { canAccessModerationThread, postMessage } from '~~/utils/torrentModeration'; @@ -18,6 +19,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const hash = getRouterParam(event, 'hash'); if (!hash) { throw createError({ statusCode: 400, message: 'Torrent hash is required' }); diff --git a/apps/api/routes/api/torrents/[hash]/tags.put.ts b/apps/api/routes/api/torrents/[hash]/tags.put.ts index c780e59a..0a9ad53b 100644 --- a/apps/api/routes/api/torrents/[hash]/tags.put.ts +++ b/apps/api/routes/api/torrents/[hash]/tags.put.ts @@ -3,13 +3,17 @@ import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { validateParam, infoHashSchema, validateBody } from '~~/utils/schemas'; import { resolveTagsByName, MAX_TAGS_PER_TORRENT } from '~~/utils/tags'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; // Accept either pre-resolved `tagIds` (kept for the existing admin UI) // or free-form `tags` strings (the user-facing flow from issue #45). // At least one of the two must be present. const updateTagsSchema = z .object({ - tagIds: z.array(z.string()).max(MAX_TAGS_PER_TORRENT).optional(), + // `z.string()` acceptait n'importe quoi et l'envoyait dans une colonne + // porteuse d'une clé étrangère : l'échec arrivait en 500 côté Postgres, pas + // en 400 côté schéma. + tagIds: z.array(z.string().uuid()).max(MAX_TAGS_PER_TORRENT).optional(), tags: z.array(z.string()).max(MAX_TAGS_PER_TORRENT).optional(), }) .refine((v) => v.tagIds !== undefined || v.tags !== undefined, { @@ -19,6 +23,11 @@ const updateTagsSchema = z export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + // `resolveTagsByName` CRÉE les étiquettes absentes : sans limite, 600 + // requêtes par minute et par IP injectent jusqu'à 6 000 lignes `tags` dans un + // catalogue administré à la main, sans laisser de trace. + await rateLimit(event, RATE_LIMITS.mutation); + const infoHash = validateParam(event, 'hash', infoHashSchema); // Get torrent @@ -51,20 +60,31 @@ export default defineEventHandler(async (event) => { }); } - // Delete existing tags - await db - .delete(schema.torrentTags) - .where(eq(schema.torrentTags.torrentId, torrent.id)); - - // Insert new tags - if (ids.length > 0) { - await db.insert(schema.torrentTags).values( - ids.map((tagId) => ({ - torrentId: torrent.id, - tagId, - })) - ); - } + // Le remplacement, dans UNE transaction. + // + // C'étaient deux écritures séparées, la première destructrice. + // `torrentTags.tagId` porte une clé étrangère vers `tags.id`, et le schéma + // n'exigeait que « tableau de chaînes » : un identifiant inexistant faisait + // donc échouer l'INSERT sur violation de clé étrangère APRÈS que le DELETE + // avait été validé. Un `PUT {"tagIds":["x"]}` répondait 500 et emportait + // TOUTES les étiquettes du torrent. + // + // La validation en `uuid()` ci-dessus ne suffit pas seule — un UUID bien + // formé mais absent échoue de la même façon : c'est la transaction qui rend + // l'échec inoffensif. + await db.transaction(async (tx) => { + await tx + .delete(schema.torrentTags) + .where(eq(schema.torrentTags.torrentId, torrent.id)); + if (ids.length > 0) { + await tx.insert(schema.torrentTags).values( + ids.map((tagId) => ({ + torrentId: torrent.id, + tagId, + })) + ); + } + }); // Fetch updated tags const updatedTags = await db.query.torrentTags.findMany({ diff --git a/apps/api/routes/api/torrents/comments/[id].delete.ts b/apps/api/routes/api/torrents/comments/[id].delete.ts index fa158043..935922ce 100644 --- a/apps/api/routes/api/torrents/comments/[id].delete.ts +++ b/apps/api/routes/api/torrents/comments/[id].delete.ts @@ -1,4 +1,5 @@ import { eq } from 'drizzle-orm'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { db } from '@trackarr/db'; import { torrentComments } from '@trackarr/db/schema'; import { requireAuthSession } from '~~/utils/adminAuth'; @@ -6,6 +7,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const commentId = getRouterParam(event, 'id'); if (!commentId) { diff --git a/apps/api/routes/api/torrents/index.post.ts b/apps/api/routes/api/torrents/index.post.ts index 4e71060f..222cebb6 100644 --- a/apps/api/routes/api/torrents/index.post.ts +++ b/apps/api/routes/api/torrents/index.post.ts @@ -203,6 +203,27 @@ export default defineEventHandler(async (event) => { throw new Error('missing info dict'); } info.private = 1; + // Les champs HORS `info`, qui portent les secrets de l'uploadeur. + // + // Un `.torrent` de tracker privé porte la passkey de son propriétaire dans + // `announce` (et dans chaque niveau d'`announce-list`). Les stocker verbatim + // laissait cette passkey au repos dans `torrent_data` : la route de sortie + // les réécrit de toute façon (`[hash]/download.get.ts`), donc la conserver + // n'apportait rien et suffisait à ce qu'un autre membre la lise — le cas + // courant en cross-seed étant que le fichier vienne d'un AUTRE tracker + // privé, dont la passkey n'a rien à faire ici. + // + // `url-list` (web seeds) sort aussi : un hôte tiers y ferait sortir le + // client de chaque téléchargeur vers lui. `comment` et `created by` sont du + // texte libre venu d'un fichier étranger, sans usage chez nous. + // + // Aucun de ces champs n'est dans `info`, donc l'infohash ne change pas et + // les lignes déjà stockées restent valides. + delete decoded.announce; + delete decoded['announce-list']; + delete decoded['url-list']; + delete decoded.comment; + delete decoded['created by']; normalizedData = Buffer.from(bencode.encode(decoded)); } catch (_err) { throw createError({ statusCode: 400, message: 'Invalid torrent file' }); @@ -311,8 +332,31 @@ export default defineEventHandler(async (event) => { // status so a previously-rejected upload can never be silently // re-introduced — that's the whole reason rejected rows are kept // in the table instead of being deleted. + // + // Une PROJECTION, pas la ligne entière. + // + // `findFirst` sans `columns` renvoyait les 33 colonnes de `torrents` — dont + // `torrentData`, c'est-à-dire les octets du `.torrent` stocké, plus + // `uploaderId`, `nfo`, `description` et l'état de modération — à tout membre + // authentifié capable de POSTer un fichier dont l'infohash correspond. + // + // Les trois gardes qui manquaient sont écrites, une par une, dans les deux + // routes qui font le même travail : `check.post.ts` (le PRÉFLIGHT de cette + // opération) porte déjà la projection, la porte de modération et le retrait + // de `uploaderId`, et `[hash]/download.get.ts` refuse une ligne non acceptée + // à qui n'est ni l'uploadeur ni du personnel. La garde avait été écrite pour + // l'annonce et oubliée sur l'opération annoncée. const existing = await db.query.torrents.findFirst({ where: (t, { eq }) => eq(t.infoHash, infoHash), + columns: { + id: true, + infoHash: true, + name: true, + moderationStatus: true, + createdAt: true, + uploaderId: true, + size: true, + }, }); if (existing) { @@ -324,14 +368,44 @@ export default defineEventHandler(async (event) => { 'This torrent has previously been rejected by moderation. Re-uploading it is not allowed.', }); } + + // La même porte que `check.post.ts` et `download.get.ts` : une ligne qui + // n'est pas acceptée est invisible à qui n'est ni son uploadeur ni du + // personnel. Sans elle, connaître un infohash en attente suffisait à en + // récupérer les octets — précisément ce que `download.get.ts` interdit. + const isStaff = !!(user.isAdmin || user.isModerator); + if ( + existing.moderationStatus !== 'accepted' && + !isStaff && + existing.uploaderId !== user.id + ) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + // Otherwise (pending / changes_requested / accepted) just hand // the existing row back. The uploader can find it on /me, and a // moderator can act on it via the queue. return { success: true, + // `outcome` porte le sens ; `message` reste pour les clients hors + // navigateur. La page d'envoi affichait `message` tel quel — donc une + // phrase anglaise en titre, sous une coche verte et au-dessus d'un + // sous-titre français qui annonçait le contraire : « la release est + // désormais indexée », alors que celle-ci existait déjà et appartient à + // quelqu'un d'autre. + outcome: 'exists' as const, message: 'Torrent already exists', data: { - ...existing, + // Pas de `...existing` : `uploaderId` sort en clair d'ici, ce qui + // défait `anonymousUploads` (`redactUploader` existe pour cela et + // n'était pas appelé), et `torrentData` sérialisé en JSON amplifie + // la réponse d'un facteur sept. + id: existing.id, + infoHash: existing.infoHash, + name: existing.name, + size: existing.size, + moderationStatus: existing.moderationStatus, + createdAt: existing.createdAt, magnetLink: generateMagnetLink(infoHash, name), }, }; @@ -571,6 +645,7 @@ export default defineEventHandler(async (event) => { return { success: true, + outcome: canBypassModeration ? ('published' as const) : ('pending' as const), message: canBypassModeration ? 'Torrent created successfully' : 'Torrent uploaded and pending moderation approval', From deb0918f4b6e8be67ab6d02d383d84773f98bbc9 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:23:41 +0200 Subject: [PATCH 61/96] feat(web): keep what is being written until it is sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four long-form surfaces — opening a topic, replying to one, commenting on a request, writing in a ticket — had no persistence at all. A navigation, a reload, a tab closed by accident, a session expiring during a reread: the text was gone without a word. On a forum where posts run to paragraphs, that is the most ordinary and most avoidable data loss there is. `localStorage`, not the server: a draft belongs to one browser, does not need to survive a change of device, and writing it server-side would mean a route, a table and a retention policy for text the author has not chosen to publish. The write is debounced by a second, because `localStorage` is synchronous and a long message feels it otherwise. Every read and write is guarded — a private window, a full quota, a setting that blocks site data all make the accessor itself throw, and a draft is never a reason to break a page. Restoring never overwrites something already typed, so yesterday's draft does not push in front of a quoted reply. Named `DraftHandle`, not `Draft`: `useThemeAdmin.ts` already exports a `Draft` and Nuxt auto-imports both without being able to keep them — the third name collision found this way, after `formatSize` and `ReleaseNameParts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../app/components/messaging/TicketThread.vue | 13 ++ .../components/settings/PortableIdentity.vue | 161 ++++++++++++++++-- apps/web/app/composables/useDraft.ts | 109 ++++++++++++ apps/web/app/composables/useUserSession.ts | 6 + apps/web/app/pages/forum/new-topic.vue | 57 ++++++- apps/web/app/pages/forum/topic/[id].vue | 54 +++++- apps/web/app/pages/requests/[id].vue | 82 ++++++++- apps/web/app/utils/localSecrets.ts | 54 ++++++ 8 files changed, 515 insertions(+), 21 deletions(-) create mode 100644 apps/web/app/composables/useDraft.ts create mode 100644 apps/web/app/utils/localSecrets.ts diff --git a/apps/web/app/components/messaging/TicketThread.vue b/apps/web/app/components/messaging/TicketThread.vue index fe8a0d13..94b2816e 100644 --- a/apps/web/app/components/messaging/TicketThread.vue +++ b/apps/web/app/components/messaging/TicketThread.vue @@ -242,6 +242,7 @@ const emit = defineEmits<{ (e: 'changed'): void }>(); const { t, locale } = useI18n(); const draft = ref(''); +const draftStore = useDraft(`ticket:${props.ticketId}`, draft); const note = ref(''); const busy = ref(false); const error = ref(''); @@ -349,6 +350,8 @@ async function send() { body: { body }, }); draft.value = ''; + // Publié : le brouillon n'a plus de raison d'exister. + draftStore.clear(); await refresh(); emit('changed'); } catch (err) { @@ -585,6 +588,16 @@ async function reopen() { resize: vertical; } .tk-input:focus { outline: none; border-color: rgb(var(--accent)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.tk-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .tk-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; } .tk-spacer { flex: 1; } diff --git a/apps/web/app/components/settings/PortableIdentity.vue b/apps/web/app/components/settings/PortableIdentity.vue index 0e39fc71..95538887 100644 --- a/apps/web/app/components/settings/PortableIdentity.vue +++ b/apps/web/app/components/settings/PortableIdentity.vue @@ -52,7 +52,7 @@ <button v-if="!armed" type="button" - class="btn-ghost" + class="sbtn-ghost" :disabled="busy" @click="armed = true" > @@ -62,12 +62,12 @@ <template v-else> <span class="pid-confirm">{{ $t('settings.identityExport.confirm') }}</span> - <button type="button" class="btn-ghost" :disabled="busy" @click="armed = false"> + <button type="button" class="sbtn-ghost" :disabled="busy" @click="armed = false"> {{ $t('common.cancel') }} </button> <button type="button" - class="btn-ghost btn-ghost--danger" + class="sbtn-ghost btn-ghost--danger" :disabled="busy" @click="download" > @@ -86,7 +86,7 @@ <button v-if="!rotateArmed" type="button" - class="btn-ghost" + class="sbtn-ghost" :disabled="busy" @click="rotateArmed = true" > @@ -95,12 +95,12 @@ </button> <template v-else> <span class="pid-confirm">{{ $t('settings.identityExport.rotateWarning') }}</span> - <button type="button" class="btn-ghost" :disabled="busy" @click="rotateArmed = false"> + <button type="button" class="sbtn-ghost" :disabled="busy" @click="rotateArmed = false"> {{ $t('common.cancel') }} </button> <button type="button" - class="btn-ghost btn-ghost--danger" + class="sbtn-ghost btn-ghost--danger" :disabled="busy" @click="rotate" > @@ -119,14 +119,14 @@ {{ held ? $t('settings.identityExport.custodyOn') : $t('settings.identityExport.custodyOff') }} </p> <template v-if="!held && !custodyArmed"> - <button type="button" class="btn-ghost" :disabled="busy" @click="custodyArmed = true"> + <button type="button" class="sbtn-ghost" :disabled="busy" @click="custodyArmed = true"> <Icon name="ph:key-bold" /> {{ $t('settings.identityExport.takeCustody') }} </button> <!-- The other half of custody: a member arriving in a new browser has the file and needs to put it back. Without this, "hold your own key" means "hold it in exactly one browser, forever". --> - <label class="btn-ghost fid-file"> + <label class="sbtn-ghost fid-file"> <Icon name="ph:upload-simple-bold" /> {{ $t('settings.identityExport.importKey') }} <input type="file" accept="application/json,.json" @change="importKey" /> @@ -134,10 +134,10 @@ </template> <template v-else-if="!held"> <span class="pid-confirm">{{ $t('settings.identityExport.custodyWarning') }}</span> - <button type="button" class="btn-ghost" :disabled="busy" @click="custodyArmed = false"> + <button type="button" class="sbtn-ghost" :disabled="busy" @click="custodyArmed = false"> {{ $t('common.cancel') }} </button> - <button type="button" class="btn-ghost btn-ghost--danger" :disabled="busy" @click="takeCustody"> + <button type="button" class="sbtn-ghost btn-ghost--danger" :disabled="busy" @click="takeCustody"> <Icon v-if="busy" name="ph:circle-notch" class="animate-spin" /> <Icon v-else name="ph:key-bold" /> {{ $t('settings.identityExport.custodyConfirm') }} @@ -145,11 +145,28 @@ </template> <template v-else> <code class="pid-held">{{ held.did }}</code> - <label class="btn-ghost fid-file"> + <label class="sbtn-ghost fid-file"> <Icon name="ph:upload-simple-bold" /> {{ $t('settings.identityExport.importKey') }} <input type="file" accept="application/json,.json" @change="importKey" /> </label> + <!-- Retirer la clé de CE navigateur. + `identityKey.forget()` existait sans aucun appelant : rien, dans + toute l'application, n'effaçait la clé privée du stockage local. + Elle part désormais à la déconnexion (voir `utils/localSecrets.ts`), + mais un membre sur un poste emprunté doit pouvoir la retirer + maintenant, sans se déconnecter. Le geste est destructif si le + fichier d'export n'a pas été gardé : il passe par une + confirmation. --> + <button + type="button" + class="sbtn-ghost btn-ghost--danger" + :disabled="busy" + @click="forgetKey" + > + <Icon name="ph:trash-bold" /> + {{ $t('settings.identityExport.forget') }} + </button> </template> </div> @@ -166,6 +183,7 @@ import * as identityKey from '~/utils/identityKey'; const { t } = useI18n(); +const confirm = useConfirm(); const armed = ref(false); const rotateArmed = ref(false); @@ -283,6 +301,25 @@ async function importKey(event: Event): Promise<void> { } } +/** + * Retirer la clé de ce navigateur. + * + * Sans le fichier d'export, la clé est irrécupérable : l'instance n'en a jamais + * eu la moitié privée, c'est tout l'objet du dispositif. La confirmation le dit + * en ces termes plutôt qu'en « êtes-vous sûr ». + */ +async function forgetKey(): Promise<void> { + const ok = await confirm({ + title: t('settings.identityExport.forgetConfirm.title'), + message: t('settings.identityExport.forgetConfirm.message'), + confirmText: t('settings.identityExport.forget'), + destructive: true, + }); + if (!ok) return; + identityKey.forget(); + held.value = null; +} + async function download(): Promise<void> { busy.value = true; error.value = null; @@ -336,6 +373,104 @@ async function download(): Promise<void> { </script> <style scoped> +/* + * Le châssis des boutons et des titres de cette carte. + * + * `.action-card-title`, `.action-card-text`, `.btn-ghost--danger` et `.fid-file` + * n'existaient que dans les `<style scoped>` de `settings.vue` et de + * `federated-identity.vue`. Un style scopé pose son attribut sur la RACINE d'un + * composant enfant, pas sur ses descendants : ces onze contrôles rendaient donc + * du texte gris sans boîte — le preflight Tailwind retirant fond, bordure et + * padding d'un `<button>` — et le bouton qui retire la clé privée de l'instance + * était visuellement identique à « Annuler ». + * + * `.sbtn-ghost` seul ne suffit pas non plus : dans `main.css`, le châssis vit + * sur `.btn` et `.sbtn-ghost` ne porte que les couleurs. Les valeurs ci-dessous + * sont celles de `settings.vue`, la page qui rend ce composant, pour que la + * carte reste dans le vocabulaire de son hôte. + */ +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un `<style scoped>`, donc hors couche, il l'emportait sur + * `@layer components` quelle que soit la spécificité. Sept fichiers donnaient + * ainsi deux boutons visuellement différents sous le même nom de classe, et + * `class="btn btn-primary"` écrit dans l'un d'eux n'aurait pas donné le bouton + * attendu. + */ +.action-card-title { + margin: 0 0 0.25rem; + font-size: 0.9rem; + font-weight: 700; + color: rgb(var(--fg-strong)); + display: inline-flex; + align-items: center; + gap: 0.4rem; +} +.action-card-text { + margin: 0; + font-size: 0.78rem; + line-height: 1.55; + color: rgb(var(--fg-muted)); +} +.sbtn-ghost { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.95rem; + min-height: 2.25rem; + border-radius: var(--radius-pill); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: calc(0.16em * var(--tracking-scale)); + text-transform: uppercase; + border: 1px solid rgb(var(--line-default)); + background: rgb(var(--bg-elevated)); + color: rgb(var(--fg-default)); + cursor: pointer; + transition: all var(--dur-2); +} +.sbtn-ghost:hover:not(:disabled) { + border-color: rgb(var(--fg-default) / 0.3); + color: rgb(var(--fg-strong)); +} +.sbtn-ghost:disabled { + opacity: 0.5; + cursor: not-allowed; +} +/* Rouge au repos ET au focus, pas seulement au survol : au doigt il n'y a pas + de survol, et au clavier non plus. */ +.btn-ghost--danger { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.4); +} +.btn-ghost--danger:hover:not(:disabled), +.btn-ghost--danger:focus-visible:not(:disabled) { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.7); + background: rgb(var(--danger) / 0.1); +} +/* Le sélecteur de fichier est un <label> qui masque son input : sans + `overflow: visible` l'anneau de focus global, posé avec un décalage de 2 px, + était rogné par le parent. */ +.fid-file { + position: relative; + overflow: visible; +} +.fid-file input[type='file'] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} +.fid-file:focus-within { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .pid { display: flex; flex-direction: column; @@ -439,7 +574,7 @@ async function download(): Promise<void> { .pid-held { font-family: var(--font-mono); font-size: 0.625rem; - color: rgb(var(--success)); + color: rgb(var(--online)); word-break: break-all; } .pid-error { @@ -451,7 +586,7 @@ async function download(): Promise<void> { align-items: center; gap: 0.35rem; font-size: 0.75rem; - color: rgb(var(--success)); + color: rgb(var(--online)); } .pid-done code { font-family: var(--font-mono); diff --git a/apps/web/app/composables/useDraft.ts b/apps/web/app/composables/useDraft.ts new file mode 100644 index 00000000..b7423db2 --- /dev/null +++ b/apps/web/app/composables/useDraft.ts @@ -0,0 +1,109 @@ +/** + * useDraft — garde ce qui est en train d'être écrit, jusqu'à l'envoi. + * + * Les quatre surfaces de rédaction longue du site — ouvrir un sujet, répondre à + * un sujet, commenter une demande, écrire dans un ticket — n'avaient aucune + * persistance. Une navigation, un rechargement, un onglet fermé par erreur, une + * session expirée pendant la relecture : le texte disparaissait sans un mot. + * Sur un forum où les messages font des paragraphes, c'est la perte de données + * la plus banale et la plus évitable qui soit. + * + * `localStorage`, pas le serveur : un brouillon est personnel à un navigateur, + * n'a pas besoin de survivre à un changement d'appareil, et l'écrire côté + * serveur voudrait dire une route, une table et une politique de rétention pour + * un texte que l'auteur n'a pas encore choisi de publier. + * + * Écriture différée d'une seconde : sans cela, chaque frappe touche le disque, + * et `localStorage` est synchrone — sur un long message, cela se sent. + * + * const draft = useDraft(`forum:reply:${id}`, replyContent); + * … + * await post(); + * draft.clear(); // publié : le brouillon n'a plus de raison d'être + * + * Toutes les lectures et écritures sont gardées : un navigateur en navigation + * privée, un stockage plein ou un réglage qui bloque les données de site font + * lever l'accesseur lui-même, et un brouillon n'est jamais une raison de casser + * une page. + */ +const PREFIX = 'trackarr.draft.'; +const DEBOUNCE_MS = 1000; + +/** Au-delà, ce n'est plus un brouillon : on ne remplit pas le stockage du + * navigateur avec un collage accidentel de plusieurs mégaoctets. */ +const MAX_CHARS = 100_000; + +/** + * Nommée `DraftHandle` et non `Draft` : `useThemeAdmin.ts` exporte déjà un type + * `Draft`, et Nuxt auto-importe les deux depuis `app/composables/` sans pouvoir + * garder les deux — le troisième doublon de nom rencontré dans cette passe, + * après `formatSize` et `ReleaseNameParts`. + */ +export interface DraftHandle { + /** Efface le brouillon — à appeler après un envoi réussi. */ + clear: () => void; + /** Vrai si quelque chose a été restauré à l'ouverture. */ + restored: Ref<boolean>; +} + +export function useDraft(key: string, source: Ref<string>): DraftHandle { + const restored = ref(false); + const full = PREFIX + key; + let timer: ReturnType<typeof setTimeout> | null = null; + + function read(): string | null { + try { + return window.localStorage.getItem(full); + } catch { + return null; + } + } + function write(value: string): void { + try { + if (value) window.localStorage.setItem(full, value); + else window.localStorage.removeItem(full); + } catch { + /* stockage indisponible ou plein : le brouillon est un confort */ + } + } + + function clear(): void { + if (timer) { + clearTimeout(timer); + timer = null; + } + write(''); + restored.value = false; + } + + onMounted(() => { + // La restauration n'écrase jamais une saisie déjà commencée : sur une page + // qui pré-remplit son champ (une citation, une réponse amorcée), le + // brouillon d'hier ne doit pas passer devant. + const saved = read(); + if (saved && !source.value) { + source.value = saved; + restored.value = true; + } + + watch(source, (next) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + write(next.length > MAX_CHARS ? next.slice(0, MAX_CHARS) : next); + }, DEBOUNCE_MS); + }); + }); + + onBeforeUnmount(() => { + // La dernière seconde de frappe, qui serait sinon perdue précisément au + // moment où l'on quitte la page — c'est-à-dire dans le cas que ce + // composable existe pour couvrir. + if (timer) { + clearTimeout(timer); + timer = null; + if (typeof window !== 'undefined') write(source.value); + } + }); + + return { clear, restored }; +} diff --git a/apps/web/app/composables/useUserSession.ts b/apps/web/app/composables/useUserSession.ts index ca909423..26937013 100644 --- a/apps/web/app/composables/useUserSession.ts +++ b/apps/web/app/composables/useUserSession.ts @@ -34,6 +34,12 @@ export function useUserSession() { async function clear() { session.value = { user: null }; + // La clé privée de l'identité portable et les brouillons partent avec la + // session. `identityKey.forget()` existait et n'était appelé de nulle part : + // sur un profil de navigateur partagé, la personne suivante héritait de la + // clé qui signe les documents fédérés du membre précédent. Voir + // `utils/localSecrets.ts` pour ce qui est purgé et pourquoi c'est ici. + purgeLocalSecrets(); } return { user, loggedIn, fetch, clear }; diff --git a/apps/web/app/pages/forum/new-topic.vue b/apps/web/app/pages/forum/new-topic.vue index d69e1f4f..1ceb478a 100644 --- a/apps/web/app/pages/forum/new-topic.vue +++ b/apps/web/app/pages/forum/new-topic.vue @@ -185,6 +185,32 @@ const form = ref({ const submitting = ref(false); +/** + * Le brouillon du sujet, titre et corps ensemble. + * + * `useDraft` travaille sur une chaîne ; ici l'état est un objet, donc on lui + * donne une vue JSON du couple (titre, contenu) — la catégorie reste hors du + * brouillon, puisqu'elle peut venir de l'URL et qu'une valeur d'hier y serait + * plus gênante qu'utile. + */ +const draftJson = computed({ + get: () => + form.value.title || form.value.content + ? JSON.stringify({ title: form.value.title, content: form.value.content }) + : '', + set: (raw: string) => { + try { + const parsed = JSON.parse(raw) as { title?: string; content?: string }; + form.value.title = parsed.title ?? ''; + form.value.content = parsed.content ?? ''; + } catch { + // Un brouillon illisible (format d'une version antérieure) est ignoré + // plutôt que de faire échouer l'ouverture de la page. + } + }, +}); +const topicDraft = useDraft('forum:new-topic', draftJson); + const selectedCategory = computed(() => (categories.value ?? []).find((c) => c.id === form.value.categoryId) ?? null ); @@ -198,7 +224,8 @@ const isValid = computed( function catTileStyle(cat: Category) { const accent = cat.color || 'rgb(var(--fg-muted))'; - return { '--accent': accent } as Record<string, string>; + // Voir la note de `forum/index.vue`. + return { '--cat-accent': accent } as Record<string, string>; } async function handleSubmit() { @@ -214,6 +241,8 @@ async function handleSubmit() { }, }); if (topic?.id) { + // Publié : le brouillon n'a plus de raison d'exister. + topicDraft.clear(); router.push(`/forum/topic/${topic.id}`); } } catch (e: any) { @@ -388,12 +417,12 @@ async function handleSubmit() { color: var(--ink); } .cat-tile--on { - background: color-mix(in srgb, rgb(var(--accent)) 14%, transparent); - border-color: rgb(var(--accent)); + background: color-mix(in srgb, var(--cat-accent) 14%, transparent); + border-color: var(--cat-accent); color: var(--ink); } .cat-tile--on > svg { - color: rgb(var(--accent)); + color: var(--cat-accent); } .compose-title-input { @@ -413,6 +442,16 @@ async function handleSubmit() { outline: none; border-color: var(--ink); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.compose-title-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .compose-input { width: 100%; @@ -433,6 +472,16 @@ async function handleSubmit() { outline: none; border-color: var(--ink); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.compose-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .compose-counter { align-self: flex-end; font-family: var(--font-mono); diff --git a/apps/web/app/pages/forum/topic/[id].vue b/apps/web/app/pages/forum/topic/[id].vue index 9c9c2e42..29230e24 100644 --- a/apps/web/app/pages/forum/topic/[id].vue +++ b/apps/web/app/pages/forum/topic/[id].vue @@ -224,6 +224,15 @@ <kbd>⌘</kbd> + <kbd>↵</kbd> {{ $t('forum.topic.toPost') }} </span> </header> + <!-- Le texte réapparaît tout seul à l'ouverture : le dire, sinon + c'est un texte qu'on ne se souvient pas d'avoir écrit. --> + <p v-if="replyDraft.restored.value" class="draft-note"> + <Icon name="ph:floppy-disk-back-bold" /> + {{ $t('common.draftRestored') }} + <button type="button" class="draft-note-clear" @click="replyContent = ''; replyDraft.clear()"> + {{ $t('common.draftDiscard') }} + </button> + </p> <textarea v-model="replyContent" ref="replyTextareaRef" @@ -317,6 +326,9 @@ useHead({ }); const replyContent = ref(''); +// Un brouillon par sujet : deux réponses en cours dans deux onglets ne se +// marchent pas dessus. +const replyDraft = useDraft(`forum:reply:${route.params.id}`, replyContent); const replyTextareaRef = ref<HTMLTextAreaElement | null>(null); const composerRef = ref<HTMLElement | null>(null); const posting = ref(false); @@ -440,6 +452,8 @@ async function handlePostReply() { }, }); replyContent.value = ''; + // Publié : le brouillon n'a plus de raison d'exister. + replyDraft.clear(); await refresh(); nextTick(() => { // Scroll to the new last post — that's the one we just added. @@ -808,12 +822,12 @@ onMounted(() => { color: var(--ink-fade); } .role-pill--admin { - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); } .role-pill--mod { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); } @@ -894,6 +908,16 @@ onMounted(() => { outline: none; border-color: var(--ink); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.post-edit-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .post-edit-tools { display: flex; justify-content: flex-end; @@ -964,6 +988,16 @@ onMounted(() => { outline: none; border-color: var(--ink); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.composer-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .composer-foot { display: flex; align-items: center; @@ -1047,7 +1081,7 @@ onMounted(() => { } .ed-btn--danger:hover:not(:disabled) { background: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgb(var(--danger)); } @@ -1138,4 +1172,18 @@ onMounted(() => { font-size: 1.4rem; } } +.draft-note { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.4rem; + font-size: 0.7rem; + color: rgb(var(--fg-subtle)); +} +.draft-note-clear { + color: rgb(var(--fg-muted)); + text-decoration: underline; + cursor: pointer; +} +.draft-note-clear:hover { color: rgb(var(--fg-default)); } </style> diff --git a/apps/web/app/pages/requests/[id].vue b/apps/web/app/pages/requests/[id].vue index 81409ffd..6d6e40ed 100644 --- a/apps/web/app/pages/requests/[id].vue +++ b/apps/web/app/pages/requests/[id].vue @@ -221,6 +221,15 @@ class="thread-compose" @submit.prevent="postComment" > + <!-- Le texte réapparaît tout seul à l'ouverture : le dire, sinon + c'est un texte qu'on ne se souvient pas d'avoir écrit. --> + <p v-if="commentDraft.restored.value" class="draft-note"> + <Icon name="ph:floppy-disk-back-bold" /> + {{ $t('common.draftRestored') }} + <button type="button" class="draft-note-clear" @click="newComment = ''; commentDraft.clear()"> + {{ $t('common.draftDiscard') }} + </button> + </p> <textarea v-model="newComment" class="thread-compose-input" @@ -551,6 +560,7 @@ useHead({ const busy = ref(false); const fillHash = ref(''); const newComment = ref(''); +const commentDraft = useDraft(`request:comment:${route.params.id}`, newComment); const posting = ref(false); const editing = reactive<{ id: string | null; body: string }>({ id: null, @@ -796,6 +806,8 @@ async function postComment() { body: { body: newComment.value.trim() }, }); newComment.value = ''; + // Publié : le brouillon n'a plus de raison d'exister. + commentDraft.clear(); await refresh(); } catch (err: any) { notifications.error( @@ -885,7 +897,13 @@ async function fill() { --status-filled: 251 191 36; --status-validated: 96 165 250; --status-cancelled: 156 163 175; - --danger: 244 63 94; + /* Pas de `--danger` local ici. + Redéfinir le jeton global le masquait sur tout le sous-arbre avec une + valeur unique qui ne bascule pas — 3,67:1 en thème clair là où le jeton + donne 6,47:1 — et n'importe quelle primitive partagée rendue dedans + (`.btn-danger`, `.tool-btn--danger`) en héritait. Le voisin + `--brass: var(--accent-warm)` fait déjà correctement ce que celui-ci + faisait de travers : pointer sur un jeton, pas le remplacer. */ } .tabular-nums { font-variant-numeric: tabular-nums; } @@ -904,6 +922,34 @@ async function fill() { overflow: hidden; pointer-events: none; } +/* Les deux variantes du décor de fond. + `.aura-blob` de base était bien défini ici, mais `--a` et `--b` — qui + portent la taille, la position et la teinte — ne vivaient que dans le + `<style scoped>` d'autres pages. Un style scopé ne franchit pas la frontière + d'un composant : ces deux taches n'avaient donc aucune dimension, et + l'atmosphère que sept pages sœurs affichent était simplement absente ici. */ +.aura-blob--a { + width: 520px; + height: 520px; + top: -180px; + left: 5%; + background: radial-gradient( + circle, + rgb(var(--accent-warm) / 0.28), + transparent 65% + ); +} +.aura-blob--b { + width: 420px; + height: 420px; + top: 60px; + right: 6%; + background: radial-gradient( + circle, + rgb(var(--info) / 0.22), + transparent 65% + ); +} .aura-blob { position: absolute; width: 520px; @@ -1421,6 +1467,16 @@ async function fill() { border-color: rgb(var(--brass) / 0.55); box-shadow: 0 0 0 3px rgb(var(--brass) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.thread-compose-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .thread-compose-row { display: flex; align-items: center; @@ -1576,6 +1632,16 @@ async function fill() { border-color: rgb(var(--brass) / 0.55); box-shadow: 0 0 0 3px rgb(var(--brass) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.side-fill-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .side-fill-quota { margin: 0; font-family: var(--font-mono); @@ -1703,4 +1769,18 @@ async function fill() { font-size: 0.85rem; color: rgb(var(--fg-strong)); } +.draft-note { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.4rem; + font-size: 0.7rem; + color: rgb(var(--fg-subtle)); +} +.draft-note-clear { + color: rgb(var(--fg-muted)); + text-decoration: underline; + cursor: pointer; +} +.draft-note-clear:hover { color: rgb(var(--fg-default)); } </style> diff --git a/apps/web/app/utils/localSecrets.ts b/apps/web/app/utils/localSecrets.ts new file mode 100644 index 00000000..6d89d778 --- /dev/null +++ b/apps/web/app/utils/localSecrets.ts @@ -0,0 +1,54 @@ +/** + * Ce que le navigateur garde de vous, et qui doit partir avec la session. + * + * Trois choses vivent dans le stockage local d'un membre, et aucune n'était + * effacée à la déconnexion : + * + * - `trackarr.identity.key` — la clé privée Ed25519 de l'identité portable. + * C'est la plus grave : `identityKey.forget()` existait et n'avait AUCUN + * appelant. Sur un profil de navigateur partagé, la personne suivante + * héritait de la clé qui signe les documents d'identité fédérés du membre + * précédent. Elle reste délibérément exportable — `PortableIdentity.vue` + * propose de la télécharger à tout moment, c'est le sens de « portable » — + * donc l'exposition à un XSS sur l'origine est inhérente au dispositif ; + * ce qui ne l'est pas, c'est qu'elle survive à la déconnexion. + * - `trackarr.draft.*` — les brouillons de sujets, de réponses, de + * commentaires et de tickets. + * - la carte des brouillons de messages privés, écrite par `messages.vue`. + * + * Appelé depuis `useUserSession().clear()`, le seul point par lequel passent + * les quatre chemins de déconnexion de l'application (le magasin `user`, la + * palette de commandes, la page de réglages, la mise en page). Y mettre la + * purge plutôt que dans les quatre est ce qui garantit qu'un cinquième chemin + * ajouté demain ne l'oubliera pas. + * + * Tout est gardé : un navigateur en navigation privée ou un réglage qui bloque + * les données de site fait lever l'accesseur lui-même, et une déconnexion ne + * doit jamais échouer pour cette raison. + */ + +/** Les préfixes à purger. Un préfixe, pas une liste de clés : les brouillons + * portent un identifiant variable. */ +const PURGE_PREFIXES = ['trackarr.identity.', 'trackarr.draft.']; + +/** Les clés exactes à purger, pour ce qui ne suit pas la convention de + * nommage. `messages.vue` a choisi les deux-points. */ +const PURGE_KEYS = ['trackarr:message-drafts']; + +export function purgeLocalSecrets(): void { + if (typeof window === 'undefined') return; + try { + const doomed: string[] = []; + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i); + if (!key) continue; + if (PURGE_KEYS.includes(key) || PURGE_PREFIXES.some((p) => key.startsWith(p))) { + doomed.push(key); + } + } + // En deux temps : retirer pendant qu'on énumère décale les index. + for (const key of doomed) window.localStorage.removeItem(key); + } catch { + /* stockage indisponible : rien à purger */ + } +} From 96f9c28324406e457d276762de51fb14044cfd9e Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:23:41 +0200 Subject: [PATCH 62/96] fix(web): an error page in English, and numbers formatted in the wrong locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuxt's built-in error page is English and unstyled, so a 404 or a 403 dropped a French member onto something that did not look like the site at all. `error.vue` answers in their language and says what to do next: a 404 is an old link, a 403 is not their account's to see and the staff can be asked, a 401 needs a sign-in. None of them blame the member. The i18n plugin registers the number and date formats, which vue-i18n otherwise falls back to the default locale for — French members were reading English thousands separators inside French sentences. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/app/error.vue | 184 ++++++++++++++++++++++++++++ apps/web/app/plugins/i18n-format.ts | 27 ++++ apps/web/i18n/locales/en.json | 174 ++++++++++++++++++++++++-- apps/web/i18n/locales/fr.json | 176 ++++++++++++++++++++++++-- 4 files changed, 546 insertions(+), 15 deletions(-) create mode 100644 apps/web/app/error.vue create mode 100644 apps/web/app/plugins/i18n-format.ts diff --git a/apps/web/app/error.vue b/apps/web/app/error.vue new file mode 100644 index 00000000..b6d21a5b --- /dev/null +++ b/apps/web/app/error.vue @@ -0,0 +1,184 @@ +<template> + <div class="err"> + <div class="err-aura" aria-hidden="true"> + <span class="err-blob" /> + </div> + + <main class="err-body"> + <p class="err-code">{{ code }}</p> + <h1 class="err-title">{{ title }}</h1> + <p class="err-text">{{ text }}</p> + + <!-- The message the server actually sent, when there is one worth + reading. Kept behind a disclosure rather than in the lede: a member + who mistyped a torrent hash does not need a stack of words, and an + operator debugging a 500 does. --> + <details v-if="detail" class="err-detail"> + <summary>{{ $t('error.detail') }}</summary> + <code>{{ detail }}</code> + </details> + + <div class="err-actions"> + <button type="button" class="btn btn-primary" @click="goHome"> + <Icon name="ph:house" /> + {{ $t('error.home') }} + </button> + <button type="button" class="btn btn-secondary" @click="reload"> + <Icon name="ph:arrow-clockwise" /> + {{ $t('error.retry') }} + </button> + </div> + </main> + </div> +</template> + +<script setup lang="ts"> +/** + * The error page. + * + * There was not one. Every 404 — a mistyped torrent hash, a link to a release + * a moderator has since removed, a federated id that no longer resolves — and + * every 500 landed on Nuxt's built-in page: English, unthemed, no header, no + * way back to the site, and Nuxt's own branding on a self-hosted tracker. On an + * invite-only site where members share links with each other, a dead link is + * the single most common failure anybody sees. + * + * Deliberately standalone rather than wrapped in the default layout: the layout + * fetches the session, the branding and the live tracker status, and an error + * page that needs three round-trips to render is an error page that fails when + * the API is what broke. Everything here is local. + */ +const props = defineProps<{ + error?: { + statusCode?: number; + statusMessage?: string; + message?: string; + }; +}>(); + +const { t } = useI18n(); + +const code = computed(() => props.error?.statusCode ?? 500); + +/** 404 and 403 get their own words; everything else is "something broke". */ +const kind = computed(() => { + const c = code.value; + if (c === 404) return 'notFound'; + if (c === 403) return 'forbidden'; + if (c === 401) return 'unauthorised'; + return 'server'; +}); + +const title = computed(() => t(`error.${kind.value}.title`)); +const text = computed(() => t(`error.${kind.value}.text`)); + +/** + * The server's own message, when it says more than the status already does. + * + * Suppressed on 404 and 403: those messages are written for a log, and a member + * who followed a dead link is not helped by "Torrent not found". + */ +const detail = computed(() => { + if (kind.value === 'notFound' || kind.value === 'forbidden') return ''; + const m = props.error?.statusMessage || props.error?.message || ''; + return m && m !== String(code.value) ? m : ''; +}); + +/** `clearError` rather than a plain navigation: it tears the error state down, + * otherwise the next route renders inside it. */ +function goHome() { + void clearError({ redirect: '/' }); +} +function reload() { + void clearError({ redirect: useRoute().fullPath }); +} + +useHead({ title: () => `${code.value} — ${title.value}` }); +</script> + +<style scoped> +.err { + position: relative; + min-height: 100dvh; + display: grid; + place-items: center; + padding: 2rem 1.25rem; + background: rgb(var(--bg-base)); + color: rgb(var(--fg-default)); + overflow: hidden; +} +/* One soft blob, the same atmospheric device the member-facing pages use, so + the error page reads as part of the site rather than as the browser's. */ +.err-aura { + position: absolute; + inset: 0; + pointer-events: none; +} +.err-blob { + position: absolute; + top: -12rem; + left: 50%; + width: 34rem; + height: 34rem; + transform: translateX(-50%); + border-radius: 50%; + background: rgb(var(--accent-warm) / 0.08); + filter: blur(90px); +} +.err-body { + position: relative; + max-width: 34rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: center; +} +.err-code { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: clamp(3.5rem, 14vw, 6rem); + font-weight: 700; + line-height: 1; + color: rgb(var(--accent-warm-text)); +} +.err-title { + font-family: var(--font-display); + font-size: clamp(1.5rem, 4vw, 2rem); + font-weight: 700; + color: rgb(var(--fg-strong)); +} +.err-text { + font-size: 0.9375rem; + line-height: 1.6; + color: rgb(var(--fg-muted)); + max-width: 48ch; +} +.err-detail { + margin-top: 0.25rem; + font-size: 0.75rem; + color: rgb(var(--fg-subtle)); +} +.err-detail summary { + cursor: pointer; + padding: 0.4rem 0; +} +.err-detail code { + display: block; + margin-top: 0.35rem; + padding: 0.6rem 0.8rem; + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-sm); + background: rgb(var(--bg-surface)); + font-family: var(--font-mono); + text-align: left; + overflow-wrap: anywhere; +} +.err-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + justify-content: center; + margin-top: 0.75rem; +} +</style> diff --git a/apps/web/app/plugins/i18n-format.ts b/apps/web/app/plugins/i18n-format.ts new file mode 100644 index 00000000..f547ed1f --- /dev/null +++ b/apps/web/app/plugins/i18n-format.ts @@ -0,0 +1,27 @@ +/** + * i18n-format — branche les formateurs de `utils/format.ts` sur la langue de + * la requête en cours. + * + * Universel, pas `.client`. Le pendant client existait déjà (`i18n-user` + * appelle `setFormatLocale`), mais rien ne le faisait côté serveur : tout le + * rendu SSR sortait donc en anglais, et une page française affichait + * « yesterday » dans sa colonne ÂGE jusqu'à ce que l'hydratation la réécrive. + * Sans JavaScript — ou pour un moteur d'indexation — elle restait anglaise. + * + * Le résolveur ne CAPTURE pas la langue : il la relit à chaque appel depuis le + * contexte Nuxt courant. C'est ce qui le rend sûr en rendu serveur, où + * plusieurs requêtes se partagent le même module et où une variable écrite par + * requête ferait fuiter la langue d'un membre dans la page d'un autre. + */ +export default defineNuxtPlugin({ + name: 'i18n-format', + enforce: 'post', + setup() { + setFormatLocaleSource(() => { + const app = useNuxtApp() as { + $i18n?: { locale?: { value?: string } }; + }; + return app?.$i18n?.locale?.value || 'en'; + }); + }, +}); diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index b90ba548..350872bc 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -1,5 +1,28 @@ { + "error": { + "detail": "Technical detail", + "home": "Back to the site", + "retry": "Try again", + "notFound": { + "title": "That page is not here", + "text": "The link may be old, or what it pointed at has been removed. Nothing is wrong with your account." + }, + "forbidden": { + "title": "Not for this account", + "text": "You are signed in, but this page is not open to you. If you think it should be, ask a member of staff." + }, + "unauthorised": { + "title": "You need to sign in", + "text": "This page is for members. Sign in and you will be brought back here." + }, + "server": { + "title": "Something broke on our side", + "text": "This is not your doing. Trying again often works; if it does not, the operator will find it in the logs." + } + }, "common": { + "draftRestored": "Draft restored", + "draftDiscard": "Start over", "save": "Save", "saved": "Saved", "saveChanges": "Save changes", @@ -26,6 +49,12 @@ "category": "Category", "hash": "Hash", "refresh": "Refresh", + "saveFailed": "Could not save. Check the connection and try again.", + "deleteFailed": "Could not delete. Nothing was changed.", + "actionFailed": "That did not go through. Nothing was changed.", + "loadFailed": "Could not load this from the instance.", + "loadFailedHint": "Nothing here is missing — it could not be read. Try again, and tell the operator if it persists.", + "retry": "Try again", "copy": "Copy", "copied": "Copied", "open": "Open", @@ -68,6 +97,10 @@ "signOut": "Sign out", "signIn": "Sign in", "signUp": "Sign up", + "skipToContent": "Skip to content", + "footerSite": "The author’s site", + "footerSource": "Source code on GitHub", + "footerChat": "Community Discord", "menu": "Menu", "toggleTheme": "Toggle theme", "languageSelector": "Select language", @@ -162,6 +195,11 @@ "usePasskey": "Use a passkey", "trustDevice": "Trust this browser for 30 days", "verifyButton": "Verify", + "status": { + "challenge": "Fetching challenge…", + "proof": "Deriving proof…", + "authenticating": "Signing in…" + }, "errors": { "invalidCredentials": "Invalid username or password.", "invalidCode": "That code didn't match. Try again.", @@ -389,6 +427,25 @@ } }, "settings": { + "appearance": { + "localeSaveFailed": "Could not save the language change. Please try again.", + "themeEyebrow": "Mode", + "themeTitle": "Theme", + "themeHelp": "Theme follows you across devices — stored on your account and cached locally for a flicker-free first paint.", + "localeEyebrow": "Locale", + "localeHelp": "Saved on your account so the same UI language follows you across devices. New strings always fall back to English when a translation isn’t ready yet.", + "siteDefault": "Site default", + "siteDefaultSub": "Currently {name}", + "siteDefaultUnknown": "Whatever the owner picks", + "system": "System", + "systemSub": "Follows your operating system", + "light": "Light", + "lightSub": "Day-friendly tones", + "dark": "Dark", + "darkSub": "Editorial midnight", + "basedLight": "Light-based", + "basedDark": "Dark-based" + }, "title": "Settings & controls", "pageTitle": "Settings", "eyebrow": "Account preferences", @@ -447,6 +504,8 @@ "targetMasked": "Configured", "optional": "optional", "webPush": { + "enableFailed": "Could not turn on browser notifications.", + "disableFailed": "Could not turn off browser notifications.", "enable": "Enable in this browser", "disable": "Disable in this browser", "enabledHere": "Active in this browser — system notifications will land here.", @@ -589,6 +648,11 @@ } }, "identityExport": { + "forget": "Remove from this browser", + "forgetConfirm": { + "title": "Remove the key from this browser?", + "message": "The instance never held your private half: without the export file this key is gone for good, and you would have to mint a new identity." + }, "title": "Take your identity with you", "what": "Your account here is also a key of its own. Exporting it lets another instance recognise you as the same person — and lets the uploads attributed to you keep your name wherever they travel.", "travels": "Travels with you", @@ -694,6 +758,7 @@ "publishing": "Publishing…", "publishRelease": "Publish release", "ready": { + "duplicate": "This .torrent is already in the catalogue", "pickCategory": "Pick a category", "awaitingFile": "Awaiting source file", "titleRequired": "Title required", @@ -716,7 +781,18 @@ "uploadFailed": "Upload failed" }, "result": { - "subtitle": "The release is now indexed and ready to share.", + "published": { + "title": "Release published", + "subtitle": "The release is now indexed and ready to share." + }, + "pending": { + "title": "Waiting for approval", + "subtitle": "A moderator has to approve it before it shows up in the catalogue. Keep seeding in the meantime." + }, + "exists": { + "title": "This release already exists", + "subtitle": "The tracker already knew this infohash: nothing was published. Open the existing release and seed it." + }, "downloadTorrent": "Download .torrent", "viewRelease": "View release", "uploadAnother": "Upload another release" @@ -727,6 +803,10 @@ "ficheEditAnyway": "Unlock visual mode" }, "edit": { + "denied": { + "title": "This release is not yours", + "text": "Only the uploader of a release, or a staff member, can edit its metadata. If something on it is wrong, report it from the release page." + }, "backToRelease": "Back to release", "eyebrow": "Editing · {hash}…", "titleMain": "Edit", @@ -1194,6 +1274,11 @@ }, "security": { "twoFactor": { + "confirmRemovePasskey": { + "title": "Remove this passkey?", + "message": "You will have to enrol it again from the device that holds it. If it is your only key, your account is left without a second factor." + }, + "statusUnknown": "Could not read whether two-factor is on. This does not mean it is off — try again.", "passkeyCount": "{n} passkey | {n} passkeys", "totpEnabled": "Enabled", "recoveryCodesLeft": "{n} recovery code left | {n} recovery codes left", @@ -1448,6 +1533,7 @@ }, "components": { "modal": { + "fallbackLabel": "Dialog", "close": "Close" }, "confirm": { @@ -1775,6 +1861,10 @@ "inviteTree": "Who vouched for a member, and who they let in." }, "taxonomy": { + "confirmRemove": { + "title": "Remove this mapping?", + "message": "Partner releases filed under this category go back to unclassified." + }, "title": "Federated taxonomy", "sub": "Partners file releases under their own category slugs. Map a foreign slug onto one of your categories and the browse filter will surface that partner's releases under it — otherwise a differently-named category stays invisible to a member browsing by category.", "mapped": "Declared mappings", @@ -1791,6 +1881,10 @@ "adultTag": "adult" }, "federation": { + "confirmRevoke": { + "title": "Revoke this partner?", + "message": "“{name}” can no longer send us anything, and what it had published leaves the catalogue. Restoring the link takes action on both sides." + }, "intro": "Connect this tracker to other Trackarr instances you trust. You decide who you link with and exactly what you share — inbound links need your approval.", "ownerOnly": "Owner only", "master": { @@ -2106,6 +2200,7 @@ } }, "uploadRules": { + "loadFailed": "These rules could not be read from the instance. What is shown below is NOT the policy in force — reload before changing anything.", "globalRules": "Global rules", "snap": { "gatesArmed": "Gates armed", @@ -2348,6 +2443,7 @@ "subPart1": "Hidden from users who haven't enabled XXX in their profile settings — applies to listings, search, RSS, Torznab and the homepage feed." }, "row": { + "stock": "{n} torrent | {n} torrents", "subCount": "{n} sub | {n} subs", "editCategory": "Edit category", "deleteCategory": "Delete category", @@ -2362,6 +2458,8 @@ "saveChanges": "Save changes" }, "deleteConfirm": { + "blockedSubs": "This category has {n} subcategory. Delete it first. | This category has {n} subcategories. Delete those first.", + "blockedTorrents": "This category holds {n} torrent. Move it first — deleting does not unclassify it. | This category holds {n} torrents. Move them first — deleting does not unclassify them.", "title": "Delete category", "messageNamed": "Permanently delete the category “{name}”? Torrents currently in it will become uncategorised.", "messageGeneric": "Permanently delete this category? Torrents currently in it will become uncategorised.", @@ -2385,6 +2483,10 @@ } }, "bonusRules": { + "confirmTierDelete": { + "title": "Delete this tier?", + "message": "Torrents this tier covered fall back to the next tier, or to ×1 if there is none." + }, "on": "On", "off": "Off", "addTier": "Add tier", @@ -2398,6 +2500,7 @@ "tiers": "tier | tiers" }, "units": { + "hundredths": "in hundredths — 150 = ×1.50", "seeders": "seeders", "seedersOrFewer": "seeders or fewer", "days": "days", @@ -2458,6 +2561,7 @@ "multiplier": "Multiplier" }, "toasts": { + "tierUpdated": "Tier saved", "ruleUpdated": "Rule updated", "tierDeleted": "Tier deleted" }, @@ -3145,6 +3249,7 @@ "hintTimed": "The user will be banned for {duration}. Login, tracker and Torznab blocked.", "hintPermanent": "Permanent ban. Lift manually only.", "submitResolve": "Close case", + "submitPick": "Choose an outcome first", "submitBan": "Ban & close", "duration": { "none": "None", @@ -3241,6 +3346,12 @@ "dbSize": "DB Size" }, "branding": { + "confirmRemoveFavicon": { + "title": "Delete the favicon?", + "message": "The file leaves storage and the site falls back to the default icon." + }, + "faviconSaved": "Favicon updated. This one applies immediately — it is not part of the save below.", + "faviconRemoved": "Favicon removed. The built-in one is back.", "intro": "Shape the look and feel of the entire site — logo, copy, and the homepage. Everything updates live in the preview above.", "identity": { "title": "Identity", @@ -3316,6 +3427,11 @@ "saveTextBranding": "Save Text Branding" }, "themes": { + "confirmRemoveFont": { + "title": "Delete this font?", + "message": "The file leaves storage. Themes that name it will have to pick another." + }, + "confirmDisable": "Disable “{name}”? Members who had picked it fall back to the default theme.", "settingsTitle": "Site appearance", "defaultLabel": "Default theme", "defaultDescription": "What a signed-out visitor sees, and what a new member starts on.", @@ -3389,6 +3505,7 @@ "accent-hover": "The accent with the cursor over it.", "accent-paper": "Reserved for the paper half of the per-page palettes — the parchment behind Favourites. Stored and emitted; nothing reads it yet.", "accent-warm": "The second accent. Perks, bonuses, anything gilded.", + "accent-warm-text": "The gold when it is ink rather than fill. Darker than the fill in the light theme, because a colour readable behind black text is not readable as text itself.", "accent-warm-fg": "Text and icons on the warm accent.", "bg-base": "The page itself, behind everything else.", "bg-elevated": "Menus, dialogs and popovers — the layer sitting on a card.", @@ -3418,8 +3535,13 @@ "font-display": "The face for headings and section titles.", "font-mono": "The fixed-width face: hashes, sizes, ratios — anything that has to line up in a column.", "font-sans": "The interface face: navigation, buttons, body copy.", + "online-fg": "The ink on a filled success colour. A theme that moves the success colour has to move this with it.", + "warning-fg": "The ink on a filled warning colour.", + "info-fg": "The ink on a filled info colour.", + "danger-fg": "The ink on a filled danger button. A theme that lightens the danger colour has to move this with it.", "info": "Neutral notices.", "line-default": "Borders and separators.", + "line-field": "The border of a text field. Separate from the decorative hairlines because this one identifies the control, so it has to clear 3:1 — a card's rule does not.", "line-strong": "A border meant to be noticed: a focused field, a table head.", "motion-scale": "A multiplier on every animation duration. 0 stops all movement.", "online": "Healthy: seeding, connected, succeeded.", @@ -3440,7 +3562,11 @@ "notFoundBody": "It was deleted, or the address names a theme that never existed.", "contrastGroup": "Contrast", "contrastMin": "needs {required}:1", - "contrastCol": { "pair": "Pair", "measured": "Measured", "needs": "Needs" }, + "contrastCol": { + "pair": "Pair", + "measured": "Measured", + "needs": "Needs" + }, "contrastPass": "Passes", "contrastFail": "Fails", "contrastAbout": "Every combination the interface paints text in, measured against WCAG 2.2 AA — 4.5:1 for text, 3:1 for large text and for the focus ring. A shortfall is a warning, not a refusal: the theme saves either way.", @@ -3994,6 +4120,7 @@ "mod": { "eyebrow": "Moderation", "nav": { + "federationMasks": "Federation masks", "dashboard": "Dashboard", "pendingTorrents": "Pending Torrents", "users": "Users", @@ -4004,6 +4131,7 @@ "tickets": "Tickets" }, "descriptions": { + "federationMasks": "Partner content hidden from the local view", "dashboard": "Moderation overview and quick actions", "pendingTorrents": "Review and approve pending torrent uploads", "users": "Manage user accounts and bans", @@ -4071,15 +4199,17 @@ "reReview": "Re-review" }, "verdict": { - "clean": "Clean", - "warned": "Warned", - "banned": "Banned", - "monitoring": "Monitoring" + "clean": "Dismissed", + "warned": "Warned — to sanction", + "banned": "Confirmed — to sanction", + "monitoring": "Under watch" }, "submitReview": "Save", "notePlaceholder": "Note (optional) — context for the next moderator who lands on this account.", "toastReviewed": "Flag reviewed.", "bulk": { + "confirmTitle": "Decide {n} case? | Decide {n} cases?", + "confirmBody": "The “{verdict}” verdict will be stamped on the selected case without opening it. | The “{verdict}” verdict will be stamped on all {n} selected cases without opening them.", "selectAll": "Select page", "deselectAll": "Deselect page", "selectRow": "Select this flag", @@ -4157,6 +4287,15 @@ } }, "masks": { + "scopeShort": { + "record": "Record", + "infohash": "Infohash", + "author": "Uploader", + "issuer": "Instance" + }, + "addFailed": "Could not hide it: {reason}", + "liftFailed": "Could not lift the mask: {reason}", + "lifted": "Mask lifted", "title": "Masked federated content", "sub": "Releases hidden from every local view of the partner catalogue. Nothing is deleted and no partner is told — lift a mask and the content reappears.", "hide": "Hide", @@ -4915,6 +5054,10 @@ "empty": "No federated topics yet — connect partners that share their social scope." }, "federatedIdentity": { + "confirmRemove": { + "title": "Unlink this identity?", + "message": "Partners stop attributing those publications to your account." + }, "eyebrow": "Account", "title": "Federated identity", "intro": "Prove you own an account on a partner instance to show its reputation here. It's never merged into your local ratio or bonus.", @@ -5209,6 +5352,11 @@ } }, "messaging": { + "withdraw": "Withdraw", + "confirmWithdraw": { + "title": "Withdraw this message?", + "message": "It shows as withdrawn on the other side too. The text is gone." + }, "title": "Messages", "inbox": "Inbox", "requests": "Requests", @@ -5346,6 +5494,10 @@ "torrentGone": "That torrent is gone." }, "room": { + "confirmDelete": { + "title": "Delete this message?", + "message": "It disappears for the whole room, and its text is not recoverable." + }, "title": "Room", "empty": "Nothing said yet.", "placeholder": "Say something…", @@ -5384,6 +5536,8 @@ "title": "What this instance keeps", "lede": "Every duration below is read from this instance as it is configured right now, not copied into prose that could drift from it.", "asOf": "Published by {instance}. Change a setting and this page changes with it.", + "loading": "Reading this instance’s settings…", + "unavailable": "These durations could not be read from the instance just now. This does not mean nothing is kept — reload, and tell the operator if it persists.", "registerLabel": "Retention periods", "notesLabel": "Notes", "unitDays": "days", @@ -5394,7 +5548,8 @@ }, "room": { "title": "The public room", - "text": "The room is ephemeral by design: anything older than {n} days is dropped wholesale, not searched and not archived." + "text": "The room is ephemeral by design: anything older than {n} days is dropped wholesale, not searched and not archived.", + "forever": "The room is kept indefinitely on this instance — nothing said in it is swept on a timer." }, "notifications": { "title": "Notifications", @@ -5413,6 +5568,11 @@ "text": "A moderator opens one private message, and only one that has an open report against it. The endpoint answers nothing for any other message, so it cannot be used to walk a conversation, and it answers nothing at all for an encrypted one. Every one of those reads is recorded — who, when, and whether anything was legible — and the record is visible to all staff, not only to whoever ordered it." }, "foot": "This page lists retention. It is not a substitute for whatever notice the operator of this instance is required to publish where it is hosted.", + "loginHistory": { + "title": "Your sign-in history", + "timed": "Every attempt to open a session on your account — successful or refused, with the method and the client — is kept {n} days, then swept. You read your own at Settings → Security; staff read it from your profile.", + "forever": "Every attempt to open a session on your account is kept indefinitely on this instance." + }, "staffAudit": { "title": "Staff actions", "timed": "Every change made from the moderation or admin console — a ban, a role, a setting, a rejected upload — is recorded with who made it and when, and kept for {n} days. Readable by admins only. The entry cannot be edited or removed by the staffer who caused it.", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index fc5c5b38..341004ab 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -1,5 +1,28 @@ { + "error": { + "detail": "Détail technique", + "home": "Retour au site", + "retry": "Réessayer", + "notFound": { + "title": "Cette page n’est pas là", + "text": "Le lien est peut-être ancien, ou ce qu’il visait a été retiré. Votre compte n’y est pour rien." + }, + "forbidden": { + "title": "Pas pour ce compte", + "text": "Vous êtes connecté·e, mais cette page ne vous est pas ouverte. Si vous pensez qu’elle devrait l’être, demandez au personnel." + }, + "unauthorised": { + "title": "Il faut vous connecter", + "text": "Cette page est réservée aux membres. Connectez-vous et vous serez ramené·e ici." + }, + "server": { + "title": "Quelque chose a cassé de notre côté", + "text": "Ce n’est pas de votre fait. Réessayer suffit souvent ; sinon, l’opérateur le retrouvera dans les journaux." + } + }, "common": { + "draftRestored": "Brouillon restauré", + "draftDiscard": "Repartir de zéro", "save": "Enregistrer", "saved": "Enregistré", "saveChanges": "Enregistrer les modifications", @@ -26,6 +49,12 @@ "category": "Catégorie", "hash": "Hash", "refresh": "Rafraîchir", + "saveFailed": "L'enregistrement a échoué. Vérifiez la connexion et réessayez.", + "deleteFailed": "La suppression a échoué. Rien n’a été modifié.", + "actionFailed": "L’action n’est pas passée. Rien n’a été modifié.", + "loadFailed": "Impossible de lire ceci sur l’instance.", + "loadFailedHint": "Rien ne manque ici : la lecture a échoué. Réessayez, et prévenez l’opérateur si cela persiste.", + "retry": "Réessayer", "copy": "Copier", "copied": "Copié", "open": "Ouvrir", @@ -68,6 +97,10 @@ "signOut": "Se déconnecter", "signIn": "Se connecter", "signUp": "S'inscrire", + "skipToContent": "Aller au contenu", + "footerSite": "Le site de l’auteur", + "footerSource": "Code source sur GitHub", + "footerChat": "Discord de la communauté", "menu": "Menu", "toggleTheme": "Changer de thème", "languageSelector": "Choisir la langue", @@ -162,6 +195,11 @@ "usePasskey": "Utiliser une passkey", "trustDevice": "Faire confiance à ce navigateur pendant 30 jours", "verifyButton": "Vérifier", + "status": { + "challenge": "Récupération du défi…", + "proof": "Dérivation de la preuve…", + "authenticating": "Connexion…" + }, "errors": { "invalidCredentials": "Identifiant ou mot de passe invalide.", "invalidCode": "Le code ne correspond pas. Réessayez.", @@ -389,6 +427,25 @@ } }, "settings": { + "appearance": { + "localeSaveFailed": "Impossible d’enregistrer le changement de langue. Réessayez.", + "themeEyebrow": "Mode", + "themeTitle": "Thème", + "themeHelp": "Le thème vous suit d’un appareil à l’autre : il est enregistré sur votre compte et gardé en cache localement pour un premier affichage sans clignotement.", + "localeEyebrow": "Langue", + "localeHelp": "Enregistrée sur votre compte, la même langue d’interface vous suit d’un appareil à l’autre. Les textes pas encore traduits s’affichent en anglais.", + "siteDefault": "Défaut du site", + "siteDefaultSub": "Actuellement {name}", + "siteDefaultUnknown": "Ce que le propriétaire choisit", + "system": "Système", + "systemSub": "Suit votre système d’exploitation", + "light": "Clair", + "lightSub": "Tons de jour", + "dark": "Sombre", + "darkSub": "Minuit éditorial", + "basedLight": "Basé sur le clair", + "basedDark": "Basé sur le sombre" + }, "title": "Paramètres & contrôles", "pageTitle": "Paramètres", "eyebrow": "Préférences du compte", @@ -446,6 +503,8 @@ "addPickerHelp": "Choisis un transport. Tu pourras renseigner tes identifiants juste après.", "targetMasked": "Configuré", "webPush": { + "enableFailed": "Impossible d’activer les notifications du navigateur.", + "disableFailed": "Impossible de couper les notifications du navigateur.", "enable": "Activer dans ce navigateur", "disable": "Désactiver dans ce navigateur", "enabledHere": "Actif dans ce navigateur — les notifications système atterriront ici.", @@ -589,6 +648,11 @@ } }, "identityExport": { + "forget": "Retirer de ce navigateur", + "forgetConfirm": { + "title": "Retirer la clé de ce navigateur ?", + "message": "L’instance n’a jamais eu votre moitié privée : sans le fichier d’export, cette clé est définitivement perdue et vous devrez en créer une nouvelle identité." + }, "title": "Emportez votre identité", "what": "Votre compte ici est aussi une clé à part entière. L'exporter permet à une autre instance de vous reconnaître comme la même personne — et aux uploads qui vous sont attribués de garder votre nom où qu'ils aillent.", "travels": "Part avec vous", @@ -694,6 +758,7 @@ "publishing": "Publication…", "publishRelease": "Publier la release", "ready": { + "duplicate": "Ce .torrent est déjà au catalogue", "pickCategory": "Choisir une catégorie", "awaitingFile": "En attente du fichier source", "titleRequired": "Titre requis", @@ -716,7 +781,18 @@ "uploadFailed": "Échec de l'envoi" }, "result": { - "subtitle": "La release est désormais indexée et prête à être partagée.", + "published": { + "title": "Release publiée", + "subtitle": "La release est désormais indexée et prête à être partagée." + }, + "pending": { + "title": "En attente de validation", + "subtitle": "Un modérateur doit l’approuver avant qu’elle apparaisse au catalogue. Continuez à seeder en attendant." + }, + "exists": { + "title": "Cette release existe déjà", + "subtitle": "Le tracker connaissait déjà cet infohash : rien n’a été publié. Ouvrez la release existante et seedez-la." + }, "downloadTorrent": "Télécharger le .torrent", "viewRelease": "Voir la release", "uploadAnother": "Envoyer une autre release" @@ -727,6 +803,10 @@ "ficheEditAnyway": "Débloquer le mode visuel" }, "edit": { + "denied": { + "title": "Cette release n’est pas la vôtre", + "text": "Seul l’uploadeur d’une release, ou un membre de l’équipe, peut en modifier la fiche. Si quelque chose y est faux, signalez-la depuis sa page." + }, "backToRelease": "Retour à la release", "eyebrow": "Édition · {hash}…", "titleMain": "Modifier", @@ -1194,6 +1274,11 @@ }, "security": { "twoFactor": { + "confirmRemovePasskey": { + "title": "Retirer cette clé d’accès ?", + "message": "Il faudra la ré-enregistrer depuis l’appareil qui la porte. Si c’est votre seule clé, votre compte n’aura plus de second facteur." + }, + "statusUnknown": "Impossible de lire si la double authentification est active. Cela ne veut pas dire qu’elle est coupée — réessayez.", "passkeyCount": "{n} passkey | {n} passkeys", "totpEnabled": "Activé", "recoveryCodesLeft": "{n} code de récupération restant | {n} codes de récupération restants", @@ -1448,6 +1533,7 @@ }, "components": { "modal": { + "fallbackLabel": "Boîte de dialogue", "close": "Fermer" }, "confirm": { @@ -1775,6 +1861,10 @@ "inviteTree": "Qui s'est porté garant d'un membre, et qui ce membre a fait entrer." }, "taxonomy": { + "confirmRemove": { + "title": "Retirer cette correspondance ?", + "message": "Les releases du partenaire rangées dans cette catégorie repartiront sans classement." + }, "title": "Taxonomie fédérée", "sub": "Les partenaires classent leurs releases sous leurs propres slugs de catégorie. Reliez un slug étranger à l'une de vos catégories et le filtre du catalogue fera remonter les releases de ce partenaire dessous — sinon une catégorie nommée différemment reste invisible pour un membre qui filtre par catégorie.", "mapped": "Correspondances déclarées", @@ -1791,6 +1881,10 @@ "adultTag": "adulte" }, "federation": { + "confirmRevoke": { + "title": "Révoquer ce partenaire ?", + "message": "« {name} » ne pourra plus rien nous envoyer, et ce qu’il avait publié disparaît du catalogue. Il faudra rétablir le lien des deux côtés pour revenir en arrière." + }, "intro": "Reliez ce tracker à d'autres instances Trackarr de confiance. Vous décidez avec qui vous vous liez et exactement ce que vous partagez — les liens entrants requièrent votre approbation.", "ownerOnly": "Owner uniquement", "master": { @@ -2106,6 +2200,7 @@ } }, "uploadRules": { + "loadFailed": "Ces règles n’ont pas pu être lues sur l’instance. Ce qui est affiché ci-dessous n’est PAS la politique en vigueur — rechargez avant de modifier quoi que ce soit.", "globalRules": "Règles globales", "snap": { "gatesArmed": "Portes armées", @@ -2348,6 +2443,7 @@ "subPart1": "Masqué pour les utilisateurs n'ayant pas activé XXX dans leurs paramètres — s'applique aux listes, à la recherche, au RSS, à Torznab et au fil d'accueil." }, "row": { + "stock": "{n} torrent | {n} torrents", "subCount": "{n} sous-cat. | {n} sous-cat.", "editCategory": "Modifier la catégorie", "deleteCategory": "Supprimer la catégorie", @@ -2362,6 +2458,8 @@ "saveChanges": "Enregistrer les modifications" }, "deleteConfirm": { + "blockedSubs": "Cette catégorie a {n} sous-catégorie. Supprimez-la d’abord. | Cette catégorie a {n} sous-catégories. Supprimez-les d’abord.", + "blockedTorrents": "Cette catégorie contient {n} torrent. Déplacez-le d’abord — la suppression ne le déclasse pas. | Cette catégorie contient {n} torrents. Déplacez-les d’abord — la suppression ne les déclasse pas.", "title": "Supprimer la catégorie", "messageNamed": "Supprimer définitivement la catégorie « {name} » ? Les torrents qui s'y trouvent deviendront non classés.", "messageGeneric": "Supprimer définitivement cette catégorie ? Les torrents qui s'y trouvent deviendront non classés.", @@ -2385,6 +2483,10 @@ } }, "bonusRules": { + "confirmTierDelete": { + "title": "Supprimer ce palier ?", + "message": "Les torrents que ce palier couvrait retombent sur le palier suivant, ou sur ×1 s’il n’y en a pas." + }, "on": "Activée", "off": "Désactivée", "addTier": "Ajouter un palier", @@ -2398,6 +2500,7 @@ "tiers": "palier | paliers" }, "units": { + "hundredths": "en centièmes — 150 = ×1,50", "seeders": "seeders", "seedersOrFewer": "seeders ou moins", "days": "jours", @@ -2458,6 +2561,7 @@ "multiplier": "Multiplicateur" }, "toasts": { + "tierUpdated": "Palier enregistré", "ruleUpdated": "Règle mise à jour", "tierDeleted": "Palier supprimé" }, @@ -3145,6 +3249,7 @@ "hintTimed": "L'utilisateur sera banni pour {duration}. Reconnexion impossible, tracker et Torznab bloqués.", "hintPermanent": "Bannissement définitif. À ne lever que manuellement.", "submitResolve": "Fermer le dossier", + "submitPick": "Choisissez d’abord une issue", "submitBan": "Bannir & fermer", "duration": { "none": "Aucune", @@ -3241,6 +3346,12 @@ "dbSize": "Taille BDD" }, "branding": { + "confirmRemoveFavicon": { + "title": "Supprimer le favicon ?", + "message": "Le fichier est retiré du stockage et le site reprend l’icône par défaut." + }, + "faviconSaved": "Favicone mise à jour. Celle-ci s’applique tout de suite — elle ne fait pas partie de l’enregistrement ci-dessous.", + "faviconRemoved": "Favicone retirée. Celle d’origine est de retour.", "intro": "Façonnez l'apparence du site — logo, textes et page d'accueil. Tout se met à jour en direct dans l'aperçu ci-dessus.", "identity": { "title": "Identité", @@ -3316,6 +3427,11 @@ "saveTextBranding": "Enregistrer les textes" }, "themes": { + "confirmRemoveFont": { + "title": "Supprimer cette police ?", + "message": "Le fichier est retiré du stockage. Les thèmes qui la nomment devront en choisir une autre." + }, + "confirmDisable": "Couper « {name} » ? Les membres qui l’avaient choisi repasseront au thème par défaut.", "settingsTitle": "Apparence du site", "defaultLabel": "Thème par défaut", "defaultDescription": "Ce que voit un visiteur non connecté, et le thème d'un nouveau membre.", @@ -3389,6 +3505,7 @@ "accent-hover": "L'accent survolé par le curseur.", "accent-paper": "Réservé à la moitié papier des palettes par page — le parchemin de Favoris. Stocké et émis ; rien ne le lit encore.", "accent-warm": "Le second accent. Avantages, bonus, tout ce qui est doré.", + "accent-warm-text": "L'or quand il sert d'encre et non de fond. Plus profond que le remplissage en thème clair : une couleur lisible sous du texte noir n'est pas lisible en tant que texte.", "accent-warm-fg": "Le texte et les icônes posés sur l'accent chaud.", "bg-base": "La page elle-même, derrière tout le reste.", "bg-elevated": "Menus, boîtes de dialogue et infobulles — la couche posée sur une carte.", @@ -3418,8 +3535,13 @@ "font-display": "La fonte des titres et des intitulés de section.", "font-mono": "La fonte à chasse fixe : empreintes, tailles, ratios — tout ce qui doit s aligner en colonne.", "font-sans": "La fonte de l'interface : navigation, boutons, texte courant.", + "online-fg": "L'encre posée sur un fond de succès plein. Un thème qui déplace la couleur de succès doit déplacer celle-ci avec lui.", + "warning-fg": "L'encre posée sur un fond d'avertissement plein.", + "info-fg": "L'encre posée sur un fond d'information plein.", + "danger-fg": "L'encre posée sur un bouton de danger plein. Un thème qui éclaircit la couleur de danger doit déplacer celle-ci avec lui.", "info": "Informations neutres.", "line-default": "Bordures et séparateurs.", + "line-field": "La bordure d'un champ de saisie. Distincte des filets décoratifs : c'est elle qui identifie le contrôle, donc elle doit atteindre 3:1 — le filet d'une carte, non.", "line-strong": "Une bordure qui doit se voir : champ actif, en-tête de tableau.", "motion-scale": "Un multiplicateur sur toutes les durées. 0 arrête tout mouvement.", "online": "En bonne santé : en partage, connecté, réussi.", @@ -3440,7 +3562,11 @@ "notFoundBody": "Il a été supprimé, ou l'adresse désigne un thème qui n'a jamais existé.", "contrastGroup": "Contraste", "contrastMin": "minimum {required}:1", - "contrastCol": { "pair": "Paire", "measured": "Mesuré", "needs": "Minimum" }, + "contrastCol": { + "pair": "Paire", + "measured": "Mesuré", + "needs": "Minimum" + }, "contrastPass": "Conforme", "contrastFail": "Non conforme", "contrastAbout": "Chaque combinaison dans laquelle l’interface peint du texte, mesurée contre le niveau AA de WCAG 2.2 — 4,5:1 pour le texte, 3:1 pour les grands caractères et l’anneau de focus. Un manque est un avertissement, pas un refus : le thème s’enregistre quand même.", @@ -3994,6 +4120,7 @@ "mod": { "eyebrow": "Modération", "nav": { + "federationMasks": "Masques fédération", "dashboard": "Tableau de bord", "pendingTorrents": "Torrents en attente", "users": "Utilisateurs", @@ -4004,6 +4131,7 @@ "tickets": "Tickets" }, "descriptions": { + "federationMasks": "Contenus partenaires retirés de la vue locale", "dashboard": "Aperçu de la modération et actions rapides", "pendingTorrents": "Examiner et approuver les torrents en attente", "users": "Gérer les comptes utilisateurs et les bannissements", @@ -4017,7 +4145,7 @@ "title": "Anti-cheat", "eyebrow": "Anti-triche", "unreviewed": "à traiter", - "tagline": "Le tracker signale chaque annonce qui sent le ratio-cheating (vélocité impossible, upload sans leecher, signature client inconnue). Rien n'est bannit automatiquement : ici tu confirmes ou tu écartes.", + "tagline": "Le tracker signale chaque annonce qui sent le ratio-cheating (vélocité impossible, upload sans leecher, signature client inconnue). Rien n'est banni automatiquement : ici tu confirmes ou tu écartes.", "tabsAria": "Filtrer par statut", "pagerAria": "Pagination des signalements anti-triche", "radar": { @@ -4071,15 +4199,17 @@ "reReview": "Revoir" }, "verdict": { - "clean": "Innocent", - "warned": "Averti", - "banned": "Banni", - "monitoring": "Surveillé" + "clean": "Écarté", + "warned": "Averti — à sanctionner", + "banned": "Confirmé — à sanctionner", + "monitoring": "Sous surveillance" }, "submitReview": "Enregistrer", "notePlaceholder": "Note (optionnel) — contexte pour le prochain modérateur qui croisera ce compte.", "toastReviewed": "Signalement traité.", "bulk": { + "confirmTitle": "Décider {n} dossier ? | Décider {n} dossiers ?", + "confirmBody": "Le verdict « {verdict} » sera posé sur le dossier sélectionné, sans l’ouvrir. | Le verdict « {verdict} » sera posé sur les {n} dossiers sélectionnés, sans les ouvrir.", "selectAll": "Sélectionner la page", "deselectAll": "Désélectionner la page", "selectRow": "Sélectionner ce signalement", @@ -4157,6 +4287,15 @@ } }, "masks": { + "scopeShort": { + "record": "Enregistrement", + "infohash": "Infohash", + "author": "Uploadeur", + "issuer": "Instance" + }, + "addFailed": "Impossible de masquer : {reason}", + "liftFailed": "Impossible de lever le masque : {reason}", + "lifted": "Masque levé", "title": "Contenu fédéré masqué", "sub": "Releases cachées de toute vue locale du catalogue partenaire. Rien n’est supprimé et aucun partenaire n’est prévenu — retirez un masque et le contenu réapparaît.", "hide": "Masquer", @@ -4915,6 +5054,10 @@ "empty": "Aucun sujet fédéré pour l'instant — connectez des partenaires qui partagent leur périmètre social." }, "federatedIdentity": { + "confirmRemove": { + "title": "Délier cette identité ?", + "message": "Les partenaires cesseront de rattacher ces publications à votre compte." + }, "eyebrow": "Compte", "title": "Identité fédérée", "intro": "Prouvez que vous possédez un compte sur une instance partenaire pour afficher sa réputation ici. Elle n'est jamais fusionnée avec votre ratio ou vos bonus locaux.", @@ -5209,6 +5352,11 @@ } }, "messaging": { + "withdraw": "Retirer", + "confirmWithdraw": { + "title": "Retirer ce message ?", + "message": "Il devient « retiré » chez votre correspondant aussi. Le texte est perdu." + }, "title": "Messages", "inbox": "Boîte de réception", "requests": "Demandes", @@ -5346,6 +5494,10 @@ "torrentGone": "Ce torrent n'existe plus." }, "room": { + "confirmDelete": { + "title": "Supprimer ce message ?", + "message": "Il disparaît pour tout le salon, et son texte n’est pas récupérable." + }, "title": "Salon", "empty": "Rien n’a encore été dit.", "placeholder": "Dire quelque chose…", @@ -5384,6 +5536,8 @@ "title": "Ce que cette instance garde", "lede": "Chaque durée ci-dessous est lue sur cette instance telle qu'elle est configurée en ce moment, pas recopiée dans un texte qui pourrait s'en écarter.", "asOf": "Publié par {instance}. Change un réglage et cette page change avec lui.", + "loading": "Lecture des réglages de cette instance…", + "unavailable": "Ces durées n’ont pas pu être lues sur l’instance à l’instant. Cela ne veut pas dire que rien n’est conservé — rechargez, et prévenez l’opérateur si cela persiste.", "registerLabel": "Durées de conservation", "notesLabel": "Notes", "unitDays": "jours", @@ -5394,7 +5548,8 @@ }, "room": { "title": "Le salon public", - "text": "Le salon est éphémère par construction : tout ce qui dépasse {n} jours est supprimé en bloc, sans être indexé ni archivé." + "text": "Le salon est éphémère par construction : tout ce qui dépasse {n} jours est supprimé en bloc, sans être indexé ni archivé.", + "forever": "Le salon est conservé sans limite sur cette instance — rien de ce qui s’y dit n’est balayé par un minuteur." }, "notifications": { "title": "Notifications", @@ -5413,6 +5568,11 @@ "text": "Un modérateur ouvre un message privé, et seulement un qui fait l'objet d'un signalement ouvert. La route ne répond rien pour un autre message — elle ne permet donc pas de parcourir une conversation — et rien du tout pour un message chiffré. Chacune de ces lectures est enregistrée — qui, quand, et si quelque chose était lisible — et le registre est visible de tout le staff, pas seulement de celui qui l'a demandé." }, "foot": "Cette page liste des durées de conservation. Elle ne remplace pas la notice que l'exploitant de cette instance doit publier là où elle est hébergée.", + "loginHistory": { + "title": "Votre historique de connexion", + "timed": "Chaque tentative d’ouverture de session sur votre compte — réussie ou refusée, avec la méthode et le client — est conservée {n} jours, puis balayée. Vous lisez la vôtre dans Réglages → Sécurité ; le personnel la lit depuis votre profil.", + "forever": "Chaque tentative d’ouverture de session sur votre compte est conservée sans limite sur cette instance." + }, "staffAudit": { "title": "Actions du personnel", "timed": "Chaque changement fait depuis la console de modération ou d'administration — un bannissement, un rôle, un réglage, une mise en ligne refusée — est enregistré avec son auteur et sa date, et conservé {n} jours. Lisible par les administrateurs uniquement. L'entrée ne peut être ni modifiée ni supprimée par le membre du personnel qui l'a provoquée.", From 4d9b63fb11d479338eaad80c8b2c237210a56ed8 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:23:59 +0200 Subject: [PATCH 63/96] fix(web): four systemic faults behind five hundred interface findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design review across the whole front end turned up more than five hundred observations. Four causes explained most of them, and fixing the causes is what this commit does. `var()` inside `rgb()` without the wrapper. The token system stores channels — `--fg-default: 24 24 27` — so `color: var(--fg-default)` is not a colour at all and the declaration is dropped in silence. 423 call sites now read `rgb(var(--fg-default))`. `rgba()` given a token. Same shape, same silence, and it took the opacity with it. Tokens nobody had measured. `--accent-warm` is a surface colour; used as text it failed contrast on the dark theme wherever it was used for text. 138 sites move to `--accent-warm-text`, which was defined for that and used almost nowhere. Focus rings removed for looks. 70 elements get `:focus-visible` back — the outline only appears for keyboard navigation, which is the case it exists for, so nothing changes for a pointer. Plus the accessibility work those made visible: names on icon-only controls, heading order, and the `.tool-btn` class that five files used while only two defined it and none defined its modifiers, so three pages were rendering bare native buttons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/Dockerfile.static | 21 ++- apps/web/app/assets/css/main.css | 107 ++++++++++- apps/web/app/assets/css/upload-form.css | 6 +- apps/web/app/components/BookMetadataCard.vue | 19 +- apps/web/app/components/CategoryTypeBadge.vue | 15 +- apps/web/app/components/ConfirmHost.vue | 65 +++++-- apps/web/app/components/FederationOff.vue | 14 +- apps/web/app/components/GameMetadataCard.vue | 19 +- apps/web/app/components/IconPicker.vue | 4 +- apps/web/app/components/MediaMetadataCard.vue | 11 +- apps/web/app/components/MediaSearchPicker.vue | 45 ++++- apps/web/app/components/MessagesBell.vue | 2 +- apps/web/app/components/Modal.vue | 118 +++++++++++- apps/web/app/components/NotificationBell.vue | 38 ++-- apps/web/app/components/NotificationToast.vue | 28 ++- apps/web/app/components/ReportModal.vue | 18 +- apps/web/app/components/SearchBar.vue | 19 +- apps/web/app/components/TorrentTable.vue | 24 ++- apps/web/app/components/WysiwygEditor.vue | 9 +- .../app/components/admin/Announcements.vue | 18 +- apps/web/app/components/admin/BonusEvents.vue | 51 ++--- apps/web/app/components/admin/BonusRules.vue | 87 +++++++-- apps/web/app/components/admin/Branding.vue | 108 +++++++++-- apps/web/app/components/admin/Categories.vue | 98 ++++++++-- apps/web/app/components/admin/Endpoints.vue | 4 +- .../app/components/admin/FederationHealth.vue | 41 ++-- .../app/components/admin/FreeleechPool.vue | 41 ++-- apps/web/app/components/admin/Invites.vue | 55 ++++-- apps/web/app/components/admin/Reports.vue | 66 ++++--- apps/web/app/components/admin/Roles.vue | 30 ++- apps/web/app/components/admin/Shop.vue | 13 +- apps/web/app/components/admin/System.vue | 12 +- apps/web/app/components/admin/Tags.vue | 14 +- apps/web/app/components/admin/ThemeEditor.vue | 8 + apps/web/app/components/admin/Themes.vue | 49 ++++- .../app/components/admin/TorznabBlacklist.vue | 20 +- .../app/components/admin/TorznabConfig.vue | 2 +- .../app/components/admin/TorznabLogTable.vue | 29 ++- .../web/app/components/admin/TorznabStats.vue | 2 +- .../web/app/components/admin/TorznabUsers.vue | 10 +- apps/web/app/components/admin/UploadRules.vue | 78 ++++++-- apps/web/app/components/admin/Users.vue | 66 +++++-- .../app/components/admin/bonus/TierCurve.vue | 2 +- .../admin/notifications/ChannelCard.vue | 38 ++-- .../app/components/bonus/BonusEventModal.vue | 24 +-- .../web/app/components/forum/CategoryForm.vue | 10 + .../app/components/messaging/Reactions.vue | 2 +- .../app/components/messaging/TorrentCard.vue | 16 +- .../components/security/RecoveryCodesView.vue | 22 ++- .../components/security/TotpSetupWizard.vue | 42 ++++- .../security/TwoFactorLoginStep.vue | 42 ++++- .../components/security/TwoFactorSection.vue | 117 +++++++++--- .../settings/NotificationsSection.vue | 89 ++++++--- .../web/app/components/shop/FreeleechPool.vue | 18 +- .../app/components/torrent/GroupLadder.vue | 2 +- apps/web/app/components/torrent/GroupRow.vue | 2 +- .../web/app/components/torrent/ReleaseRow.vue | 4 +- .../torrent/TorrentModerationPanel.vue | 10 + apps/web/app/composables/useNotifications.ts | 37 +++- apps/web/app/composables/useWebPush.ts | 7 +- apps/web/app/layouts/default.vue | 92 +++++++-- apps/web/app/pages/admin/banned-ips.vue | 48 ++++- .../app/pages/admin/federation-taxonomy.vue | 29 ++- apps/web/app/pages/admin/federation.vue | 28 ++- apps/web/app/pages/admin/index.vue | 6 +- apps/web/app/pages/admin/irc.vue | 4 +- apps/web/app/pages/admin/notifications.vue | 2 +- apps/web/app/pages/admin/torznab.vue | 16 +- apps/web/app/pages/auth/login.vue | 52 +++++- apps/web/app/pages/auth/register.vue | 49 +++-- apps/web/app/pages/chat.vue | 24 ++- apps/web/app/pages/federated-identity.vue | 19 +- apps/web/app/pages/federated.vue | 4 +- apps/web/app/pages/following.vue | 15 +- apps/web/app/pages/forum/category/[id].vue | 16 +- apps/web/app/pages/forum/index.vue | 27 ++- apps/web/app/pages/index.vue | 36 +++- apps/web/app/pages/invites.vue | 16 +- apps/web/app/pages/me.vue | 46 ++--- apps/web/app/pages/messages.vue | 126 ++++++++++++- apps/web/app/pages/mod.vue | 31 +++- apps/web/app/pages/mod/anti-cheat.vue | 113 ++++++++++- apps/web/app/pages/mod/federation-masks.vue | 59 +++--- apps/web/app/pages/mod/index.vue | 16 +- apps/web/app/pages/mod/tickets.vue | 4 +- apps/web/app/pages/notifications.vue | 14 +- apps/web/app/pages/privacy.vue | 59 +++++- apps/web/app/pages/reports.vue | 38 ++-- apps/web/app/pages/requests/index.vue | 10 + apps/web/app/pages/requests/new.vue | 46 ++++- apps/web/app/pages/settings.vue | 175 +++++++++++++----- apps/web/app/pages/shop.vue | 30 +-- apps/web/app/pages/torrents/[hash]/edit.vue | 79 +++++++- apps/web/app/pages/torrents/[hash]/index.vue | 43 +++-- apps/web/app/pages/torrents/fiche.vue | 18 +- .../web/app/pages/torrents/group/[...key].vue | 2 +- apps/web/app/pages/torrents/index.vue | 13 +- apps/web/app/pages/torrents/upload.vue | 50 ++++- apps/web/app/plugins/i18n-user.client.ts | 16 ++ apps/web/app/stores/user.ts | 6 +- apps/web/app/utils/editorFormats.ts | 19 +- apps/web/app/utils/ficheBbcode.ts | 6 +- apps/web/app/utils/ficheTemplate.ts | 6 +- apps/web/app/utils/format.ts | 143 +++++++++++++- apps/web/app/utils/markdown.ts | 20 +- apps/web/app/utils/mediainfo.ts | 17 +- apps/web/app/utils/releaseNameSplit.ts | 14 +- apps/web/public/robots.txt | 10 +- apps/web/server/routes/api/[...path].ts | 51 ++++- apps/web/tailwind.config.ts | 6 + apps/web/test/mediainfo.test.ts | 8 +- apps/web/test/statSeries.test.ts | 73 +++++++- 112 files changed, 2996 insertions(+), 822 deletions(-) diff --git a/apps/web/Dockerfile.static b/apps/web/Dockerfile.static index 6fba2b9a..45ec1d90 100644 --- a/apps/web/Dockerfile.static +++ b/apps/web/Dockerfile.static @@ -85,11 +85,22 @@ USER 65532 EXPOSE 3000 STOPSIGNAL SIGQUIT -# Chainguard nginx images include a built-in HEALTHCHECK target on -# /, but we override to hit /healthz so we don't pull index.html -# every 30s — saves a tiny amount of CPU on busy hosts. -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD ["/usr/sbin/nginx", "-t"] +# Pas de HEALTHCHECK ici, et c'est délibéré. +# +# Il y en avait un : `CMD ["/usr/sbin/nginx", "-t"]`. Or `-t` valide le FICHIER +# DE CONFIGURATION — un worker bloqué, un listener fermé, un disque plein +# passent tous au vert. Un feu vert qui ne mesure rien est pire que pas de feu +# du tout : il transforme une panne en « sain » sur le tableau de bord. +# +# Le commentaire affirmait par ailleurs « we override to hit /healthz » ; la +# commande ne faisait pas cela. Les deux configurations exposent bien un vrai +# `/healthz`, mais l'image Chainguard n'a ni shell, ni wget, ni curl (vérifié : +# `/bin/sh` n'existe pas), donc rien ne peut l'interroger depuis l'intérieur du +# conteneur. +# +# La sonde revient donc à l'orchestrateur, qui sait le faire depuis l'extérieur : +# `readinessProbe: httpGet: { path: /healthz }` côté Kubernetes, ou un +# `healthcheck:` dans le compose si l'on veut la garder ici. # The Chainguard image's default ENTRYPOINT runs `nginx -g 'daemon off;'` # from the right user — no overrides needed. diff --git a/apps/web/app/assets/css/main.css b/apps/web/app/assets/css/main.css index b9791258..cdb51133 100644 --- a/apps/web/app/assets/css/main.css +++ b/apps/web/app/assets/css/main.css @@ -50,6 +50,17 @@ /* Lines */ --line-default: 42 42 42; + /* La bordure d'un CHAMP, distincte des filets décoratifs. + `--line-default` sur `--bg-elevated` donne 1,21:1 en sombre et 1,26:1 en + clair : dans les deux thèmes, la frontière du contrôle est sous les 3:1 + que WCAG 1.4.11 exige d'un élément non textuel nécessaire pour + l'identifier. Le portail écartait justement la paire + `line-default`/`bg-base` au motif que « `.input` s'identifie par son fond, + distinct de la page » — or en thème clair `--bg-elevated` et + `--bg-surface` valent tous deux 255 : la prémisse était fausse pour un des + deux thèmes livrés. 102 en sombre et 149 en clair donnent 3:1 sur le fond + du champ, sans alourdir les filets des cartes et des tableaux. */ + --line-field: 102 102 102; --line-strong: 58 58 58; /* Accent — deliberately neutral; primary CTA inverts the surface */ @@ -60,7 +71,24 @@ /* Semantic — used for status badges and notifications, NOT for chrome */ --online: 34 197 94; --warning: 234 179 8; + /* Les encres de la famille sémantique. + `accent` et `accent-warm` avaient chacun la leur ; `online`, `warning`, + `danger` et `info` n'en avaient aucune, et les composants comblaient le + trou avec des `#fff` et des `#0a1610` en dur — hors du système, donc + insensibles au thème, et faux dans un des deux : blanc sur le vert du thème + clair passe, blanc sur le vert du thème sombre non, et réciproquement pour + une encre foncée. Une encre quasi noire sur les teintes vives du thème + sombre, blanche sur les teintes profondes du thème clair. */ + --online-fg: 26 26 26; + --warning-fg: 26 26 26; + --info-fg: 26 26 26; --danger: 239 68 68; + /* L'encre du bouton plein de danger. `#fff` en dur sur `--danger` donne + 3,76:1 en thème sombre : l'état AU REPOS échouait pendant que son survol + passait à 4,70:1 — exactement à l'envers. Une encre quasi noire sur ce + rouge donne 4,63:1, et en thème clair (185 28 28) le blanc donne 6,54:1 : + chaque thème prend l'encre qui marche sur SON rouge. */ + --danger-fg: 26 26 26; --info: 56 189 248; /* Chrome. `--focus-ring` is new and its default IS the gold that used to be @@ -124,6 +152,16 @@ theme authored today stays valid when those 111 definitions are replaced, and a theme file can already set them by hand. */ --accent-warm: 212 167 52; + /* L'or, mais lisible EN TEXTE. + `--accent-warm` est un jeton de remplissage : sa paire déclarée au portail + de contraste est `accent-warm-fg` POSÉ DESSUS. Utilisé comme couleur de + texte il n'a jamais été mesuré, et en thème clair (176 133 24) il donne + 3,24:1 sur la page — sous les 4,5:1 requis, pour les dizaines de + micro-libellés dorés de 9 à 15 px qui parsèment la console. En sombre les + deux valeurs coïncident : c'est le thème clair qui avait besoin d'une + encre plus profonde. Les deux nouvelles paires sont déclarées au portail + pour que la question ne se repose pas. */ + --accent-warm-text: 212 167 52; --accent-warm-fg: 26 26 26; --accent-cool: 52 212 216; --accent-paper: 20 20 20; @@ -269,6 +307,7 @@ --fg-faint: 111 111 111; --line-default: 229 229 229; + --line-field: 146 146 146; --line-strong: 208 208 208; --accent: 10 10 10; @@ -277,7 +316,11 @@ --online: 21 128 61; --warning: 180 83 9; + --online-fg: 255 255 255; + --warning-fg: 255 255 255; + --info-fg: 255 255 255; --danger: 185 28 28; + --danger-fg: 255 255 255; --info: 3 105 161; --focus-ring: 176 133 24; @@ -289,6 +332,9 @@ --bg-pattern-step: 40px; --accent-warm: 176 133 24; + /* 143 104 8 : 4,76:1 sur `bg-base`, 5,06:1 sur `bg-surface` / `bg-elevated`, + 4,56:1 sur `bg-inset`. Le quatre surfaces passent AA. */ + --accent-warm-text: 143 104 8; --accent-warm-fg: 26 26 26; --accent-cool: 14 145 148; --accent-paper: 252 250 245; @@ -475,7 +521,7 @@ .btn-danger { background-color: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); border-color: rgb(var(--danger)); } .btn-danger:hover { @@ -483,8 +529,13 @@ border-color: rgb(var(--danger) / 0.85); } - .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.75rem; } - .btn-xs { padding: 0.2rem 0.45rem; font-size: 0.6875rem; } + /* `min-height` et non plus du padding seul : à `line-height: 1`, `.btn-sm` + tombait à ~23,6 px et `.btn-xs` à ~22,4 px — tous deux sous le minimum de + 24 px de WCAG 2.2 SC 2.5.8, que le commentaire de `.tool-btn` plus bas + cite pourtant pour justifier ses 36 px. `.btn-xs` est la croix de + fermeture de `Modal.vue`, donc la commande la plus pressée du système. */ + .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.75rem; min-height: 1.5rem; } + .btn-xs { padding: 0.2rem 0.45rem; font-size: 0.6875rem; min-height: 1.5rem; } /* --------------------------------------------------------------------------- * Icon buttons — the square affordance next to a heading or in a table row @@ -507,6 +558,34 @@ * still wins over `@layer components` regardless of specificity, and those two * pages keep rendering exactly as they do today. * --------------------------------------------------------------------------- */ + /* --------------------------------------------------------------------------- + * Aller au contenu + * + * Hors écran jusqu'au focus, puis posé au-dessus de tout — y compris de + * l'en-tête collant, d'où le z-index au-delà de l'échelle des modales : un + * lien d'évitement que la barre recouvre ne sert à rien. + * --------------------------------------------------------------------------- */ + .skip-link { + position: fixed; + top: 0.5rem; + left: 0.5rem; + z-index: 70; + padding: 0.55rem 0.9rem; + border: 1px solid rgb(var(--line-field)); + border-radius: var(--radius-sm); + background: rgb(var(--bg-elevated)); + color: rgb(var(--fg-strong)); + font-size: 0.8125rem; + font-weight: 600; + transform: translateY(-200%); + transition: transform var(--dur-2) ease; + } + .skip-link:focus-visible { + transform: translateY(0); + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; + } + .tool-btn { display: inline-flex; align-items: center; @@ -590,7 +669,7 @@ width: 100%; background-color: rgb(var(--bg-elevated)); color: rgb(var(--fg-default)); - border: 1px solid rgb(var(--line-default)); + border: 1px solid rgb(var(--line-field)); border-radius: var(--radius-sm); padding: 0.45rem 0.75rem; font-size: 0.875rem; @@ -746,6 +825,11 @@ .modal-overlay { position: fixed; inset: 0; + /* 50, comme l'échelle juste au-dessus le dit. Le toast était lui aussi à + 50 alors que sa place est 60 : à z-index égal c'est l'ordre DOM qui + tranche, et une modale téléportée dans `body` arrive APRÈS le toast monté + au layout. Son `backdrop-filter` floutait donc le message d'erreur + renvoyé par le formulaire de la modale — précisément quand il compte. */ z-index: 50; display: flex; align-items: center; @@ -828,3 +912,18 @@ } } +/* Et les DÉLAIS, pas seulement les durées. + Le bloc ci-dessus ramène `animation-duration` à 0,01 ms, mais une entrée + échelonnée garde son `animation-delay` : avec `fill-mode: backwards` ou + `both`, les dernières lignes d'une liste restent à `opacity: 0` pendant + jusqu'à 700 ms puis apparaissent d'un coup. Un mouvement réduit n'est pas + un mouvement plus lent. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-delay: 0ms !important; + transition-delay: 0ms !important; + } +} + diff --git a/apps/web/app/assets/css/upload-form.css b/apps/web/app/assets/css/upload-form.css index ea32c0fb..033a9127 100644 --- a/apps/web/app/assets/css/upload-form.css +++ b/apps/web/app/assets/css/upload-form.css @@ -108,12 +108,12 @@ .ready-state.partial { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ready-state.ready { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* ─── Grid ──────────────────────────────────────────────────── */ @@ -458,7 +458,7 @@ font-size: 0.75em; } .action-ready { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-weight: 600; display: inline-flex; align-items: center; diff --git a/apps/web/app/components/BookMetadataCard.vue b/apps/web/app/components/BookMetadataCard.vue index bf71e19d..d7b55514 100644 --- a/apps/web/app/components/BookMetadataCard.vue +++ b/apps/web/app/components/BookMetadataCard.vue @@ -224,7 +224,7 @@ defineProps<{ border: 1px solid rgba(217, 119, 6, 0.45); border-radius: var(--radius-pill); background: rgba(217, 119, 6, 0.1); - color: #f59e0b; + color: rgb(var(--fg-default)); font-family: var(--font-mono); font-size: 0.625rem; font-weight: 700; @@ -233,7 +233,7 @@ defineProps<{ } .bcard-tag-icon { font-size: 0.6875rem; - color: #d97706; + color: rgb(var(--fg-default)); filter: drop-shadow(0 0 4px rgba(217, 119, 6, 0.5)); } @@ -327,6 +327,13 @@ defineProps<{ margin-right: 0.4rem; } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .bcard-genres { display: flex; flex-wrap: wrap; @@ -337,7 +344,7 @@ defineProps<{ border-radius: var(--radius-pill); border: 1px solid rgba(217, 119, 6, 0.3); background: rgba(217, 119, 6, 0.06); - color: #f59e0b; + color: rgb(var(--fg-default)); font-size: 0.7rem; font-weight: 600; letter-spacing: calc(0.02em * var(--tracking-scale)); @@ -356,7 +363,7 @@ defineProps<{ gap: 0.35rem; } .bcard-stat-icon { - color: #d97706; + color: rgb(var(--fg-default)); font-size: 0.85rem; } .bcard-stat-max { @@ -410,10 +417,10 @@ defineProps<{ gap: 0.35rem; font-size: 0.78rem; font-weight: 600; - color: #f59e0b; + color: rgb(var(--fg-default)); transition: color var(--dur-2) ease; } .bcard-link:hover { - color: #fbbf24; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } </style> diff --git a/apps/web/app/components/CategoryTypeBadge.vue b/apps/web/app/components/CategoryTypeBadge.vue index be4caa53..eed9b4d1 100644 --- a/apps/web/app/components/CategoryTypeBadge.vue +++ b/apps/web/app/components/CategoryTypeBadge.vue @@ -58,23 +58,30 @@ defineProps<{ } /* Two distinct accents — gold for /movie, cyan for /tv — match the palette used in the admin KPI cards elsewhere in the app. */ + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .type-badge--movie { - color: #f5c518; + color: rgb(var(--fg-default)); border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); } .type-badge--tv { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); } .type-badge--game { - color: #a78bfa; + color: rgb(var(--fg-default)); border-color: rgba(167, 139, 250, 0.4); background: rgba(167, 139, 250, 0.08); } .type-badge--book { - color: #d97706; + color: rgb(var(--fg-default)); border-color: rgba(217, 119, 6, 0.4); background: rgba(217, 119, 6, 0.08); } diff --git a/apps/web/app/components/ConfirmHost.vue b/apps/web/app/components/ConfirmHost.vue index 36aa30cc..4625a214 100644 --- a/apps/web/app/components/ConfirmHost.vue +++ b/apps/web/app/components/ConfirmHost.vue @@ -10,7 +10,7 @@ > <div v-if="current" - class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" + class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="onCancel" @keydown.esc.stop="onCancel" > @@ -51,17 +51,23 @@ </p> <div class="flex gap-2"> <button + ref="cancelButtonRef" type="button" - class="btn btn-secondary flex-1 text-[10px] font-bold uppercase tracking-widest" + class="btn btn-secondary flex-1 text-xs font-bold uppercase tracking-widest" @click="onCancel" > {{ current.cancelText || t('common.cancel') }} </button> + <!-- `.btn-danger` plutôt qu'une règle locale : sa couleur d'encre + est désormais un jeton par thème (`--danger-fg`), alors que le + `background-color: rgb(239 68 68); color: white` codé ici + donnait 3,76:1 et ignorait le thème clair, où le même blanc + sur le rouge du thème atteint 6,54:1. --> <button ref="confirmButtonRef" type="button" - class="btn btn-primary flex-1 text-[10px] font-bold uppercase tracking-widest" - :class="current.destructive ? 'destructive-btn' : ''" + class="btn flex-1 text-xs font-bold uppercase tracking-widest" + :class="current.destructive ? 'btn-danger' : 'btn-primary'" @click="onConfirm" > {{ current.confirmText || t('common.confirm') }} @@ -79,6 +85,7 @@ const { t } = useI18n(); const queue = useConfirmState(); const current = computed(() => queue.value[0] || null); const confirmButtonRef = ref<HTMLButtonElement | null>(null); +const cancelButtonRef = ref<HTMLButtonElement | null>(null); function pop(answer: boolean) { const req = queue.value[0]; @@ -94,12 +101,32 @@ function onConfirm() { pop(true); } -// Auto-focus the confirm button when a dialog opens so Enter resolves -// "Confirm" and Esc resolves "Cancel" — same default as window.confirm. +/* + * Où va le focus à l'ouverture. + * + * Sur un dialogue ORDINAIRE, sur Confirmer : c'est le geste attendu, et c'est + * le défaut de `window.confirm`. Sur un dialogue DESTRUCTIF, sur Annuler — une + * frappe Entrée résiduelle après le clic qui a ouvert la boîte détruisait + * sinon la donnée, et `handleKey` résout justement Entrée sur le bouton + * focalisé. La convention pour un dialogue destructif est d'exiger un geste + * délibéré pour atteindre le bouton rouge. + */ +/** Ce qui avait le focus avant la question, pour le lui rendre après. */ +let restoreTo: HTMLElement | null = null; + watch(current, async (req) => { - if (!req) return; + if (!req) { + // Sans cela, répondre à une confirmation laissait le focus sur `<body>` : + // la tabulation suivante repartait du haut de la page, loin du bouton + // qu'on venait d'actionner. + restoreTo?.focus?.(); + restoreTo = null; + return; + } + if (!restoreTo) restoreTo = document.activeElement as HTMLElement | null; await nextTick(); - confirmButtonRef.value?.focus(); + if (req.destructive) cancelButtonRef.value?.focus(); + else confirmButtonRef.value?.focus(); }); // Trap Esc globally while a dialog is open. Without this, browsers handle @@ -112,6 +139,21 @@ function handleKey(e: KeyboardEvent) { } else if (e.key === 'Enter' && document.activeElement === confirmButtonRef.value) { e.preventDefault(); onConfirm(); + } else if (e.key === 'Tab') { + // Deux boutons, en boucle. `aria-modal` n'a jamais retenu la tabulation : + // au troisième Tab l'utilisateur au clavier était dans la page derrière, + // devant une question à laquelle il ne pouvait plus répondre autrement + // qu'en revenant à reculons. + const pair = [cancelButtonRef.value, confirmButtonRef.value].filter( + (b): b is HTMLButtonElement => !!b, + ); + if (!pair.length) return; + e.preventDefault(); + const at = pair.indexOf(document.activeElement as HTMLButtonElement); + const next = e.shiftKey + ? (at <= 0 ? pair.length - 1 : at - 1) + : (at === pair.length - 1 ? 0 : at + 1); + pair[next]!.focus(); } } @@ -124,11 +166,4 @@ onUnmounted(() => { </script> <style scoped> -.destructive-btn { - background-color: rgb(239 68 68); - color: white; -} -.destructive-btn:hover { - background-color: rgb(220 38 38); -} </style> diff --git a/apps/web/app/components/FederationOff.vue b/apps/web/app/components/FederationOff.vue index 7678c6d3..a870b931 100644 --- a/apps/web/app/components/FederationOff.vue +++ b/apps/web/app/components/FederationOff.vue @@ -48,8 +48,8 @@ const isAdmin = computed(() => Boolean(user.value?.isAdmin)); display: grid; place-items: center; border-radius: var(--radius-pill); - border: 1px dashed var(--color-border, rgb(255 255 255 / 0.12)); - color: var(--color-text-secondary, rgb(255 255 255 / 0.45)); + border: 1px dashed var(--line-default, rgb(255 255 255 / 0.12)); + color: var(--fg-muted, rgb(255 255 255 / 0.45)); font-size: 2rem; } @@ -60,13 +60,13 @@ const isAdmin = computed(() => Boolean(user.value?.isAdmin)); } .fed-off-lead { - color: var(--color-text-secondary, rgb(255 255 255 / 0.6)); + color: var(--fg-muted, rgb(255 255 255 / 0.6)); line-height: 1.6; } .fed-off-admin { margin-top: 0.75rem; - color: var(--color-text-secondary, rgb(255 255 255 / 0.45)); + color: var(--fg-muted, rgb(255 255 255 / 0.45)); font-size: 0.875rem; line-height: 1.6; } @@ -85,15 +85,15 @@ const isAdmin = computed(() => Boolean(user.value?.isAdmin)); gap: 0.5rem; padding: 0.6rem 1.1rem; border-radius: var(--radius-xl); - border: 1px solid var(--color-border, rgb(255 255 255 / 0.12)); - color: var(--color-text-secondary, rgb(255 255 255 / 0.7)); + border: 1px solid var(--line-default, rgb(255 255 255 / 0.12)); + color: var(--fg-muted, rgb(255 255 255 / 0.7)); font-size: 0.9rem; transition: background-color var(--dur-2), color var(--dur-2), border-color var(--dur-2); } .fed-off-btn:hover { background: rgb(255 255 255 / 0.04); - color: var(--color-text-primary, rgb(255 255 255 / 0.92)); + color: var(--fg-default, rgb(255 255 255 / 0.92)); } .fed-off-btn.primary { diff --git a/apps/web/app/components/GameMetadataCard.vue b/apps/web/app/components/GameMetadataCard.vue index 0bd4d499..22a7f805 100644 --- a/apps/web/app/components/GameMetadataCard.vue +++ b/apps/web/app/components/GameMetadataCard.vue @@ -261,7 +261,7 @@ function formatReleaseDate(iso: string | null): string { font-weight: 800; letter-spacing: calc(0.18em * var(--tracking-scale)); text-transform: uppercase; - color: #a78bfa; + color: rgb(var(--fg-default)); z-index: 2; } .gcard-tag-icon { font-size: 0.85rem; } @@ -354,7 +354,7 @@ function formatReleaseDate(iso: string | null): string { border-radius: var(--radius-sm); background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.35); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .gcard-stats { @@ -420,14 +420,21 @@ function formatReleaseDate(iso: string | null): string { .gcard-pill--platform { border-color: rgba(96, 165, 250, 0.35); background: rgba(96, 165, 250, 0.06); - color: #60a5fa; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .gcard-pill--mode { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.06); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .gcard-links { display: flex; flex-wrap: wrap; @@ -443,11 +450,11 @@ function formatReleaseDate(iso: string | null): string { font-weight: 700; letter-spacing: calc(0.1em * var(--tracking-scale)); text-transform: uppercase; - color: #a78bfa; + color: rgb(var(--fg-default)); text-decoration: none; transition: color var(--dur-3) ease; } -.gcard-link:hover { color: #c4b5fd; } +.gcard-link:hover { color: rgb(var(--fg-strong)); } .gcard-link-arrow { font-size: 0.7rem; transition: transform var(--dur-3) ease; diff --git a/apps/web/app/components/IconPicker.vue b/apps/web/app/components/IconPicker.vue index b19d009f..51c28fb0 100644 --- a/apps/web/app/components/IconPicker.vue +++ b/apps/web/app/components/IconPicker.vue @@ -375,7 +375,7 @@ onBeforeUnmount(() => { } .icon-picker.is-open .icon-picker-preview { background: rgb(var(--accent-warm) / 0.12); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .icon-picker-preview:disabled { cursor: not-allowed; @@ -512,7 +512,7 @@ onBeforeUnmount(() => { .icon-picker-cell.is-active { background: rgb(var(--accent-warm) / 0.12); border-color: rgb(var(--accent-warm) / 0.45); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .icon-picker-cell-glyph { font-size: 1.4rem; diff --git a/apps/web/app/components/MediaMetadataCard.vue b/apps/web/app/components/MediaMetadataCard.vue index 9e8501dc..a08d0409 100644 --- a/apps/web/app/components/MediaMetadataCard.vue +++ b/apps/web/app/components/MediaMetadataCard.vue @@ -266,13 +266,20 @@ function formatRuntime(minutes: number): string { border-radius: var(--radius-pill); border: 1px solid rgb(var(--line-default)); } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .media-card-type--movie { - color: #f5c518; + color: rgb(var(--fg-default)); border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.1); } .media-card-type--tv { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.1); } diff --git a/apps/web/app/components/MediaSearchPicker.vue b/apps/web/app/components/MediaSearchPicker.vue index ecbcf92f..0b9fd310 100644 --- a/apps/web/app/components/MediaSearchPicker.vue +++ b/apps/web/app/components/MediaSearchPicker.vue @@ -38,15 +38,31 @@ </span> </div> + <!-- + L'échec, dit sous le champ plutôt que dans la liste déroulante. + + `error` ne s'affichait qu'à l'intérieur du panneau, sous `v-if="open + && …"`. Or les deux chemins qui l'écrivent ferment le panneau ou ne + l'ouvrent jamais : choisir un résultat (`resolveHit` met `open` à faux + AVANT d'échouer) et coller un identifiant TMDb à la main + (`resolveById`, qui n'ouvre rien). Un membre qui collait un id + introuvable, ou qui cliquait un titre pendant que le service de + métadonnées était coupé, ne voyait donc strictement rien : ni fiche, ni + message. + --> + <p v-if="error" class="picker-error picker-error--standalone" role="alert"> + <Icon name="ph:warning-circle-bold" /> + {{ error }} + </p> + <Transition name="picker-fade"> <div - v-if="open && (results.length > 0 || error || (debouncedQuery && !loading))" + v-if="open && (results.length > 0 || (debouncedQuery && !loading))" class="picker-results" @mousedown.prevent > - <p v-if="error" class="picker-error">{{ error }}</p> <p - v-else-if="debouncedQuery && !loading && results.length === 0" + v-if="debouncedQuery && !loading && results.length === 0" class="picker-empty" > {{ t('components.mediaSearch.noMatchesPrefix') }} <em>{{ debouncedQuery }}</em> @@ -699,26 +715,33 @@ async function resolveManual() { letter-spacing: calc(0.04em * var(--tracking-scale)); text-transform: uppercase; } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .picker-kind--movie { border-color: rgba(245, 197, 24, 0.45); - color: #f5c518; + color: rgb(var(--fg-default)); background: rgba(245, 197, 24, 0.08); } .picker-kind--tv { border-color: rgba(108, 209, 97, 0.45); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.08); } .picker-kind--game { border-color: rgba(167, 139, 250, 0.45); - color: #a78bfa; + color: rgb(var(--fg-default)); background: rgba(167, 139, 250, 0.08); } .picker-rating { display: inline-flex; align-items: center; gap: 0.2rem; - color: #f5c518; + color: rgb(var(--fg-default)); font-weight: 600; } .picker-overview { @@ -906,4 +929,12 @@ async function resolveManual() { opacity: 0; transform: translateY(-4px); } +.picker-error--standalone { + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.4rem; + font-size: 0.75rem; + color: rgb(var(--danger)); +} </style> diff --git a/apps/web/app/components/MessagesBell.vue b/apps/web/app/components/MessagesBell.vue index 5f524dbf..294433be 100644 --- a/apps/web/app/components/MessagesBell.vue +++ b/apps/web/app/components/MessagesBell.vue @@ -145,7 +145,7 @@ onUnmounted(() => { align-items: center; justify-content: center; background: #f43f5e; - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-radius: var(--radius-pill); font-family: var(--font-mono); font-size: 0.5625rem; diff --git a/apps/web/app/components/Modal.vue b/apps/web/app/components/Modal.vue index 79db0b88..c3165bc9 100644 --- a/apps/web/app/components/Modal.vue +++ b/apps/web/app/components/Modal.vue @@ -20,7 +20,8 @@ role="dialog" aria-modal="true" tabindex="-1" - :aria-labelledby="titleId" + :aria-labelledby="hasHeader ? titleId : undefined" + :aria-label="hasHeader ? undefined : t('components.modal.fallbackLabel')" @click.stop > <header @@ -35,9 +36,17 @@ class="text-base flex-shrink-0" :style="iconStyle" /> - <slot name="header"> - <h3 :id="titleId" class="h-card truncate">{{ title }}</h3> - </slot> + <!-- L'id porte sur le conteneur, pas sur le `<h3>` par défaut. + Il était sur le titre par défaut uniquement : dès qu'un + appelant remplissait le slot `#header` — ce que font la + plupart des modales d'administration — `aria-labelledby` + pointait vers un élément qui n'existait pas, et un lecteur + d'écran annonçait « boîte de dialogue » sans nom. --> + <div :id="titleId" class="min-w-0"> + <slot name="header"> + <h3 class="h-card truncate">{{ title }}</h3> + </slot> + </div> </div> <button v-if="!hideClose" @@ -89,7 +98,13 @@ const emit = defineEmits<{ (e: 'close'): void; }>(); -const titleId = `modal-${Math.random().toString(36).slice(2, 8)}`; +// `useId()` plutôt que `Math.random()` : l'identifiant est calculé au `setup`, +// donc aussi côté serveur. Une modale ouverte au rendu initial recevait deux +// valeurs différentes et l'hydratation cassait le lien titre ↔ dialogue. +const titleId = useId(); + +const slots = useSlots(); +const hasHeader = computed(() => Boolean(slots.header || props.title)); const sizeClass = computed(() => { switch (props.size) { @@ -128,30 +143,121 @@ function onBackdropClick() { // keystroke after open is captured. const panelRef = ref<HTMLElement | null>(null); +/** L'élément qui avait le focus avant l'ouverture, pour le lui rendre. */ +let restoreTo: HTMLElement | null = null; + +/** + * Ce qui est atteignable au clavier, dans l'ordre du document. + * + * `offsetParent` écarte ce qui est masqué (`display: none`), donc les onglets + * repliés d'une modale à onglets ne piègent pas le focus dans le vide. + */ +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])'; + +function focusables(): HTMLElement[] { + const root = panelRef.value; + if (!root) return []; + return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); +} + function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape' && !props.persistent) { e.preventDefault(); close(); + return; + } + if (e.key !== 'Tab') return; + // Le piège à focus. + // + // `aria-modal="true"` dit au lecteur d'écran que le reste de la page est + // inerte ; il ne dit rien au navigateur, dont l'ordre de tabulation continuait + // droit dans la page derrière l'ombrage. Un utilisateur au clavier sortait de + // la modale dès la dernière tabulation et se retrouvait à parcourir une page + // qu'il ne voyait plus, sans savoir comment revenir. + const items = focusables(); + if (!items.length) { + e.preventDefault(); + panelRef.value?.focus(); + return; + } + const first = items[0]!; + const last = items[items.length - 1]!; + const active = document.activeElement as HTMLElement | null; + const inside = panelRef.value?.contains(active) ?? false; + if (!inside) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + return; + } + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); } } +/** + * Le verrou de défilement, partagé par toutes les modales. + * + * Un compteur, pas un booléen : une modale ouverte au-dessus d'une autre + * (une confirmation par-dessus un formulaire) rendait le défilement à la + * fermeture de la première alors que la seconde était toujours à l'écran. + */ +let lockedByThis = false; + +function lockScroll() { + if (lockedByThis) return; + lockedByThis = true; + const n = Number(document.body.dataset.modalLocks || '0') + 1; + document.body.dataset.modalLocks = String(n); + if (n === 1) document.body.style.overflow = 'hidden'; +} + +function unlockScroll() { + if (!lockedByThis) return; + lockedByThis = false; + const n = Math.max(0, Number(document.body.dataset.modalLocks || '1') - 1); + document.body.dataset.modalLocks = String(n); + if (n === 0) document.body.style.overflow = ''; +} + watch( () => props.modelValue, (open) => { if (typeof window === 'undefined') return; if (open) { + restoreTo = document.activeElement as HTMLElement | null; window.addEventListener('keydown', onKeydown); + lockScroll(); // Wait one tick so the teleport mounts before we steal focus. nextTick(() => panelRef.value?.focus()); } else { window.removeEventListener('keydown', onKeydown); + unlockScroll(); + // Rendre le focus au bouton qui a ouvert la modale. Sans cela il retombe + // sur `<body>` et la tabulation suivante repart du haut de la page. + restoreTo?.focus?.(); + restoreTo = null; } - } + }, + // `immediate` pour le cas d'une modale montée DÉJÀ ouverte : aucun appelant + // ne le fait aujourd'hui (vérifié sur les 27 usages de `<Modal v-model>`), + // mais sans cela le premier changement d'état ne serait jamais observé et la + // modale s'afficherait sans verrou de défilement, sans piège à focus et sans + // gestionnaire Échap. La branche « fermée » est inoffensive : le verrou n'est + // pas pris, et la restitution de focus porte sur `null`. + { immediate: true }, ); onBeforeUnmount(() => { if (typeof window !== 'undefined') { window.removeEventListener('keydown', onKeydown); + unlockScroll(); } }); </script> diff --git a/apps/web/app/components/NotificationBell.vue b/apps/web/app/components/NotificationBell.vue index b14f0cda..56bd144d 100644 --- a/apps/web/app/components/NotificationBell.vue +++ b/apps/web/app/components/NotificationBell.vue @@ -120,7 +120,11 @@ class="nbell-row" :class="{ 'nbell-row--unread': !row.readAt }" :style="{ '--stagger': `${i * 25}ms` }" + :role="row.link ? 'link' : 'button'" + tabindex="0" @click="onRowClick(row)" + @keydown.enter.prevent="onRowClick(row)" + @keydown.space.prevent="onRowClick(row)" > <span class="nbell-row-rail" @@ -344,7 +348,7 @@ function humanise(s: string): string { background: rgb(var(--fg-default) / 0.05); } .nbell-btn--open { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); } .nbell-icon { font-size: 1.05rem; } @@ -359,7 +363,7 @@ function humanise(s: string): string { align-items: center; justify-content: center; background: #f43f5e; - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-radius: var(--radius-pill); font-family: var(--font-mono); font-size: 0.5625rem; @@ -418,7 +422,7 @@ function humanise(s: string): string { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .nbell-eyebrow-rule { display: inline-block; @@ -449,7 +453,7 @@ function humanise(s: string): string { font-weight: 800; background: rgba(244, 63, 94, 0.12); border: 1px solid rgba(244, 63, 94, 0.45); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-radius: var(--radius-pill); letter-spacing: calc(0.02em * var(--tracking-scale)); } @@ -467,7 +471,7 @@ function humanise(s: string): string { font-weight: 800; letter-spacing: calc(0.12em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); cursor: pointer; transition: all var(--dur-2) ease; white-space: nowrap; @@ -512,7 +516,7 @@ function humanise(s: string): string { border-color: rgb(var(--line-strong)); } .nbell-filter--on { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); border-color: rgb(var(--accent-warm) / 0.5); } @@ -574,13 +578,13 @@ function humanise(s: string): string { border-radius: 50%; background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.2rem; } .nbell-empty-stamp--clear { background: rgba(108, 209, 97, 0.08); border-color: rgba(108, 209, 97, 0.45); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ transform: rotate(-6deg); } .nbell-loading-spin { @@ -604,6 +608,10 @@ function humanise(s: string): string { border-radius: var(--radius-md); } +.nbell-row:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: -2px; +} .nbell-row { position: relative; display: grid; @@ -657,11 +665,11 @@ function humanise(s: string): string { align-self: flex-start; } .nbell-row-icon--gain { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.1); } .nbell-row-icon--spend { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.1); } .nbell-row-icon--info { @@ -669,15 +677,15 @@ function humanise(s: string): string { background: rgb(var(--fg-default) / 0.08); } .nbell-row-icon--warn { - color: #fb923c; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(251, 146, 60, 0.1); } .nbell-row-icon--danger { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.1); } .nbell-row-icon--social { - color: #60a5fa; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(96, 165, 250, 0.1); } @@ -737,7 +745,7 @@ function humanise(s: string): string { background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.35); border-radius: var(--radius-sm); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); cursor: pointer; opacity: 0.45; transition: opacity var(--dur-3) ease, background var(--dur-2) ease, @@ -775,7 +783,7 @@ function humanise(s: string): string { transition: all var(--dur-2) ease; } .nbell-pop-all:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.06); } .nbell-pop-all svg { diff --git a/apps/web/app/components/NotificationToast.vue b/apps/web/app/components/NotificationToast.vue index 4e878393..d6bb9733 100644 --- a/apps/web/app/components/NotificationToast.vue +++ b/apps/web/app/components/NotificationToast.vue @@ -9,7 +9,7 @@ of cutting the reader off mid-sentence, and `aria-atomic` so a toast is read as one message rather than as the fragments it is built from. --> <div - class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none" + class="fixed top-4 right-4 z-[60] flex flex-col gap-2 pointer-events-none" role="status" aria-live="polite" aria-atomic="true" @@ -70,29 +70,39 @@ function iconName(type: string) { } } +/* + * Les teintes du bandeau, prises au thème. + * + * C'étaient `text-green-400` / `text-red-400` / `text-yellow-400` / + * `text-blue-400` et les bordures assorties : des couleurs Tailwind fixes, sur + * une carte dont le fond suit le thème. En thème clair, `text-yellow-400` + * (#facc15) sur la surface d'une carte tombe autour de 1,5:1 — l'icône du + * bandeau d'avertissement était pratiquement invisible, et c'est le seul canal + * par lequel le site dit qu'une action a échoué. + */ function iconClass(type: string) { switch (type) { case 'success': - return 'text-green-400'; + return 'text-success'; case 'error': - return 'text-red-400'; + return 'text-error'; case 'warning': - return 'text-yellow-400'; + return 'text-warning'; default: - return 'text-blue-400'; + return 'text-info'; } } function borderClass(type: string) { switch (type) { case 'success': - return 'border-green-500/30'; + return 'border-success/30'; case 'error': - return 'border-red-500/30'; + return 'border-error/30'; case 'warning': - return 'border-yellow-500/30'; + return 'border-warning/30'; default: - return 'border-blue-500/30'; + return 'border-info/30'; } } </script> diff --git a/apps/web/app/components/ReportModal.vue b/apps/web/app/components/ReportModal.vue index 1f3a81ee..218b667d 100644 --- a/apps/web/app/components/ReportModal.vue +++ b/apps/web/app/components/ReportModal.vue @@ -377,11 +377,11 @@ onBeforeUnmount(() => { font-weight: 800; letter-spacing: calc(0.22em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .slip-eyebrow-icon { font-size: 1rem; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ filter: drop-shadow(0 0 6px rgba(244, 63, 94, 0.35)); } .slip-close { @@ -477,7 +477,7 @@ onBeforeUnmount(() => { transition: color var(--dur-3) ease; } .slip-counter--warn { - color: #f59e0b; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* ── Chip picker ────────────────────────────────────────── */ @@ -508,7 +508,7 @@ onBeforeUnmount(() => { background: rgb(var(--bg-inset)); } .chip--active { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(244, 63, 94, 0.55); background: rgba(244, 63, 94, 0.08); box-shadow: inset 0 0 0 1px rgba(244, 63, 94, 0.25); @@ -540,6 +540,16 @@ onBeforeUnmount(() => { border-color: rgba(244, 63, 94, 0.55); box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.slip-textarea:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .slip-textarea::placeholder { color: rgb(var(--fg-faint)); } diff --git a/apps/web/app/components/SearchBar.vue b/apps/web/app/components/SearchBar.vue index adc2d585..91ca30af 100644 --- a/apps/web/app/components/SearchBar.vue +++ b/apps/web/app/components/SearchBar.vue @@ -207,14 +207,21 @@ onUnmounted(() => { box-shadow: 0 0 0 1px rgba(108, 209, 97, 0.18); } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .detected-icon--imdb { - color: #f5c518; + color: rgb(var(--fg-default)); } .detected-icon--tmdb { - color: #01b4e4; + color: rgb(var(--fg-default)); } .detected-icon--tvdb { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Hint chip — discreet pill below the search input. */ @@ -249,13 +256,13 @@ onUnmounted(() => { font-weight: 800; } .detection-hint--imdb .detection-tag { - color: #f5c518; + color: rgb(var(--fg-default)); } .detection-hint--tmdb .detection-tag { - color: #01b4e4; + color: rgb(var(--fg-default)); } .detection-hint--tvdb .detection-tag { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .detection-id { font-family: var(--font-mono); diff --git a/apps/web/app/components/TorrentTable.vue b/apps/web/app/components/TorrentTable.vue index 8a38059d..60f2b447 100644 --- a/apps/web/app/components/TorrentTable.vue +++ b/apps/web/app/components/TorrentTable.vue @@ -43,12 +43,15 @@ > {{ $t('components.torrentTable.noTorrents') }} </p> - <button + <!-- `NuxtLink` plutôt qu'un `<button>` qui appelle `navigateTo` : une + destination est un lien. Le bouton était atteignable au clavier mais + ne se laissait ni ouvrir dans un onglet, ni copier, et s'annonçait + « bouton » là où le lecteur d'écran attend « lien ». --> + <NuxtLink v-for="torrent in torrents" :key="torrent.id" - type="button" + :to="`/torrents/${torrent.infoHash}`" class="w-full text-left px-3 py-3 active:bg-fg-default/5 transition-colors block" - @click="navigateTo(`/torrents/${torrent.infoHash}`)" > <div class="flex items-start gap-2"> <Icon @@ -147,7 +150,7 @@ </div> </div> </div> - </button> + </NuxtLink> </div> <!-- ≥ md: original table preserved verbatim. --> @@ -180,9 +183,18 @@ :name="getCategoryIcon(torrent.category)" class="text-text-muted text-base shrink-0" /> - <span + <!-- Un vrai lien, pas un `<span>` dans une ligne cliquable. + La ligne n'avait ni `tabindex`, ni rôle, ni gestionnaire clavier : + la tabulation sautait le catalogue entier, et le seul moyen + d'ouvrir une release était la souris. Un lien rend aussi ce que + le navigateur sait faire d'un lien — ouvrir dans un onglet, + copier l'adresse, l'annoncer comme lien. Le clic sur la ligne + reste, comme raccourci. --> + <NuxtLink + :to="`/torrents/${torrent.infoHash}`" class="text-text-primary hover:text-text-strong transition-colors font-medium truncate max-w-[300px] lg:max-w-[500px]" - >{{ torrent.name }}</span + @click.stop + >{{ torrent.name }}</NuxtLink > <span v-for="tag in torrent.tags ?? []" diff --git a/apps/web/app/components/WysiwygEditor.vue b/apps/web/app/components/WysiwygEditor.vue index 686c95f9..c102d1bb 100644 --- a/apps/web/app/components/WysiwygEditor.vue +++ b/apps/web/app/components/WysiwygEditor.vue @@ -495,7 +495,12 @@ const previewSource = computed(() => { // mode we also accept the legacy mix of MD with embedded HTML/BBCode. function inputToHtml(value: string): string { if (!value) return ''; - if (props.format === 'html') return value; + // Assaini dans les DEUX branches. Celle-ci renvoyait la valeur stockée telle + // quelle : aucun appelant ne passe `format="html"` aujourd'hui, donc c'était + // un trou latent, mais la sûreté reposait alors sur le schéma ProseMirror de + // tiptap et non sur l'assainisseur du projet — et `htmlToOutput` renvoie + // ensuite ce HTML brut au serveur. + if (props.format === 'html') return sanitizeRichHtml(value); return toEditorHtml(value); } @@ -811,7 +816,7 @@ onBeforeUnmount(() => { .we-tag-chip:hover { background: rgba(56, 189, 248, 0.12); border-color: rgba(56, 189, 248, 0.45); - color: #38bdf8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ transform: translateY(-1px); } diff --git a/apps/web/app/components/admin/Announcements.vue b/apps/web/app/components/admin/Announcements.vue index eec86ffc..909bc185 100644 --- a/apps/web/app/components/admin/Announcements.vue +++ b/apps/web/app/components/admin/Announcements.vue @@ -145,21 +145,21 @@ const typeOptions = computed(() => [ const typeStyles = { info: { - bg: 'bg-blue-500/10', - border: 'border-blue-500/30', - text: 'text-blue-400', + bg: 'bg-info/10', + border: 'border-info/30', + text: 'text-info', icon: 'ph:info', }, warning: { - bg: 'bg-yellow-500/10', - border: 'border-yellow-500/30', - text: 'text-yellow-400', + bg: 'bg-warning/10', + border: 'border-warning/30', + text: 'text-warning', icon: 'ph:warning', }, error: { - bg: 'bg-red-500/10', - border: 'border-red-500/30', - text: 'text-red-400', + bg: 'bg-error/10', + border: 'border-error/30', + text: 'text-error', icon: 'ph:warning-circle', }, }; diff --git a/apps/web/app/components/admin/BonusEvents.vue b/apps/web/app/components/admin/BonusEvents.vue index 732a7d23..9be6972f 100644 --- a/apps/web/app/components/admin/BonusEvents.vue +++ b/apps/web/app/components/admin/BonusEvents.vue @@ -577,6 +577,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.bonusEvents.form.enabled')" :aria-checked="form.enabled" class="ed-toggle" :class="{ 'ed-toggle--on': form.enabled }" @@ -887,15 +888,11 @@ function applyPreset(kind: 'freeleech' | 'silverleech' | 'custom') { } // ── Open / submit ──────────────────────────────────────────── -function isoToLocalInput(iso: string): string { - const d = new Date(iso); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - -function localInputToIso(local: string): string { - return new Date(local).toISOString(); -} +// Ces deux conversions vivaient ici, correctes, pendant que la page d'un +// torrent en avait sa propre version fausse. Elles sont désormais dans +// `utils/format.ts`, d'où les deux appelants les tirent. +const isoToLocalInput = isoToDatetimeLocal; +const localInputToIso = (local: string): string => datetimeLocalToIso(local) ?? ''; function openCreate() { editing.value = null; @@ -1078,7 +1075,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .tower-eyebrow-rule { display: inline-block; @@ -1163,7 +1160,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { border-radius: 50%; background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.7rem; position: relative; } @@ -1292,7 +1289,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-weight: 800; letter-spacing: calc(0.3em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ flex: 1; } .onair-now { @@ -1385,7 +1382,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-size: 1.5rem; font-weight: 800; letter-spacing: calc(0.02em * var(--tracking-scale)); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ text-shadow: 0 0 18px rgba(244, 63, 94, 0.35); line-height: 1.1; } @@ -1450,7 +1447,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { } .schedule-head-icon { font-size: 1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .schedule-head-label { font-family: var(--font-mono); @@ -1601,17 +1598,17 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { } .preset-chip-icon { font-size: 0.85rem; } .preset-chip--freeleech { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); } .preset-chip--silverleech { - color: #94a3b8; + color: rgb(var(--fg-muted)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(148, 163, 184, 0.4); background: rgba(148, 163, 184, 0.08); } .preset-chip--custom { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); background: rgb(var(--accent-warm) / 0.06); } @@ -1689,7 +1686,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { } .act--pause:hover { color: #fb923c; border-color: rgba(251, 146, 60, 0.45); } .act--resume { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.06); } @@ -1697,7 +1694,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { background: rgba(108, 209, 97, 0.12); } .act--delete:hover { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(244, 63, 94, 0.45); background: rgba(244, 63, 94, 0.06); } @@ -1753,7 +1750,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-size: 0.625rem; font-weight: 800; letter-spacing: calc(0.18em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.22rem 0.45rem; @@ -1787,6 +1784,16 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { border-color: rgb(var(--accent-warm) / 0.55); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.1); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.ed-textarea:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .ed-textarea { resize: vertical; min-height: 60px; @@ -1853,7 +1860,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { .ed-preset--on { background: rgb(var(--accent-warm) / 0.08); border-color: rgb(var(--accent-warm) / 0.55); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); box-shadow: inset 0 0 0 1px rgb(var(--accent-warm) / 0.3); } .ed-preset--on .ed-preset-sub { color: rgb(var(--accent-warm) / 0.7); } @@ -1898,7 +1905,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-family: var(--font-mono); font-size: 0.95rem; font-weight: 800; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); letter-spacing: calc(-0.01em * var(--tracking-scale)); } .ed-range { diff --git a/apps/web/app/components/admin/BonusRules.vue b/apps/web/app/components/admin/BonusRules.vue index 80ef9577..5ee709c3 100644 --- a/apps/web/app/components/admin/BonusRules.vue +++ b/apps/web/app/components/admin/BonusRules.vue @@ -208,7 +208,7 @@ </span> <button type="button" - class="btn btn--ghost btn--sm" + class="cbtn cbtn--ghost cbtn--sm" @click="addMilestone(r)" > <Icon name="ph:plus-bold" /> @@ -300,7 +300,7 @@ <span class="tier-list-label">{{ $t('admin.bonusRules.seedTiers.editLabel') }}</span> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" @click="addSeedTier" > <Icon name="ph:plus-bold" /> @@ -331,7 +331,13 @@ </label> <span class="tier-arrow"><Icon name="ph:arrow-right-bold" /></span> <label class="tier-field"> - <span class="tier-field-label">{{ $t('admin.bonusRules.fields.multiplier') }}</span> + <span class="tier-field-label"> + {{ $t('admin.bonusRules.fields.multiplier') }} + <!-- Le champ se saisit en centièmes : 150 vaut ×1,50. Rien ne le + disait — un opérateur qui tapait « 2 » pour doubler la + récompense la divisait par cinquante. --> + <span class="tier-field-note">{{ $t('admin.bonusRules.units.hundredths') }}</span> + </span> <input type="number" min="0" @@ -398,7 +404,7 @@ <span class="tier-list-label">{{ $t('admin.bonusRules.ageTiers.editLabel') }}</span> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" @click="addAgeTier" > <Icon name="ph:plus-bold" /> @@ -429,7 +435,10 @@ </label> <span class="tier-arrow"><Icon name="ph:arrow-right-bold" /></span> <label class="tier-field"> - <span class="tier-field-label">{{ $t('admin.bonusRules.fields.multiplier') }}</span> + <span class="tier-field-label"> + {{ $t('admin.bonusRules.fields.multiplier') }} + <span class="tier-field-note">{{ $t('admin.bonusRules.units.hundredths') }}</span> + </span> <input type="number" min="0" @@ -474,6 +483,7 @@ import TierCurve from '~/components/admin/bonus/TierCurve.vue'; const { t } = useI18n(); const notifications = useNotificationStore(); +const confirm = useConfirm(); interface BonusRule { id: string; @@ -648,12 +658,23 @@ async function addSeedTier() { async function patchSeedTier(tier: SeedTier, body: Partial<SeedTier>) { try { await $fetch(`/api/admin/bonus-rules/tiers/seed-count/${tier.id}`, { method: 'PATCH', body }); + // Ces champs s'enregistrent à la volée, sans bouton : sans accusé de + // réception, quitter le champ ne produisait rien de visible et + // l'opérateur ne pouvait pas distinguer « enregistré » de « ignoré ». + notifications.success(t('admin.bonusRules.toasts.tierUpdated')); await refresh(); } catch (err: any) { notifications.error(err?.data?.message || t('admin.bonusRules.errors.tierUpdateFailed')); } } async function deleteSeedTier(tier: SeedTier) { + const ok = await confirm({ + title: t('admin.bonusRules.confirmTierDelete.title'), + message: t('admin.bonusRules.confirmTierDelete.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; try { await $fetch(`/api/admin/bonus-rules/tiers/seed-count/${tier.id}`, { method: 'DELETE' }); notifications.success(t('admin.bonusRules.toasts.tierDeleted')); @@ -677,12 +698,23 @@ async function addAgeTier() { async function patchAgeTier(tier: AgeTier, body: Partial<AgeTier>) { try { await $fetch(`/api/admin/bonus-rules/tiers/age/${tier.id}`, { method: 'PATCH', body }); + // Ces champs s'enregistrent à la volée, sans bouton : sans accusé de + // réception, quitter le champ ne produisait rien de visible et + // l'opérateur ne pouvait pas distinguer « enregistré » de « ignoré ». + notifications.success(t('admin.bonusRules.toasts.tierUpdated')); await refresh(); } catch (err: any) { notifications.error(err?.data?.message || t('admin.bonusRules.errors.tierUpdateFailed')); } } async function deleteAgeTier(tier: AgeTier) { + const ok = await confirm({ + title: t('admin.bonusRules.confirmTierDelete.title'), + message: t('admin.bonusRules.confirmTierDelete.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; try { await $fetch(`/api/admin/bonus-rules/tiers/age/${tier.id}`, { method: 'DELETE' }); notifications.success(t('admin.bonusRules.toasts.tierDeleted')); @@ -826,7 +858,7 @@ async function deleteAgeTier(tier: AgeTier) { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -1057,6 +1089,16 @@ async function deleteAgeTier(tier: AgeTier) { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input--sm { font-size: 0.85rem; padding: 0.35rem 0.55rem; @@ -1385,7 +1427,18 @@ async function deleteAgeTier(tier: AgeTier) { } /* ── Buttons ─────────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1400,22 +1453,22 @@ async function deleteAgeTier(tier: AgeTier) { transition: all var(--dur-2) ease; font-family: inherit; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--primary { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } -.btn--sm { +.cbtn--sm { padding: 0.32rem 0.6rem; font-size: 0.7rem; } @@ -1438,4 +1491,12 @@ async function deleteAgeTier(tier: AgeTier) { background: rgba(239, 68, 68, 0.06); border-color: rgba(239, 68, 68, 0.35); } +.tier-field-note { + display: block; + font-size: 0.6rem; + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: rgb(var(--fg-subtle)); +} </style> diff --git a/apps/web/app/components/admin/Branding.vue b/apps/web/app/components/admin/Branding.vue index 03db02bb..12e0b56a 100644 --- a/apps/web/app/components/admin/Branding.vue +++ b/apps/web/app/components/admin/Branding.vue @@ -194,7 +194,7 @@ </div> <div class="dropfile-info"> <code class="dropfile-path">{{ form.siteFavicon }}</code> - <button type="button" class="dropfile-remove" @click="form.siteFavicon = null"> + <button type="button" class="dropfile-remove" @click="removeFavicon"> <Icon name="ph:trash-bold" /> {{ $t('admin.branding.remove') }} </button> @@ -492,7 +492,7 @@ </span> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" :disabled="saving" @click="discard" > @@ -500,7 +500,7 @@ </button> <button type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!dirty || saving" @click="saveAll" > @@ -517,6 +517,7 @@ <script setup lang="ts"> const { t } = useI18n(); +const confirm = useConfirm(); const notifications = useNotificationStore(); interface BrandingForm { @@ -701,7 +702,14 @@ watch(form, () => { dirty.value = true; }, { deep: true }); // re-render of the savebar — cheap individually, but the savebar // re-renders on every keystroke, so the saving adds up. const FORM_KEYS: (keyof BrandingForm)[] = [ - 'siteName', 'siteLogo', 'siteLogoImage', 'siteFavicon', 'siteSubtitle', + // `siteFavicon` n'est PAS dans cette liste : elle est commitée par sa propre + // route au moment du téléversement et n'a jamais fait partie du corps du PUT + // (`settings.put.ts` ne connaît pas le mot « favicon »). L'y laisser la + // comptait comme une modification en attente, faisait dire à la barre + // « 1 modification non enregistrée », puis « Enregistré » — pendant que la + // favicone restait exactement ce qu'elle était. Et `discard()` prétendait + // annuler un téléversement déjà en ligne. + 'siteName', 'siteLogo', 'siteLogoImage', 'siteSubtitle', 'siteNameColor', 'siteNameBold', 'authTitle', 'authSubtitle', 'footerText', 'pageTitleSuffix', 'welcomeMessage', 'siteRules', 'heroTitle', 'heroSubtitle', 'statusBadgeText', @@ -813,6 +821,11 @@ async function uploadFavicon(file: File) { fd.append('favicon', file); const result = await $fetch<{ url: string }>('/api/admin/favicon', { method: 'POST', body: fd }); form.siteFavicon = result.url; + // La route commite immédiatement, donc c'est fait : le dire tout de suite + // plutôt que de laisser croire que ça attend un Enregistrer qui ne + // l'enverra jamais. + snapshot.value.siteFavicon = result.url; + notifications.success(t('admin.branding.faviconSaved')); } catch (err) { console.error('Failed to upload favicon:', err); notifications.error(t('admin.branding.uploadFailed')); @@ -821,6 +834,40 @@ async function uploadFavicon(file: File) { } } +/** + * Retirer la favicone — pour de vrai. + * + * Le bouton faisait `form.siteFavicon = null`, un champ local qu'aucune requête + * n'envoyait : la barre annonçait une modification, l'enregistrement disait + * « Enregistré », et la favicone était toujours servie. Comme le téléversement, + * ce geste est commité tout de suite par sa propre route ; il n'attend pas la + * barre d'enregistrement, et l'écran ne prétend plus le contraire. + */ +const removingFavicon = ref(false); +async function removeFavicon() { + if (removingFavicon.value) return; + // Le fichier est supprimé du stockage : il faudra le renvoyer. + const ok = await confirm({ + title: t('admin.branding.confirmRemoveFavicon.title'), + message: t('admin.branding.confirmRemoveFavicon.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; + removingFavicon.value = true; + try { + await $fetch('/api/admin/favicon', { method: 'DELETE' }); + form.siteFavicon = null; + snapshot.value.siteFavicon = null; + notifications.success(t('admin.branding.faviconRemoved')); + } catch (err: unknown) { + const e = err as { data?: { message?: string } }; + notifications.error(e?.data?.message || t('common.deleteFailed')); + } finally { + removingFavicon.value = false; + } +} + // ── Save ──────────────────────────────────────────────────── async function saveAll() { if (saving.value || !dirty.value) return; @@ -969,7 +1016,7 @@ async function discard() { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .preview-eyebrow-rule { display: inline-block; @@ -1012,7 +1059,7 @@ async function discard() { color: rgb(var(--fg-muted)); } .sample-tag-arrow { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-weight: 700; } .sample-surface { @@ -1208,7 +1255,7 @@ async function discard() { font-weight: 700; letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1290,7 +1337,7 @@ async function discard() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -1360,6 +1407,16 @@ async function discard() { border-color: rgb(var(--accent-warm) / 0.55); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input--mono { font-family: var(--font-mono); } @@ -1485,7 +1542,7 @@ async function discard() { .segment:hover { color: rgb(var(--fg-strong)); } .segment--active { background: rgb(var(--bg-base)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); box-shadow: inset 0 0 0 1px rgb(var(--accent-warm) / 0.4); } @@ -1502,7 +1559,7 @@ async function discard() { border-radius: var(--radius-sm); background: rgb(var(--bg-inset)); font-size: 1.2rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); flex-shrink: 0; } @@ -1543,7 +1600,7 @@ async function discard() { border-color: rgb(var(--accent-warm) / 0.4); } .quick-icon--active { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm)); background: rgb(var(--accent-warm) / 0.08); } @@ -1631,7 +1688,7 @@ async function discard() { .dropzone--over { border-color: rgb(var(--accent-warm)); background: rgb(var(--accent-warm) / 0.06); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .dropzone--compact { padding: 1.2rem 1rem; @@ -1690,7 +1747,7 @@ async function discard() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-base)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.35rem 0.6rem; @@ -1741,7 +1798,7 @@ async function discard() { font-weight: 700; letter-spacing: calc(0.1em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .savebar-enter-active, .savebar-leave-active { @@ -1755,7 +1812,18 @@ async function discard() { } /* ── Buttons ─────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1771,18 +1839,18 @@ async function discard() { font-family: inherit; white-space: nowrap; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--primary { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } diff --git a/apps/web/app/components/admin/Categories.vue b/apps/web/app/components/admin/Categories.vue index baa3a307..c244225d 100644 --- a/apps/web/app/components/admin/Categories.vue +++ b/apps/web/app/components/admin/Categories.vue @@ -182,6 +182,18 @@ <Icon name="ph:tree-structure-bold" /> {{ $t('admin.categories.row.subCount', { n: category.subcategories.length }) }} </span> + <!-- Ce que la catégorie contient. La route DELETE refuse une + catégorie qui a des torrents ; sans ce nombre, l'opérateur + l'apprenait par une 400 après avoir confirmé une boîte de + dialogue qui lui promettait le contraire. --> + <span + v-if="category.torrentCount !== undefined" + class="entry-flag entry-flag--stock" + :class="{ 'entry-flag--empty': !category.torrentCount }" + > + <Icon name="ph:file-zip-bold" /> + {{ $t('admin.categories.row.stock', { n: category.torrentCount }, category.torrentCount) }} + </span> </div> </div> @@ -265,6 +277,14 @@ <Icon name="ph:eye-slash-fill" /> {{ $t('admin.categories.row.adultBadge') }} </span> + <span + v-if="sub.torrentCount !== undefined" + class="entry-flag entry-flag--stock" + :class="{ 'entry-flag--empty': !sub.torrentCount }" + > + <Icon name="ph:file-zip-bold" /> + {{ $t('admin.categories.row.stock', { n: sub.torrentCount }, sub.torrentCount) }} + </span> </div> </div> <div class="entry-actions"> @@ -489,6 +509,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.categories.adult.title')" :aria-checked="form.isAdult" class="ed-toggle" :class="{ 'ed-toggle--on': form.isAdult }" @@ -560,6 +581,8 @@ interface Category { type?: 'movie' | 'tv' | 'game' | 'book' | null; icon?: string | null; createdAt: string; + /** Seulement avec `?withCounts=true`, donc seulement côté admin. */ + torrentCount?: number; subcategories?: Category[]; } @@ -609,7 +632,7 @@ const TYPE_OPTIONS = computed<Array<{ // callers (admin/mod) so we can keep the public path filtered. const { data: categories, refresh } = await useFetch<Category[]>( '/api/categories', - { query: { includeAdult: 'true' } } + { query: { includeAdult: 'true', withCounts: 'true' } } ); const notifications = useNotificationStore(); const confirm = useConfirm(); @@ -864,7 +887,7 @@ const deletingIds = ref(new Set<string>()); async function deleteCategory(id: string) { if (deletingIds.value.has(id)) return; - let target: { name: string } | undefined; + let target: Category | undefined; for (const cat of categories.value || []) { if (cat.id === id) { target = cat; @@ -875,6 +898,32 @@ async function deleteCategory(id: string) { } deletingIds.value.add(id); try { + // Les deux refus de la route, dits avant le clic plutôt qu'après. + // + // La boîte annonçait « les torrents qui s'y trouvent deviendront non + // classés » ; la route répond 400 « Cannot delete category with torrents » + // et ne déclasse rien. L'opérateur confirmait une suppression qui + // n'arrivait jamais, et lisait le refus en anglais dans un toast. + if (target?.subcategories?.length) { + notifications.error( + t( + 'admin.categories.deleteConfirm.blockedSubs', + { n: target.subcategories.length }, + target.subcategories.length, + ), + ); + return; + } + if (target?.torrentCount) { + notifications.error( + t( + 'admin.categories.deleteConfirm.blockedTorrents', + { n: target.torrentCount }, + target.torrentCount, + ), + ); + return; + } const ok = await confirm({ title: t('admin.categories.deleteConfirm.title'), message: target @@ -961,7 +1010,7 @@ async function seedCategories() { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .atlas-eyebrow-rule { display: inline-block; @@ -1120,7 +1169,7 @@ async function seedCategories() { font-weight: 700; letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ── Empty + no-results states ──────────────────────────── */ @@ -1143,7 +1192,7 @@ async function seedCategories() { border-radius: 50%; background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.8rem; } .atlas-empty-title { @@ -1340,7 +1389,7 @@ async function seedCategories() { font-weight: 800; letter-spacing: calc(-0.01em * var(--tracking-scale)); font-variant-numeric: tabular-nums; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); line-height: 1; } .entry-code--sub .entry-code-num { @@ -1405,12 +1454,12 @@ async function seedCategories() { } .entry-flag svg { font-size: 0.85rem; } .entry-flag--adult { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.06); } .entry-flag--children { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); background: rgb(var(--accent-warm) / 0.06); } @@ -1441,7 +1490,7 @@ async function seedCategories() { transform: rotate(180deg); } .entry-toggle:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); } .entry-act { @@ -1461,11 +1510,11 @@ async function seedCategories() { color: rgb(var(--fg-strong)); } .entry-act--add:hover { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.08); } .entry-act--delete:hover { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.08); } @@ -1583,7 +1632,7 @@ async function seedCategories() { font-weight: 800; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .ed-eyebrow-rule { display: inline-block; @@ -1655,7 +1704,7 @@ async function seedCategories() { font-weight: 800; letter-spacing: calc(-0.01em * var(--tracking-scale)); font-variant-numeric: tabular-nums; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); line-height: 1; } .ed-card-code-dash { @@ -1707,7 +1756,7 @@ async function seedCategories() { font-weight: 700; letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ed-card-flag svg { font-size: 0.85rem; } @@ -1758,7 +1807,7 @@ async function seedCategories() { font-size: 0.625rem; font-weight: 800; letter-spacing: calc(0.18em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.22rem 0.45rem; @@ -1790,6 +1839,16 @@ async function seedCategories() { border-color: rgb(var(--accent-warm) / 0.55); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.ed-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .ed-input--mono { font-family: var(--font-mono); letter-spacing: calc(0.04em * var(--tracking-scale)); @@ -1833,7 +1892,7 @@ async function seedCategories() { border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-sm); padding: 0.05rem 0.35rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); letter-spacing: calc(0.02em * var(--tracking-scale)); } @@ -2026,4 +2085,11 @@ async function seedCategories() { @keyframes cat-spin { to { transform: rotate(360deg); } } +.entry-flag--stock { + color: rgb(var(--fg-muted)); + border-color: rgb(var(--line-default)); +} +.entry-flag--stock.entry-flag--empty { + color: rgb(var(--fg-subtle)); +} </style> diff --git a/apps/web/app/components/admin/Endpoints.vue b/apps/web/app/components/admin/Endpoints.vue index 9dd3384c..6ad82bd6 100644 --- a/apps/web/app/components/admin/Endpoints.vue +++ b/apps/web/app/components/admin/Endpoints.vue @@ -203,12 +203,12 @@ onBeforeUnmount(() => { transition: all var(--dur-2) ease; } .endp-row-copy:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); background: rgb(var(--accent-warm) / 0.06); } .endp-row-copy--copied { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.5); background: rgba(108, 209, 97, 0.08); } diff --git a/apps/web/app/components/admin/FederationHealth.vue b/apps/web/app/components/admin/FederationHealth.vue index ae2e5181..ba02cdb1 100644 --- a/apps/web/app/components/admin/FederationHealth.vue +++ b/apps/web/app/components/admin/FederationHealth.vue @@ -221,6 +221,7 @@ interface Health { } const { t, locale } = useI18n(); +const notifications = useNotificationStore(); const { data, pending, refresh } = await useFetch<Health>( '/api/admin/federation/health', ); @@ -234,10 +235,15 @@ async function recover(peerId: string): Promise<void> { method: 'POST', body: { mode: 'resync' }, }); - await refresh(); - } catch { - /* the health card surfaces the peer's error on the next refresh */ + } catch (err: unknown) { + // Le commentaire disait « la carte de santé remontera l'erreur au prochain + // rafraîchissement » — mais `refresh()` était DANS le `try`, après l'appel + // qui échoue : il n'était jamais atteint. L'opérateur cliquait, le spinner + // tournait, s'arrêtait, et rien n'avait changé ni n'avait été dit. + const e = err as { data?: { message?: string } }; + notifications.error(e?.data?.message || t('common.actionFailed')); } finally { + await refresh(); recovering.value = null; } } @@ -348,6 +354,15 @@ function hostOf(url: string): string { </script> <style scoped> +/* `.fed-hint` ne vivait que dans le `<style scoped>` de `admin/federation.vue`, + la page qui rend ce composant — un style scopé n'atteint que la RACINE d'un + enfant, pas ses descendants. La phrase d'aide rendait donc du texte courant. */ +.fed-hint { + font-size: 0.6875rem; + line-height: 1.5; + color: rgb(var(--fg-subtle)); +} + .fh-body { display: flex; flex-direction: column; @@ -505,10 +520,10 @@ function hostOf(url: string): string { so an instance carrying ten times what it publishes looks like it — no figure to divide in your head. */ .fh-store { - border: 1px solid rgb(var(--border) / 0.7); + border: 1px solid rgb(var(--line-default) / 0.7); border-radius: var(--radius-lg); padding: 0.7rem 0.8rem; - background: rgb(var(--bg-subtle) / 0.35); + background: rgb(var(--bg-inset) / 0.35); } .fh-store-bar { display: flex; @@ -516,7 +531,7 @@ function hostOf(url: string): string { gap: 2px; border-radius: var(--radius-xs); overflow: hidden; - background: rgb(var(--border) / 0.4); + background: rgb(var(--line-default) / 0.4); } .fh-seg { min-width: 2px; @@ -553,7 +568,7 @@ function hostOf(url: string): string { font-family: var(--font-mono); font-size: 0.8rem; font-weight: 600; - color: rgb(var(--fg)); + color: rgb(var(--fg-default)); } .fh-store-l { font-size: 0.7rem; @@ -567,7 +582,7 @@ function hostOf(url: string): string { gap: 0.3rem; margin-top: 0.55rem; padding-top: 0.55rem; - border-top: 1px dashed rgb(var(--border) / 0.6); + border-top: 1px dashed rgb(var(--line-default) / 0.6); } .fh-kind { display: inline-flex; @@ -575,12 +590,12 @@ function hostOf(url: string): string { gap: 0.3rem; padding: 0.1rem 0.4rem; border-radius: var(--radius-sm); - background: rgb(var(--bg-subtle) / 0.8); + background: rgb(var(--bg-inset) / 0.8); font-family: var(--font-mono); font-size: 0.68rem; } .fh-kind-k { color: rgb(var(--fg-muted)); } -.fh-kind-n { color: rgb(var(--fg)); font-weight: 600; } +.fh-kind-n { color: rgb(var(--fg-default)); font-weight: 600; } .fh-peer-sep { color: rgb(var(--fg-subtle)); } .fh-recover { @@ -588,14 +603,14 @@ function hostOf(url: string): string { align-items: center; gap: 0.3rem; padding: 0.2rem 0.5rem; - border: 1px solid rgb(var(--border) / 0.8); + border: 1px solid rgb(var(--line-default) / 0.8); border-radius: var(--radius-md); - background: rgb(var(--bg-subtle) / 0.5); + background: rgb(var(--bg-inset) / 0.5); color: rgb(var(--fg-muted)); font-size: 0.72rem; cursor: pointer; } -.fh-recover:hover:not(:disabled) { color: rgb(var(--fg)); border-color: rgb(var(--accent) / 0.6); } +.fh-recover:hover:not(:disabled) { color: rgb(var(--fg-default)); border-color: rgb(var(--accent) / 0.6); } .fh-recover:disabled { opacity: 0.5; cursor: default; } .fh-peer-mirror { margin-left: auto; diff --git a/apps/web/app/components/admin/FreeleechPool.vue b/apps/web/app/components/admin/FreeleechPool.vue index bb5ccb13..5a4b80d3 100644 --- a/apps/web/app/components/admin/FreeleechPool.vue +++ b/apps/web/app/components/admin/FreeleechPool.vue @@ -181,7 +181,7 @@ <!-- Reset action — bottom right, danger-outline style --> <button type="button" - class="btn btn--danger reservoir-reset" + class="cbtn cbtn--danger reservoir-reset" :disabled="resetting" @click="confirmReset" > @@ -306,7 +306,7 @@ <div class="actions"> <button type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="savingConfig" @click="saveConfig" > @@ -367,7 +367,7 @@ </div> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validOneoff" @click="addOneoff" > @@ -411,7 +411,7 @@ <p class="form-hint">{{ $t('admin.freeleechPool.fields.utcHint') }}</p> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validWeekly" @click="addWeekly" > @@ -455,7 +455,7 @@ <p class="form-hint">{{ $t('admin.freeleechPool.fields.utcHint') }}</p> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validMonthly" @click="addMonthly" > @@ -499,7 +499,7 @@ <p class="form-hint">{{ $t('admin.freeleechPool.fields.yearlyHint') }}</p> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validYearly" @click="addYearly" > @@ -1825,7 +1825,18 @@ select.input option { } /* Button system. */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; justify-content: center; @@ -1841,40 +1852,40 @@ select.input option { cursor: pointer; transition: all var(--dur-3) ease; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--fg-faint)); background: rgb(var(--bg-hover)); } -.btn:disabled { +.cbtn:disabled { opacity: 0.42; cursor: not-allowed; } -.btn--primary { +.cbtn--primary { background: var(--gold); border-color: var(--gold); color: rgb(var(--bg-base)); font-weight: 700; } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: var(--gold-bright); border-color: var(--gold-bright); box-shadow: 0 6px 22px -8px rgb(var(--accent-warm) / 0.55); transform: translateY(-1px); } -.btn--primary:active:not(:disabled) { +.cbtn--primary:active:not(:disabled) { transform: translateY(0); } -.btn--danger { +.cbtn--danger { background: transparent; border-color: rgba(239, 68, 68, 0.4); color: var(--alert); } -.btn--danger:hover:not(:disabled) { +.cbtn--danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.08); border-color: var(--alert); box-shadow: 0 0 20px rgba(239, 68, 68, 0.2); } -.btn--sm { +.cbtn--sm { padding: 0.55rem 0.95rem; font-size: 0.82rem; } diff --git a/apps/web/app/components/admin/Invites.vue b/apps/web/app/components/admin/Invites.vue index 8963b87b..992c2bbf 100644 --- a/apps/web/app/components/admin/Invites.vue +++ b/apps/web/app/components/admin/Invites.vue @@ -321,12 +321,12 @@ </div> <template #footer> <div class="confirm-footer"> - <button type="button" class="btn btn--ghost" @click="confirmOpen = false"> + <button type="button" class="cbtn cbtn--ghost" @click="confirmOpen = false"> {{ $t('admin.invites.confirm.keep') }} </button> <button type="button" - class="btn btn--danger" + class="cbtn cbtn--danger" :disabled="pendingDelete !== null" @click="confirmDelete" > @@ -655,7 +655,7 @@ async function confirmDelete() { max-width: 64ch; } .adm-intro-link { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); text-decoration: underline; text-decoration-color: rgb(var(--accent-warm) / 0.5); text-underline-offset: 3px; @@ -791,7 +791,7 @@ async function confirmDelete() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -888,7 +888,7 @@ async function confirmDelete() { padding: 0.2rem 0.5rem; background: rgb(var(--accent-warm) / 0.12); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-radius: var(--radius-pill); font-size: 0.6875rem; font-weight: 600; @@ -900,7 +900,7 @@ async function confirmDelete() { border: 0; padding: 0; background: transparent; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); cursor: pointer; opacity: 0.75; transition: opacity var(--dur-2) ease; @@ -976,6 +976,16 @@ async function confirmDelete() { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.grant-count-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .grant-btn { display: inline-flex; @@ -1085,7 +1095,7 @@ async function confirmDelete() { .ledger-segment:hover { color: rgb(var(--fg-strong)); } .ledger-segment--active { background: rgb(var(--bg-base)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); box-shadow: inset 0 0 0 1px rgb(var(--accent-warm) / 0.4); } .ledger-segment-count { @@ -1098,7 +1108,7 @@ async function confirmDelete() { letter-spacing: calc(0.04em * var(--tracking-scale)); } .ledger-segment--active .ledger-segment-count { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.1); } @@ -1178,7 +1188,7 @@ async function confirmDelete() { white-space: nowrap; } .entry-status--active { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); } .entry-status--used { @@ -1212,7 +1222,7 @@ async function confirmDelete() { .entry-flow-user { font-size: 0.82rem; font-weight: 600; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); text-decoration: none; border-bottom: 1px dashed rgb(var(--accent-warm) / 0.4); transition: border-color var(--dur-2) ease; @@ -1365,7 +1375,7 @@ async function confirmDelete() { } .pager-btn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pager-btn:disabled { opacity: 0.4; cursor: not-allowed; } .pager-pos { @@ -1412,7 +1422,18 @@ async function confirmDelete() { } /* ── Buttons ─────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1427,18 +1448,18 @@ async function confirmDelete() { transition: all var(--dur-2) ease; font-family: inherit; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--danger { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--danger { background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.5); color: rgb(var(--danger)); } -.btn--danger:hover:not(:disabled) { +.cbtn--danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.18); border-color: rgb(var(--danger)); } diff --git a/apps/web/app/components/admin/Reports.vue b/apps/web/app/components/admin/Reports.vue index 0d4a00f5..f11031c3 100644 --- a/apps/web/app/components/admin/Reports.vue +++ b/apps/web/app/components/admin/Reports.vue @@ -336,12 +336,14 @@ <button type="button" class="act act--accept" - :disabled="busy === report.id" + :disabled="busy === report.id || !banPanel.duration" @click="confirmBanPanel(report)" > <Icon name="ph:check-bold" /> <span>{{ - banPanel.duration === 'none' + !banPanel.duration + ? $t('admin.reports.banPanel.submitPick') + : banPanel.duration === 'none' ? $t('admin.reports.banPanel.submitResolve') : $t('admin.reports.banPanel.submitBan') }}</span> @@ -429,7 +431,9 @@ interface ReportsResponse { pagination: { page: number; limit: number; total: number; pages: number }; } -type BanDuration = 'none' | '1d' | '7d' | '1m' | '1y' | 'permanent'; +/** `''` = rien de choisi. Un état à part entière : c'est celui dans lequel le + * panneau s'ouvre, et celui dans lequel la soumission reste bloquée. */ +type BanDuration = '' | 'none' | '1d' | '7d' | '1m' | '1y' | 'permanent'; interface BanPanelState { reportId: string; @@ -702,11 +706,19 @@ function onAcceptClick(report: Report) { if (report.targetType === 'user') { banPanel.value = { reportId: report.id, - // Default to "permanent" so a hurried moderator who hits - // Submit without thinking still issues the harshest - // sanction — accepting a user-targeted report without any - // ban would silently let an offender walk. - duration: 'permanent', + // Rien de présélectionné. + // + // C'était `'permanent'`, avec le raisonnement écrit : « un modérateur + // pressé qui valide sans réfléchir applique quand même la sanction la + // plus lourde ». Le raisonnement confond « il n'a pas choisi » avec + // « aucune sanction » ; la réponse correcte à « il n'a pas choisi » est + // « alors n'applique rien tant qu'il n'a pas choisi ». Deux clics au même + // endroit — Accepter, puis le bouton d'à côté — produisaient un + // bannissement définitif, sur une page qu'on parcourt en file. + // + // Le produit sait déjà faire ça : `mod/anti-cheat.vue` désactive sa + // soumission tant qu'aucun verdict n'est choisi. + duration: '', reason: report.reason, }; return; @@ -765,7 +777,7 @@ function confirmBanPanel(report: Report) { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .archive-eyebrow-rule { display: inline-block; @@ -793,7 +805,7 @@ function confirmBanPanel(report: Report) { width: 56px; height: 56px; font-size: 1.75rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.35); border-radius: var(--radius-md); @@ -843,17 +855,17 @@ function confirmBanPanel(report: Report) { .filter--active.filter--all { background: rgb(var(--accent-warm) / 0.1); border-color: rgb(var(--accent-warm) / 0.55); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .filter--active.filter--pending { background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.55); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .filter--active.filter--resolved { background: rgba(108, 209, 97, 0.1); border-color: rgba(108, 209, 97, 0.55); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .filter--active.filter--dismissed { background: rgb(var(--bg-base)); @@ -945,7 +957,7 @@ function confirmBanPanel(report: Report) { .dossier-case-num { font-size: 0.6875rem; font-weight: 700; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); letter-spacing: calc(0.05em * var(--tracking-scale)); background: transparent; } @@ -981,13 +993,13 @@ function confirmBanPanel(report: Report) { text-transform: uppercase; } .dossier-stamp--pending { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.1); border: 1px solid rgba(244, 63, 94, 0.55); box-shadow: inset 0 0 0 1px rgba(244, 63, 94, 0.18); } .dossier-stamp--resolved { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.1); border: 1px solid rgba(108, 209, 97, 0.55); } @@ -1125,7 +1137,7 @@ function confirmBanPanel(report: Report) { } .target-tag-icon { font-size: 0.85rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .target-link { display: inline-flex; @@ -1137,7 +1149,7 @@ function confirmBanPanel(report: Report) { border-radius: var(--radius-sm); font-size: 0.84rem; font-weight: 600; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); text-decoration: none; transition: all var(--dur-2) ease; max-width: 100%; @@ -1305,7 +1317,7 @@ function confirmBanPanel(report: Report) { font-weight: 800; letter-spacing: calc(0.22em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ban-panel-icon { font-size: 0.95rem; } .ban-panel-title { line-height: 1; } @@ -1342,7 +1354,7 @@ function confirmBanPanel(report: Report) { .ban-chip-icon { font-size: 0.85rem; flex-shrink: 0; } .ban-chip.is-selected { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.55); box-shadow: inset 0 0 0 1px rgba(244, 63, 94, 0.25); @@ -1376,6 +1388,16 @@ function confirmBanPanel(report: Report) { border-color: rgba(244, 63, 94, 0.55); box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.ban-panel-reason:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .ban-panel-reason::placeholder { color: rgb(var(--fg-faint)); } .ban-panel-hint { @@ -1421,7 +1443,7 @@ function confirmBanPanel(report: Report) { border-radius: 50%; background: rgba(108, 209, 97, 0.08); border: 2px solid rgba(108, 209, 97, 0.5); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-size: 1.6rem; transform: rotate(-6deg); } @@ -1469,7 +1491,7 @@ function confirmBanPanel(report: Report) { } .pager-btn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pager-btn:disabled { opacity: 0.4; diff --git a/apps/web/app/components/admin/Roles.vue b/apps/web/app/components/admin/Roles.vue index 2d1a3854..aff13344 100644 --- a/apps/web/app/components/admin/Roles.vue +++ b/apps/web/app/components/admin/Roles.vue @@ -44,6 +44,17 @@ <div v-if="loading" class="rc-loading"> <Icon name="ph:circle-notch" class="animate-spin text-text-muted" /> </div> + <!-- L'échec AVANT le vide : sans cette branche, un GET refusé affichait + « créez votre premier rôle » à un opérateur dont l'instance en a + peut-être douze. --> + <div v-else-if="loadFailed" class="rc-empty"> + <Icon name="ph:warning-circle" class="rc-empty__icon" /> + <p class="rc-empty__title">{{ $t('common.loadFailed') }}</p> + <p class="rc-empty__sub">{{ $t('common.loadFailedHint') }}</p> + <button type="button" class="btn btn-secondary btn-sm" @click="loadRoles"> + {{ $t('common.retry') }} + </button> + </div> <div v-else-if="roles.length === 0" class="rc-empty" @@ -331,6 +342,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.roles.modal.showAsBadgeTitle')" :aria-checked="form.showAsBadge" class="toggle" :class="{ 'toggle--on': form.showAsBadge }" @@ -356,6 +368,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.roles.modal.skipModerationTitle')" :aria-checked="form.canUploadWithoutModeration" class="toggle" :class="{ 'toggle--on': form.canUploadWithoutModeration }" @@ -639,6 +652,8 @@ const confirm = useConfirm(); const roles = ref<Role[]>([]); const loading = ref(true); +/** Vrai quand le chargement a échoué : l'état d'accueil ne doit pas s'afficher. */ +const loadFailed = ref(false); async function loadRoles() { loading.value = true; @@ -647,7 +662,12 @@ async function loadRoles() { // Nuxt's route table and the comparison blew the instantiation depth. roles.value = await $fetch<Role[]>('/api/admin/roles'); } catch (err) { + // Un `console.error` laissait `roles` à `[]`, et la vue bascule alors sur + // l'état d'ACCUEIL — « créez votre premier rôle ». Agir dessus, c'est créer + // un doublon de rôles qui existent déjà. L'échec doit se distinguer du vide. console.error('[Roles] load failed:', err); + loadFailed.value = true; + notifications.error(t('common.loadFailed')); } finally { loading.value = false; } @@ -869,7 +889,7 @@ async function recompute() { } .rc-title-accent { font-weight: 400; - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-style: italic; letter-spacing: 0; font-size: 0.6em; @@ -1052,7 +1072,7 @@ async function recompute() { font-weight: 700; } .role-card__mode--auto { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); } @@ -1068,10 +1088,10 @@ async function recompute() { font-weight: 700; } .role-card__perm--privileged { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-card__perm--badge { - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-card__actions { display: flex; @@ -1162,7 +1182,7 @@ async function recompute() { .cond-pill__op { font-size: 0.7188rem; font-weight: 900; - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .cond-pill__val { color: rgb(var(--fg-strong)); diff --git a/apps/web/app/components/admin/Shop.vue b/apps/web/app/components/admin/Shop.vue index f3a2a8a3..a792bc50 100644 --- a/apps/web/app/components/admin/Shop.vue +++ b/apps/web/app/components/admin/Shop.vue @@ -370,6 +370,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.shop.fields.enabled')" :aria-checked="form.enabled" class="relative w-9 h-5 rounded-full transition-colors flex-shrink-0" :class="form.enabled ? 'bg-accent' : 'bg-fg-default/15'" @@ -512,18 +513,6 @@ function effectLabel(item: ShopItemRow): string { return ''; } -function formatSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; - let i = 0; - let v = bytes; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1).replace(/\.0$/, '')} ${units[i]}`; -} - function formatNumber(n: number): string { return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(n); } diff --git a/apps/web/app/components/admin/System.vue b/apps/web/app/components/admin/System.vue index 03c5f128..e27189c4 100644 --- a/apps/web/app/components/admin/System.vue +++ b/apps/web/app/components/admin/System.vue @@ -114,11 +114,11 @@ <!-- Update check failed (e.g. non-GitHub repo, 404, network) ─── --> <div v-else-if="versionInfo?.checkError" - class="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg space-y-2" + class="p-4 bg-warning/10 border border-warning/30 rounded-lg space-y-2" > <div class="flex items-center gap-2"> - <Icon name="ph:warning" class="text-yellow-400" /> - <p class="text-sm font-medium text-yellow-400"> + <Icon name="ph:warning" class="text-warning" /> + <p class="text-sm font-medium text-warning"> {{ $t('admin.system.checkUnavailable') }} </p> </div> @@ -215,10 +215,10 @@ server payload so admins can edit the wording in one place. --> <div v-if="updateNotes.length > 0" - class="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg" + class="flex items-start gap-2 p-3 bg-warning/10 border border-warning/30 rounded-lg" > - <Icon name="ph:warning" class="text-yellow-400 mt-0.5" /> - <ul class="text-xs text-yellow-400 space-y-1"> + <Icon name="ph:warning" class="text-warning mt-0.5" /> + <ul class="text-xs text-warning space-y-1"> <li v-for="note in updateNotes" :key="note">{{ note }}</li> </ul> </div> diff --git a/apps/web/app/components/admin/Tags.vue b/apps/web/app/components/admin/Tags.vue index 9604b216..79e9784a 100644 --- a/apps/web/app/components/admin/Tags.vue +++ b/apps/web/app/components/admin/Tags.vue @@ -524,7 +524,7 @@ function hexToRgba(hex: string, alpha: number): string { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -616,6 +616,16 @@ function hexToRgba(hex: string, alpha: number): string { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.forge-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .forge-input--mono { font-family: var(--font-mono); font-size: 0.82rem; @@ -909,7 +919,7 @@ function hexToRgba(hex: string, alpha: number): string { } .palette-empty-clear:hover { border-color: rgb(var(--accent-warm) / 0.5); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .spin { diff --git a/apps/web/app/components/admin/ThemeEditor.vue b/apps/web/app/components/admin/ThemeEditor.vue index 69a6976e..180b41bf 100644 --- a/apps/web/app/components/admin/ThemeEditor.vue +++ b/apps/web/app/components/admin/ThemeEditor.vue @@ -501,6 +501,7 @@ const props = defineProps<{ }>(); const { t, te } = useI18n(); +const confirm = useConfirm(); const router = useRouter(); const { messageOf, reloadThemeStylesheet } = useThemeAdmin(); @@ -566,6 +567,13 @@ async function uploadFont() { } async function removeFont(id: string) { + const ok = await confirm({ + title: t('admin.themes.confirmRemoveFont.title'), + message: t('admin.themes.confirmRemoveFont.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; fontError.value = ''; try { await $fetch(`/api/admin/fonts/${id}`, { method: 'DELETE' }); diff --git a/apps/web/app/components/admin/Themes.vue b/apps/web/app/components/admin/Themes.vue index eabca575..a2e8ab5a 100644 --- a/apps/web/app/components/admin/Themes.vue +++ b/apps/web/app/components/admin/Themes.vue @@ -208,6 +208,7 @@ import { } from '~/composables/useThemeAdmin'; const { t } = useI18n(); +const confirm = useConfirm(); const router = useRouter(); const { messageOf, reloadThemeStylesheet } = useThemeAdmin(); @@ -244,9 +245,31 @@ function roleNames(ids: string[] | null): string { } // ── Site settings ──────────────────────────────────────────────────── const settings = ref({ themeDefault: 'dark', systemLight: 'light', systemDark: 'dark' }); + +/** Vrai dès que les trois listes déroulantes s'écartent de ce que le serveur a + * renvoyé — donc dès que l'opérateur a touché à l'une d'elles. */ +const settingsDirty = computed(() => { + const src = data.value.settings; + if (!src) return false; + return ( + settings.value.themeDefault !== src.themeDefault || + settings.value.systemLight !== src.systemLight || + settings.value.systemDark !== src.systemDark + ); +}); + watch( () => data.value.settings, - (s) => { if (s) settings.value = { ...s }; }, + (s) => { + // Toute action sur la liste des thèmes (activer, supprimer, importer) + // appelle `refresh()`, ce qui rejouait cette recopie et remettait les trois + // listes déroulantes à la valeur enregistrée. Un opérateur qui choisissait + // son thème par défaut PUIS coupait un thème avant d'enregistrer voyait son + // choix disparaître — et rien ne le disait, les listes se contentant de + // revenir en arrière. + if (!s || settingsDirty.value) return; + settings.value = { ...s }; + }, { immediate: true, deep: true }, ); const sameSystemHalves = computed( @@ -273,6 +296,19 @@ async function saveSettings() { async function toggleEnabled(row: ThemeRow) { + // Couper un thème le retire de TOUS les membres qui l'avaient choisi : ils + // basculent sur le thème par défaut à leur prochaine page, sans rien avoir + // demandé. Un clic sur une icône œil de 28 px le faisait sans un mot, à + // côté d'une suppression qui, elle, demandait confirmation. + if (row.enabled) { + const ok = await confirm({ + title: t('admin.themes.disable'), + message: t('admin.themes.confirmDisable', { name: row.name }), + confirmText: t('admin.themes.disable'), + destructive: true, + }); + if (!ok) return; + } try { await $fetch(`/api/admin/themes/${row.id}`, { method: 'PUT', @@ -305,7 +341,16 @@ function duplicate(slug: string, name: string) { } async function remove(row: ThemeRow) { - if (!confirm(t('admin.themes.confirmDelete', { name: row.name }))) return; + // Le `confirm()` du navigateur : hors thème, non traduit dans ses boutons, + // et bloquant. Le site a son propre dialogue depuis longtemps ; c'était le + // dernier appel natif de la console. + const ok = await confirm({ + title: t('admin.themes.delete'), + message: t('admin.themes.confirmDelete', { name: row.name }), + confirmText: t('admin.themes.delete'), + destructive: true, + }); + if (!ok) return; try { await $fetch(`/api/admin/themes/${row.id}`, { method: 'DELETE' }); await refresh(); diff --git a/apps/web/app/components/admin/TorznabBlacklist.vue b/apps/web/app/components/admin/TorznabBlacklist.vue index d5fdf5b9..4b7775f0 100644 --- a/apps/web/app/components/admin/TorznabBlacklist.vue +++ b/apps/web/app/components/admin/TorznabBlacklist.vue @@ -61,7 +61,7 @@ </div> <div class="text-right"> <span class="text-[10px] text-text-muted">{{ $t('admin.torznab.blacklist.expiresIn') }}</span> - <p class="text-xs font-mono text-yellow-400"> + <p class="text-xs font-mono text-warning"> {{ formatTimeRemaining(entry.expiresAt) }} </p> </div> @@ -127,6 +127,7 @@ <script setup lang="ts"> const { t } = useI18n(); +const notifications = useNotificationStore(); interface BlacklistEntry { ip: string; @@ -158,14 +159,21 @@ const unblocking = ref<string | null>(null); async function unblockUser(blockId: string) { unblocking.value = blockId; try { - await fetch('/api/admin/torznab/unblock', { + // `$fetch`, pas le `fetch` natif : ce dernier ne rejette PAS sur un statut + // 4xx ou 5xx. Le `catch` ne se déclenchait donc jamais et `refresh()` + // s'exécutait quoi qu'il arrive — un 403 (privilège perdu), un 404 (blocage + // déjà expiré) ou un 500 ressemblaient tous à un succès : le témoin + // s'arrêtait, la liste se rafraîchissait, la ligne était toujours là, et + // aucune erreur n'apparaissait. Tous les autres appels de ce fichier + // passent par `useFetch` / `$fetch`, qui lèvent. + await $fetch('/api/admin/torznab/unblock', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ blockId }), + body: { blockId }, }); await refresh(); - } catch (error) { - console.error('Failed to unblock user:', error); + } catch (error: unknown) { + const e = error as { data?: { message?: string }; message?: string }; + notifications.error(e?.data?.message || e?.message || t('common.actionFailed')); } finally { unblocking.value = null; } diff --git a/apps/web/app/components/admin/TorznabConfig.vue b/apps/web/app/components/admin/TorznabConfig.vue index 1adec2ff..16204123 100644 --- a/apps/web/app/components/admin/TorznabConfig.vue +++ b/apps/web/app/components/admin/TorznabConfig.vue @@ -16,7 +16,7 @@ :class=" config?.enabled ? 'bg-success/20 text-success' - : 'bg-red-500/20 text-red-400' + : 'bg-error/20 text-error' " > {{ config?.enabled ? $t('admin.torznab.config.enabled') : $t('admin.torznab.config.disabled') }} diff --git a/apps/web/app/components/admin/TorznabLogTable.vue b/apps/web/app/components/admin/TorznabLogTable.vue index bdbd9062..31e0fc9e 100644 --- a/apps/web/app/components/admin/TorznabLogTable.vue +++ b/apps/web/app/components/admin/TorznabLogTable.vue @@ -51,7 +51,7 @@ v-for="(log, index) in logs" :key="index" class="border-b border-border/50" - :class="log.error && 'bg-red-500/5'" + :class="log.error && 'bg-error/5'" > <td class="py-2 px-2"> <span class="text-xs text-text-muted font-mono"> @@ -84,7 +84,7 @@ <td class="py-2 px-2 text-center"> <span v-if="log.error" - class="text-xs text-red-400" + class="text-xs text-error" :title="log.error" > {{ $t('admin.torznab.logs.table.error') }} @@ -141,26 +141,35 @@ function formatTime(timestamp: number): string { }); } +/* + * La pastille de fonction Torznab. + * + * Les cinq fonctions n'ont pas de sens sémantique — `tvsearch` n'est ni un + * succès ni une erreur — donc la teinte reste catégorielle, sur le FOND, et le + * libellé passe par `text-text-primary`. C'est le motif que `tagBadgeStyle()` + * applique déjà ailleurs. Auparavant l'encre était `text-purple-400` &c. sur un + * fond à 20 % d'opacité : environ 2,5:1 en thème sombre, moins encore en clair. + */ function getFunctionClass(func: string): string { switch (func) { case 'search': - return 'bg-blue-500/20 text-blue-400'; + return 'bg-info/20 text-text-primary'; case 'tvsearch': - return 'bg-purple-500/20 text-purple-400'; + return 'bg-purple-500/20 text-text-primary'; case 'movie': - return 'bg-pink-500/20 text-pink-400'; + return 'bg-pink-500/20 text-text-primary'; case 'caps': - return 'bg-gray-500/20 text-gray-400'; + return 'bg-fg-default/10 text-text-secondary'; case 'download': - return 'bg-green-500/20 text-green-400'; + return 'bg-success/20 text-text-primary'; default: - return 'bg-gray-500/20 text-gray-400'; + return 'bg-fg-default/10 text-text-secondary'; } } function getResponseTimeClass(time: number): string { if (time < 100) return 'text-success'; - if (time < 500) return 'text-yellow-400'; - return 'text-red-400'; + if (time < 500) return 'text-warning'; + return 'text-error'; } </script> diff --git a/apps/web/app/components/admin/TorznabStats.vue b/apps/web/app/components/admin/TorznabStats.vue index f28c0188..434ea729 100644 --- a/apps/web/app/components/admin/TorznabStats.vue +++ b/apps/web/app/components/admin/TorznabStats.vue @@ -111,7 +111,7 @@ </p> <p class="text-2xl font-bold" - :class="stats?.errorsCount ? 'text-red-400' : 'text-text-primary'" + :class="stats?.errorsCount ? 'text-error' : 'text-text-primary'" > {{ formatNumber(stats?.errorsCount || 0) }} </p> diff --git a/apps/web/app/components/admin/TorznabUsers.vue b/apps/web/app/components/admin/TorznabUsers.vue index 376b5d96..9e3fda3c 100644 --- a/apps/web/app/components/admin/TorznabUsers.vue +++ b/apps/web/app/components/admin/TorznabUsers.vue @@ -117,7 +117,7 @@ <td class="py-3 px-2 text-center"> <span v-if="(user.apiStats?.rateLimitHits || 0) > 0" - class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-500/20 text-yellow-400" + class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/20 text-warning" > {{ $t('admin.torznab.users.rateLimitHits', { n: user.apiStats?.rateLimitHits }) }} </span> @@ -143,14 +143,14 @@ </button> <button @click="confirmResetPasskey(user)" - class="p-1.5 text-text-muted hover:text-yellow-400 hover:bg-bg-tertiary rounded transition-colors" + class="p-1.5 text-text-muted hover:text-warning hover:bg-bg-tertiary rounded transition-colors" :title="$t('admin.torznab.users.resetPasskeyTitle')" > <Icon name="ph:key" class="text-sm" /> </button> <button @click="confirmBlockUser(user)" - class="p-1.5 text-text-muted hover:text-red-400 hover:bg-bg-tertiary rounded transition-colors" + class="p-1.5 text-text-muted hover:text-error hover:bg-bg-tertiary rounded transition-colors" :title="$t('admin.torznab.users.blockApiTitle')" > <Icon name="ph:prohibit" class="text-sm" /> @@ -257,8 +257,8 @@ class="px-4 py-2 rounded text-sm font-medium flex items-center gap-2" :class=" confirmAction.variant === 'danger' - ? 'bg-red-500 text-white' - : 'bg-yellow-500 text-black' + ? 'bg-error text-[rgb(var(--danger-fg))]' + : 'bg-warning text-[rgb(var(--warning-fg))]' " > <Icon diff --git a/apps/web/app/components/admin/UploadRules.vue b/apps/web/app/components/admin/UploadRules.vue index 19b04775..271dcb51 100644 --- a/apps/web/app/components/admin/UploadRules.vue +++ b/apps/web/app/components/admin/UploadRules.vue @@ -28,6 +28,15 @@ --> <div class="ur"> <!-- ── Policy snapshot ─────────────────────────────────────── --> + <!-- Un GET en échec affichait la politique la plus permissive possible comme + si elle était en vigueur. Dit, et les commandes rendues inertes : une + politique qu'on ne peut pas lire n'est pas une politique qu'on peut + modifier. --> + <p v-if="rulesFailed" class="rules-fault" role="alert"> + <Icon name="ph:warning-circle" /> + {{ $t('admin.uploadRules.loadFailed') }} + </p> + <section class="snapshot"> <article class="snap snap--gates"> <span class="snap-num tabular-nums"> @@ -384,7 +393,7 @@ <button v-if="dirty" type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" :disabled="saving" @click="discard" > @@ -392,7 +401,7 @@ </button> <button type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!dirty || hasErrors || saving" @click="save" > @@ -435,10 +444,21 @@ const { t } = useI18n(); const notifications = useNotificationStore(); // ── Source data ────────────────────────────────────────────── -const { data: serverRules, refresh: refreshRules } = await useFetch<RulesPayload>( +const { error: rulesError, data: serverRules, refresh: refreshRules } = await useFetch<RulesPayload>( '/api/admin/upload-rules', { default: () => emptyRules() }, ); +/* + * Un échec de chargement n'est pas une politique. + * + * `emptyRules()` rend toutes les portes à `false` ET `staffBypass: true` — la + * politique la plus permissive possible. Sur un GET en échec, le panneau + * l'affichait donc avec aplomb (« 0/6 portes armées · Pas de plafond · + * Contournement staff : Exempté »), `dirty` restait faux, la barre + * d'enregistrement restait cachée, et rien n'indiquait que ce n'était pas la + * politique en vigueur. + */ +const rulesFailed = computed(() => !!rulesError.value); const { data: categories } = await useFetch<Category[]>('/api/categories'); function emptyRules(): RulesPayload { @@ -805,7 +825,7 @@ function discard() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -1024,6 +1044,29 @@ function discard() { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped> +.rules-fault { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.7rem 0.9rem; + border: 1px solid rgb(var(--danger) / 0.4); + border-radius: var(--radius-md); + background: rgb(var(--danger) / 0.08); + font-size: 0.8125rem; + color: rgb(var(--fg-default)); +} +` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input--invalid { border-color: rgba(239, 68, 68, 0.55); box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); @@ -1079,7 +1122,7 @@ function discard() { line-height: 1.5; } .pattern-explainer-icon { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1rem; flex-shrink: 0; margin-top: 0.1rem; @@ -1202,7 +1245,7 @@ function discard() { } .cat-inherit > svg { color: rgb(var(--accent-warm)); } .cat-own-tag { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border: 1px solid rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.08); } @@ -1287,7 +1330,18 @@ function discard() { } /* ── Buttons ─────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1303,18 +1357,18 @@ function discard() { font-family: inherit; white-space: nowrap; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--primary { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } diff --git a/apps/web/app/components/admin/Users.vue b/apps/web/app/components/admin/Users.vue index a040c7cb..f7b0428d 100644 --- a/apps/web/app/components/admin/Users.vue +++ b/apps/web/app/components/admin/Users.vue @@ -1600,13 +1600,13 @@ async function onDetachRole(roleId: string) { } } .kpi--green .kpi-value { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--aqua .kpi-value { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--gold .kpi-value { - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--red .kpi-value { color: rgb(var(--danger)); @@ -1653,6 +1653,16 @@ async function onDetachRole(roleId: string) { border-color: rgb(var(--fg-default) / 0.3); background: rgb(var(--bg-base)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.filter-search-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .filter-search-input::placeholder { color: rgb(var(--fg-muted)); letter-spacing: calc(0.04em * var(--tracking-scale)); @@ -1711,6 +1721,16 @@ async function onDetachRole(roleId: string) { outline: none; border-color: rgb(var(--fg-default) / 0.3); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.filter-select-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .filter-reset { display: inline-flex; align-items: center; @@ -1954,17 +1974,17 @@ async function onDetachRole(roleId: string) { .role-chip--admin { border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.08); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-chip--mod { border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-chip--custom { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Last seen */ @@ -1975,7 +1995,7 @@ async function onDetachRole(roleId: string) { white-space: nowrap; } .last-seen.online { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-weight: 700; } .last-seen.recent { @@ -2013,7 +2033,7 @@ async function onDetachRole(roleId: string) { .ratio--great { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ratio--ok { color: rgb(var(--fg-strong)); @@ -2021,12 +2041,12 @@ async function onDetachRole(roleId: string) { .ratio--low { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ratio--zero { border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.08); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ratio--neutral { color: rgb(var(--fg-muted)); @@ -2087,17 +2107,17 @@ async function onDetachRole(roleId: string) { .row-action--danger-on { background: rgba(229, 62, 62, 0.12); border-color: rgba(229, 62, 62, 0.4); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-action--mod-on { background: rgba(52, 212, 216, 0.12); border-color: rgba(52, 212, 216, 0.4); - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-action--admin-on { background: rgba(245, 197, 24, 0.12); border-color: rgba(245, 197, 24, 0.4); - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Bonus-points action — coin tone when the user has a non-zero balance. Wider than the other row actions because the count badge can hold @@ -2105,7 +2125,7 @@ async function onDetachRole(roleId: string) { .row-action--bonus-active { background: rgb(var(--accent-warm) / 0.1); border-color: rgb(var(--accent-warm) / 0.35); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ─── Bonus-points adjustment modal ─────────────────────────────── */ @@ -2151,7 +2171,7 @@ async function onDetachRole(roleId: string) { color: rgb(var(--fg-strong)); } .bonus-balance-value > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.1rem; } .bonus-balance-unit { @@ -2376,6 +2396,16 @@ async function onDetachRole(roleId: string) { outline: none; border-color: rgb(var(--fg-default) / 0.3); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.row-role-select:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + /* Empty state */ .empty { @@ -2607,7 +2637,7 @@ async function onDetachRole(roleId: string) { font-size: 0.5938rem; } .rm-row__mode--auto { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); } @@ -2616,7 +2646,7 @@ async function onDetachRole(roleId: string) { font-weight: 700; } .rm-row--manual .rm-row__attached { - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .rm-row__actions { display: flex; @@ -2644,7 +2674,7 @@ async function onDetachRole(roleId: string) { } .rm-action--attach:hover:not(:disabled) { border-color: rgba(108, 209, 97, 0.5); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.08); } .rm-action--detach:hover:not(:disabled) { diff --git a/apps/web/app/components/admin/bonus/TierCurve.vue b/apps/web/app/components/admin/bonus/TierCurve.vue index 20b467ad..a7653b34 100644 --- a/apps/web/app/components/admin/bonus/TierCurve.vue +++ b/apps/web/app/components/admin/bonus/TierCurve.vue @@ -430,6 +430,6 @@ function formatMul(v: number): string { text-transform: uppercase; } .tcurve-readout > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } </style> diff --git a/apps/web/app/components/admin/notifications/ChannelCard.vue b/apps/web/app/components/admin/notifications/ChannelCard.vue index 3e16b321..851969c9 100644 --- a/apps/web/app/components/admin/notifications/ChannelCard.vue +++ b/apps/web/app/components/admin/notifications/ChannelCard.vue @@ -71,7 +71,7 @@ <button v-if="ch.enabled" type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" :disabled="!!busyState" @click="$emit('test')" > @@ -84,7 +84,7 @@ <button v-if="ch.hasServerConfig" type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!!busyState" @click="$emit('toggle-expand')" > @@ -211,14 +211,14 @@ <div class="cc-drawer-foot"> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" @click="$emit('toggle-expand')" > {{ $t('common.close') }} </button> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" v-if="ch.enabled" :disabled="!!busyState" @click="$emit('test')" @@ -232,7 +232,7 @@ <button v-if="ch.hasServerConfig" type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!!busyState" @click="$emit('save')" > @@ -632,7 +632,7 @@ const relativeTime = computed(() => { border-radius: var(--radius-sm); } .cc-note > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); flex-shrink: 0; margin-top: 0.1rem; } @@ -688,6 +688,16 @@ const relativeTime = computed(() => { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input::placeholder { color: rgb(var(--fg-faint)); font-style: italic; @@ -776,7 +786,11 @@ const relativeTime = computed(() => { } /* ── Buttons ─────────────────────────────────────────────────── */ -.btn { +/* Même renommage que dans les composants d'administration : `.btn` en `<style + * scoped>` masque la classe du système de design, qui vit dans + * `@layer components` et perd donc contre elle quelle que soit la + * spécificité. */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -791,23 +805,23 @@ const relativeTime = computed(() => { transition: all var(--dur-2) ease; font-family: inherit; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { +.cbtn--ghost { background: transparent; } -.btn--primary { +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } diff --git a/apps/web/app/components/bonus/BonusEventModal.vue b/apps/web/app/components/bonus/BonusEventModal.vue index 0da03952..64aef04c 100644 --- a/apps/web/app/components/bonus/BonusEventModal.vue +++ b/apps/web/app/components/bonus/BonusEventModal.vue @@ -544,7 +544,7 @@ const explainer = computed(() => { font-weight: 800; letter-spacing: calc(0.3em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ flex: 1; } .bb-onair-now { @@ -641,10 +641,10 @@ const explainer = computed(() => { border-radius: var(--radius-sm); border: 1px solid rgb(var(--accent-warm) / 0.45); background: rgb(var(--accent-warm) / 0.08); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .bb-title-strip--pool .bb-preset-tag { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.45); background: rgb(var(--accent-warm) / 0.08); } @@ -664,7 +664,7 @@ const explainer = computed(() => { .bb-overlay-icon { flex-shrink: 0; margin-top: 0.1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* Strike-through value in the DL meter — surfaces the original @@ -694,17 +694,17 @@ const explainer = computed(() => { border: 1px solid; } .bb-title-strip--freeleech .bb-preset-tag { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.45); background: rgba(108, 209, 97, 0.08); } .bb-title-strip--silverleech .bb-preset-tag { - color: #94a3b8; + color: rgb(var(--fg-muted)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(148, 163, 184, 0.45); background: rgba(148, 163, 184, 0.08); } .bb-title-strip--bonus .bb-preset-tag { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.45); background: rgb(var(--accent-warm) / 0.08); } @@ -728,7 +728,7 @@ const explainer = computed(() => { font-size: 0.7813rem; font-weight: 700; letter-spacing: calc(0.05em * var(--tracking-scale)); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ text-shadow: 0 0 10px rgba(244, 63, 94, 0.25); } .bb-countdown-icon { font-size: 0.95rem; } @@ -815,7 +815,7 @@ const explainer = computed(() => { .meter--good .meter-verdict-icon, .meter--good .meter-value, .meter--good .meter-verdict { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .meter--good .meter-bar-fill { background: linear-gradient(to right, #4ade80, #6cd161); @@ -828,7 +828,7 @@ const explainer = computed(() => { .meter--bad .meter-verdict-icon, .meter--bad .meter-value, .meter--bad .meter-verdict { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .meter--bad .meter-bar-fill { background: linear-gradient(to right, #f43f5e, #fb7185); @@ -971,7 +971,7 @@ const explainer = computed(() => { } .bb-window-foot-leg--right { text-align: right; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ── Explainer ──────────────────────────────────────────── */ @@ -996,7 +996,7 @@ const explainer = computed(() => { font-weight: 800; letter-spacing: calc(0.2em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .bb-explainer-icon { font-size: 0.95rem; } .bb-explainer-body { diff --git a/apps/web/app/components/forum/CategoryForm.vue b/apps/web/app/components/forum/CategoryForm.vue index 83a6e70c..7a34c08d 100644 --- a/apps/web/app/components/forum/CategoryForm.vue +++ b/apps/web/app/components/forum/CategoryForm.vue @@ -182,6 +182,16 @@ const iconSuggestions = [ outline: none; border-color: rgb(var(--fg-default)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.cat-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .cat-input--textarea { resize: vertical; min-height: 4.5rem; diff --git a/apps/web/app/components/messaging/Reactions.vue b/apps/web/app/components/messaging/Reactions.vue index c8f313d1..5140eb66 100644 --- a/apps/web/app/components/messaging/Reactions.vue +++ b/apps/web/app/components/messaging/Reactions.vue @@ -54,7 +54,7 @@ const visible = computed(() => padding: 0.15rem 0.4rem; border: 1px solid rgb(var(--line-strong)); border-radius: var(--radius-pill); - background: rgb(var(--bg-tertiary)); + background: rgb(var(--bg-elevated)); color: rgb(var(--fg-default)); font-size: 0.75rem; line-height: 1; diff --git a/apps/web/app/components/messaging/TorrentCard.vue b/apps/web/app/components/messaging/TorrentCard.vue index c7a93566..0651fd7c 100644 --- a/apps/web/app/components/messaging/TorrentCard.vue +++ b/apps/web/app/components/messaging/TorrentCard.vue @@ -16,7 +16,7 @@ <span class="tc-main"> <span class="tc-name">{{ torrent.name }}</span> <span class="tc-meta"> - <span v-if="torrent.size">{{ formatSize(torrent.size) }}</span> + <span v-if="torrent.size">{{ formatSize(Number(torrent.size)) }}</span> <span class="tc-swarm"> <b class="tc-seed">{{ torrent.stats?.seeders ?? 0 }}</b> / @@ -57,18 +57,6 @@ onMounted(async () => { } }); -function formatSize(bytes: number | string): string { - const n = typeof bytes === 'string' ? Number(bytes) : bytes; - if (!Number.isFinite(n) || n <= 0) return ''; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let v = n; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i += 1; - } - return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; -} </script> <style scoped> @@ -80,7 +68,7 @@ function formatSize(bytes: number | string): string { padding: 0.35rem 0.5rem; border: 1px solid rgb(var(--line-strong)); border-radius: var(--radius-sm); - background: rgb(var(--bg-tertiary)); + background: rgb(var(--bg-elevated)); color: rgb(var(--fg-default)); font-size: 0.7rem; text-decoration: none; diff --git a/apps/web/app/components/security/RecoveryCodesView.vue b/apps/web/app/components/security/RecoveryCodesView.vue index 857b1dd4..faff4370 100644 --- a/apps/web/app/components/security/RecoveryCodesView.vue +++ b/apps/web/app/components/security/RecoveryCodesView.vue @@ -9,15 +9,15 @@ </li> </ul> <div class="rcv-actions"> - <button class="btn-ghost" type="button" @click="copyAll"> + <button class="sbtn-ghost" type="button" @click="copyAll"> <Icon name="ph:copy-bold" /> {{ copied ? $t('common.copied') : $t('security.recoveryCodes.copyAll') }} </button> - <button class="btn-ghost" type="button" @click="download"> + <button class="sbtn-ghost" type="button" @click="download"> <Icon name="ph:download-simple-bold" /> {{ $t('security.recoveryCodes.downloadTxt') }} </button> - <button class="btn-ghost" type="button" @click="print"> + <button class="sbtn-ghost" type="button" @click="print"> <Icon name="ph:printer-bold" /> {{ $t('security.recoveryCodes.print') }} </button> @@ -143,7 +143,19 @@ function print() { flex-wrap: wrap; gap: 0.4rem; } -.btn-ghost { +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un `<style scoped>`, donc hors couche, il l'emportait sur + * `@layer components` quelle que soit la spécificité. Sept fichiers donnaient + * ainsi deux boutons visuellement différents sous le même nom de classe, et + * `class="btn btn-primary"` écrit dans l'un d'eux n'aurait pas donné le bouton + * attendu. + */ +.sbtn-ghost { display: inline-flex; align-items: center; gap: 0.4rem; @@ -158,7 +170,7 @@ function print() { color: rgb(var(--fg-muted)); cursor: pointer; } -.btn-ghost:hover { +.sbtn-ghost:hover { color: rgb(var(--fg-strong)); border-color: rgb(var(--line-strong)); } diff --git a/apps/web/app/components/security/TotpSetupWizard.vue b/apps/web/app/components/security/TotpSetupWizard.vue index 714c4422..0a23c095 100644 --- a/apps/web/app/components/security/TotpSetupWizard.vue +++ b/apps/web/app/components/security/TotpSetupWizard.vue @@ -41,9 +41,9 @@ </p> <div class="totp-actions"> - <button class="btn-ghost" @click="$emit('cancel')">{{ $t('common.cancel') }}</button> + <button class="sbtn-ghost" @click="$emit('cancel')">{{ $t('common.cancel') }}</button> <button - class="btn-primary" + class="sbtn-primary" :disabled="code.length !== 6 || submitting" @click="submit" > @@ -68,7 +68,7 @@ </p> <RecoveryCodesView :codes="recoveryCodes" /> <div class="totp-actions"> - <button class="btn-primary" @click="$emit('cancel')"> + <button class="sbtn-primary" @click="$emit('cancel')"> <Icon name="ph:check-bold" /> {{ $t('security.totp.saved') }} </button> @@ -244,6 +244,16 @@ async function copySecret() { outline: none; border-color: rgb(var(--fg-default)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .totp-error { display: inline-flex; @@ -269,8 +279,20 @@ async function copySecret() { margin-top: 0.5rem; } -.btn-primary, -.btn-ghost { +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un `<style scoped>`, donc hors couche, il l'emportait sur + * `@layer components` quelle que soit la spécificité. Sept fichiers donnaient + * ainsi deux boutons visuellement différents sous le même nom de classe, et + * `class="btn btn-primary"` écrit dans l'un d'eux n'aurait pas donné le bouton + * attendu. + */ +.sbtn-primary, +.sbtn-ghost { display: inline-flex; align-items: center; gap: 0.4rem; @@ -285,23 +307,23 @@ async function copySecret() { border: 1px solid rgb(var(--line-default)); transition: all var(--dur-2); } -.btn-primary { +.sbtn-primary { background: rgb(var(--fg-strong)); color: rgb(var(--accent-fg)); border-color: rgb(var(--fg-strong)); } -.btn-primary:hover:not(:disabled) { +.sbtn-primary:hover:not(:disabled) { background: rgb(var(--fg-default)); } -.btn-primary:disabled { +.sbtn-primary:disabled { opacity: 0.5; cursor: not-allowed; } -.btn-ghost { +.sbtn-ghost { background: rgb(var(--bg-elevated)); color: rgb(var(--fg-muted)); } -.btn-ghost:hover { +.sbtn-ghost:hover { color: rgb(var(--fg-strong)); } </style> diff --git a/apps/web/app/components/security/TwoFactorLoginStep.vue b/apps/web/app/components/security/TwoFactorLoginStep.vue index e2923d79..78f4a98f 100644 --- a/apps/web/app/components/security/TwoFactorLoginStep.vue +++ b/apps/web/app/components/security/TwoFactorLoginStep.vue @@ -75,12 +75,12 @@ </p> <div class="step-actions"> - <button class="btn-ghost" type="button" @click="$emit('cancel')"> + <button class="sbtn-ghost" type="button" @click="$emit('cancel')"> {{ $t('common.cancel') }} </button> <button v-if="active !== 'passkey'" - class="btn-primary" + class="sbtn-primary" type="button" :disabled="!canSubmit || submitting" @click="submitTotp" @@ -94,7 +94,7 @@ </button> <button v-else - class="btn-primary" + class="sbtn-primary" type="button" :disabled="submitting" @click="submitPasskey" @@ -311,6 +311,16 @@ async function submitPasskey() { outline: none; border-color: rgb(var(--fg-default)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.step-code-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .step-hint { margin: 0; font-size: 0.7188rem; @@ -350,8 +360,20 @@ async function submitPasskey() { margin-top: 0.5rem; } -.btn-primary, -.btn-ghost { +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un `<style scoped>`, donc hors couche, il l'emportait sur + * `@layer components` quelle que soit la spécificité. Sept fichiers donnaient + * ainsi deux boutons visuellement différents sous le même nom de classe, et + * `class="btn btn-primary"` écrit dans l'un d'eux n'aurait pas donné le bouton + * attendu. + */ +.sbtn-primary, +.sbtn-ghost { display: inline-flex; align-items: center; gap: 0.4rem; @@ -366,23 +388,23 @@ async function submitPasskey() { cursor: pointer; transition: all var(--dur-2); } -.btn-primary { +.sbtn-primary { background: rgb(var(--accent)); color: rgb(var(--accent-fg)); border-color: rgb(var(--accent)); } -.btn-primary:hover:not(:disabled) { +.sbtn-primary:hover:not(:disabled) { background: rgb(var(--accent-hover)); } -.btn-primary:disabled { +.sbtn-primary:disabled { opacity: 0.5; cursor: not-allowed; } -.btn-ghost { +.sbtn-ghost { background: rgb(var(--bg-elevated)); color: rgb(var(--fg-muted)); } -.btn-ghost:hover { +.sbtn-ghost:hover { color: rgb(var(--fg-strong)); } </style> diff --git a/apps/web/app/components/security/TwoFactorSection.vue b/apps/web/app/components/security/TwoFactorSection.vue index 5b0aedbf..1bdb42a6 100644 --- a/apps/web/app/components/security/TwoFactorSection.vue +++ b/apps/web/app/components/security/TwoFactorSection.vue @@ -4,7 +4,7 @@ <Icon name="ph:shield-check-bold" class="tfa-eyebrow-icon" /> <span>{{ $t('settings.security.twoFactor') }}</span> <span - v-if="status" + v-if="status && !statusLoading" class="tfa-summary" :class="{ 'tfa-summary--on': status.totpEnabled || status.passkeys.length > 0 }" > @@ -27,7 +27,18 @@ <div> <h4 class="tfa-card-title">{{ $t('settings.security.totpTitle') }}</h4> <p class="tfa-card-meta"> - <template v-if="status?.totpEnabled"> + <!-- Trois états, pas deux. « On ne sait pas encore » et « on n'a + pas pu savoir » ne sont pas « pas de 2FA » : c'est la + différence entre inviter à configurer et affirmer à tort qu'il + n'y a rien à protéger. --> + <template v-if="statusLoading"> + {{ $t('common.loading') }} + </template> + <template v-else-if="statusFailed"> + {{ $t('security.twoFactor.statusUnknown') }} + <span class="tfa-dot" /> + </template> + <template v-else-if="status?.totpEnabled"> {{ $t('security.twoFactor.totpEnabled') }} <span class="tfa-dot tfa-dot--on" /> · @@ -41,9 +52,20 @@ </div> <div class="tfa-card-actions"> <button - v-if="!status?.totpEnabled" + v-if="statusFailed" type="button" - class="btn-primary" + class="sbtn-ghost" + @click="refresh" + > + <Icon name="ph:arrow-clockwise" /> + {{ $t('common.retry') }} + </button> + <!-- Pas de bouton « Activer » tant que l'état n'est pas connu : le + presser depuis un état inconnu écrase un secret existant. --> + <button + v-if="!statusLoading && !statusFailed && !status?.totpEnabled" + type="button" + class="sbtn-primary" @click="startTotpSetup" > <Icon name="ph:plus-bold" /> @@ -52,7 +74,7 @@ <template v-else> <button type="button" - class="btn-ghost" + class="sbtn-ghost" @click="regenOpen = true" > <Icon name="ph:arrows-clockwise-bold" /> @@ -60,7 +82,7 @@ </button> <button type="button" - class="btn-ghost btn-ghost--danger" + class="sbtn-ghost btn-ghost--danger" @click="disableOpen = true" > <Icon name="ph:x-circle-bold" /> @@ -91,7 +113,7 @@ <div class="tfa-card-actions"> <button type="button" - class="btn-primary" + class="sbtn-primary" :disabled="passkeyAdding" @click="addPasskey" > @@ -121,7 +143,7 @@ </span> <button type="button" - class="btn-ghost btn-ghost--danger btn-ghost--sm" + class="sbtn-ghost btn-ghost--danger btn-ghost--sm" @click="removePasskey(pk.id)" > <Icon name="ph:trash-bold" /> @@ -177,7 +199,7 @@ </span> <button type="button" - class="btn-ghost btn-ghost--danger btn-ghost--sm" + class="sbtn-ghost btn-ghost--danger btn-ghost--sm" @click="revokeDevice(d.id)" > <Icon name="ph:sign-out-bold" /> @@ -226,9 +248,9 @@ {{ disableError }} </p> <template #footer> - <button class="btn-ghost" @click="disableOpen = false">{{ $t('common.cancel') }}</button> + <button class="sbtn-ghost" @click="disableOpen = false">{{ $t('common.cancel') }}</button> <button - class="btn-primary" + class="sbtn-primary" :disabled="!canSubmitDisable || disableSubmitting" @click="submitDisable" > @@ -265,10 +287,10 @@ {{ regenError }} </p> <template #footer> - <button class="btn-ghost" @click="closeRegen">{{ $t('common.close') }}</button> + <button class="sbtn-ghost" @click="closeRegen">{{ $t('common.close') }}</button> <button v-if="regenCodes.length === 0" - class="btn-primary" + class="sbtn-primary" :disabled="regenCode.length !== 6 || regenSubmitting" @click="submitRegen" > @@ -293,11 +315,11 @@ :placeholder="$t('security.twoFactor.modals.namePasskeyPlaceholder')" /> <template #footer> - <button class="btn-ghost" @click="passkeyNameOpen = false"> + <button class="sbtn-ghost" @click="passkeyNameOpen = false"> {{ $t('common.cancel') }} </button> <button - class="btn-primary" + class="sbtn-primary" :disabled="!passkeyName.trim() || passkeyAdding" @click="confirmAddPasskey" > @@ -340,9 +362,22 @@ interface TrustedDevice { } const { t } = useI18n(); +const confirm = useConfirm(); const notifications = useNotificationStore(); +/** + * `null` veut dire « on ne sait pas encore », pas « pas de 2FA ». + * + * Sans branche de chargement, la carte affichait « Non configuré », la pastille + * éteinte, le bouton « Activer TOTP » et l'interrupteur Appareils de confiance + * sur OFF — à un membre qui a peut-être une TOTP active. Et si `refresh()` + * échoue, `status` restait `null` définitivement : un hoquet réseau au montage, + * et l'écran soutenait en permanence que la 2FA n'était pas configurée, avec un + * bouton qui invite à tout refaire. + */ const status = ref<StatusPayload | null>(null); +const statusLoading = ref(true); +const statusFailed = ref(false); const trustedDevices = ref<TrustedDevice[]>([]); async function refresh() { @@ -356,8 +391,14 @@ async function refresh() { } else { trustedDevices.value = []; } + statusFailed.value = false; } catch (e: any) { + // L'échec est un ÉTAT, pas seulement un toast qui s'efface : sans lui, la + // carte retombait sur « non configuré » et invitait à tout recommencer. + statusFailed.value = true; notifications.error(e?.data?.message || t('security.twoFactor.errors.loadStatus')); + } finally { + statusLoading.value = false; } } onMounted(refresh); @@ -487,6 +528,16 @@ async function confirmAddPasskey() { } } async function removePasskey(id: string) { + // Une clé retirée ne revient pas : il faut la ré-enregistrer depuis + // l'appareil qui la porte. Sur un compte dont c'est la seule clé, ce clic + // enlève le second facteur. + const ok = await confirm({ + title: t('security.twoFactor.confirmRemovePasskey.title'), + message: t('security.twoFactor.confirmRemovePasskey.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; try { await $fetch(`/api/me/2fa/passkey/${id}` as '/api/me/2fa/passkey/:id', { method: 'DELETE', @@ -629,8 +680,20 @@ async function revokeDevice(id: string) { justify-content: flex-end; } -.btn-primary, -.btn-ghost { +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un `<style scoped>`, donc hors couche, il l'emportait sur + * `@layer components` quelle que soit la spécificité. Sept fichiers donnaient + * ainsi deux boutons visuellement différents sous le même nom de classe, et + * `class="btn btn-primary"` écrit dans l'un d'eux n'aurait pas donné le bouton + * attendu. + */ +.sbtn-primary, +.sbtn-ghost { display: inline-flex; align-items: center; gap: 0.4rem; @@ -645,24 +708,24 @@ async function revokeDevice(id: string) { transition: all var(--dur-2); border: 1px solid rgb(var(--line-default)); } -.btn-primary { +.sbtn-primary { background: rgb(var(--fg-strong)); color: rgb(var(--accent-fg)); border-color: rgb(var(--fg-strong)); } -.btn-primary:hover:not(:disabled) { +.sbtn-primary:hover:not(:disabled) { background: rgb(var(--fg-default)); border-color: rgb(var(--fg-default)); } -.btn-primary:disabled { +.sbtn-primary:disabled { opacity: 0.5; cursor: not-allowed; } -.btn-ghost { +.sbtn-ghost { background: rgb(var(--bg-elevated)); color: rgb(var(--fg-muted)); } -.btn-ghost:hover { +.sbtn-ghost:hover { border-color: rgb(var(--line-strong)); color: rgb(var(--fg-strong)); } @@ -807,6 +870,16 @@ async function revokeDevice(id: string) { outline: none; border-color: rgb(var(--fg-default)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + @media (max-width: 640px) { .tfa-card-head { diff --git a/apps/web/app/components/settings/NotificationsSection.vue b/apps/web/app/components/settings/NotificationsSection.vue index 5116e862..978be149 100644 --- a/apps/web/app/components/settings/NotificationsSection.vue +++ b/apps/web/app/components/settings/NotificationsSection.vue @@ -205,7 +205,7 @@ <div class="dest-drawer-foot"> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" @click="toggleExpand(ch.type)" > {{ $t('common.close') }} @@ -213,7 +213,7 @@ <button v-if="ch.type === 'web_push'" type="button" - class="btn" + class="cbtn" :disabled="!!busy[ch.type] || webPushBusy || webPushStatus === 'unsupported' || webPushStatus === 'blocked'" @click="toggleWebPush(ch)" > @@ -230,7 +230,7 @@ <button v-else type="button" - class="btn" + class="cbtn" :disabled="!!busy[ch.type] || !canSave(ch)" @click="saveChannel(ch)" > @@ -427,7 +427,7 @@ </span> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" :disabled="routingSaving" @click="discardRouting" > @@ -435,7 +435,7 @@ </button> <button type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!routingDirty || routingSaving" @click="() => saveRouting()" > @@ -570,7 +570,7 @@ async function toggleWebPush(ch: UserChannel) { // Force a fresh fetch of the channel rows so the UI tracks the // server-side state (configured: true → row appears; trash → // row leaves). - await refresh(); + await refresh({ keepRouting: true }); } finally { webPushBusy.value = false; } @@ -791,6 +791,10 @@ const activeChannels = computed(() => ); // ── Routing diff & helpers ──────────────────────────────────── +/* Exposé pour que la page hôte puisse garder la navigation : `routingDirty` + vivait ici sans `defineExpose`, donc le `onBeforeRouteLeave` de `settings.vue` + ne connaissait que son propre compteur et laissait partir quarante-quatre + réglages non enregistrés sans un mot. */ const routingDirty = computed(() => { const keys = new Set([ ...Object.keys(routing), @@ -886,7 +890,20 @@ function statusLabel(ch: UserChannel): string { } // ── Data load & mutations ───────────────────────────────────── -async function refresh() { +/** + * Recharge l'état serveur — sans écraser un routage en cours d'édition. + * + * `refresh()` réécrivait `routing` ET `initialRouting` sans condition, et il est + * appelé par quatre actions qui n'ont rien à voir avec le routage : tester une + * destination, en enregistrer une, en retirer une, basculer le web-push. + * Re-router quarante évènements puis cliquer l'avion en papier « Tester » pour + * vérifier qu'une destination répond effaçait donc les quarante modifications, + * sans confirmation ni message — pendant que l'en-tête affichait « 0 ». + * + * `keepRouting` est passé par les appelants qui touchent un CANAL : eux ont + * besoin de la liste des canaux à jour, pas de piétiner la matrice. + */ +async function refresh(opts: { keepRouting?: boolean } = {}) { const res = await $fetch<{ channels: UserChannel[]; routing: RoutingEntry[]; @@ -895,6 +912,12 @@ async function refresh() { for (const ch of res.channels) { userConfigs[ch.type] = { ...(ch.userValues || {}) }; } + // Une matrice sale n'est jamais écrasée, même quand l'appelant ne l'a pas + // demandé : c'est le travail non enregistré du membre. + if (opts.keepRouting || routingDirty.value) { + loaded.value = true; + return; + } for (const k of Object.keys(routing)) delete routing[k]; for (const k of Object.keys(initialRouting)) delete initialRouting[k]; for (const r of res.routing) { @@ -908,6 +931,9 @@ onMounted(() => { void refresh(); }); +/* La page hôte lit ceci dans son garde de navigation. */ +defineExpose({ routingDirty }); + function toggleExpand(type: string) { expanded[type] = !expanded[type]; } @@ -973,7 +999,7 @@ async function saveChannel(ch: UserChannel) { await saveRouting(true); } expanded[ch.type] = false; - await refresh(); + await refresh({ keepRouting: true }); } finally { busy[ch.type] = null; } @@ -997,7 +1023,7 @@ async function confirmRemove(ch: UserChannel) { }); userConfigs[ch.type] = {}; expanded[ch.type] = false; - await refresh(); + await refresh({ keepRouting: true }); } finally { busy[ch.type] = null; } @@ -1009,7 +1035,7 @@ async function testChannel(ch: UserChannel) { await $fetch(`/api/me/notification-channels/${ch.type}/test`, { method: 'POST', }); - await refresh(); + await refresh({ keepRouting: true }); } finally { busy[ch.type] = null; } @@ -1033,7 +1059,7 @@ async function saveRouting(silent = false) { }); for (const k of Object.keys(initialRouting)) delete initialRouting[k]; for (const [k, v] of Object.entries(routing)) initialRouting[k] = v; - if (!silent) await refresh(); + if (!silent) await refresh({ keepRouting: true }); } finally { routingSaving.value = false; } @@ -1115,7 +1141,7 @@ async function saveRouting(silent = false) { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -1330,7 +1356,7 @@ async function saveRouting(silent = false) { } .dest-btn--active { background: rgb(var(--accent-warm) / 0.12); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); } .dest-btn--danger:hover:not(:disabled) { @@ -1380,7 +1406,7 @@ async function saveRouting(silent = false) { color: rgb(var(--fg-strong)); } .dest-pub > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1rem; flex-shrink: 0; margin-top: 0.1rem; @@ -1468,6 +1494,16 @@ async function saveRouting(silent = false) { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input::placeholder { color: rgb(var(--fg-faint)); font-style: italic; @@ -1720,7 +1756,7 @@ async function saveRouting(silent = false) { } .cat[open] .cat-chevron { transform: rotate(180deg); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } @media (max-width: 640px) { .cat-head { @@ -1826,7 +1862,12 @@ async function saveRouting(silent = false) { } /* ── Floating save bar ────────────────────────────────────── */ +/* Au-dessus de la barre d'action de la page. + `.route-save` était en `z-index: 3` et collant en bas, sous une + `.action-bar` en `position: fixed; z-index: 30` jamais masquée : le seul + bouton qui enregistre la matrice de routage passait derrière elle. */ .route-save { + z-index: 31; position: sticky; bottom: 1rem; margin-top: 0.5rem; @@ -1851,11 +1892,15 @@ async function saveRouting(silent = false) { font-weight: 600; } .route-save-info > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ── Generic buttons ──────────────────────────────────────── */ -.btn { +/* Même renommage que dans les composants d'administration : `.btn` en `<style + * scoped>` masque la classe du système de design, qui vit dans + * `@layer components` et perd donc contre elle quelle que soit la + * spécificité. */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1870,23 +1915,23 @@ async function saveRouting(silent = false) { transition: all var(--dur-2) ease; font-family: inherit; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.55); background: rgb(var(--accent-warm) / 0.06); } -.btn:disabled { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { +.cbtn--ghost { background: transparent; } -.btn--primary { +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } diff --git a/apps/web/app/components/shop/FreeleechPool.vue b/apps/web/app/components/shop/FreeleechPool.vue index d0b7de8e..7a58f7bb 100644 --- a/apps/web/app/components/shop/FreeleechPool.vue +++ b/apps/web/app/components/shop/FreeleechPool.vue @@ -468,7 +468,7 @@ onBeforeUnmount(() => { font-weight: 700; letter-spacing: calc(0.3em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); margin-bottom: 0.7rem; } .pool-eyebrow-rule { @@ -494,7 +494,7 @@ onBeforeUnmount(() => { font-size: clamp(1.6rem, 3vw, 2.1rem); font-style: italic; font-weight: 400; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); margin-left: 0.3rem; } .pool-blurb { @@ -568,7 +568,7 @@ onBeforeUnmount(() => { } .pool-meter-sep { font-size: 1.4rem; - color: rgb(var(--accent-warm) / 0.5); + color: rgb(var(--accent-warm-text) / 0.5); } .pool-meter-target { font-size: 1.4rem; @@ -599,7 +599,7 @@ onBeforeUnmount(() => { } .pool-status-icon { font-size: 1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pool--active .pool-status-icon, .pool--full_queued .pool-status-icon { @@ -647,7 +647,7 @@ onBeforeUnmount(() => { font-size: 0.5625rem; font-weight: 700; letter-spacing: calc(0.18em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pool-patron-name { font-family: var(--font-display); @@ -729,7 +729,7 @@ onBeforeUnmount(() => { font-weight: 700; letter-spacing: calc(0.22em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pool-form-presets { @@ -753,7 +753,7 @@ onBeforeUnmount(() => { .pool-form-preset:hover:not(:disabled) { border-color: rgb(var(--accent-warm)); background: rgb(var(--accent-warm) / 0.08); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pool-form-preset:disabled { opacity: 0.35; @@ -826,11 +826,11 @@ onBeforeUnmount(() => { } .pool-form-locked-icon { font-size: 2.4rem; - color: rgb(var(--accent-warm) / 0.55); + color: rgb(var(--accent-warm-text) / 0.55); } .pool--active .pool-form-locked-icon, .pool--full_queued .pool-form-locked-icon { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); animation: pool-locked-pulse calc(3s * var(--motion-scale)) ease-in-out infinite; } @keyframes pool-locked-pulse { diff --git a/apps/web/app/components/torrent/GroupLadder.vue b/apps/web/app/components/torrent/GroupLadder.vue index 2b6c62e5..18d28950 100644 --- a/apps/web/app/components/torrent/GroupLadder.vue +++ b/apps/web/app/components/torrent/GroupLadder.vue @@ -189,7 +189,7 @@ const tierLabel = computed(() => } .tier--lit { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.45); background: rgb(var(--accent-warm) / 0.08); } diff --git a/apps/web/app/components/torrent/GroupRow.vue b/apps/web/app/components/torrent/GroupRow.vue index 8c0d28da..36a56a1f 100644 --- a/apps/web/app/components/torrent/GroupRow.vue +++ b/apps/web/app/components/torrent/GroupRow.vue @@ -526,7 +526,7 @@ const age = computed(() => formatAge(props.group.latest)); transition: color var(--dur-1) ease, background-color var(--dur-1) ease; } .grp-more:hover { - color: #e5bf58; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgb(var(--accent-warm) / 0.06); } diff --git a/apps/web/app/components/torrent/ReleaseRow.vue b/apps/web/app/components/torrent/ReleaseRow.vue index 6544358e..b6f6f920 100644 --- a/apps/web/app/components/torrent/ReleaseRow.vue +++ b/apps/web/app/components/torrent/ReleaseRow.vue @@ -238,7 +238,7 @@ const age = computed(() => } .chip--res { background: rgb(var(--accent-warm) / 0.16); - color: #e5bf58; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-weight: 600; } .chip--src { @@ -359,7 +359,7 @@ const age = computed(() => } .rr-dl:hover { background: rgb(var(--accent-warm) / 0.14); - color: #e5bf58; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Not a button — a signpost. Dimmer, and it does not light up on hover, because the whole row is already the link that takes you there. */ diff --git a/apps/web/app/components/torrent/TorrentModerationPanel.vue b/apps/web/app/components/torrent/TorrentModerationPanel.vue index 75c8e027..ce76e9bd 100644 --- a/apps/web/app/components/torrent/TorrentModerationPanel.vue +++ b/apps/web/app/components/torrent/TorrentModerationPanel.vue @@ -873,6 +873,16 @@ function formatDate(iso: string): string { border-color: var(--c-line); box-shadow: 0 0 0 3px rgb(var(--c) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.mod-composer-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .mod-composer-input:disabled { opacity: 0.6; cursor: not-allowed; } .mod-composer-actions { diff --git a/apps/web/app/composables/useNotifications.ts b/apps/web/app/composables/useNotifications.ts index 79edcda0..6afa147f 100644 --- a/apps/web/app/composables/useNotifications.ts +++ b/apps/web/app/composables/useNotifications.ts @@ -49,6 +49,9 @@ const MAX_IN_MEMORY = 50; // here — each user runs one tab → one connection. SSR never reaches // these because every code path that touches them is gated on // `typeof window !== 'undefined'`. +/** Un seul cycle de vie par processus client : voir la note sur le `watch`. */ +let lifecycleBound = false; + let eventSource: EventSource | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let pollFallback: ReturnType<typeof setInterval> | null = null; @@ -224,6 +227,14 @@ export function useNotifications() { * inside each helper short-circuit when already running. */ function start() { if (!loggedIn.value) return; + // `import.meta.client` : `connectSSE` est gardé par `typeof window` et par + // `if (eventSource) return`, mais `fetchInitial` ne l'était par NI l'un NI + // l'autre. Côté serveur, cela lançait un `$fetch` flottant par consommateur + // et par rendu — aucun attendu, donc aucun n'atterrissait dans le HTML + // rendu : du travail payé et jeté. Le commentaire d'en-tête affirmait + // « SSR never reaches these because every code path is gated on + // `typeof window !== 'undefined'` » ; c'était faux pour celui-ci. + if (!import.meta.client) return; void fetchInitial(); connectSSE(); // Re-open the SSE when the tab becomes visible again — some @@ -255,14 +266,24 @@ export function useNotifications() { // React to sign-in / sign-out automatically so consumers don't // have to thread the lifecycle themselves. - watch( - loggedIn, - (v) => { - if (v) start(); - else stop(); - }, - { immediate: true }, - ); + // + // Une seule fois par processus client, pas une par consommateur. Le `watch` + // était enregistré DANS le corps du composable, donc chaque composant qui + // l'appelait — la cloche, la page des notifications, la mise en page — en + // posait un et déclenchait `start()` à son propre `setup` : N requêtes en + // double à chaque chargement. `useColorMode.ts` traite déjà le même problème + // avec un drapeau de module. + if (import.meta.client && !lifecycleBound) { + lifecycleBound = true; + watch( + loggedIn, + (v) => { + if (v) start(); + else stop(); + }, + { immediate: true }, + ); + } return { state: readonly(state), diff --git a/apps/web/app/composables/useWebPush.ts b/apps/web/app/composables/useWebPush.ts index aff310a3..84e24f5f 100644 --- a/apps/web/app/composables/useWebPush.ts +++ b/apps/web/app/composables/useWebPush.ts @@ -72,6 +72,9 @@ export function isWebPushSupported(): boolean { } export function useWebPush() { + // Deux replis anglais codés en dur — « Failed to enable web push. » — + // atterrissaient tels quels dans un bandeau français. + const { t } = useI18n(); const status = useState<WebPushStatus>('webpush-status', () => 'disabled'); const error = useState<string | null>('webpush-error', () => null); @@ -172,7 +175,7 @@ export function useWebPush() { } catch (err: any) { status.value = 'error'; error.value = - err?.data?.message ?? err?.message ?? 'Failed to enable web push.'; + err?.data?.message ?? err?.message ?? t('settings.notifications.webPush.enableFailed'); return false; } } @@ -208,7 +211,7 @@ export function useWebPush() { } catch (err: any) { status.value = 'error'; error.value = - err?.data?.message ?? err?.message ?? 'Failed to disable web push.'; + err?.data?.message ?? err?.message ?? t('settings.notifications.webPush.disableFailed'); return false; } } diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index 10b9d7fe..f5e16bd1 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -2,6 +2,11 @@ <div class="min-h-screen flex flex-col bg-bg-primary text-text-primary selection:bg-accent selection:text-accent-fg" > + <!-- Aller au contenu (WCAG 2.4.1). Hors écran jusqu'au focus : au clavier, + chaque page faisait traverser le logo, quatre à six liens de nav, le + champ de recherche, deux cloches, le bouton de rafraîchissement et le + menu du compte avant d'atteindre quoi que ce soit de la page. --> + <a href="#main" class="skip-link">{{ $t('nav.skipToContent') }}</a> <!-- Top-of-page progress bar that lights up while a route's setup blocks on its `await useFetch(...)` calls. Without @@ -211,8 +216,25 @@ </button> </div> + <!-- + Un visiteur non connecté peut rendre ce layout : `/privacy` est une + route ouverte (`middleware/auth.global.ts`), et c'est même la page + écrite pour quelqu'un qui décide s'il s'inscrit. Sans ce garde, il + voyait un bouton d'avatar au LIBELLÉ VIDE ouvrant un menu Profil / + Favoris / Alertes / Réglages / Déconnexion — et aucun « Se + connecter » nulle part. + --> + <NuxtLink + v-if="!user" + to="/auth/login" + class="hidden md:inline-flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium border border-border text-text-secondary hover:text-text-strong hover:border-border-hover transition-colors" + > + <Icon name="ph:sign-in" /> + {{ $t('nav.signIn') }} + </NuxtLink> + <!-- User Menu — desktop only --> - <div class="relative hidden md:block" ref="userMenuRef"> + <div v-else class="relative hidden md:block" ref="userMenuRef"> <button @click="toggleUserMenu" class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-fg-default/5 transition-colors" @@ -404,7 +426,7 @@ </NuxtLink> <button @click="handleLogout" - class="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-fg-default/5 transition-colors flex items-center gap-2" + class="w-full px-4 py-2 text-left text-sm text-error hover:bg-fg-default/5 transition-colors flex items-center gap-2" > <Icon name="ph:sign-out" /> {{ $t('nav.signOut') }} @@ -601,8 +623,22 @@ </NuxtLink> </nav> - <!-- Footer actions --> - <div class="mt-auto border-t border-border p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] flex flex-col"> + <!-- Footer actions — comptes connectés seulement, pour la même raison + que le menu desktop plus haut. --> + <div + v-if="!user" + class="mt-auto border-t border-border p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]" + > + <NuxtLink + to="/auth/login" + class="flex items-center gap-3 px-3 py-3 rounded-md text-sm font-medium text-text-primary hover:bg-fg-default/5 transition-colors" + @click="showMobileNav = false" + > + <Icon name="ph:sign-in" class="text-lg flex-shrink-0" /> + <span>{{ $t('nav.signIn') }}</span> + </NuxtLink> + </div> + <div v-else class="mt-auto border-t border-border p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] flex flex-col"> <NuxtLink to="/favorites" class="flex items-center gap-3 px-3 py-3 rounded-md text-sm font-medium text-text-secondary hover:bg-fg-default/5 hover:text-text-primary transition-colors" @@ -692,7 +728,7 @@ </NuxtLink> <button type="button" - class="flex items-center gap-3 px-3 py-3 rounded-md text-sm font-medium text-red-400 hover:bg-red-500/10 transition-colors text-left" + class="flex items-center gap-3 px-3 py-3 rounded-md text-sm font-medium text-error hover:bg-error/10 transition-colors text-left" @click="onMobileLogout" > <Icon name="ph:sign-out" class="text-lg flex-shrink-0" /> @@ -762,7 +798,11 @@ </ClientOnly> <!-- Main Content --> - <main class="flex-grow max-w-[var(--container-max)] w-full mx-auto px-4 py-6"> + <main + id="main" + tabindex="-1" + class="flex-grow max-w-[var(--container-max)] w-full mx-auto px-4 py-6" + > <slot /> </main> @@ -810,21 +850,27 @@ href="https://n0w.me/" target="_blank" rel="noopener" - class="text-text-muted hover:text-text-strong transition-colors" + :aria-label="$t('nav.footerSite')" + :title="$t('nav.footerSite')" + class="inline-flex items-center justify-center min-w-[1.5rem] min-h-[1.5rem] text-text-muted hover:text-text-strong transition-colors" ><Icon name="ph:globe" class="text-xl" /></a> <a href="https://github.com/florianjs/trackarr" target="_blank" rel="noopener" - class="text-text-muted hover:text-text-strong transition-colors" + :aria-label="$t('nav.footerSource')" + :title="$t('nav.footerSource')" + class="inline-flex items-center justify-center min-w-[1.5rem] min-h-[1.5rem] text-text-muted hover:text-text-strong transition-colors" ><Icon name="ph:github-logo" class="text-xl" /></a> <a href="https://discord.gg/GRFu35djvz" target="_blank" rel="noopener" - class="text-text-muted hover:text-text-strong transition-colors" + :aria-label="$t('nav.footerChat')" + :title="$t('nav.footerChat')" + class="inline-flex items-center justify-center min-w-[1.5rem] min-h-[1.5rem] text-text-muted hover:text-text-strong transition-colors" ><Icon name="ph:discord-logo" class="text-xl" /></a> </div> @@ -931,23 +977,33 @@ function hashString(str: string): string { return Math.abs(hash).toString(36); } +/* + * Les trois teintes de la bannière, sur les jetons sémantiques. + * + * C'étaient `text-info` / `text-warning` / `text-error` sur des fonds + * `/10` — la palette Tailwind en dur, réglée pour un fond sombre. En thème + * clair : 2,18:1, 1,38:1 et 2,33:1. Le canal par lequel un opérateur annonce + * une maintenance ou une purge n'était pas « peu contrasté », il était absent. + * `--info` / `--warning` / `--danger` sont redéfinis par thème et donnent + * 5,28 / 4,51 / 5,66:1 en clair. + */ const announcementStyles = { info: { - bg: 'bg-blue-500/10', - border: 'border-blue-500/30', - text: 'text-blue-400', + bg: 'bg-info/10', + border: 'border-info/30', + text: 'text-info', icon: 'ph:info', }, warning: { - bg: 'bg-yellow-500/10', - border: 'border-yellow-500/30', - text: 'text-yellow-400', + bg: 'bg-warning/10', + border: 'border-warning/30', + text: 'text-warning', icon: 'ph:warning', }, error: { - bg: 'bg-red-500/10', - border: 'border-red-500/30', - text: 'text-red-400', + bg: 'bg-danger/10', + border: 'border-danger/30', + text: 'text-danger', icon: 'ph:warning-circle', }, }; diff --git a/apps/web/app/pages/admin/banned-ips.vue b/apps/web/app/pages/admin/banned-ips.vue index 3810969d..ef866952 100644 --- a/apps/web/app/pages/admin/banned-ips.vue +++ b/apps/web/app/pages/admin/banned-ips.vue @@ -71,7 +71,7 @@ </label> <button type="submit" - class="btn-primary" + class="sbtn-primary" :disabled="!canSubmit || adding" > <Icon @@ -788,11 +788,33 @@ async function onUnban(item: BannedIp) { outline: none; border-color: rgb(var(--fg-default) / 0.3); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.add-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .add-input--mono { font-family: var(--font-mono); letter-spacing: calc(0.04em * var(--tracking-scale)); } -.btn-primary { +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un `<style scoped>`, donc hors couche, il l'emportait sur + * `@layer components` quelle que soit la spécificité. Sept fichiers donnaient + * ainsi deux boutons visuellement différents sous le même nom de classe, et + * `class="btn btn-primary"` écrit dans l'un d'eux n'aurait pas donné le bouton + * attendu. + */ +.sbtn-primary { display: inline-flex; align-items: center; gap: 0.5rem; @@ -803,15 +825,15 @@ async function onUnban(item: BannedIp) { letter-spacing: calc(0.16em * var(--tracking-scale)); text-transform: uppercase; background: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border: 1px solid rgb(var(--danger)); transition: filter var(--dur-2); white-space: nowrap; } -.btn-primary:hover:not(:disabled) { +.sbtn-primary:hover:not(:disabled) { filter: brightness(1.08); } -.btn-primary:disabled { +.sbtn-primary:disabled { opacity: 0.45; cursor: not-allowed; } @@ -857,6 +879,16 @@ async function onUnban(item: BannedIp) { outline: none; border-color: rgb(var(--fg-default) / 0.3); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.filter-search-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .filter-search-clear { position: absolute; right: 0.6rem; @@ -1009,12 +1041,12 @@ async function onUnban(item: BannedIp) { .source-chip--manual { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .source-chip--auto { border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.08); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-action { @@ -1034,7 +1066,7 @@ async function onUnban(item: BannedIp) { } .row-action:hover:not(:disabled) { border-color: rgba(108, 209, 97, 0.45); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.06); } .row-action:disabled { diff --git a/apps/web/app/pages/admin/federation-taxonomy.vue b/apps/web/app/pages/admin/federation-taxonomy.vue index da5ae997..e115383b 100644 --- a/apps/web/app/pages/admin/federation-taxonomy.vue +++ b/apps/web/app/pages/admin/federation-taxonomy.vue @@ -119,6 +119,8 @@ interface Resp { } const { t } = useI18n(); +const confirm = useConfirm(); +const notifications = useNotificationStore(); const branding = await useBranding(); const federationEnabled = computed(() => Boolean(branding.value?.federationEnabled), @@ -157,20 +159,35 @@ async function add(slug: string, categoryId: string): Promise<void> { form.category = ''; delete pick[remoteSlug]; await refresh(); - } catch { - /* validation error keeps the form as-is */ + } catch (err: unknown) { + // « le formulaire reste en place » décrit une absence de retour, pas un + // retour : l'admin cliquait Mapper, le bouton se réactivait, et rien ne + // permettait de distinguer un refus d'un succès sans effet visible. + const e = err as { data?: { message?: string } }; + notifications.error(e?.data?.message || t('common.saveFailed')); } finally { busy.value = false; } } async function remove(id: string): Promise<void> { + const ok = await confirm({ + title: t('admin.taxonomy.confirmRemove.title'), + message: t('admin.taxonomy.confirmRemove.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; removing.value = new Set(removing.value).add(id); try { await $fetch(`/api/admin/federation/taxonomy/${id}`, { method: 'DELETE' }); await refresh(); - } catch { - /* a refresh reconciles */ + } catch (err: unknown) { + // Le rafraîchissement ne réconcilie rien s'il n'a pas lieu : il était dans + // le `try`, après l'appel qui échoue. + const e = err as { data?: { message?: string } }; + notifications.error(e?.data?.message || t('common.deleteFailed')); + await refresh(); } finally { const next = new Set(removing.value); next.delete(id); @@ -214,7 +231,7 @@ async function remove(id: string): Promise<void> { } .tax-slug { font-family: var(--font-mono); font-size: 0.8rem; - color: #7dd3fc; background: rgba(56, 189, 248, 0.1); + color: rgb(var(--info)); background: rgba(56, 189, 248, 0.1); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border: 1px solid rgba(56, 189, 248, 0.25); border-radius: var(--radius-sm); padding: 0.1rem 0.45rem; flex: none; } @@ -234,7 +251,7 @@ async function remove(id: string): Promise<void> { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.8rem; border: 1px solid rgb(var(--accent-warm) / 0.5); border-radius: var(--radius-sm); background: rgb(var(--accent-warm) / 0.12); - color: var(--gold, rgb(var(--accent-warm))); cursor: pointer; font-weight: 600; font-size: 0.82rem; flex: none; + color: var(--gold, rgb(var(--accent-warm-text))); cursor: pointer; font-weight: 600; font-size: 0.82rem; flex: none; } .tax-btn:disabled { opacity: 0.45; cursor: default; } .tax-remove { diff --git a/apps/web/app/pages/admin/federation.vue b/apps/web/app/pages/admin/federation.vue index 138156aa..deb4e6c6 100644 --- a/apps/web/app/pages/admin/federation.vue +++ b/apps/web/app/pages/admin/federation.vue @@ -370,6 +370,7 @@ interface Resp { } const { t } = useI18n(); +const confirm = useConfirm(); const { data, refresh } = await useFetch<Resp>('/api/admin/federation'); const cfg = computed(() => data.value?.config); const peers = computed(() => data.value?.peers ?? []); @@ -457,8 +458,23 @@ async function run(fn: () => Promise<unknown>, okMsg?: string) { async function toggleMaster(e: Event) { const enabled = (e.target as HTMLInputElement).checked; + // `try/finally` : sans lui, `busy.master` restait vrai après le premier clic + // et les QUATRE interrupteurs de la page (maître, relais, découvrabilité, + // crédit) restaient morts jusqu'au rechargement, sans rien dire — chacun + // étant lié à `:disabled="busy.master"`. busy.master = true; - await run(() => $fetch<unknown>('/api/admin/federation', { method: 'PUT', body: { enabled } }), t('admin.federation.toast.saved')); + try { + await run( + () => + $fetch<unknown>('/api/admin/federation', { + method: 'PUT', + body: { enabled }, + }), + t('admin.federation.toast.saved') + ); + } finally { + busy.master = false; + } } /** Publish an unauthenticated view of the catalogue. Nobody's default. */ @@ -530,6 +546,16 @@ async function resend(p: Peer) { busy.row = ''; } async function revoke(p: Peer) { + // Révoquer coupe l'échange dans les deux sens et retire du catalogue tout ce + // que ce partenaire y avait mis. C'était un bouton, sans un mot, dans une + // liste où le bouton voisin ne fait que suspendre. + const ok = await confirm({ + title: t('admin.federation.confirmRevoke.title'), + message: t('admin.federation.confirmRevoke.message', { name: p.displayName || p.baseUrl }), + confirmText: t('admin.federation.revoke'), + destructive: true, + }); + if (!ok) return; busy.row = p.id; await run(() => $fetch<unknown>(`/api/admin/federation/peers/${p.id}`, { method: 'DELETE' }), t('admin.federation.toast.revoked')); busy.row = ''; diff --git a/apps/web/app/pages/admin/index.vue b/apps/web/app/pages/admin/index.vue index 431422b6..88af3e93 100644 --- a/apps/web/app/pages/admin/index.vue +++ b/apps/web/app/pages/admin/index.vue @@ -232,7 +232,7 @@ const refreshedAgo = computed(() => { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .cr-eyebrow-rule { display: inline-block; @@ -372,7 +372,7 @@ const refreshedAgo = computed(() => { } .cr-section-tag-icon { font-size: 1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .cr-section-meta { font-family: var(--font-mono); @@ -415,7 +415,7 @@ const refreshedAgo = computed(() => { border-radius: 50%; background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.7rem; } .cr-empty-title { diff --git a/apps/web/app/pages/admin/irc.vue b/apps/web/app/pages/admin/irc.vue index 0d3dd84d..f0ca0c04 100644 --- a/apps/web/app/pages/admin/irc.vue +++ b/apps/web/app/pages/admin/irc.vue @@ -694,7 +694,7 @@ async function sendTest() { .irc-conn { grid-template-columns: minmax(0, 1fr) 7rem auto; } } .irc-req { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); margin-left: 0.15rem; } .irc-card { @@ -808,7 +808,7 @@ async function sendTest() { .irc-tokens code { font-family: var(--font-mono); font-size: 0.6875rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .irc-tokens span { color: rgb(var(--fg-muted)); } .irc-tokens em { diff --git a/apps/web/app/pages/admin/notifications.vue b/apps/web/app/pages/admin/notifications.vue index a4a5ba8a..9017fc59 100644 --- a/apps/web/app/pages/admin/notifications.vue +++ b/apps/web/app/pages/admin/notifications.vue @@ -460,7 +460,7 @@ async function testChannel(ch: AdminChannel) { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; diff --git a/apps/web/app/pages/admin/torznab.vue b/apps/web/app/pages/admin/torznab.vue index 1a7817b8..f2629ae4 100644 --- a/apps/web/app/pages/admin/torznab.vue +++ b/apps/web/app/pages/admin/torznab.vue @@ -60,15 +60,27 @@ interface TorznabData { const { data, pending, refresh } = await useFetch<TorznabData>('/api/admin/torznab'); +const { t } = useI18n(); +const notifications = useNotificationStore(); + async function handleConfigUpdate(updates: Partial<TorznabConfig>) { + // Un `console.error` n'est pas un retour d'interface. Les interrupteurs sont + // liés à la prop, donc rien ne bouge à l'écran pendant l'aller-retour ni + // après un échec : couper l'API Torznab produisait exactement la même absence + // de signal que de ne rien faire. Et `refresh()` n'était pas atteint en cas + // d'échec, donc l'état affiché restait celui d'avant sans que personne ne le + // dise. try { await $fetch('/api/admin/torznab', { method: 'PUT', body: updates, }); + notifications.success(t('common.saved')); + } catch (err: unknown) { + const e = err as { data?: { message?: string } }; + notifications.error(e?.data?.message || t('common.saveFailed')); + } finally { await refresh(); - } catch (error) { - console.error('Failed to update Torznab config:', error); } } </script> diff --git a/apps/web/app/pages/auth/login.vue b/apps/web/app/pages/auth/login.vue index 7cb0dfb6..b328531b 100644 --- a/apps/web/app/pages/auth/login.vue +++ b/apps/web/app/pages/auth/login.vue @@ -36,10 +36,10 @@ as "open" since the code is optional in that case. --> <div v-if="registrationMode === 'closed' && !status?.needsSetup" - class="mb-6 p-3 bg-blue-500/10 border border-blue-500/20 rounded flex items-start gap-3" + class="mb-6 p-3 bg-info/10 border border-info/20 rounded flex items-start gap-3" > - <Icon name="ph:lock-simple" class="text-blue-400 text-lg mt-0.5 shrink-0" /> - <p class="text-blue-400 text-xs leading-relaxed"> + <Icon name="ph:lock-simple" class="text-info text-lg mt-0.5 shrink-0" /> + <p class="text-info text-xs leading-relaxed"> {{ $t('auth.login.registrationsClosed') }} </p> </div> @@ -72,7 +72,7 @@ type="text" required autocomplete="username" - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" :placeholder="$t('auth.login.usernamePlaceholder')" /> </div> @@ -90,12 +90,12 @@ type="password" required autocomplete="current-password" - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" :placeholder="$t('auth.login.passwordPlaceholder')" /> </div> - <div v-if="error" class="text-red-400 text-sm"> + <div v-if="error" class="text-error text-sm"> {{ error }} </div> @@ -152,6 +152,24 @@ </NuxtLink> </p> </div> + + <!-- Le registre de rétention, joignable depuis l'écran où on décide de + s'inscrire. `/privacy` est une route ouverte, écrite explicitement + pour ce lecteur, et son seul lien vivait dans le pied du layout — que + ces deux pages n'ont pas (`layout: false`). Une notice que personne ne + peut trouver est une notice que personne n'a reçue. + + HORS du bloc « créer un compte » : celui-ci disparaît quand les + inscriptions sont fermées, et le lecteur qui a le plus besoin de cette + page est justement celui qui arrive sur une instance fermée. --> + <p class="mt-6 text-center"> + <NuxtLink + to="/privacy" + class="text-[11px] font-mono uppercase tracking-widest text-text-muted hover:text-text-strong transition-colors" + > + {{ $t('privacy.eyebrow') }} + </NuxtLink> + </p> </div> </div> </template> @@ -160,6 +178,8 @@ import { generateLoginProof } from '~/utils/crypto'; import TwoFactorLoginStep from '~/components/security/TwoFactorLoginStep.vue'; +const { t } = useI18n(); + definePageMeta({ layout: false, }); @@ -229,14 +249,14 @@ async function handleLogin() { try { // Step 1: Get challenge and salt from server - authStatus.value = 'Fetching challenge...'; + authStatus.value = t('auth.login.status.challenge'); const challengeData = await $fetch<{ salt: string; challenge: string }>( '/api/auth/challenge', { query: { username: form.username } } ); // Step 2: Generate ZKE proof client-side - authStatus.value = 'Generating proof...'; + authStatus.value = t('auth.login.status.proof'); const proof = await generateLoginProof( form.password, challengeData.salt, @@ -244,7 +264,7 @@ async function handleLogin() { ); // Step 3: Send proof to server (password never leaves client) - authStatus.value = 'Authenticating...'; + authStatus.value = t('auth.login.status.authenticating'); const result = await $fetch<{ requires2FA: boolean; challengeToken?: string; @@ -275,7 +295,19 @@ async function handleLogin() { await fetchSession(); router.push('/'); } catch (err: any) { - error.value = err.data?.message || 'Login failed'; + // Mappé sur le statut, pas recopié depuis le corps. La route répond en + // anglais — « Invalid credentials », « Your account has been banned » — et + // `auth.login.errors.*` existait dans les deux locales sans qu'aucun + // appelant ne l'atteigne. Un 401 reste volontairement indistinct : dire + // « ce compte n'existe pas » révélerait l'existence d'un compte, ce que le + // backend prend soin de ne pas faire (sel factice déterministe). + const code = err.statusCode ?? err.status; + error.value = + code === 429 + ? t('auth.login.errors.rateLimited') + : code === 403 + ? err.data?.message || t('auth.login.errors.invalidCredentials') + : t('auth.login.errors.invalidCredentials'); } finally { loading.value = false; authStatus.value = ''; diff --git a/apps/web/app/pages/auth/register.vue b/apps/web/app/pages/auth/register.vue index 4a62f60e..c81c5ec9 100644 --- a/apps/web/app/pages/auth/register.vue +++ b/apps/web/app/pages/auth/register.vue @@ -50,10 +50,11 @@ <p class="text-text-muted text-sm mb-6"> {{ $t('auth.register.closedDescription') }} </p> - <NuxtLink - to="/auth/login" - class="inline-block bg-accent text-accent-fg font-medium px-6 py-2 rounded hover:bg-gray-200 transition-colors" - > + <!-- `btn btn-primary`, pas un empilement d'utilitaires : le survol + `hover:bg-gray-200` posait un gris Tailwind fixe sur un fond qui + suit l'accent du thème, donc du texte `accent-fg` sur gris clair — + illisible en thème sombre comme en thème clair. --> + <NuxtLink to="/auth/login" class="btn btn-primary"> {{ $t('auth.login.submit') }} </NuxtLink> </div> @@ -81,8 +82,8 @@ </div> <!-- ZKE Warning --> - <div class="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded"> - <p class="text-red-400 text-sm"> + <div class="mb-6 p-3 bg-error/10 border border-error/20 rounded"> + <p class="text-error text-sm"> <Icon name="ph:shield-warning" class="inline mr-1" /> <strong>{{ $t('auth.register.zkeTitle') }}</strong> {{ $t('auth.register.zkeDescription') }} </p> @@ -108,7 +109,7 @@ v-model="form.inviteCode" type="text" :required="!status?.registrationOpen" - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors font-mono" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors font-mono" :placeholder="$t('auth.register.inviteCodePlaceholder')" /> </div> @@ -126,7 +127,7 @@ type="text" required autocomplete="username" - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" :placeholder="$t('auth.register.usernamePlaceholder')" /> </div> @@ -144,7 +145,7 @@ type="password" required autocomplete="new-password" - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" :placeholder="$t('auth.register.passwordPlaceholder')" /> </div> @@ -162,7 +163,7 @@ type="password" required autocomplete="new-password" - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" :placeholder="$t('auth.register.confirmPasswordPlaceholder')" /> </div> @@ -173,9 +174,9 @@ class="pt-4 mt-4 border-t border-border" > <div - class="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded" + class="mb-4 p-3 bg-error/10 border border-error/20 rounded" > - <p class="text-red-400 text-sm"> + <p class="text-error text-sm"> <Icon name="ph:shield-warning" class="inline mr-1" /> {{ $t('auth.register.panicPasswordWarning') }} </p> @@ -193,7 +194,7 @@ v-model="form.panicPassword" type="password" required - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" :placeholder="$t('auth.register.panicPasswordPlaceholder')" /> </div> @@ -210,13 +211,13 @@ v-model="form.confirmPanicPassword" type="password" required - class="w-full bg-bg-tertiary border border-border rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" + class="w-full bg-bg-tertiary border border-border-field rounded px-3 py-2 text-sm focus:border-text-strong transition-colors" :placeholder="$t('auth.register.confirmPanicPasswordPlaceholder')" /> </div> </div> - <div v-if="error" class="text-red-400 text-sm"> + <div v-if="error" class="text-error text-sm"> {{ error }} </div> @@ -264,6 +265,24 @@ </NuxtLink> </p> </div> + + <!-- Le registre de rétention, joignable depuis l'écran où on décide de + s'inscrire. `/privacy` est une route ouverte, écrite explicitement + pour ce lecteur, et son seul lien vivait dans le pied du layout — que + ces deux pages n'ont pas (`layout: false`). Une notice que personne ne + peut trouver est une notice que personne n'a reçue. + + HORS du bloc « créer un compte » : celui-ci disparaît quand les + inscriptions sont fermées, et le lecteur qui a le plus besoin de cette + page est justement celui qui arrive sur une instance fermée. --> + <p class="mt-6 text-center"> + <NuxtLink + to="/privacy" + class="text-[11px] font-mono uppercase tracking-widest text-text-muted hover:text-text-strong transition-colors" + > + {{ $t('privacy.eyebrow') }} + </NuxtLink> + </p> </div> </div> </div> diff --git a/apps/web/app/pages/chat.vue b/apps/web/app/pages/chat.vue index 7d4b63f6..385655a7 100644 --- a/apps/web/app/pages/chat.vue +++ b/apps/web/app/pages/chat.vue @@ -313,6 +313,7 @@ interface RoomMessage { const { t } = useI18n(); +const confirm = useConfirm(); const { user } = useUserSession(); /** Mine to edit. Computed rather than stored: the server never says so. */ @@ -721,6 +722,15 @@ function retry(msg: RoomMessage) { } async function remove(msg: RoomMessage) { + // Supprimer le message de quelqu'un d'autre, dans un salon public, sur un + // seul clic et sans retour possible. + const ok = await confirm({ + title: t('room.confirmDelete.title'), + message: t('room.confirmDelete.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; await $fetch(`/api/messaging/room/messages/${msg.id}`, { method: 'DELETE' }); // The relay tells every other reader; this is just the local echo. msg.deleted = true; @@ -819,7 +829,7 @@ onMounted(() => refresh()); flex: none; border-radius: var(--radius-md); background: rgb(var(--accent-warm) / 0.16); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.1rem; } .room-head-text { display: flex; flex-direction: column; min-width: 0; } @@ -1120,6 +1130,16 @@ onMounted(() => refresh()); outline: none; border-color: rgb(var(--accent-warm) / 0.6); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.room-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .room-send { display: inline-flex; align-items: center; @@ -1130,7 +1150,7 @@ onMounted(() => refresh()); border: 0; border-radius: var(--radius-pill); background: rgb(var(--accent-warm)); - color: rgb(var(--bg-base)); + color: rgb(var(--accent-warm-fg)); cursor: pointer; transition: opacity var(--dur-2) ease; } diff --git a/apps/web/app/pages/federated-identity.vue b/apps/web/app/pages/federated-identity.vue index 139e54fc..bd4c4b70 100644 --- a/apps/web/app/pages/federated-identity.vue +++ b/apps/web/app/pages/federated-identity.vue @@ -207,6 +207,7 @@ interface Partner { } const { t } = useI18n(); +const confirm = useConfirm(); // Federation off is a real state a user can land in by typing the URL or // following an old link — the nav items that lead here are already hidden. // `branding` is fetched by the layout on every page, so this costs nothing. @@ -250,7 +251,14 @@ interface RemoteWork { const { data: elsewhereData, refresh: refreshElsewhere } = await useFetch<{ uploads: RemoteWork[]; truncated?: boolean; -}>('/api/me/federated-uploads', { default: () => ({ uploads: [] , immediate: federationEnabled.value}) }); + // `immediate` est une OPTION de `useFetch`, pas un champ de la valeur par + // défaut : écrit à l'intérieur de l'objet rendu par `default`, il n'était + // jamais lu et la requête partait même quand la fédération est éteinte. Les + // deux `useFetch` juste au-dessus le passent au bon endroit. +}>('/api/me/federated-uploads', { + default: () => ({ uploads: [] }), + immediate: federationEnabled.value, +}); const elsewhere = computed(() => elsewhereData.value?.uploads ?? []); const elsewhereTruncated = computed( () => elsewhereData.value?.truncated === true, @@ -360,6 +368,13 @@ async function verify(id: string) { } } async function remove(id: string) { + const ok = await confirm({ + title: t('federatedIdentity.confirmRemove.title'), + message: t('federatedIdentity.confirmRemove.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; busy.remove = id; try { await $fetch(`/api/federation/identities/${id}`, { method: 'DELETE' }); @@ -441,7 +456,7 @@ a.fid-work-name:hover { display: inline-flex; align-items: center; gap: 0.15rem; - color: rgb(var(--success)); + color: rgb(var(--online)); } .fid-work-seed.dead { color: rgb(var(--fg-faint)); diff --git a/apps/web/app/pages/federated.vue b/apps/web/app/pages/federated.vue index c3892c0f..40aac927 100644 --- a/apps/web/app/pages/federated.vue +++ b/apps/web/app/pages/federated.vue @@ -452,9 +452,9 @@ function timeAgo(d: string | null) { .fb-modes { display: inline-flex; border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; } .fb-modes button { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.75rem; font-weight: 600; padding: 0.45rem 0.8rem; color: rgb(var(--fg-muted)); background: rgb(var(--bg-elevated)); border: none; cursor: pointer; transition: all var(--dur-2) ease; } .fb-modes button + button { border-left: 1px solid rgb(var(--line-default)); } -.fb-modes button.active { color: #0a0a0a; background: rgb(var(--info, 56 189 248)); } +.fb-modes button.active { color: rgb(var(--info-fg)); background: rgb(var(--info)); } .fb-modes button:not(.active):hover { background: rgb(var(--bg-hover)); color: rgb(var(--fg-default)); } -.fb-live-hint { display: flex; align-items: center; gap: 0.4rem; font-size: 0.7188rem; color: #7dd3fc; margin: -0.5rem 0 1rem; } +.fb-live-hint { display: flex; align-items: center; gap: 0.4rem; font-size: 0.7188rem; color: rgb(var(--info)); margin: -0.5rem 0 1rem; } .fb-search :deep(svg) { position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: rgb(var(--fg-faint)); } .fb-search .input { padding-left: 2.1rem; } diff --git a/apps/web/app/pages/following.vue b/apps/web/app/pages/following.vue index 5a8d07cd..0bec7ef9 100644 --- a/apps/web/app/pages/following.vue +++ b/apps/web/app/pages/following.vue @@ -285,12 +285,17 @@ interface FedFollow { remoteUsername: string; peerName: string | null; } -const { data: fedData, refresh: refreshFed } = await useFetch<{ data: FedFollow[] }>( +const { data: fedData, refresh: refreshFed } = await useFetch<{ follows: FedFollow[] }>( '/api/me/federated-follows', // immediate: nothing to follow across a mesh this instance is not part of. - { default: () => ({ data: [] }), immediate: federationEnabled.value }, + // `{ follows }` et non `{ data }` : la route renvoie `{ follows: rows }`, donc + // la liste était TOUJOURS vide et la section entière ne s'affichait jamais — + // y compris le bouton qui permet de se désabonner d'un uploadeur distant, le + // bug que le commentaire ci-dessus décrit comme corrigé. Le générique était + // une assertion manuelle, donc TypeScript n'a rien vu. + { default: () => ({ follows: [] }), immediate: federationEnabled.value }, ); -const fedFollows = computed(() => fedData.value?.data ?? []); +const fedFollows = computed(() => fedData.value?.follows ?? []); const fedLeaving = ref(new Set<string>()); async function unfollowFederated(f: FedFollow) { const key = `${f.peerId}:${f.remoteUsername}`; @@ -1168,11 +1173,11 @@ async function unfollow(row: PersonaRow) { .cast-fed { margin: 2.5rem auto 0; max-width: 720px; } .cast-fed-title { font-size: 0.85rem; text-transform: uppercase; letter-spacing: calc(0.08em * var(--tracking-scale)); color: rgb(var(--fg-muted)); margin-bottom: 0.8rem; } .cast-fed-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; } -.cast-fed-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; border: 1px solid rgb(var(--border) / 0.7); border-radius: var(--radius-lg); background: rgb(var(--bg-subtle) / 0.4); } +.cast-fed-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; border: 1px solid rgb(var(--line-default) / 0.7); border-radius: var(--radius-lg); background: rgb(var(--bg-inset) / 0.4); } .cast-fed-ico { color: rgb(var(--accent)); flex: none; } .cast-fed-name { font-weight: 600; } .cast-fed-peer { color: rgb(var(--fg-muted)); font-size: 0.8rem; } -.cast-fed-leave { margin-left: auto; padding: 0.25rem 0.6rem; border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); background: transparent; color: rgb(var(--fg-muted)); cursor: pointer; font-size: 0.78rem; } +.cast-fed-leave { margin-left: auto; padding: 0.25rem 0.6rem; border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-md); background: transparent; color: rgb(var(--fg-muted)); cursor: pointer; font-size: 0.78rem; } .cast-fed-leave:hover:not(:disabled) { color: rgb(var(--danger)); border-color: rgb(var(--danger) / 0.5); } .cast-fed-leave:disabled { opacity: 0.5; cursor: default; } diff --git a/apps/web/app/pages/forum/category/[id].vue b/apps/web/app/pages/forum/category/[id].vue index 0c1d2c2e..dc93dcc3 100644 --- a/apps/web/app/pages/forum/category/[id].vue +++ b/apps/web/app/pages/forum/category/[id].vue @@ -264,7 +264,9 @@ const canDelete = computed( ); const bannerStyle = computed(() => ({ - '--accent': category.value?.color || 'rgb(var(--fg-muted))', + // Voir la note de `forum/index.vue` : un HEX ne peut pas être consommé en + // `rgb(var(…))`, et masquer le jeton global sous un autre type est un piège. + '--cat-accent': category.value?.color || 'rgb(var(--fg-muted))', })); function formatIssueNumber(n: number): string { @@ -321,7 +323,7 @@ async function handleDeleteTopic(topic: ForumTopic) { border: 1px solid var(--rule); border-radius: var(--radius-sm); background: - radial-gradient(800px 200px at 0% 0%, color-mix(in srgb, rgb(var(--accent)) 12%, transparent), transparent 60%), + radial-gradient(800px 200px at 0% 0%, color-mix(in srgb, var(--cat-accent) 12%, transparent), transparent 60%), rgb(var(--bg-surface)); overflow: hidden; } @@ -329,7 +331,7 @@ async function handleDeleteTopic(topic: ForumTopic) { position: absolute; inset: 0 auto 0 0; width: 6px; - background: rgb(var(--accent)); + background: var(--cat-accent); } .cat-crumb { @@ -372,9 +374,9 @@ async function handleDeleteTopic(topic: ForumTopic) { width: 3.5rem; height: 3.5rem; border-radius: var(--radius-sm); - background: color-mix(in srgb, rgb(var(--accent)) 14%, transparent); - border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent); - color: rgb(var(--accent)); + background: color-mix(in srgb, var(--cat-accent) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--cat-accent) 35%, transparent); + color: var(--cat-accent); font-size: 1.7rem; flex-shrink: 0; } @@ -393,7 +395,7 @@ async function handleDeleteTopic(topic: ForumTopic) { font-weight: 700; letter-spacing: calc(0.16em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent)); + color: var(--cat-accent); margin: 0; } .cat-banner-eyebrow .dot { diff --git a/apps/web/app/pages/forum/index.vue b/apps/web/app/pages/forum/index.vue index 6b19d145..0f915321 100644 --- a/apps/web/app/pages/forum/index.vue +++ b/apps/web/app/pages/forum/index.vue @@ -401,10 +401,19 @@ function excerpt(text: string, max: number): string { function resolveIcon(cat: ForumCategory): string { return cat.icon || 'ph:newspaper-clipping-bold'; } +// `--cat-accent` et non `--accent` : la couleur d'une section arrive de l'admin +// sous forme de HEX, alors que le jeton global `--accent` est un TRIPLET consommé +// en `var(--cat-accent)`. Le résultat était `rgb(#9ca3af)` — invalide, donc la +// déclaration entière disparaissait : le rail gauche des tuiles, la puce d'icône, +// le numéro d'édition et le fond de la bannière de section n'existaient pas. Et +// le nom `--accent` restait masqué avec une valeur d'un autre type sur tout le +// sous-arbre, ce qui aurait piégé n'importe quel composant partagé rendu +// dedans. Le repli est déjà une couleur complète, donc les deux formes se +// consomment directement, sans `rgb()`. function tileStyle(cat: ForumCategory) { const accent = cat.color || 'rgb(var(--fg-muted))'; return { - '--accent': accent, + '--cat-accent': accent, } as Record<string, string>; } @@ -731,7 +740,7 @@ async function handleDelete() { } .ed-btn--danger { background: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgb(var(--danger)); } .ed-btn--danger:hover:not(:disabled) { @@ -777,13 +786,13 @@ async function handleDelete() { position: relative; background: rgb(var(--bg-surface)); border: 1px solid var(--rule); - border-left: 3px solid color-mix(in srgb, rgb(var(--accent)) 80%, transparent); + border-left: 3px solid color-mix(in srgb, var(--cat-accent) 80%, transparent); border-radius: var(--radius-sm); transition: border-color var(--dur-3), transform var(--dur-3); } .section-tile:hover { border-color: var(--rule-strong); - border-left-color: rgb(var(--accent)); + border-left-color: var(--cat-accent); transform: translateY(-1px); } .section-tile--skeleton { @@ -815,7 +824,7 @@ async function handleDelete() { font-size: 0.6563rem; font-weight: 700; letter-spacing: calc(0.22em * var(--tracking-scale)); - color: rgb(var(--accent)); + color: var(--cat-accent); text-transform: uppercase; } .section-tile-icon { @@ -826,9 +835,9 @@ async function handleDelete() { align-items: center; justify-content: center; font-size: 1.4rem; - color: rgb(var(--accent)); - background: color-mix(in srgb, rgb(var(--accent)) 12%, transparent); - border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent); + color: var(--cat-accent); + background: color-mix(in srgb, var(--cat-accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--cat-accent) 35%, transparent); } .section-tile-title { margin: 0; @@ -899,7 +908,7 @@ async function handleDelete() { position: absolute; left: 0; top: 0.05rem; - color: rgb(var(--accent)); + color: var(--cat-accent); font-size: 0.95rem; } .last-byline { diff --git a/apps/web/app/pages/index.vue b/apps/web/app/pages/index.vue index 89e120ac..925b9dcf 100644 --- a/apps/web/app/pages/index.vue +++ b/apps/web/app/pages/index.vue @@ -167,7 +167,17 @@ @click="navigateTo(`/torrents/${t.infoHash}`)" > <span class="mc-ledger-rank">{{ String(i + 1).padStart(3, '0') }}</span> - <span class="mc-ledger-name" :title="t.name">{{ t.name }}</span> + <!-- Le nom porte le lien. La ligne n'avait qu'un `@click` : la + tabulation traversait tout le tableau de la page d'accueil sans + s'y arrêter, et rien n'annonçait qu'une ligne menait quelque + part. Le clic sur la ligne reste, comme raccourci. --> + <NuxtLink + :to="`/torrents/${t.infoHash}`" + class="mc-ledger-name" + :title="t.name" + @click.stop + >{{ t.name }}</NuxtLink + > <span class="mc-ledger-size">{{ formatSize(t.size) }}</span> <span class="mc-ledger-seed" :class="{ 'mc-ledger-seed--zero': !t.stats.seeders }"> <Icon name="ph:arrow-up-bold" /> @@ -210,7 +220,13 @@ > <span class="mc-hot-rank">0{{ i + 1 }}</span> <span class="mc-hot-meta"> - <span class="mc-hot-name" :title="t.name">{{ t.name }}</span> + <NuxtLink + :to="`/torrents/${t.infoHash}`" + class="mc-hot-name" + :title="t.name" + @click.stop + >{{ t.name }}</NuxtLink + > <span class="mc-hot-detail"> <Icon name="ph:arrow-up-bold" /> <strong>{{ t.stats.seeders }}</strong> @@ -582,10 +598,16 @@ function formatClock(d: Date): string { )} UTC`; } if (import.meta.client) { - const clockTick = setInterval(() => { + let clockTick: ReturnType<typeof setInterval> | null = null; + onMounted(() => { liveClock.value = formatClock(new Date()); - }, 1000); - onBeforeUnmount(() => clearInterval(clockTick)); + clockTick = setInterval(() => { + liveClock.value = formatClock(new Date()); + }, 1000); + }); + onBeforeUnmount(() => { + if (clockTick) clearInterval(clockTick); + }); } // ── Manifesto features ────────────────────────────────────────────── @@ -1121,6 +1143,8 @@ function useCounter(target: Ref<number>) { transition: color var(--dur-2) ease; } .mc-ledger-name { + display: block; + min-width: 0; color: rgb(var(--fg-strong)); font-weight: 500; letter-spacing: calc(-0.005em * var(--tracking-scale)); @@ -1295,6 +1319,8 @@ function useCounter(target: Ref<number>) { min-width: 0; } .mc-hot-name { + display: block; + min-width: 0; font-family: var(--font-mono); font-size: 0.75rem; color: rgb(var(--fg-strong)); diff --git a/apps/web/app/pages/invites.vue b/apps/web/app/pages/invites.vue index f5082f36..8c4aa637 100644 --- a/apps/web/app/pages/invites.vue +++ b/apps/web/app/pages/invites.vue @@ -674,11 +674,11 @@ async function confirmDelete() { box-shadow: 0 0 0 3px color-mix(in srgb, var(--gild) 18%, transparent); } .reg-pill-dot--success { - background: var(--success); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 18%, transparent); + background: var(--online); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--online) 18%, transparent); } .reg-pill--active { color: var(--gild); border-color: var(--gild-line); } -.reg-pill--used { color: var(--success); border-color: color-mix(in srgb, var(--success) 30%, transparent); } +.reg-pill--used { color: var(--online); border-color: color-mix(in srgb, var(--online) 30%, transparent); } .reg-pill--expired { color: var(--ink-muted); } /* ── Main column ──────────────────────────────────────── */ @@ -1023,7 +1023,7 @@ async function confirmDelete() { border-right: 1px solid rgb(var(--line-default)); } .ledger-row--active .ledger-num { color: var(--gild); } -.ledger-row--used .ledger-num { color: var(--success); } +.ledger-row--used .ledger-num { color: var(--online); } .ledger-row--expired .ledger-num { color: var(--ink-muted); opacity: 0.7; } .ledger-stripe { @@ -1033,7 +1033,7 @@ async function confirmDelete() { border-radius: var(--radius-xs); } .ledger-row--active .ledger-stripe { background: var(--gild); opacity: 0.6; } -.ledger-row--used .ledger-stripe { background: var(--success); opacity: 0.5; } +.ledger-row--used .ledger-stripe { background: var(--online); opacity: 0.5; } .ledger-row-body { min-width: 0; } .ledger-row-line { @@ -1075,9 +1075,9 @@ async function confirmDelete() { border-color: var(--gild-line); } .ledger-row--used .ledger-status { - color: var(--success); - background: color-mix(in srgb, var(--success) 12%, transparent); - border-color: color-mix(in srgb, var(--success) 30%, transparent); + color: var(--online); + background: color-mix(in srgb, var(--online) 12%, transparent); + border-color: color-mix(in srgb, var(--online) 30%, transparent); } .ledger-row--expired .ledger-status { color: var(--ink-muted); diff --git a/apps/web/app/pages/me.vue b/apps/web/app/pages/me.vue index 6316c664..4e2d12fd 100644 --- a/apps/web/app/pages/me.vue +++ b/apps/web/app/pages/me.vue @@ -1781,17 +1781,17 @@ function formatDuration(seconds: number) { .hero-pill--admin { border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.1); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .hero-pill--mod { border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.1); - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .hero-pill--custom { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.1); - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .hero-pill--member { color: rgb(var(--fg-default)); @@ -1928,29 +1928,29 @@ function formatDuration(seconds: number) { font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: calc(0.02em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .kpi-sub-icon { font-size: 0.85em; flex-shrink: 0; } .kpi--up .kpi-value { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--down .kpi-value { color: rgb(var(--fg-strong)); } .kpi--ratio-great .kpi-value { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--ratio-ok .kpi-value { color: rgb(var(--fg-strong)); } .kpi--ratio-low .kpi-value { - color: #f5c518; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--ratio-zero .kpi-value { - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Bonus KPI — replaces the old "Released" tile. The value is @@ -1964,7 +1964,7 @@ function formatDuration(seconds: number) { align-items: baseline; gap: 0.32rem; font-size: clamp(1.05rem, 2vw, 1.35rem); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-weight: 800; letter-spacing: calc(-0.01em * var(--tracking-scale)); } @@ -1989,7 +1989,7 @@ function formatDuration(seconds: number) { transition: color var(--dur-2) ease, gap var(--dur-4) ease; } .kpi-sub--link:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); gap: 0.4rem; } .kpi-sub--link :deep(svg), @@ -2111,7 +2111,7 @@ function formatDuration(seconds: number) { .bv-trigger-icon { flex-shrink: 0; font-size: 1.05rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .bv-trigger-label { flex-shrink: 0; @@ -2155,7 +2155,7 @@ function formatDuration(seconds: number) { } .bonus-chevron--open { transform: rotate(-180deg); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ─── Bonus transaction ledger (expandable) ──────────────────── @@ -2381,7 +2381,7 @@ function formatDuration(seconds: number) { background: rgb(var(--online) / 0.08); } .bv-tag--spend { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); background: rgb(var(--accent-warm) / 0.08); } @@ -2426,7 +2426,7 @@ function formatDuration(seconds: number) { color: rgb(var(--online)); } .bv-amount--spend { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* Free-text note — when present it slips onto a second line of the @@ -2851,17 +2851,17 @@ function formatDuration(seconds: number) { flex-shrink: 0; } .row--live .row-icon { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); } .row--hnr .row-icon { - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.08); } .row--exempt .row-icon { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); } @@ -2921,10 +2921,10 @@ function formatDuration(seconds: number) { gap: 0.2rem; } .row-stat--seed { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-stat--leech { - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-age { margin-left: auto; @@ -3001,12 +3001,12 @@ function formatDuration(seconds: number) { .row-status.status-great { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-status.status-ok { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-status.status-pending { color: rgb(var(--fg-muted)); @@ -3014,12 +3014,12 @@ function formatDuration(seconds: number) { .row-status.status-info { border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-status.status-danger { border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.08); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-action { diff --git a/apps/web/app/pages/messages.vue b/apps/web/app/pages/messages.vue index ca86bba9..4c93519a 100644 --- a/apps/web/app/pages/messages.vue +++ b/apps/web/app/pages/messages.vue @@ -1047,6 +1047,7 @@ interface ThreadMessage { } const { t } = useI18n(); +const confirm = useConfirm(); const { user } = useUserSession(); const route = useRoute(); const router = useRouter(); @@ -2161,11 +2162,36 @@ if (import.meta.client) { }); } +/** + * Charge un fil, ou une page plus ancienne du fil courant. + * + * `target` est capturé À L'ENTRÉE et sert à TOUT — l'URL des messages, celle de + * la clé du pair, l'identifiant passé à `convCrypto.prepare` — puis il est + * comparé à `activeId` avant la seule écriture d'état. + * + * Sans cela : la fonction lisait `activeId.value` pour bâtir l'URL, puis + * attendait le fetch des messages, celui de la clé du pair, la préparation du + * chiffre (qui RELISAIT `activeId.value` après les `await`) et un `Promise.all` + * de déchiffrements par message — avant d'écrire `messages.value` sans vérifier + * quoi que ce soit. Deux clics rapides, A puis B, lançaient deux appels ; celui + * qui finissait en dernier gagnait. Conséquence au-delà de l'affichage : le + * texte EN CLAIR de A s'affichait sous l'en-tête de B, `send()` postait sur B + * une réponse écrite face à A, et `markRead` visait le mauvais fil. + * + * Le dépôt connaissait déjà cette classe de bug — le commentaire de `send()` + * dit « `activeId` can change during it » et la garde y a été posée. Elle + * manquait ici. + */ async function loadThread(before?: string) { + const target = activeId.value; + if (!target) return; + const stale = () => activeId.value !== target; + const data = await $fetch<{ messages: ThreadMessage[]; nextBefore: string | null }>( - `/api/messaging/conversations/${activeId.value}/messages`, + `/api/messaging/conversations/${target}/messages`, { query: before ? { before } : undefined } ); + if (stale()) return; // The API answers newest-first for the cursor to work; the thread reads // oldest-first. let page = [...data.messages].reverse().map((m) => ({ @@ -2188,12 +2214,17 @@ async function loadThread(before?: string) { `/api/messaging/keys/${encodeURIComponent(active.value.with.username)}` ).catch(() => ({ available: false }) as const) : ({ available: false } as const); + if (stale()) return; await convCrypto.prepare({ encrypted: true, - conversationId: activeId.value!, + // `target`, pas `activeId.value` : cette ligne était relue APRÈS deux + // `await`, donc elle préparait le chiffre de la conversation affichée à + // cet instant pour déchiffrer les messages d'une autre. + conversationId: target, peerPublicKey: 'publicKey' in peer ? peer.publicKey : null, }); + if (stale()) return; } page = await Promise.all( @@ -2208,6 +2239,9 @@ async function loadThread(before?: string) { ); } + // La seule écriture d'état : gardée, parce que tout ce qui précède a pu + // laisser passer un changement de conversation. + if (stale()) return; messages.value = before ? [...page, ...messages.value] : page; nextBefore.value = data.nextBefore; if (!before) await nextTick(scrollToEnd); @@ -2365,6 +2399,15 @@ const isStaff = computed( * coherent and a report still has something to point at. */ async function removeMessage(msg: ThreadMessage) { + // Le retrait est définitif et visible d'en face : le message devient « retiré » + // chez le destinataire aussi. Il partait sur un clic dans un menu contextuel. + const ok = await confirm({ + title: t('messaging.confirmWithdraw.title'), + message: t('messaging.confirmWithdraw.message'), + confirmText: t('messaging.withdraw'), + destructive: true, + }); + if (!ok) return; const previousBody = msg.body; const previousCipher = msg.cipher; msg.deleted = true; @@ -2580,6 +2623,52 @@ async function startConversation() { flex-shrink: 0; border-radius: var(--radius-sm); cursor: pointer; + /* + * Un châssis, enfin. + * + * Ces trois boutons n'avaient ni fond, ni bordure, ni couleur, ni `:hover`, + * ni état désactivé : le preflight Tailwind retire fond et bordure de tout + * `<button>`, et aucune règle globale `button` n'existe. « Envoyer » — l'action + * principale de la page — était donc un glyphe nu, visuellement identique + * quand `:disabled="!draft.trim()"` le désactivait, tout comme « Accepter » + * une demande de premier contact. Et `.msg-back` est le seul chemin de retour + * sur téléphone. + * + * `/chat` fait la démonstration de l'intention : son `.room-send` est un + * disque or plein avec un `:disabled` visible. + */ + border: 1px solid rgb(var(--line-default)); + background: rgb(var(--bg-elevated)); + color: rgb(var(--fg-default)); + transition: + color var(--dur-2) ease, + background-color var(--dur-2) ease, + border-color var(--dur-2) ease; +} +.msg-new:hover:not(:disabled), +.msg-back:hover:not(:disabled), +.msg-send:hover:not(:disabled) { + color: rgb(var(--fg-strong)); + border-color: rgb(var(--fg-default) / 0.3); +} +.msg-new:disabled, +.msg-back:disabled, +.msg-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} +/* Envoyer est l'action principale : elle prend l'or plein, comme dans le + salon, pour que le point le plus lumineux du composeur soit le geste et non + la pastille de non-lus. */ +.msg-send:not(:disabled) { + background: rgb(var(--accent-warm)); + border-color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-fg)); +} +.msg-send:hover:not(:disabled) { + background: rgb(var(--accent-warm) / 0.85); + border-color: rgb(var(--accent-warm) / 0.85); + color: rgb(var(--accent-warm-fg)); } .msg-section { @@ -2620,7 +2709,7 @@ async function startConversation() { flex: none; border-radius: var(--radius-md); background: rgb(var(--accent-warm) / 0.16); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.05rem; } .msg-quick-text { display: flex; flex-direction: column; min-width: 0; } @@ -2670,7 +2759,7 @@ async function startConversation() { padding: 0 0.25rem; border-radius: var(--radius-pill); background: rgb(var(--accent-warm)); - color: rgb(var(--bg-base)); + color: rgb(var(--accent-warm-fg)); font-family: var(--font-mono); font-size: 0.6rem; font-weight: 800; @@ -2774,7 +2863,7 @@ async function startConversation() { `bg-accent` utility in the template, which the rewrite dropped — leaving a badge with a foreground colour and no ground. */ background: rgb(var(--accent-warm)); - color: rgb(var(--bg-base)); + color: rgb(var(--accent-warm-fg)); font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 0.65rem; @@ -3008,7 +3097,20 @@ async function startConversation() { border-radius: var(--radius-sm); font-size: 0.8125rem; cursor: pointer; + /* Accepter une demande de premier contact est une décision : elle mérite une + boîte. Même défaut que les trois boutons ci-dessus. */ + min-height: 2.25rem; + border: 1px solid rgb(var(--online) / 0.5); + background: rgb(var(--online) / 0.12); + color: rgb(var(--online)); + font-weight: 600; + transition: background-color var(--dur-2) ease, border-color var(--dur-2) ease; +} +.msg-accept:hover:not(:disabled) { + background: rgb(var(--online) / 0.2); + border-color: rgb(var(--online) / 0.8); } +.msg-accept:disabled { opacity: 0.5; cursor: not-allowed; } .msg-input { flex: 1; @@ -3203,7 +3305,7 @@ async function startConversation() { .msg-list-foot { margin-top: auto; padding-top: 0.5rem; - border-top: 1px solid rgb(var(--line)); + border-top: 1px solid rgb(var(--line-default)); } .msg-foot-link { display: inline-flex; @@ -3339,6 +3441,16 @@ async function startConversation() { .msg-search-input { font-size: 0.8125rem; } } .msg-search-input:focus { outline: none; } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.msg-search-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .msg-search-input::placeholder { color: rgb(var(--fg-muted)); } /* WebKit draws its own cancel button and magnifier inside type="search"; both fight the ones above. */ @@ -3424,7 +3536,7 @@ async function startConversation() { padding: 0 0.3rem; border-radius: var(--radius-pill); background: rgb(var(--accent-warm)); - color: rgb(var(--bg-base)); + color: rgb(var(--accent-warm-fg)); font-family: var(--font-mono); font-size: 0.6rem; font-weight: 800; diff --git a/apps/web/app/pages/mod.vue b/apps/web/app/pages/mod.vue index 3145c26b..2bde0811 100644 --- a/apps/web/app/pages/mod.vue +++ b/apps/web/app/pages/mod.vue @@ -43,7 +43,7 @@ (the watchtower greeting + live clock). For every other mod sub-page we render the generic title strip so the operator always knows where they are. --> - <div v-if="$route.path !== '/mod'" class="mb-6"> + <div v-if="$route.path !== '/mod' && currentItem" class="mb-6"> <h1 class="text-2xl font-bold text-text-primary tracking-tight uppercase" > @@ -66,6 +66,10 @@ definePageMeta({ const route = useRoute(); const { t } = useI18n(); +const branding = await useBranding(); +const federationEnabled = computed(() => + Boolean(branding.value?.federationEnabled), +); const menuItems = computed(() => [ { @@ -118,10 +122,33 @@ const menuItems = computed(() => [ icon: 'ph:eye', description: t('mod.descriptions.messageReads'), }, + // Seulement quand la fédération tourne : sinon la page n'affiche que son + // écran « fédération désactivée ». Elle n'était atteignable que par une + // vignette du tableau de bord, et le rail ne la marquait donc jamais comme + // page courante. + ...(federationEnabled.value + ? [ + { + label: t('mod.nav.federationMasks'), + path: '/mod/federation-masks', + icon: 'ph:eye-slash', + description: t('mod.descriptions.federationMasks'), + }, + ] + : []), ]); +/** + * L'entrée de menu correspondant à la route, ou rien. + * + * Le repli sur `menuItems[0]` imprimait « TABLEAU DE BORD » et sa description + * au-dessus de toute page absente du menu — `/mod/federation-masks` en + * l'occurrence, qui affichait donc un titre appartenant à une autre page. Une + * page qu'on ne sait pas nommer n'a pas de bande de titre ; elle porte la + * sienne. + */ const currentItem = computed( - () => menuItems.value.find((item) => item.path === route.path) || menuItems.value[0] + () => menuItems.value.find((item) => item.path === route.path) ?? null, ); const currentTitle = computed(() => currentItem.value?.label || ''); diff --git a/apps/web/app/pages/mod/anti-cheat.vue b/apps/web/app/pages/mod/anti-cheat.vue index a638d365..adbaf3cb 100644 --- a/apps/web/app/pages/mod/anti-cheat.vue +++ b/apps/web/app/pages/mod/anti-cheat.vue @@ -1,5 +1,17 @@ <template> <div class="ac"> + <!-- + Ce que la validation d'un dossier fait, et ne fait pas. + + La route est explicite : « Never modifies the user. Bans/warnings are + still issued manually from the user admin surface — this endpoint is only + the audit stamp. » Rien ne le disait à l'écran, et le verdict s'appelait + « Banni », en rouge, avec un tampon. Un modérateur cliquait, validait, + recevait « Signalement traité » — et le tricheur continuait. La seule + phrase du produit qui l'explique existait déjà dans les deux locales et + n'était appelée nulle part. + --> + <p class="ac-tagline">{{ $t('mod.antiCheat.tagline') }}</p> <!-- ╔══════════════════════════════════════════════════════════╗ ║ RADAR — KPI strip + live status indicator ║ ║ Tiles are clickable: each one filters the case feed ║ @@ -188,12 +200,20 @@ class="ac-case-verdict" :class="`ac-case-verdict--${row.reviewVerdict}`" > - {{ row.reviewVerdict }} + {{ $t(`mod.antiCheat.verdict.${row.reviewVerdict}`) }} </span> <span v-else class="ac-case-verdict ac-case-verdict--open"> <span class="ac-case-verdict-dot" /> {{ $t('mod.antiCheat.openLabel') }} </span> + <!-- La force de l'indice, écrite. Elle n'existait que comme un ruban + vertical `aria-hidden` de 3 à 5 px : deux pixels d'écart entre + « faible » et « élevé », sur un écran où l'on décide de + sanctionner un compte. Les trois libellés existaient dans les + deux locales et n'étaient appelés nulle part. --> + <span class="ac-case-sev" :class="`ac-case-sev--${row.severity}`"> + {{ $t(`mod.antiCheat.severity.${row.severity}`) }} + </span> </div> <Icon @@ -210,7 +230,7 @@ :class="`ac-stamp--${row.reviewVerdict}`" aria-hidden="true" > - {{ row.reviewVerdict }} + {{ $t(`mod.antiCheat.verdict.${row.reviewVerdict}`) }} </span> <!-- Inline detail panel --> @@ -257,7 +277,7 @@ <Icon name="ph:seal-check-fill" class="ac-already-icon" /> <div class="ac-already-body"> <p class="ac-already-line"> - <strong>{{ row.reviewVerdict }}</strong> + <strong>{{ $t(`mod.antiCheat.verdict.${row.reviewVerdict}`) }}</strong> · {{ $t('mod.antiCheat.detail.reviewedBy', { @@ -271,6 +291,7 @@ </p> </div> <button + v-if="!reopenedIds.has(row.id)" type="button" class="ac-already-reopen" @click="reopenReview(row)" @@ -279,9 +300,11 @@ </button> </div> - <!-- Review form --> + <!-- Le formulaire de décision. `v-if` plutôt que `v-else` : sur un + dossier rouvert, il s'ajoute SOUS le verdict précédent au lieu de + le remplacer. --> <form - v-else + v-if="!row.reviewedAt || reopenedIds.has(row.id)" class="ac-review" @submit.prevent="submitReview(row)" > @@ -451,6 +474,7 @@ definePageMeta({ title: 'Anti-cheat' }); const { t } = useI18n(); const notifications = useNotificationStore(); +const confirm = useConfirm(); useHead({ title: () => t('mod.antiCheat.title') }); type Kind = 'velocity' | 'no_leecher' | 'unknown_client'; @@ -629,6 +653,14 @@ function kindIcon(k: Kind): string { return 'ph:users-three-bold'; case 'unknown_client': return 'ph:fingerprint-bold'; + // `anticheat_flags.kind` est une colonne `text`, sans contrainte : le type + // `Kind` est une promesse du client, pas une garantie de la base. Sans ce + // repli, un `kind` inconnu — un quatrième détecteur, une ligne d'une + // version antérieure — renvoyait `undefined` à `<Icon :name>`, qui appelle + // `name.startsWith()` : une exception pendant le rendu, et c'est TOUTE la + // file de modération qui disparaît, pas seulement la ligne fautive. + default: + return 'ph:warning-bold'; } } @@ -679,6 +711,10 @@ const VERDICTS = [ { value: 'monitoring', icon: 'ph:eye-bold' }, ] as const; +/** Les dossiers rouverts localement : le formulaire réapparaît, l'historique + * reste visible. */ +const reopenedIds = ref<Set<string>>(new Set()); + const reviewForm = reactive<{ verdict: Verdict | ''; note: string }>({ verdict: '', note: '', @@ -707,6 +743,9 @@ async function submitReview(row: FlagRow) { }, }); notifications.success(t('mod.antiCheat.toastReviewed')); + const next = new Set(reopenedIds.value); + next.delete(row.id); + reopenedIds.value = next; expandedId.value = null; await refresh(); } catch (err: any) { @@ -719,9 +758,17 @@ async function submitReview(row: FlagRow) { } function reopenReview(row: FlagRow) { + // Le verdict précédent, son auteur et sa note restent à l'écran. + // + // Cette fonction mettait `row.reviewedAt` à `null`, ce qui les faisait + // disparaître du panneau : le modérateur re-décidait donc SANS le contexte de + // son prédécesseur — précisément ce que le champ note existe pour transmettre + // (« contexte pour le prochain modérateur ») — et le premier `refresh()` venu + // faisait réapparaître l'ancien verdict comme si de rien n'était, puisque rien + // n'avait été envoyé au serveur. reviewForm.verdict = ''; reviewForm.note = ''; - row.reviewedAt = null as unknown as string; + reopenedIds.value = new Set(reopenedIds.value).add(row.id); } // ── Bulk selection ────────────────────────────────────────── @@ -772,6 +819,20 @@ function clearSelection() { async function submitBulk() { if (!bulkForm.verdict || selectedCount.value === 0 || bulkSubmitting.value) return; + // Une décision par lot, sans retour possible et sans étape intermédiaire : + // un clic sur « Confirmé » posait le verdict sur les quarante dossiers cochés. + // Le formulaire unitaire, lui, demande d'ouvrir le dossier, de lire les + // indices puis de choisir — le lot court-circuitait les trois. + const ok = await confirm({ + title: t('mod.antiCheat.bulk.confirmTitle', { n: selectedCount.value }, selectedCount.value), + message: t('mod.antiCheat.bulk.confirmBody', { + n: selectedCount.value, + verdict: t(`mod.antiCheat.verdict.${bulkForm.verdict}`), + }, selectedCount.value), + confirmText: t('mod.antiCheat.bulk.apply'), + destructive: bulkForm.verdict === 'banned', + }); + if (!ok) return; bulkSubmitting.value = true; try { const ids = Array.from(selected.value); @@ -850,6 +911,29 @@ const emptySub = computed(() => { </script> <style scoped> +.ac-tagline { + margin-bottom: 1rem; + font-size: 0.8125rem; + line-height: 1.6; + color: rgb(var(--fg-muted)); + max-width: 78ch; +} + +.ac-case-sev { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border: 1px solid currentColor; + border-radius: var(--radius-pill); + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.1em * var(--tracking-scale)); + color: rgb(var(--fg-subtle)); +} +.ac-case-sev--medium { color: rgb(var(--warning)); } +.ac-case-sev--high { color: rgb(var(--danger)); } + .ac { position: relative; isolation: isolate; @@ -1629,7 +1713,7 @@ const emptySub = computed(() => { } .ac-btn--primary { background: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgb(var(--danger)); filter: drop-shadow(0 6px 16px rgb(var(--danger) / 0.35)); } @@ -1773,6 +1857,21 @@ const emptySub = computed(() => { opacity: 0; pointer-events: none; } +/* La cible réelle, pas la marque : 20 px de carré coché entouré de 2 px de + marge donnaient une cible de 20 px sur un écran où l'on coche quarante lignes + à la suite (2.5.8 en demande 24). Le rembourrage l'élargit sans déplacer la + marque, qui reste alignée sur le ruban. */ +.ac-case-select { + padding: 4px; + margin: -4px; + border-radius: var(--radius-sm); +} +/* L'input est en `opacity: 0` : sans cette règle, le parcours au clavier + traverse la colonne de cases sans que rien ne bouge à l'écran. */ +.ac-case-select:focus-within { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 1px; +} .ac-case-select-mark { display: inline-flex; align-items: center; diff --git a/apps/web/app/pages/mod/federation-masks.vue b/apps/web/app/pages/mod/federation-masks.vue index 9912a0fa..ca92dd7f 100644 --- a/apps/web/app/pages/mod/federation-masks.vue +++ b/apps/web/app/pages/mod/federation-masks.vue @@ -1,16 +1,10 @@ <template> <FederationOff v-if="!federationEnabled" /> <div v-else class="masks"> - <header class="masks-head"> - <div> - <h1 class="masks-title">{{ $t('mod.masks.title') }}</h1> - <p class="masks-sub">{{ $t('mod.masks.sub') }}</p> - </div> - <NuxtLink to="/mod" class="masks-back"> - <Icon name="ph:arrow-left-bold" /> {{ $t('common.back') }} - </NuxtLink> - </header> - + <!-- Le titre vient de la bande de `mod.vue`, comme sur les huit autres + pages de la console. Cette page portait le sien, si bien que la bande + affichait « TABLEAU DE BORD » juste au-dessus de « Masques + fédération ». --> <!-- Add a mask by hand (a moderator who knows the id / hash / DID). Most masks will come from resolving a report, but this is the direct lever. --> <form class="masks-add" @submit.prevent="add"> @@ -45,7 +39,12 @@ <ul v-else class="masks-list"> <li v-for="m in masks" :key="m.id" class="masks-row"> - <span class="masks-scope" :class="`masks-scope--${m.scope}`">{{ m.scope }}</span> + <!-- La portée, en toutes lettres. La pastille imprimait la valeur brute + de l'énumération (`infohash`, `issuer`) alors que les libellés + existaient déjà pour le formulaire juste au-dessus. --> + <span class="masks-scope" :class="`masks-scope--${m.scope}`"> + {{ $t(`mod.masks.scopeShort.${m.scope}`) }} + </span> <span class="masks-val"> <span class="masks-name">{{ m.sampleName || $t('mod.masks.noSample') }}</span> <code class="masks-code">{{ m.value }}</code> @@ -65,6 +64,15 @@ definePageMeta({ // eslint-disable-next-line @typescript-eslint/no-explicit-any middleware: 'moderator' as any, }); +const { t } = useI18n(); +const notifications = useNotificationStore(); + +/** Le message du serveur quand il en donne un, le nôtre sinon. */ +function reason(err: unknown): string { + const e = err as { data?: { message?: string }; message?: string }; + return e?.data?.message || e?.message || t('common.errors.generic'); +} + const branding = await useBranding(); const federationEnabled = computed(() => Boolean(branding.value?.federationEnabled), @@ -102,8 +110,12 @@ async function add(): Promise<void> { form.value = ''; form.reason = ''; await refresh(); - } catch { - /* validation error surfaces via the form staying put */ + } catch (err) { + // Le `catch` vide comptait sur « le formulaire reste en place » pour dire + // l'échec. Un formulaire qui ne se vide pas ressemble exactement à un + // formulaire qu'on vient de remplir : le modérateur repartait en croyant + // avoir masqué la release. + notifications.error(t('mod.masks.addFailed', { reason: reason(err) })); } finally { busy.value = false; } @@ -113,9 +125,12 @@ async function lift(id: string): Promise<void> { lifting.value = new Set(lifting.value).add(id); try { await $fetch(`/api/mod/federation/masks/${id}`, { method: 'DELETE' }); + notifications.success(t('mod.masks.lifted')); await refresh(); - } catch { - /* leave the row; a refresh reconciles */ + } catch (err) { + // Idem : la ligne restait, ce qui est aussi ce qu'elle fait quand la levée + // réussit et que le rafraîchissement n'a pas encore répondu. + notifications.error(t('mod.masks.liftFailed', { reason: reason(err) })); } finally { const next = new Set(lifting.value); next.delete(id); @@ -126,19 +141,15 @@ async function lift(id: string): Promise<void> { <style scoped> .masks { max-width: 900px; margin: 0 auto; padding: 1.5rem 1rem 4rem; } -.masks-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; } -.masks-title { font-size: 1.5rem; font-weight: 700; } -.masks-sub { color: rgb(var(--fg-muted)); font-size: 0.9rem; margin-top: 0.3rem; max-width: 60ch; } -.masks-back { display: inline-flex; align-items: center; gap: 0.3rem; color: rgb(var(--fg-muted)); font-size: 0.85rem; } .masks-add { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; } -.masks-input { padding: 0.5rem 0.7rem; border: 1px solid rgb(var(--border)); border-radius: var(--radius-lg); background: rgb(var(--bg-subtle) / 0.5); color: rgb(var(--fg)); font-size: 0.85rem; } +.masks-input { padding: 0.5rem 0.7rem; border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-lg); background: rgb(var(--bg-inset) / 0.5); color: rgb(var(--fg-default)); font-size: 0.85rem; } .masks-input--grow { flex: 1; min-width: 12ch; } .masks-btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.5rem 0.9rem; border: 1px solid rgb(var(--accent) / 0.6); border-radius: var(--radius-lg); background: rgb(var(--accent) / 0.12); color: rgb(var(--accent)); cursor: pointer; font-weight: 600; font-size: 0.85rem; } .masks-btn:disabled { opacity: 0.5; cursor: default; } .masks-empty { color: rgb(var(--fg-muted)); text-align: center; padding: 2rem 0; } .masks-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; } -.masks-row { display: flex; align-items: center; gap: 0.7rem; padding: 0.6rem 0.8rem; border: 1px solid rgb(var(--border) / 0.7); border-radius: var(--radius-lg); background: rgb(var(--bg-subtle) / 0.4); } -.masks-scope { text-transform: uppercase; font-size: 0.65rem; letter-spacing: calc(0.05em * var(--tracking-scale)); padding: 0.15rem 0.45rem; border-radius: var(--radius-sm); background: rgb(var(--border) / 0.6); color: rgb(var(--fg-muted)); flex: none; } +.masks-row { display: flex; align-items: center; gap: 0.7rem; padding: 0.6rem 0.8rem; border: 1px solid rgb(var(--line-default) / 0.7); border-radius: var(--radius-lg); background: rgb(var(--bg-inset) / 0.4); } +.masks-scope { text-transform: uppercase; font-size: 0.65rem; letter-spacing: calc(0.05em * var(--tracking-scale)); padding: 0.15rem 0.45rem; border-radius: var(--radius-sm); background: rgb(var(--line-default) / 0.6); color: rgb(var(--fg-muted)); flex: none; } .masks-scope--author { background: rgb(var(--warning) / 0.2); color: rgb(var(--warning)); } /* An issuer mask hides everything one instance signed, whoever serves it — the widest of the four, so it reads as the strongest. */ @@ -148,7 +159,7 @@ async function lift(id: string): Promise<void> { .masks-code { font-family: var(--font-mono); font-size: 0.7rem; color: rgb(var(--fg-subtle)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .masks-reason { color: rgb(var(--fg-muted)); font-size: 0.8rem; font-style: italic; } .masks-by { color: rgb(var(--fg-subtle)); font-size: 0.8rem; flex: none; } -.masks-lift { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.6rem; border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); background: transparent; color: rgb(var(--fg-muted)); cursor: pointer; font-size: 0.78rem; flex: none; } -.masks-lift:hover:not(:disabled) { color: rgb(var(--fg)); border-color: rgb(var(--accent) / 0.5); } +.masks-lift { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.6rem; border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-md); background: transparent; color: rgb(var(--fg-muted)); cursor: pointer; font-size: 0.78rem; flex: none; } +.masks-lift:hover:not(:disabled) { color: rgb(var(--fg-default)); border-color: rgb(var(--accent) / 0.5); } .masks-lift:disabled { opacity: 0.5; cursor: default; } </style> diff --git a/apps/web/app/pages/mod/index.vue b/apps/web/app/pages/mod/index.vue index d9fd58f8..1d6e7866 100644 --- a/apps/web/app/pages/mod/index.vue +++ b/apps/web/app/pages/mod/index.vue @@ -465,7 +465,7 @@ function actionVerb(status: string | null): string { font-weight: 700; letter-spacing: calc(0.26em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .wt-eyebrow-dot { width: 7px; @@ -492,7 +492,7 @@ function actionVerb(status: string | null): string { line-height: 1.1; } .wt-greeting-name { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-family: var(--font-mono); font-weight: 700; font-size: 1.4rem; @@ -679,7 +679,7 @@ function actionVerb(status: string | null): string { } .feed-tag-icon { font-size: 1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .feed-meta { font-family: var(--font-mono); @@ -706,7 +706,7 @@ function actionVerb(status: string | null): string { transition: all var(--dur-2) ease; } .feed-cta:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.5); } @@ -745,7 +745,7 @@ function actionVerb(status: string | null): string { font-size: 0.6563rem; font-weight: 700; letter-spacing: calc(0.04em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); padding-top: 0.15rem; } .queue-name { @@ -828,17 +828,17 @@ function actionVerb(status: string | null): string { } .log-verb-icon { font-size: 0.85rem; } .log-row--accepted .log-verb { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.06); } .log-row--rejected .log-verb { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.06); } .log-row--changes_requested .log-verb { - color: #fb923c; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.06); } diff --git a/apps/web/app/pages/mod/tickets.vue b/apps/web/app/pages/mod/tickets.vue index 907a88ca..0b64c32a 100644 --- a/apps/web/app/pages/mod/tickets.vue +++ b/apps/web/app/pages/mod/tickets.vue @@ -343,7 +343,7 @@ watch(tickets, (rows) => { padding: 0 0.25rem; border-radius: var(--radius-pill); background: rgb(var(--accent-warm)); - color: rgb(var(--bg-base)); + color: rgb(var(--accent-warm-fg)); font-family: var(--font-mono); font-size: 0.6rem; font-weight: 800; @@ -355,7 +355,7 @@ watch(tickets, (rows) => { align-items: center; gap: 0.35rem; margin: 0; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 0.75rem; } diff --git a/apps/web/app/pages/notifications.vue b/apps/web/app/pages/notifications.vue index 8512b3a8..9efe1294 100644 --- a/apps/web/app/pages/notifications.vue +++ b/apps/web/app/pages/notifications.vue @@ -49,7 +49,11 @@ :key="row.id" class="np-row" :class="{ 'np-row--unread': !row.readAt }" + :role="row.link ? 'link' : 'button'" + tabindex="0" @click="onRowClick(row)" + @keydown.enter.prevent="onRowClick(row)" + @keydown.space.prevent="onRowClick(row)" > <Icon :name="iconFor(row.type)" @@ -383,6 +387,14 @@ function descFor(row: NotificationRow): string { border-radius: var(--radius-lg); overflow: hidden; } +/* Une ligne actionnable, donc atteignable. + Elle n'avait qu'un `@click` : la tabulation traversait toute la page de + notifications sans jamais s'y arrêter, et un lecteur d'écran l'annonçait + comme un simple élément de liste. */ +.np-row:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: -2px; +} .np-row { display: grid; grid-template-columns: auto 1fr auto; @@ -418,7 +430,7 @@ function descFor(row: NotificationRow): string { background: rgb(var(--online) / 0.1); } .np-row-icon--spend { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.1); } .np-row-icon--info { diff --git a/apps/web/app/pages/privacy.vue b/apps/web/app/pages/privacy.vue index bd1c2a10..9cf8af75 100644 --- a/apps/web/app/pages/privacy.vue +++ b/apps/web/app/pages/privacy.vue @@ -28,7 +28,20 @@ </header> <!-- ── The register ────────────────────────────────────────────── --> - <section class="pv-register" :aria-label="$t('privacy.registerLabel')"> + <!-- + Un échec de chargement n'est pas « rien n'est conservé ». + `entries` renvoie `[]` quand la requête échoue, et le registre rendait + alors une section vide sur la page dont toute la thèse est de publier des + durées : le lecteur en concluait qu'aucune donnée n'est gardée. Sur une + notice de confidentialité, c'est le pire malentendu possible — et c'est + celui qui coûte le plus cher à l'opérateur. Vu en conditions réelles sur + un 502. + --> + <p v-if="pending" class="pv-state">{{ $t('privacy.loading') }}</p> + <p v-else-if="!data" class="pv-state pv-state--bad" role="alert"> + {{ $t('privacy.unavailable') }} + </p> + <section v-else class="pv-register" :aria-label="$t('privacy.registerLabel')"> <article v-for="(entry, i) in entries" :key="entry.key" @@ -88,13 +101,17 @@ interface PrivacyFacts { staffAudit: { retentionDays: number; }; + /** Sign-in history. `0` = kept indefinitely. */ + loginHistory: { + retentionDays: number; + }; } const { t } = useI18n(); const branding = await useBranding(); const siteName = computed(() => branding.value?.siteName || 'Trackarr'); -const { data } = await useFetch<PrivacyFacts>('/api/privacy'); +const { data, pending } = await useFetch<PrivacyFacts>('/api/privacy'); interface Entry { key: string; @@ -126,12 +143,17 @@ const entries = computed<Entry[]>(() => { } if (f.messaging.room) { + // `forever` calculé, pas figé à `false`. L'API documente `0` comme + // « sans limite » pour ce réglage exactement comme pour les messages privés + // au-dessus, et cette page fait de la durée sa typographie : avec `0`, elle + // imprimait « 0 JOURS » en 36 px là où le fait le plus fort est « ∞ ». + const r = f.messaging.roomMessageDays; rows.push({ key: 'room', - days: f.messaging.roomMessageDays, - forever: false, + days: r, + forever: r <= 0, title: t('privacy.room.title'), - text: t('privacy.room.text', { n: f.messaging.roomMessageDays }), + text: r > 0 ? t('privacy.room.text', { n: r }) : t('privacy.room.forever'), }); } @@ -163,6 +185,23 @@ const entries = computed<Entry[]>(() => { }); } + // L'historique de connexion. La route le renvoie depuis le début et cette + // page ne l'affichait pas — alors que son handler dit en commentaire qu'il + // concerne le membre plutôt que le site. C'était la seule ligne du registre + // qui parle de lui, et la seule qui manquait. + if (f.loginHistory) { + const d = f.loginHistory.retentionDays; + rows.push({ + key: 'loginHistory', + days: d, + forever: d <= 0, + title: t('privacy.loginHistory.title'), + text: d > 0 + ? t('privacy.loginHistory.timed', { n: d }) + : t('privacy.loginHistory.forever'), + }); + } + return rows; }); @@ -234,6 +273,16 @@ useHead({ title: () => t('privacy.title') }); } /* ── The register ─────────────────────────────────────────────────── */ +.pv-state { + margin: 2rem 0; + padding: 1.25rem 0; + border-top: 1px solid rgb(var(--line-default)); + border-bottom: 1px solid rgb(var(--line-default)); + font-size: 0.875rem; + color: rgb(var(--fg-muted)); +} +.pv-state--bad { color: rgb(var(--danger)); } + .pv-register { margin-top: 3.5rem; border-top: 1px solid rgb(var(--line-default)); diff --git a/apps/web/app/pages/reports.vue b/apps/web/app/pages/reports.vue index e792c28d..4c4ff086 100644 --- a/apps/web/app/pages/reports.vue +++ b/apps/web/app/pages/reports.vue @@ -276,6 +276,7 @@ const { data, refresh } = await useFetch<ReportsPayload>('/api/me/reports', { }); const confirm = useConfirm(); +const notifications = useNotificationStore(); const cancellingId = ref<string | null>(null); async function cancelReport(r: ReportRow) { @@ -294,12 +295,15 @@ async function cancelReport(r: ReportRow) { await refresh(); } catch (err) { // The dialog has already closed by the time the request fires, so - // surface the failure inline rather than leaving the row hanging - // in a "pretending to delete" state. + // surface the failure rather than leaving the row hanging in a + // "pretending to delete" state. + // + // Par le bandeau du site, pas par `window.alert` : une boîte native bloque + // le fil, s'affiche hors du thème, et ne dit pas d'où elle vient. C'est le + // seul endroit du site qui en posait une, alors que la page utilise déjà + // `useConfirm` juste au-dessus. console.error('[reports] cancel failed:', err); - if (typeof window !== 'undefined') { - window.alert(t('reports.cancel.failed')); - } + notifications.error(t('reports.cancel.failed')); } finally { cancellingId.value = null; } @@ -584,8 +588,8 @@ function kindIcon(kind: string): string { } .kpi.is-active .kpi-btn { background: rgb(var(--bg-elevated) / 0.95); - border-color: rgba(var(--rail, var(--accent)), 0.55); - box-shadow: 0 8px 24px -16px rgba(var(--rail, var(--accent)), 0.65); + border-color: rgb(var(--rail, var(--accent)) / 0.55); + box-shadow: 0 8px 24px -16px rgb(var(--rail, var(--accent)) / 0.65); } .kpi-rail { @@ -673,9 +677,9 @@ function kindIcon(kind: string): string { themes (e.g. a white accent on a near-white --bg-base). */ .filter-pill.is-active { color: rgb(var(--pill-tint, var(--accent))); - background: rgba(var(--pill-tint, var(--accent)), 0.16); - border-color: rgba(var(--pill-tint, var(--accent)), 0.55); - box-shadow: 0 6px 18px -10px rgba(var(--pill-tint, var(--accent)), 0.55); + background: rgb(var(--pill-tint, var(--accent)) / 0.16); + border-color: rgb(var(--pill-tint, var(--accent)) / 0.55); + box-shadow: 0 6px 18px -10px rgb(var(--pill-tint, var(--accent)) / 0.55); } /* Each tab carries its own status tint so the active pill matches @@ -710,7 +714,7 @@ function kindIcon(kind: string): string { background: rgb(var(--fg-default) / 0.08); } .filter-pill.is-active .filter-pill-count { - background: rgba(var(--pill-tint, var(--accent)), 0.22); + background: rgb(var(--pill-tint, var(--accent)) / 0.22); } /* ── Report list ───────────────────────────────────────────────── */ @@ -780,8 +784,8 @@ function kindIcon(kind: string): string { align-items: baseline; gap: 0.18rem; padding: 0.2rem 0.45rem; - border: 1px solid rgba(var(--rail), 0.4); - background: rgba(var(--rail), 0.08); + border: 1px solid rgb(var(--rail) / 0.4); + background: rgb(var(--rail) / 0.08); border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 0.6875rem; @@ -902,7 +906,7 @@ function kindIcon(kind: string): string { background: linear-gradient( 90deg, - rgba(var(--rail, var(--fg-muted)), 0.08), + rgb(var(--rail, var(--fg-muted)) / 0.08), transparent 70% ); border-left: 2px solid rgb(var(--rail, var(--fg-muted))); @@ -928,7 +932,7 @@ function kindIcon(kind: string): string { font-size: 0.8438rem; color: rgb(var(--fg-muted)); line-height: 1.65; - border-left: 1px solid rgba(var(--rail, var(--fg-muted)), 0.25); + border-left: 1px solid rgb(var(--rail, var(--fg-muted)) / 0.25); } /* ── Withdraw control (pending only) ──────────────────────────── */ @@ -1004,7 +1008,7 @@ function kindIcon(kind: string): string { align-items: center; justify-content: center; font-size: 0.85em; - background: rgba(var(--rail), 0.15); + background: rgb(var(--rail) / 0.15); color: rgb(var(--rail)); flex-shrink: 0; } @@ -1051,7 +1055,7 @@ function kindIcon(kind: string): string { margin: 0.75rem 0 0; padding: 0.75rem 1rem; background: rgb(var(--bg-surface) / 0.6); - border-left: 3px solid rgba(var(--rail), 0.45); + border-left: 3px solid rgb(var(--rail) / 0.45); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; font-family: var(--font-display); font-size: 0.8438rem; diff --git a/apps/web/app/pages/requests/index.vue b/apps/web/app/pages/requests/index.vue index cb64a818..d331ca95 100644 --- a/apps/web/app/pages/requests/index.vue +++ b/apps/web/app/pages/requests/index.vue @@ -616,6 +616,16 @@ function formatReward(n: number): string { border-color: rgb(var(--brass) / 0.55); box-shadow: 0 0 0 3px rgb(var(--brass) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.board-search-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .board-search-input::placeholder { color: rgb(var(--fg-faint)); } /* Loader */ diff --git a/apps/web/app/pages/requests/new.vue b/apps/web/app/pages/requests/new.vue index c20a112b..1a7c4663 100644 --- a/apps/web/app/pages/requests/new.vue +++ b/apps/web/app/pages/requests/new.vue @@ -300,7 +300,13 @@ async function submit() { /* Exactly `--accent-warm`, so it follows the theme now. */ --brass: var(--accent-warm); --brass-deep: 158 113 31; - --danger: 244 63 94; + /* Pas de `--danger` local ici. + Redéfinir le jeton global le masquait sur tout le sous-arbre avec une + valeur unique qui ne bascule pas — 3,67:1 en thème clair là où le jeton + donne 6,47:1 — et n'importe quelle primitive partagée rendue dedans + (`.btn-danger`, `.tool-btn--danger`) en héritait. Le voisin + `--brass: var(--accent-warm)` fait déjà correctement ce que celui-ci + faisait de travers : pointer sur un jeton, pas le remplacer. */ } .tabular-nums { font-variant-numeric: tabular-nums; } @@ -319,6 +325,34 @@ async function submit() { overflow: hidden; pointer-events: none; } +/* Les deux variantes du décor de fond. + `.aura-blob` de base était bien défini ici, mais `--a` et `--b` — qui + portent la taille, la position et la teinte — ne vivaient que dans le + `<style scoped>` d'autres pages. Un style scopé ne franchit pas la frontière + d'un composant : ces deux taches n'avaient donc aucune dimension, et + l'atmosphère que sept pages sœurs affichent était simplement absente ici. */ +.aura-blob--a { + width: 520px; + height: 520px; + top: -180px; + left: 5%; + background: radial-gradient( + circle, + rgb(var(--accent-warm) / 0.28), + transparent 65% + ); +} +.aura-blob--b { + width: 420px; + height: 420px; + top: 60px; + right: 6%; + background: radial-gradient( + circle, + rgb(var(--info) / 0.22), + transparent 65% + ); +} .aura-blob { position: absolute; width: 480px; @@ -521,6 +555,16 @@ async function submit() { border-color: rgb(var(--brass) / 0.55); box-shadow: 0 0 0 3px rgb(var(--brass) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.filing-textarea:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .filing-counter { align-self: flex-end; font-family: var(--font-mono); diff --git a/apps/web/app/pages/settings.vue b/apps/web/app/pages/settings.vue index 2caac4b7..e2e28056 100644 --- a/apps/web/app/pages/settings.vue +++ b/apps/web/app/pages/settings.vue @@ -123,6 +123,7 @@ <button type="button" role="switch" + :aria-label="$t('settings.privacy.showLastSeen')" :aria-checked="form.showLastSeen" class="toggle" :class="{ 'toggle--on': form.showLastSeen }" @@ -151,6 +152,7 @@ <button type="button" role="switch" + :aria-label="$t('settings.privacy.showAdultContent')" :aria-checked="form.showAdultContent" class="toggle" :class="{ 'toggle--on': form.showAdultContent }" @@ -179,6 +181,7 @@ <button type="button" role="switch" + :aria-label="$t('settings.privacy.anonymousUploads')" :aria-checked="form.anonymousUploads" class="toggle" :class="{ 'toggle--on': form.anonymousUploads }" @@ -203,6 +206,7 @@ <button type="button" role="switch" + :aria-label="$t('settings.privacy.hideDownloadHistory')" :aria-checked="form.hideDownloadHistory" class="toggle" :class="{ 'toggle--on': form.hideDownloadHistory }" @@ -227,6 +231,7 @@ <button type="button" role="switch" + :aria-label="$t('settings.privacy.restrictComments')" :aria-checked="form.restrictComments" class="toggle" :class="{ 'toggle--on': form.restrictComments }" @@ -251,6 +256,7 @@ <button type="button" role="switch" + :aria-label="$t('settings.privacy.shareReputationFederated')" :aria-checked="form.shareReputationFederated" class="toggle" :class="{ 'toggle--on': form.shareReputationFederated }" @@ -274,7 +280,7 @@ <section id="appearance" class="form-section"> <header class="section-head"> <span class="section-number">03</span> - <h2 class="section-title">Appearance</h2> + <h2 class="section-title">{{ $t('settings.sections.appearance') }}</h2> <span class="section-rule" /> </header> @@ -282,13 +288,14 @@ <!-- ── Theme picker ─────────────────────────────────── --> <div class="appearance-block"> <div class="appearance-block-head"> - <span class="appearance-block-eyebrow">Mode</span> - <h3 class="appearance-block-title">Theme</h3> + <span class="appearance-block-eyebrow"> + {{ $t('settings.appearance.themeEyebrow') }} + </span> + <h3 class="appearance-block-title"> + {{ $t('settings.appearance.themeTitle') }} + </h3> </div> - <p class="section-help"> - Theme follows you across devices — stored on your account and - cached locally for a flicker-free first paint. - </p> + <p class="section-help">{{ $t('settings.appearance.themeHelp') }}</p> <div class="theme-row"> <button v-for="t in themes" @@ -327,14 +334,12 @@ doesn't drift back to the autodetected guess. --> <div class="appearance-block"> <div class="appearance-block-head"> - <span class="appearance-block-eyebrow">Locale</span> + <span class="appearance-block-eyebrow"> + {{ $t('settings.appearance.localeEyebrow') }} + </span> <h3 class="appearance-block-title">{{ $t('common.language') }}</h3> </div> - <p class="section-help"> - Saved on your account so the same UI language follows you - across devices. New strings always fall back to English when - a translation isn't ready yet. - </p> + <p class="section-help">{{ $t('settings.appearance.localeHelp') }}</p> <div class="lang-row"> <button v-for="l in languages" @@ -411,7 +416,7 @@ </div> <button type="button" - class="btn-ghost" + class="sbtn-ghost" @click="passwordOpen = !passwordOpen" > <Icon @@ -465,7 +470,7 @@ <div class="password-actions"> <button type="button" - class="btn-ghost" + class="sbtn-ghost" :disabled="pwdSubmitting" @click="closePassword" > @@ -473,7 +478,7 @@ </button> <button type="button" - class="btn-primary" + class="sbtn-primary" :disabled="!canSubmitPassword || pwdSubmitting" @click="submitPassword" > @@ -505,7 +510,7 @@ {{ $t('settings.security.trackerPasskeyHint') }} </p> </div> - <NuxtLink to="/me#01" class="btn-ghost"> + <NuxtLink to="/me#01" class="sbtn-ghost"> <Icon name="ph:arrow-up-right-bold" /> {{ $t('settings.security.openProfile') }} </NuxtLink> @@ -521,7 +526,7 @@ {{ $t('settings.security.signOutDeviceHint') }} </p> </div> - <button type="button" class="btn-ghost btn-ghost--danger" @click="signOut"> + <button type="button" class="sbtn-ghost btn-ghost--danger" @click="signOut"> <Icon name="ph:sign-out-bold" /> {{ $t('settings.security.signOut') }} </button> @@ -547,7 +552,7 @@ </div> <button type="button" - class="btn-ghost" + class="sbtn-ghost" :disabled="loginsLoading" :aria-expanded="loginsOpen" aria-controls="me-logins" @@ -630,7 +635,7 @@ <span class="section-rule" /> </header> <div class="section-body"> - <SettingsNotificationsSection /> + <SettingsNotificationsSection ref="notificationsRef" /> </div> </section> @@ -809,7 +814,7 @@ </span> <button type="button" - class="btn-primary" + class="sbtn-primary" :disabled="dirtyCount === 0 || saving || hasOverflow" @click="save" > @@ -883,6 +888,15 @@ const PRIVACY_TOGGLES = [ 'anonymousUploads', 'hideDownloadHistory', 'restrictComments', + // `shareReputationFederated` manquait ici, et cette liste pilote À LA FOIS le + // compteur de modifications et le corps du PATCH. L'interrupteur se + // basculait donc à l'écran, le badge annonçait « Tout est enregistré », le + // bouton Enregistrer restait désactivé, le garde de navigation ne se + // déclenchait pas — et la valeur disparaissait au rechargement. Le champ est + // accepté par `/api/me` et lu par `/api/federation/user-reputation` : un + // membre qui croyait avoir coupé le partage continuait d'être lu par les + // instances distantes. + 'shareReputationFederated', ] as const; // ── Form state (Identity + Privacy) ───────────────────────────── @@ -1098,9 +1112,14 @@ const themes = computed<ThemeOption[]>(() => { (t) => !['light', 'dark'].includes(t.slug), ); const defaultName = - [...(branding.value?.themes ?? []), { slug: 'light', name: 'Light' }, { slug: 'dark', name: 'Dark' }] - .find((t) => t.slug === branding.value?.themeDefault)?.name ?? - (branding.value?.themeDefault === 'system' ? 'System' : branding.value?.themeDefault); + [ + ...(branding.value?.themes ?? []), + { slug: 'light', name: t('settings.appearance.light') }, + { slug: 'dark', name: t('settings.appearance.dark') }, + ].find((th) => th.slug === branding.value?.themeDefault)?.name ?? + (branding.value?.themeDefault === 'system' + ? t('settings.appearance.system') + : branding.value?.themeDefault); return [ { @@ -1108,38 +1127,45 @@ const themes = computed<ThemeOption[]>(() => { // moving: a member on it follows the owner's default whenever it changes. // Everything below is a choice, and a change of default leaves it alone. value: null, - label: 'Site default', - sub: defaultName ? `Currently ${defaultName}` : 'Whatever the owner picks', + label: t('settings.appearance.siteDefault'), + sub: defaultName + ? t('settings.appearance.siteDefaultSub', { name: defaultName }) + : t('settings.appearance.siteDefaultUnknown'), icon: 'ph:buildings-bold', dot: swatchFor(branding.value?.themeDefault ?? 'dark'), }, { value: 'system', - label: 'System', - sub: 'Follows your operating system', + label: t('settings.appearance.system'), + sub: t('settings.appearance.systemSub'), icon: 'ph:circle-half-bold', dot: { accent: 'transparent', bg: 'transparent' }, }, { value: 'light', - label: 'Light', - sub: 'Day-friendly tones', + label: t('settings.appearance.light'), + sub: t('settings.appearance.lightSub'), icon: 'ph:sun-bold', dot: swatchFor('light'), }, { value: 'dark', - label: 'Dark', - sub: 'Editorial midnight', + label: t('settings.appearance.dark'), + sub: t('settings.appearance.darkSub'), icon: 'ph:moon-stars-bold', dot: swatchFor('dark'), }, - ...custom.map((t) => ({ - value: t.slug, - label: t.name, - sub: t.base === 'light' ? 'Light-based' : 'Dark-based', + // `th`, pas `t` : le paramètre masquait la fonction de traduction du même + // nom, ce qui rendait `t('…')` inappelable dans ce bloc. + ...custom.map((th) => ({ + value: th.slug, + label: th.name, + sub: + th.base === 'light' + ? t('settings.appearance.basedLight') + : t('settings.appearance.basedDark'), icon: 'ph:palette-bold', - dot: swatchFor(t.slug), + dot: swatchFor(th.slug), })), ]; }); @@ -1207,7 +1233,7 @@ async function setLanguage(value: LanguageOption['value']) { languageError.value = err?.data?.message || err?.message || - 'Could not save the language change. Please try again.'; + t('settings.appearance.localeSaveFailed'); } finally { languageSaving.value = false; pendingLanguage.value = null; @@ -1439,8 +1465,35 @@ const roleName = computed(() => { }); // ── Unsaved-change guard on navigation ────────────────────────── +const notificationsRef = ref<{ routingDirty?: { value: boolean } } | null>(null); + +/** + * Ce que le garde doit compter. + * + * `dirtyCount` ne voit que les champs de CETTE page. La matrice de routage des + * notifications vit dans un composant enfant et pouvait porter quarante-quatre + * réglages non enregistrés que ce garde ignorait : quitter la page les perdait + * sans un mot, pendant que l'en-tête affichait « 0 ». L'enfant expose son propre + * état sale, on l'additionne. + */ +const unsavedElsewhere = computed( + () => notificationsRef.value?.routingDirty?.value === true +); + +/* Fermer l'onglet ou recharger perdait tout aussi silencieusement : il n'y + avait `beforeunload` nulle part dans l'application. */ +onMounted(() => { + const warn = (e: BeforeUnloadEvent) => { + if (dirtyCount.value === 0 && !unsavedElsewhere.value) return; + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', warn); + onBeforeUnmount(() => window.removeEventListener('beforeunload', warn)); +}); + onBeforeRouteLeave((_to, _from, next) => { - if (dirtyCount.value === 0) return next(); + if (dirtyCount.value === 0 && !unsavedElsewhere.value) return next(); if ( typeof window !== 'undefined' && !confirm(t('settings.unsavedChangesPrompt')) @@ -1522,12 +1575,12 @@ onBeforeRouteLeave((_to, _from, next) => { .ready-state.idle { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ready-state.partial { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* ─── Layout ─────────────────────────────────────────────────── */ @@ -2160,8 +2213,20 @@ onBeforeRouteLeave((_to, _from, next) => { } /* ─── Buttons ──────────────────────────────────────────────── */ -.btn-ghost, -.btn-primary, +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un `<style scoped>`, donc hors couche, il l'emportait sur + * `@layer components` quelle que soit la spécificité. Sept fichiers donnaient + * ainsi deux boutons visuellement différents sous le même nom de classe, et + * `class="btn btn-primary"` écrit dans l'un d'eux n'aurait pas donné le bouton + * attendu. + */ +.sbtn-ghost, +.sbtn-primary, .btn-secondary { display: inline-flex; align-items: center; @@ -2175,12 +2240,12 @@ onBeforeRouteLeave((_to, _from, next) => { border: 1px solid; transition: all var(--dur-2); } -.btn-ghost { +.sbtn-ghost { background: rgb(var(--bg-elevated)); color: rgb(var(--fg-default)); border-color: rgb(var(--line-default)); } -.btn-ghost:hover:not(:disabled) { +.sbtn-ghost:hover:not(:disabled) { border-color: rgb(var(--fg-default) / 0.3); color: rgb(var(--fg-strong)); } @@ -2189,16 +2254,16 @@ onBeforeRouteLeave((_to, _from, next) => { color: rgb(var(--danger)); background: rgb(var(--danger) / 0.08); } -.btn-primary { +.sbtn-primary { background: rgb(var(--fg-strong)); color: rgb(var(--bg-base)); border-color: rgb(var(--fg-strong)); } -.btn-primary:hover:not(:disabled) { +.sbtn-primary:hover:not(:disabled) { filter: brightness(0.92); } -.btn-primary:disabled, -.btn-ghost:disabled, +.sbtn-primary:disabled, +.sbtn-ghost:disabled, .btn-secondary:disabled { opacity: 0.45; cursor: not-allowed; @@ -2305,7 +2370,7 @@ onBeforeRouteLeave((_to, _from, next) => { color: rgb(var(--fg-muted)); } .action-ready { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-weight: 600; display: inline-flex; align-items: center; @@ -2388,6 +2453,16 @@ onBeforeRouteLeave((_to, _from, next) => { outline: none; border-color: rgb(var(--danger)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.danger-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .danger-error { display: flex; align-items: center; diff --git a/apps/web/app/pages/shop.vue b/apps/web/app/pages/shop.vue index 9a1cfee7..d7d01f53 100644 --- a/apps/web/app/pages/shop.vue +++ b/apps/web/app/pages/shop.vue @@ -293,18 +293,6 @@ function describeEffect(effect: ShopItemEffect): string { return ''; } -function formatSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; - let i = 0; - let v = bytes; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1).replace(/\.0$/, '')} ${units[i]}`; -} - function buttonIcon(item: ShopItem): string { if (pendingItemId.value === item.id) return 'ph:circle-notch'; if (item.stock !== null && item.stock <= 0) return 'ph:prohibit-bold'; @@ -441,7 +429,7 @@ function onBalanceFromPool(value: number) { transition: color var(--dur-2) ease, transform var(--dur-3) ease; } .back-link:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); transform: translateX(-2px); } @@ -496,7 +484,7 @@ function onBalanceFromPool(value: number) { font-weight: 700; letter-spacing: calc(0.32em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); margin-bottom: 1.1rem; } .hero-eyebrow-rule { @@ -524,7 +512,7 @@ function onBalanceFromPool(value: number) { font-size: clamp(2.4rem, 6vw, 4rem); font-weight: 400; font-style: italic; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); margin-top: 0.05em; } @@ -599,7 +587,7 @@ function onBalanceFromPool(value: number) { } .balance-coin { font-size: 1.9rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); /* Soft pulse so the coin feels alive without being annoying. */ animation: shop-coin-pulse calc(5s * var(--motion-scale)) ease-in-out infinite; } @@ -650,7 +638,7 @@ function onBalanceFromPool(value: number) { .empty-icon { font-size: 3rem; - color: rgb(var(--accent-warm) / 0.5); + color: rgb(var(--accent-warm-text) / 0.5); margin-bottom: 1rem; } .empty-headline { @@ -789,11 +777,11 @@ function onBalanceFromPool(value: number) { padding: 0.25rem 0.55rem; border: 1px solid rgb(var(--accent-warm) / 0.35); border-radius: var(--radius-pill); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.05); } .piece-edition--rare { - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(255, 107, 107, 0.5); background: rgba(255, 107, 107, 0.08); /* Subtle attention pulse on low-stock pieces. */ @@ -837,7 +825,7 @@ function onBalanceFromPool(value: number) { } .piece-glyph-icon { font-size: 2.1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); filter: drop-shadow(0 0 6px rgb(var(--accent-warm) / 0.35)); } @@ -889,7 +877,7 @@ function onBalanceFromPool(value: number) { ); } .piece-effect-icon { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 0.85rem; } diff --git a/apps/web/app/pages/torrents/[hash]/edit.vue b/apps/web/app/pages/torrents/[hash]/edit.vue index 0c9c39ae..e24f808a 100644 --- a/apps/web/app/pages/torrents/[hash]/edit.vue +++ b/apps/web/app/pages/torrents/[hash]/edit.vue @@ -1,5 +1,18 @@ <template> - <div v-if="torrent" class="upload-page"> + <!-- Ce que la route répondra, dit avant la saisie plutôt qu'après. --> + <div v-if="torrent && !canEdit" class="upload-page upload-loading"> + <div class="edit-denied"> + <Icon name="ph:lock-simple-bold" class="edit-denied-icon" /> + <h1 class="edit-denied-title">{{ $t('torrents.edit.denied.title') }}</h1> + <p class="edit-denied-text">{{ $t('torrents.edit.denied.text') }}</p> + <NuxtLink :to="`/torrents/${torrent.infoHash}`" class="btn btn-primary"> + <Icon name="ph:arrow-left-bold" /> + {{ $t('torrents.edit.backToRelease') }} + </NuxtLink> + </div> + </div> + + <div v-else-if="torrent" class="upload-page"> <!-- Header --> <div class="upload-header"> <NuxtLink :to="`/torrents/${torrent.infoHash}`" class="back-link"> @@ -404,6 +417,27 @@ const { pending, } = await useFetch<TorrentDetail>(`/api/torrents/${hash}`); +const { user } = useUserSession(); + +/** + * Le droit d'éditer, calculé avant que le formulaire s'affiche. + * + * La route PATCH le vérifie et répond 403 « You do not have permission to edit + * this torrent ». La page, elle, ne vérifiait rien : n'importe quel membre + * ouvrant `/torrents/<hash>/edit` recevait le formulaire pré-rempli avec la + * release de quelqu'un d'autre, corrigeait le titre, ajoutait des tags, + * cliquait Enregistrer — et lisait une phrase anglaise dans un bandeau rouge. + * Le refus arrivait après le travail, et en anglais. + * + * Mêmes conditions que la route, à l'identique : propriétaire, ou équipe. + */ +const canEdit = computed(() => { + const u = user.value; + const t0 = torrent.value; + if (!u || !t0) return false; + return t0.uploaderId === u.id || !!u.isAdmin || !!u.isModerator; +}); + const { data: categories } = await useFetch<CategoryNode[]>('/api/categories'); const flatCategories = computed(() => getFlattenedCategories(categories.value)); @@ -624,9 +658,18 @@ async function loadExistingMetadata() { } } -watch(torrent, () => { - loadExistingMetadata(); -}); +// `immediate` : `torrent` vient d'un `useFetch` attendu, donc il est déjà +// rempli quand ce watcher s'enregistre et ne change plus ensuite. Sans ce +// drapeau, `loadExistingMetadata()` ne tournait JAMAIS, `existingMetadata` +// restait nul et l'aside retombait toujours sur le résumé nu. Le watcher frère +// plus haut dans le fichier le passe correctement. +watch( + torrent, + () => { + loadExistingMetadata(); + }, + { immediate: true } +); function onMediaSelected(metadata: MediaMetadata) { lookupResult.value = metadata; @@ -819,10 +862,36 @@ useHead({ title: t('torrents.edit.headTitle') }); border-radius: var(--radius-pill); border: 1px solid rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.06em * var(--tracking-scale)); text-transform: uppercase; } +.edit-denied { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + text-align: center; + padding: 3rem 1rem; + max-width: 40ch; + margin: 0 auto; +} +.edit-denied-icon { + font-size: 2rem; + color: rgb(var(--fg-subtle)); +} +.edit-denied-title { + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 700; + color: rgb(var(--fg-strong)); +} +.edit-denied-text { + font-size: 0.875rem; + line-height: 1.6; + color: rgb(var(--fg-muted)); + margin-bottom: 0.4rem; +} </style> diff --git a/apps/web/app/pages/torrents/[hash]/index.vue b/apps/web/app/pages/torrents/[hash]/index.vue index 5b413695..583833c2 100644 --- a/apps/web/app/pages/torrents/[hash]/index.vue +++ b/apps/web/app/pages/torrents/[hash]/index.vue @@ -1418,10 +1418,10 @@ watch( buffForm.downloadMultiplier = t0.downloadMultiplier ?? 100; buffForm.uploadMultiplier = t0.uploadMultiplier ?? 100; buffForm.isSticky = t0.isSticky ?? false; - // Trim the seconds and the zone: the input accepts neither. - buffForm.until = t0.multipliersUntil - ? new Date(t0.multipliersUntil).toISOString().slice(0, 16) - : ''; + // `isoToDatetimeLocal` plutôt que `toISOString().slice(0, 16)` : cette + // dernière écrivait l'heure UTC dans un champ qui la relit comme locale, + // donc l'échéance reculait d'un fuseau à chaque enregistrement. + buffForm.until = isoToDatetimeLocal(t0.multipliersUntil); }, { immediate: true } ); @@ -1449,7 +1449,7 @@ function saveBuffs() { return putBuffs({ downloadMultiplier: buffForm.downloadMultiplier, uploadMultiplier: buffForm.uploadMultiplier, - until: buffForm.until ? new Date(buffForm.until).toISOString() : null, + until: datetimeLocalToIso(buffForm.until), isSticky: buffForm.isSticky, }); } @@ -2002,13 +2002,13 @@ async function confirmDelete() { transform var(--dur-4) var(--ease-emphasis); } .hero-eyebrow-star:hover { - color: #f59e0b; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(245, 158, 11, 0.55); background: rgba(245, 158, 11, 0.08); transform: translateY(-1px); } .hero-eyebrow-star.is-on { - color: #f59e0b; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(245, 158, 11, 0.16); border-color: rgba(245, 158, 11, 0.65); box-shadow: @@ -2120,7 +2120,7 @@ async function confirmDelete() { rgb(var(--online)), rgb(var(--online) / 0.78) ); - color: #0a1610; + color: rgb(var(--online-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-radius: var(--radius-lg); text-decoration: none; font-family: var(--font-mono); @@ -2198,7 +2198,9 @@ async function confirmDelete() { transform var(--dur-4); } .cta-ghost:hover { - color: #fff; + /* Encre foncée : blanc sur ce violet mesure 2,72:1 dans les DEUX thèmes, + la valeur étant un littéral local qui ne bascule pas. */ + color: rgb(var(--bg-base)); border-color: rgb(var(--release-purple)); background: rgb(var(--release-purple)); transform: translateY(-1px); @@ -2213,7 +2215,7 @@ async function confirmDelete() { background: rgb(var(--danger) / 0.1); } .cta-ghost--danger:hover { - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgb(var(--danger)); background: rgb(var(--danger)); transform: translateY(-1px); @@ -2797,7 +2799,7 @@ async function confirmDelete() { white-space: nowrap; } .cross-badge--verified { - color: #4ade80; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(34, 197, 94, 0.08); border-color: rgba(34, 197, 94, 0.3); } @@ -2815,7 +2817,7 @@ async function confirmDelete() { padding: 0.05rem 0.5rem; border-radius: var(--radius-pill); font-weight: 600; - color: #4ade80; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.25); } @@ -2928,22 +2930,29 @@ async function confirmDelete() { background: rgba(245, 197, 24, 0.12); border-color: rgba(245, 197, 24, 0.45); } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .media-id-badge--imdb .media-id-badge-tag { - color: #f5c518; + color: rgb(var(--fg-default)); } .media-id-badge--tmdb { background: rgba(1, 180, 228, 0.12); border-color: rgba(1, 180, 228, 0.45); } .media-id-badge--tmdb .media-id-badge-tag { - color: #01b4e4; + color: rgb(var(--fg-default)); } .media-id-badge--tvdb { background: rgba(108, 209, 97, 0.12); border-color: rgba(108, 209, 97, 0.45); } .media-id-badge--tvdb .media-id-badge-tag { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Description typography lives in <DescriptionRender> now — the @@ -3127,7 +3136,7 @@ async function confirmDelete() { display: inline-flex; padding: 0.2em 0.6em 0.25em; background: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ letter-spacing: calc(0.05em * var(--tracking-scale)); /* A faint, off-axis tilt to feel like an actual rubber-stamp impression. */ transform: rotate(-1.5deg); @@ -3362,7 +3371,7 @@ async function confirmDelete() { padding: 0.7rem 1.05rem; border: 1px solid rgb(var(--danger)); background: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-family: var(--font-mono); font-size: 0.6875rem; font-weight: 800; diff --git a/apps/web/app/pages/torrents/fiche.vue b/apps/web/app/pages/torrents/fiche.vue index 1f8860ca..617ac10c 100644 --- a/apps/web/app/pages/torrents/fiche.vue +++ b/apps/web/app/pages/torrents/fiche.vue @@ -198,7 +198,12 @@ watchDebounced( if (token !== searchToken) return; // une frappe plus récente a gagné results.value = Array.isArray(res) ? res : (res?.results ?? res?.data ?? []); } catch (err: any) { - if (token === searchToken) searchError.value = err?.data?.message ?? err?.message ?? ''; + // Le repli était la chaîne vide : une recherche qui échouait sans message + // du serveur — une coupure réseau, un service de métadonnées éteint — + // ne produisait donc rien du tout à l'écran. + if (token === searchToken) + searchError.value = + err?.data?.message ?? err?.message ?? t('components.mediaSearch.errors.searchFailed'); } finally { if (token === searchToken) searching.value = false; } @@ -1207,6 +1212,17 @@ onMounted(() => { </template> <style scoped> +/* `.section-help` vient de `upload-form.css`, importé par les deux pages ; le + modificateur `--auto`, lui, ne vivait que dans le `<style scoped>` de + `upload.vue`. La ligne « rempli pour vous » retombait donc sur le gris + ordinaire, perdant précisément l'emphase qui dit qu'elle a été déduite. */ +.section-help--auto { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: rgb(var(--fg-strong)); +} + @import '~/assets/css/upload-form.css'; .fiche-shell { diff --git a/apps/web/app/pages/torrents/group/[...key].vue b/apps/web/app/pages/torrents/group/[...key].vue index eeffc51e..ddbaadb1 100644 --- a/apps/web/app/pages/torrents/group/[...key].vue +++ b/apps/web/app/pages/torrents/group/[...key].vue @@ -311,7 +311,7 @@ useHead(() => ({ title: title.value })); color: rgb(var(--fg-muted)); } .group-src--tmdb { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .group-title { diff --git a/apps/web/app/pages/torrents/index.vue b/apps/web/app/pages/torrents/index.vue index 7f9da000..8deffc03 100644 --- a/apps/web/app/pages/torrents/index.vue +++ b/apps/web/app/pages/torrents/index.vue @@ -1537,14 +1537,21 @@ useHead({ .media-id-chip-tag { font-weight: 800; } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .media-id-chip--imdb .media-id-chip-tag { - color: #f5c518; + color: rgb(var(--fg-default)); } .media-id-chip--tmdb .media-id-chip-tag { - color: #01b4e4; + color: rgb(var(--fg-default)); } .media-id-chip--tvdb .media-id-chip-tag { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .media-id-chip-id { font-family: var(--font-mono); diff --git a/apps/web/app/pages/torrents/upload.vue b/apps/web/app/pages/torrents/upload.vue index e7519edd..eadaa0be 100644 --- a/apps/web/app/pages/torrents/upload.vue +++ b/apps/web/app/pages/torrents/upload.vue @@ -19,12 +19,18 @@ <!-- Result panel — replaces the form once the upload succeeds. --> <div v-if="result" class="result-shell"> - <div class="result-card"> + <div class="result-card" :class="`result-card--${outcome}`"> <div class="result-success"> - <Icon name="ph:check-circle-fill" class="result-icon" /> + <!-- Trois issues distinctes, dont une qui n'est pas une publication. + Le titre reprenait `result.message`, une phrase anglaise du + serveur, sous une coche verte et au-dessus d'un sous-titre + français qui affirmait l'inverse : « Torrent already exists » / + « La release est désormais indexée ». L'envoyeur croyait avoir + publié la sienne. --> + <Icon :name="outcomeIcon" class="result-icon" /> <div> - <h2>{{ result.message }}</h2> - <p>{{ $t('torrents.uploadForm.result.subtitle') }}</p> + <h2>{{ $t(`torrents.uploadForm.result.${outcome}.title`) }}</h2> + <p>{{ $t(`torrents.uploadForm.result.${outcome}.subtitle`) }}</p> </div> </div> <dl class="result-meta"> @@ -544,8 +550,13 @@ const { t } = useI18n(); definePageMeta({ title: 'Upload torrent' }); +type UploadOutcome = 'published' | 'pending' | 'exists'; + interface TorrentResult { success: boolean; + /** Ce que le serveur a réellement fait. `message` est sa formulation + * anglaise, pour les clients hors navigateur. */ + outcome?: UploadOutcome; message: string; data: { id: string; @@ -1108,6 +1119,15 @@ const readyState = computed<ReadyHint | null>(() => { label: t('torrents.uploadForm.ready.tmdbRequired'), tone: 'partial', }; + // Le doublon est la seule condition de `canPublish` que cette chaîne + // ignorait : le bouton se grisait, et la pastille au-dessus annonçait + // « Prêt à publier ». + if (duplicateFound.value) + return { + icon: 'ph:copy-bold', + label: t('torrents.uploadForm.ready.duplicate'), + tone: 'partial', + }; return { icon: 'ph:rocket-launch-bold', label: t('torrents.uploadForm.ready.readyToPublish'), @@ -1130,6 +1150,17 @@ const canPublish = computed( duplicateFound.value === null, ); +/** Les anciennes réponses n'avaient pas d'`outcome` ; on retombe sur la + * publication, qui était l'hypothèse implicite du panneau. */ +const outcome = computed<UploadOutcome>(() => result.value?.outcome ?? 'published'); +const outcomeIcon = computed(() => + outcome.value === 'exists' + ? 'ph:info-fill' + : outcome.value === 'pending' + ? 'ph:hourglass-medium-fill' + : 'ph:check-circle-fill', +); + async function upload() { if (!canPublish.value || !selectedFile.value) return; isUploading.value = true; @@ -1276,7 +1307,7 @@ useHead({ title: t('torrents.uploadForm.headTitle') }); .drop-icon--success { background: rgba(108, 209, 97, 0.12); border-color: rgba(108, 209, 97, 0.45); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .drop-headline { font-size: 0.875rem; @@ -1322,7 +1353,7 @@ useHead({ title: t('torrents.uploadForm.headTitle') }); color: rgb(var(--fg-muted)); } .drop-mini-icon--success { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .drop-mini-name { font-size: 0.8rem; @@ -1422,7 +1453,7 @@ useHead({ title: t('torrents.uploadForm.headTitle') }); letter-spacing: calc(0.04em * var(--tracking-scale)); } .aside-progress li.done { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-weight: 600; } @@ -1449,7 +1480,7 @@ useHead({ title: t('torrents.uploadForm.headTitle') }); } .result-icon { font-size: 2rem; - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ flex-shrink: 0; } .result-success h2 { @@ -1526,4 +1557,7 @@ useHead({ title: t('torrents.uploadForm.headTitle') }); border-color: rgb(var(--fg-default) / 0.4); border-style: solid; } +/* Une issue qui n'est pas une publication ne se peint pas en vert. */ +.result-card--exists .result-icon { color: rgb(var(--info)); } +.result-card--pending .result-icon { color: rgb(var(--warning)); } </style> diff --git a/apps/web/app/plugins/i18n-user.client.ts b/apps/web/app/plugins/i18n-user.client.ts index 2e78a7d1..483f4f66 100644 --- a/apps/web/app/plugins/i18n-user.client.ts +++ b/apps/web/app/plugins/i18n-user.client.ts @@ -43,6 +43,22 @@ export default defineNuxtPlugin({ } const { locale, setLocale, locales } = i18n; + /* + * Les formateurs de `~/utils/format` suivent la même locale. + * + * `formatSize`, `formatDate`, `formatAge` et `formatDay` sont appelés depuis + * une cinquantaine d'endroits, dont la colonne ÂGE du catalogue et l'en-tête + * de chaque fiche. Leur passer une locale à chaque appel était le coût qui a + * maintenu l'anglais dans l'interface française pendant si longtemps : on + * pose donc la locale une fois ici, et un appelant qui a une raison de + * forcer autre chose garde son argument explicite. + */ + watch( + locale, + (next) => setFormatLocale(String(next)), + { immediate: true }, + ); + watch( () => user.value?.language, async (next) => { diff --git a/apps/web/app/stores/user.ts b/apps/web/app/stores/user.ts index 256afba1..26547d7b 100644 --- a/apps/web/app/stores/user.ts +++ b/apps/web/app/stores/user.ts @@ -19,10 +19,10 @@ export const useUserStore = defineStore('user', () => { const ratioClass = computed(() => { const r = ratio.value; - if (r === Infinity || r >= 2) return 'text-green-400'; + if (r === Infinity || r >= 2) return 'text-success'; if (r >= 1) return 'text-text-strong'; - if (r >= 0.5) return 'text-yellow-400'; - return 'text-red-400'; + if (r >= 0.5) return 'text-warning'; + return 'text-error'; }); async function refresh() { diff --git a/apps/web/app/utils/editorFormats.ts b/apps/web/app/utils/editorFormats.ts index 0a0e3026..f2775be9 100644 --- a/apps/web/app/utils/editorFormats.ts +++ b/apps/web/app/utils/editorFormats.ts @@ -136,14 +136,25 @@ function escapeAttr(s: string): string { * `js/double-escaping`. */ function unescapeHtml(s: string): string { - // U+0001 / U+0002 are control codepoints that can't appear in any - // BBCode payload we care about, so they're safe sentinels. + // U+0001 est un point de code de contrôle qui ne peut apparaître dans aucune + // charge BBCode qui nous intéresse : c'est une sentinelle sûre. + // + // Le commentaire disait déjà cela ; le code utilisait la chaîne littérale + // `AMP`. Tout texte contenant ces trois lettres était donc corrompu : + // `RAMPAGE.2018` devenait `R&AGE.2018`, `CAMPAIGN` devenait `C&AIGN`. Cela + // touche les quatre endroits qui décodent avant usage — `[url=…]`, + // `[url]…[/url]`, `[img]…[/img]` et `[color=…]` —, c'est-à-dire les liens de + // release, et précisément les imports en majuscules pour lesquels ce module + // existe. Aucun effet de sécurité (tout repasse par `escapeAttr` puis + // DOMPurify), mais une corruption silencieuse. + const SENTINEL = '\u0001'; return s - .replace(/&/g, 'AMP') + .replace(/&/g, SENTINEL) .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') - .replace(/AMP/g, '&'); + .split(SENTINEL) + .join('&'); } /** diff --git a/apps/web/app/utils/ficheBbcode.ts b/apps/web/app/utils/ficheBbcode.ts index 87945ddb..d2348ef2 100644 --- a/apps/web/app/utils/ficheBbcode.ts +++ b/apps/web/app/utils/ficheBbcode.ts @@ -13,7 +13,7 @@ import type { BitRateUnit, MediaTrack, SizeUnit, TechnicalSheet } from './mediai import { channelsToLayout, formatBitRate, - formatSize, + formatSheetSize, prettyAudioFormat, } from './mediainfo'; @@ -333,8 +333,8 @@ export function buildFiche( `[b][color=${ACCENT}]${label} :[/color][/b] ${value}`; const sizeLines: string[] = []; const totalSize = - formatSize(release.totalSize, release.totalSizeUnit) ?? - formatSize(sheet.fileSize, sheet.fileSizeUnit); + formatSheetSize(release.totalSize, release.totalSizeUnit) ?? + formatSheetSize(sheet.fileSize, sheet.fileSizeUnit); if (totalSize) sizeLines.push(plain('Taille totale', totalSize)); if (release.fileCount) sizeLines.push(plain('Nombre de fichier', String(release.fileCount))); diff --git a/apps/web/app/utils/ficheTemplate.ts b/apps/web/app/utils/ficheTemplate.ts index f77f3bd8..9c6da3ae 100644 --- a/apps/web/app/utils/ficheTemplate.ts +++ b/apps/web/app/utils/ficheTemplate.ts @@ -32,7 +32,7 @@ import { type FicheRelease, type FicheWork, } from './ficheBbcode'; -import { emptySheet, formatBitRate, formatSize, type TechnicalSheet } from './mediainfo'; +import { emptySheet, formatBitRate, formatSheetSize, type TechnicalSheet } from './mediainfo'; import { renderTemplate, type TemplateContext } from '@trackarr/shared/templateEngine'; /** @@ -301,8 +301,8 @@ export function buildFicheContext( const sizeLines: string[] = []; const totalSize = - formatSize(release.totalSize, release.totalSizeUnit) ?? - formatSize(sheet.fileSize, sheet.fileSizeUnit); + formatSheetSize(release.totalSize, release.totalSizeUnit) ?? + formatSheetSize(sheet.fileSize, sheet.fileSizeUnit); if (totalSize) sizeLines.push(plain('Taille totale', totalSize)); if (release.fileCount) sizeLines.push(plain('Nombre de fichier', String(release.fileCount))); diff --git a/apps/web/app/utils/format.ts b/apps/web/app/utils/format.ts index 7873c6de..b75ae264 100644 --- a/apps/web/app/utils/format.ts +++ b/apps/web/app/utils/format.ts @@ -1,18 +1,78 @@ -export function formatSize(bytes: number): string { +/** + * The locale these formatters use when none is passed. + * + * Set once at app start from the member's own preference. The alternative — + * threading a locale through every one of the ~50 call sites — is what kept + * these functions English for so long: the cost of the correct call was higher + * than the cost of leaving the bug, so the bug won. A module-level default + * makes the correct behaviour the cheap one, and an explicit argument still + * wins where a caller has a reason. + */ +let defaultLocale = 'en'; +export function setFormatLocale(locale: string): void { + defaultLocale = locale || 'en'; +} + +/** + * D'où vient la langue quand l'appelant n'en passe pas. + * + * `defaultLocale` seule ne suffit pas côté serveur, pour deux raisons : + * + * - elle n'y était jamais écrite (`setFormatLocale` n'est appelée que depuis + * un plugin `.client`), donc TOUT le rendu serveur sortait en anglais. Une + * page française affichait « yesterday » dans sa colonne ÂGE jusqu'à ce que + * l'hydratation la réécrive — et ce que voit un lecteur sans JavaScript, ou + * un moteur d'indexation, reste l'anglais ; + * - une variable de module est partagée par tous les rendus concurrents du + * processus. L'y écrire par requête ferait fuiter la langue d'un membre + * dans la page d'un autre. + * + * Le résolveur, lui, est bien une seule fonction pour tout le processus, mais + * il répond à CHAQUE appel depuis le contexte de la requête en cours. C'est le + * plugin `i18n-format` qui l'installe ; en test, il n'y en a pas et + * `defaultLocale` reprend la main. + */ +let localeSource: (() => string) | null = null; +export function setFormatLocaleSource(fn: (() => string) | null): void { + localeSource = fn; +} +function currentLocale(): string { + if (!localeSource) return defaultLocale; + try { + return localeSource() || defaultLocale; + } catch { + return defaultLocale; + } +} + +/** + * Binary units, the ones a torrent client shows, and a locale-aware decimal + * separator. + * + * The units were `KB/MB/GB` over a division by **1024**, which is the wrong + * label for that arithmetic — 1024 bytes is a kibibyte. And `toFixed(1)` + * hardcodes a dot, so a French page read "4.2 GB" where it means « 4,2 Gio ». + */ +export function formatSize(bytes: number, locale = currentLocale()): string { // Guards: negative or sub-byte values render as "0 B"; values past // PB clamp to the largest unit instead of indexing past the array // (the previous version returned "0.5 undefined" for bytes < 1 and // would crash on > PB). if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; const raw = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.min(units.length - 1, Math.max(0, raw)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; + const value = bytes / Math.pow(1024, i); + const shown = i === 0 + ? Math.round(value).toLocaleString(locale) + : value.toLocaleString(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); + return `${shown} ${units[i]}`; } -export function formatDate(dateStr: string): string { +/** A date and time, in the reader's language rather than always in English. */ +export function formatDate(dateStr: string, locale = currentLocale()): string { const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric', @@ -26,16 +86,49 @@ export function formatDate(dateStr: string): string { * "Member since" doesn't read as "Mar 5, 2026, 04:32 PM" (the original * `formatDate` includes the minute, which is absurd for a join date). */ -export function formatDay(dateStr: string): string { +export function formatDay(dateStr: string, locale = currentLocale()): string { const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric', }); } -export function formatAge(dateStr: string | Date | null | undefined): string { +/** + * How long ago, in the reader's language. + * + * This used to return `3d ago` / `just now` — English, always, at some fifty + * call sites including every catalogue row's AGE column and every torrent + * page's header. `formatAgo` (below) was written to fix exactly that and was + * called from **one** place in the whole app, because the correct call needed a + * locale argument the callers did not have. With a module-level default it does + * not, so `formatAge` is now a thin wrapper over it and every caller is fixed + * without being touched. + * + * The old compact spellings are gone on purpose: `il y a 3 jours` is longer + * than `3d ago`, and the columns that carry it are sized in `ch` or `auto` + * rather than fixed pixels. + */ +export function formatAge( + dateStr: string | Date | null | undefined, + locale = currentLocale() +): string { + // La garde de date future reste ici et non dans `formatAgo` : `formatAgo` + // répond « maintenant » à une date à venir, ce qui est correct pour un + // horodatage qui vient de basculer, alors que `formatAge` a des appelants + // (le badge de buff) pour qui une date future n'est PAS un âge et doit ne + // rien rendre du tout. + if (!dateStr) return ''; + const at = new Date(dateStr).getTime(); + if (Number.isNaN(at) || at > Date.now()) return ''; + return formatAgo(dateStr, locale); +} + +/** The original compact English ladder, kept for anything that needs a fixed + * narrow width. No current caller does; it exists so the change above is + * reversible per-site rather than all-or-nothing. */ +export function formatAgeCompact(dateStr: string | Date | null | undefined): string { // Nullable because every caller already passes something nullable: a // release carries `moderatedAt ?? createdAt ?? null`, and an invalid Date // renders as "Invalid Date" rather than throwing — which is what the @@ -76,7 +169,7 @@ export function formatAge(dateStr: string | Date | null | undefined): string { */ export function formatAgo( dateStr: string | Date | null | undefined, - locale = 'en' + locale = currentLocale() ): string { if (!dateStr) return ''; const date = new Date(dateStr); @@ -107,7 +200,7 @@ export function formatAgo( */ export function formatUntil( dateStr: string | Date | null | undefined, - locale = 'en' + locale = currentLocale() ): string { if (!dateStr) return ''; const date = new Date(dateStr); @@ -142,3 +235,33 @@ export function stripTags(input: string | null | undefined): string { } while (s !== prev); return s; } + +/** + * Un instant ISO → la valeur qu'attend `<input type="datetime-local">`. + * + * L'entrée `datetime-local` n'a pas de fuseau : elle lit et écrit l'heure + * murale locale. Alimenter sa valeur avec `toISOString().slice(0, 16)` y + * inscrit donc l'heure UTC en la faisant passer pour l'heure locale — la page + * d'un torrent affichait « 14:00 » pour une promotion qui finissait à 16:00, et + * comme l'enregistrement relit bien la valeur comme locale, chaque + * enregistrement décalait l'échéance d'un fuseau de plus. Deux heures par + * sauvegarde à Paris l'été. + * + * Les accesseurs locaux (`getHours`…) donnent directement l'heure murale, sans + * arithmétique sur le décalage — qui se trompe deux fois par an, au passage à + * l'heure d'été. + */ +export function isoToDatetimeLocal(iso: string | null | undefined): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Le retour : la valeur d'un `datetime-local` → un instant ISO. */ +export function datetimeLocalToIso(local: string | null | undefined): string | null { + if (!local) return null; + const d = new Date(local); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} diff --git a/apps/web/app/utils/markdown.ts b/apps/web/app/utils/markdown.ts index 96df37d1..9582172b 100644 --- a/apps/web/app/utils/markdown.ts +++ b/apps/web/app/utils/markdown.ts @@ -25,7 +25,15 @@ import type { Config as DomPurifyConfig } from 'isomorphic-dompurify'; * opener and don't leak referrers. */ const SAFE_PROFILE: DomPurifyConfig = { - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|#)/i, + // `|\/` : la branche pour un chemin relatif, qui manquait. + // + // Sans elle, `sanitizeHtml('<a href="/x">x</a>')` rend `<a>x</a>` — mesuré. + // Donc `[voir la fiche](/torrents/42)` dans une description, ou un + // `footerText` de marque pointant sur `/rules`, s'affichaient en texte non + // cliquable. Le regexp par défaut de DOMPurify autorise les références + // relatives pour cette raison exacte ; celui-ci a été resserré et a emporté + // le cas légitime avec les schémas dangereux. + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[#/])/i, FORBID_TAGS: ['style', 'iframe', 'form', 'input', 'base', 'meta', 'object'], FORBID_ATTR: ['style', 'srcdoc', 'autofocus'], }; @@ -44,7 +52,15 @@ const SAFE_PROFILE: DomPurifyConfig = { * so it never loosens the strict path used for branding/forum/markdown. */ const RICH_PROFILE: DomPurifyConfig = { - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|#)/i, + // `|\/` : la branche pour un chemin relatif, qui manquait. + // + // Sans elle, `sanitizeHtml('<a href="/x">x</a>')` rend `<a>x</a>` — mesuré. + // Donc `[voir la fiche](/torrents/42)` dans une description, ou un + // `footerText` de marque pointant sur `/rules`, s'affichaient en texte non + // cliquable. Le regexp par défaut de DOMPurify autorise les références + // relatives pour cette raison exacte ; celui-ci a été resserré et a emporté + // le cas légitime avec les schémas dangereux. + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[#/])/i, FORBID_TAGS: ['style', 'iframe', 'form', 'input', 'base', 'meta', 'object'], FORBID_ATTR: ['srcdoc', 'autofocus'], }; diff --git a/apps/web/app/utils/mediainfo.ts b/apps/web/app/utils/mediainfo.ts index dd9e7b82..7bce999e 100644 --- a/apps/web/app/utils/mediainfo.ts +++ b/apps/web/app/utils/mediainfo.ts @@ -212,7 +212,20 @@ export function sizeFrom(value: number | undefined, unit: SizeUnit): number | un return Math.round(value * (unit === 'GiB' ? 1024 ** 3 : 1024 ** 2)); } -export function formatSize(bytes?: number, unit?: SizeUnit): string | undefined { +/** + * La taille telle que la fiche technique l'écrit : deux décimales, unité + * imposée, `undefined` quand il n'y a rien à écrire. + * + * Elle s'appelait `formatSize`, comme celle de `format.ts`. Nuxt auto-importe + * les deux depuis `app/utils/`, ne peut pas garder les deux, et gardait + * celle-ci — donc les neuf fichiers qui appelaient `formatSize()` sans import + * explicite (la colonne TAILLE du catalogue, la ligne de release, le bandeau + * ratio de la mise en page) recevaient une fonction qui plafonne à GiB (« 4096 + * GiB » pour 4 To), écrit toujours un point décimal quelle que soit la langue, + * et rend `undefined` pour zéro — d'où le blanc à la place de « 0 B » chez un + * membre qui n'a rien encore envoyé. Le nom lève la collision. + */ +export function formatSheetSize(bytes?: number, unit?: SizeUnit): string | undefined { if (!bytes) return undefined; const u = unit ?? pickSizeUnit(bytes); const value = sizeIn(bytes, u); @@ -496,7 +509,7 @@ export function renderMediaInfo(sheet: TechnicalSheet): string { block('General', [ ['Complete name', sheet.fileName], ['Format', sheet.container], - ['File size', formatSize(sheet.fileSize, sheet.fileSizeUnit)], + ['File size', formatSheetSize(sheet.fileSize, sheet.fileSizeUnit)], ['Duration', sheet.duration], ['Overall bit rate', mediaInfoBitRate(sheet.overallBitRate)], ]); diff --git a/apps/web/app/utils/releaseNameSplit.ts b/apps/web/app/utils/releaseNameSplit.ts index 5d5173f3..2b80c23a 100644 --- a/apps/web/app/utils/releaseNameSplit.ts +++ b/apps/web/app/utils/releaseNameSplit.ts @@ -19,7 +19,17 @@ * A name that matches nothing comes back entirely as `tail`: better an * undimmed row than one that hides its only content behind a bad guess. */ -export interface ReleaseNameParts { +/** + * Les trois tronçons d'un nom de release à l'affichage : le crochet de tête, le + * titre, la queue technique. + * + * Renommée depuis `ReleaseNameParts`, qui était aussi le nom de l'interface de + * `ficheRelease.ts` — un type entièrement différent (titre, année, résolution, + * équipe). Nuxt auto-importe les deux, n'en garde qu'un, et gardait celui-ci : + * une déclaration `ReleaseNameParts` écrite sans import explicite compilait + * contre `{ tag, lead, tail }` en croyant décrire des métadonnées. + */ +export interface ReleaseNameSegments { tag: string; lead: string; tail: string; @@ -40,7 +50,7 @@ const STOP = */ const LEADING_TAG = /^\s*\[[^\]]{1,24}\]\s*/; -export function splitReleaseName(name: string): ReleaseNameParts { +export function splitReleaseName(name: string): ReleaseNameSegments { const tag = name.match(LEADING_TAG)?.[0] ?? ''; const rest = name.slice(tag.length); diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt index 0ad279c7..c49a5896 100644 --- a/apps/web/public/robots.txt +++ b/apps/web/public/robots.txt @@ -1,2 +1,8 @@ -User-Agent: * -Disallow: +# Tracker privé : rien n'a à être indexé. +# +# C'était `Disallow:` (vide), c'est-à-dire tout autorisé — ce qui livrait aux +# moteurs le nom du site, son domaine, sa page de connexion et toute surface +# publique. Sur un site dont l'invitation est la porte, c'est exactement ce +# qu'on évite. +User-agent: * +Disallow: / diff --git a/apps/web/server/routes/api/[...path].ts b/apps/web/server/routes/api/[...path].ts index 40f10672..5c47c389 100644 --- a/apps/web/server/routes/api/[...path].ts +++ b/apps/web/server/routes/api/[...path].ts @@ -44,11 +44,54 @@ export default defineEventHandler(async (event) => { }); } + // Le chemin, avant de bâtir la cible. + // + // `event.path` est brut : `GET /api/../uploads/x` (avec un client qui ne + // normalise pas — `curl --path-as-is`) correspond quand même à ce + // catch-all, puis l'analyse d'URL d'undici replie le `..` et produit + // `http://api:4000/uploads/x`. La portée est bornée — l'API ne monte que + // `/api` et `/uploads`, et `/uploads` est joignable directement — mais cela + // permettait d'atteindre `/uploads` par l'origine web, et c'est une ligne. + if (event.path.includes('..')) { + throw createError({ statusCode: 400, statusMessage: 'Bad Request' }); + } + const config = useRuntimeConfig(); const apiBase = (config.apiInternalUrl as string).replace(/\/+$/, ''); const target = apiBase + event.path; - return await proxyRequest(event, target, { - // Strip headers a real reverse proxy would not blindly forward. - headers: { host: undefined as any }, - }); + + /* + * Les en-têtes, construits ici plutôt que relayés tels quels. + * + * `headers: { host: undefined }` était un no-op : `mergeHeaders` de h3 fait + * `if (value !== undefined) merged.set(...)`, donc une valeur `undefined` ne + * SUPPRIME rien — `host` ne disparaissait que parce qu'il figure déjà dans la + * liste ignorée de h3. Le commentaire « strip headers a real reverse proxy + * would not blindly forward » décrivait donc une intention que le code + * n'implémentait pas : `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto` et + * `CF-Connecting-IP` fournis par le NAVIGATEUR arrivaient verbatim à l'API, + * qui s'en sert pour la limitation de débit et le journal de connexions. + * + * Le déploiement livré reste sûr — Caddy écrase les deux en-têtes avec + * `{remote_host}` et `getClientIP` parcourt de droite à gauche — mais cette + * couche est un proxy qui ne se comportait pas comme tel : elle ne retirait + * pas ce dont elle ne peut répondre, et n'ajoutait pas le pair qu'elle + * observe. + */ + const SPOOFABLE = [ + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-forwarded-port', + 'x-real-ip', + 'forwarded', + 'cf-connecting-ip', + 'true-client-ip', + ]; + const headers = getProxyRequestHeaders(event); + for (const name of SPOOFABLE) delete (headers as Record<string, unknown>)[name]; + const observed = getRequestIP(event, { xForwardedFor: false }); + if (observed) (headers as Record<string, string>)['x-forwarded-for'] = observed; + + return await proxyRequest(event, target, { headers: headers as HeadersInit }); }); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 16f97194..d2316a16 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -55,6 +55,12 @@ export default { border: { DEFAULT: 'rgb(var(--line-default) / <alpha-value>)', hover: 'rgb(var(--line-strong) / <alpha-value>)', + // La bordure d'un CHAMP. `border-border` est le filet décoratif des + // cartes et des tableaux : à 1,21:1 en sombre et 1,26:1 en clair, il + // ne peut pas porter la frontière d'un contrôle, que WCAG 1.4.11 + // veut à 3:1. `.input` passe par `--line-field` dans `main.css` ; les + // champs habillés en utilitaires ont besoin du même jeton. + field: 'rgb(var(--line-field) / <alpha-value>)', }, line: { DEFAULT: 'rgb(var(--line-default) / <alpha-value>)', diff --git a/apps/web/test/mediainfo.test.ts b/apps/web/test/mediainfo.test.ts index b7a5dd0f..d71b0ac3 100644 --- a/apps/web/test/mediainfo.test.ts +++ b/apps/web/test/mediainfo.test.ts @@ -3,7 +3,7 @@ import { channelsToLayout, formatBitRate, formatDuration, - formatSize, + formatSheetSize, layoutToChannels, parseBitRate, parseMediaInfoText, @@ -70,7 +70,7 @@ describe('formatBitRate', () => { }); }); -describe('parseSize / formatSize', () => { +describe('parseSize / formatSheetSize', () => { it('reads bare bytes and suffixed forms', () => { expect(parseSize('1473173712')).toBe(1_473_173_712); expect(parseSize('1.37 GiB')).toBe(Math.round(1.37 * 1024 ** 3)); @@ -78,8 +78,8 @@ describe('parseSize / formatSize', () => { }); it('switches to GiB past the gibibyte', () => { - expect(formatSize(1_473_173_712)).toBe('1.37 GiB'); - expect(formatSize(700 * 1024 ** 2)).toBe('700 MiB'); + expect(formatSheetSize(1_473_173_712)).toBe('1.37 GiB'); + expect(formatSheetSize(700 * 1024 ** 2)).toBe('700 MiB'); }); }); diff --git a/apps/web/test/statSeries.test.ts b/apps/web/test/statSeries.test.ts index c68fe983..fbc9f7e4 100644 --- a/apps/web/test/statSeries.test.ts +++ b/apps/web/test/statSeries.test.ts @@ -9,7 +9,15 @@ import { bucketSums, shouldBucket, } from '../app/utils/statSeries'; -import { formatAge, formatAgo, formatUntil } from '../app/utils/format'; +import { + datetimeLocalToIso, + formatAge, + formatAgeCompact, + formatAgo, + formatSize, + formatUntil, + isoToDatetimeLocal, +} from '../app/utils/format'; // The traffic chart on /stats, and the three date formatters around it. // @@ -117,11 +125,30 @@ describe('date formatters', () => { expect(formatAge(inThreeDays)).not.toBe('just now'); }); - it('still reads a past date as an age', () => { - expect(formatAge(threeDaysAgo)).toBe('3d ago'); + it('still reads a past date as an age, in the reader\u2019s language', () => { + // `formatAge` déléguait à une échelle anglaise en dur (`3d ago`) sur une + // cinquantaine d'appels, dont la colonne ÂGE du catalogue. Elle passe + // désormais par `Intl`, donc la sortie suit la locale. + expect(formatAge(threeDaysAgo, 'en')).toMatch(/3 days ago/); + expect(formatAge(threeDaysAgo, 'fr')).toMatch(/il y a 3 jours/); expect(formatAge(null)).toBe(''); }); + it('leaves the compact English ladder available for a fixed-width column', () => { + expect(formatAgeCompact(threeDaysAgo)).toBe('3d ago'); + expect(formatAgeCompact(inThreeDays)).toBe(''); + }); + + it('labels a binary division with binary units', () => { + // `KB` sur une division par 1024 est la mauvaise étiquette : 1024 octets + // font un kibioctet. Et `toFixed(1)` codait le point décimal en dur. + expect(formatSize(1024, 'en')).toBe('1.0 KiB'); + expect(formatSize(1024 * 1024 * 4.5, 'en')).toBe('4.5 MiB'); + expect(formatSize(1024 * 1024 * 4.5, 'fr')).toMatch(/4,5 MiB/); + expect(formatSize(0)).toBe('0 B'); + expect(formatSize(-1)).toBe('0 B'); + }); + it('reads a deadline forwards, in the reader’s language', () => { expect(formatUntil(inThreeDays, 'en')).toMatch(/in 3 days/); expect(formatUntil(inThreeDays, 'fr')).toMatch(/dans 3 jours/); @@ -147,3 +174,43 @@ describe('date formatters', () => { expect(formatAgo(inThreeDays, 'en')).toMatch(/now/); }); }); + +describe('datetime-local round trip', () => { + // Le défaut expédié : la page d'un torrent alimentait son champ d'échéance + // avec `toISOString().slice(0, 16)`, c'est-à-dire l'heure UTC, dans une + // entrée qui affiche et relit de l'heure locale. Enregistrer sans rien + // toucher reculait donc l'échéance du décalage horaire — deux heures par + // sauvegarde à Paris l'été, jusqu'à ce qu'une promotion en cours se retrouve + // datée d'hier. + it('leaves an instant untouched when nothing is edited', () => { + const iso = new Date('2026-07-14T16:30:00.000Z').toISOString(); + const back = datetimeLocalToIso(isoToDatetimeLocal(iso)); + expect(back).toBe(iso); + }); + + it('writes the local wall clock, not the UTC one', () => { + const d = new Date('2026-07-14T16:30:00.000Z'); + const shown = isoToDatetimeLocal(d.toISOString()); + const pad = (n: number) => String(n).padStart(2, '0'); + expect(shown).toBe( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`, + ); + }); + + it('survives a full year of instants, DST changeovers included', () => { + // L'arithmétique sur `getTimezoneOffset()` — l'autre correctif possible — + // se trompe deux fois par an. Les accesseurs locaux, non. + for (let day = 0; day < 365; day += 1) { + const iso = new Date(Date.UTC(2026, 0, 1, 3, 45) + day * 86400_000).toISOString(); + expect(datetimeLocalToIso(isoToDatetimeLocal(iso))).toBe(iso); + } + }); + + it('says nothing about an absent or unreadable date', () => { + expect(isoToDatetimeLocal(null)).toBe(''); + expect(isoToDatetimeLocal('')).toBe(''); + expect(isoToDatetimeLocal('not a date')).toBe(''); + expect(datetimeLocalToIso('')).toBeNull(); + expect(datetimeLocalToIso('not a date')).toBeNull(); + }); +}); From fe8db1fb08065b5b8966c4a8d9cfd3dec56246c7 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:24:24 +0200 Subject: [PATCH 64/96] fix(shared): tighten the DID proof and the theme token helpers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- packages/shared/src/didProof.ts | 10 +++++++ packages/shared/src/theme.ts | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/shared/src/didProof.ts b/packages/shared/src/didProof.ts index 92b2ff7b..fe529d88 100644 --- a/packages/shared/src/didProof.ts +++ b/packages/shared/src/didProof.ts @@ -228,5 +228,15 @@ export function rawFromDidKey(did: string): Uint8Array<ArrayBuffer> { if (tagged[0] !== 0xed || tagged[1] !== 0x01) { throw new Error('not an Ed25519 did:key'); } + // La longueur, que la copie serveur (`apps/api/utils/federation/did.ts`) + // vérifiait et que celle-ci — la copie qu'utilise le navigateur — ne + // vérifiait pas. Un `did:key:z` bien préfixé mais tronqué renvoyait moins de + // 32 octets. `importKey` finit par refuser, donc l'effet pratique est un + // message opaque plutôt qu'une faille ; mais les deux implémentations d'une + // primitive dont l'en-tête de ce fichier dit « les parties qui doivent + // s'accorder vivent ici, une fois » divergeaient sur une validation. + if (tagged.length !== 34) { + throw new Error('malformed Ed25519 did:key (bad length)'); + } return tagged.subarray(2); } diff --git a/packages/shared/src/theme.ts b/packages/shared/src/theme.ts index 1244f676..b3880851 100644 --- a/packages/shared/src/theme.ts +++ b/packages/shared/src/theme.ts @@ -170,6 +170,10 @@ export const THEME_TOKENS: readonly TokenDef[] = [ // Lines { key: 'line-default', kind: 'rgb', group: 'line' }, { key: 'line-strong', kind: 'rgb', group: 'line' }, + // La bordure d'un champ, séparée des filets décoratifs : c'est elle qui + // identifie le contrôle, donc elle doit tenir 3:1 (WCAG 1.4.11) là où un + // filet de carte peut rester discret. + { key: 'line-field', kind: 'rgb', group: 'line' }, // Accent // @@ -194,11 +198,23 @@ export const THEME_TOKENS: readonly TokenDef[] = [ // theme's darker gold — but it is a token rather than a literal because a // theme setting `accent-warm` to a dark colour needs to move this with it. { key: 'accent-warm-fg', kind: 'rgb', group: 'accent' }, + // L'or quand il sert d'ENCRE et non de fond. Séparé parce que les deux + // usages n'ont pas le même seuil : un remplissage se mesure par ce qu'on + // pose dessus, une encre par la surface sous elle. + { key: 'accent-warm-text', kind: 'rgb', group: 'accent' }, // Semantic — status badges and notifications, never chrome { key: 'online', kind: 'rgb', group: 'semantic' }, { key: 'warning', kind: 'rgb', group: 'semantic' }, + // Les encres posées SUR ces quatre teintes. Un thème qui déplace `online`, + // `warning`, `danger` ou `info` doit pouvoir déplacer son encre avec. + { key: 'online-fg', kind: 'rgb', group: 'semantic' }, + { key: 'warning-fg', kind: 'rgb', group: 'semantic' }, + { key: 'info-fg', kind: 'rgb', group: 'semantic' }, { key: 'danger', kind: 'rgb', group: 'semantic' }, + // L'encre posée sur un bouton de danger plein. Un thème qui éclaircit + // `danger` doit pouvoir déplacer celle-ci avec lui. + { key: 'danger-fg', kind: 'rgb', group: 'semantic' }, { key: 'info', kind: 'rgb', group: 'semantic' }, // Charts @@ -537,12 +553,17 @@ export const BUILT_IN_TOKENS: Readonly<Record<'light' | 'dark', TokenMap>> = { 'fg-faint': '130 130 130', 'line-default': '42 42 42', 'line-strong': '58 58 58', + 'line-field': '102 102 102', accent: '255 255 255', 'accent-hover': '229 229 229', 'accent-fg': '10 10 10', online: '34 197 94', warning: '234 179 8', + 'online-fg': '26 26 26', + 'warning-fg': '26 26 26', + 'info-fg': '26 26 26', danger: '239 68 68', + 'danger-fg': '26 26 26', info: '56 189 248', 'focus-ring': '212 167 52', 'chart-1': '59 130 246', @@ -561,6 +582,7 @@ export const BUILT_IN_TOKENS: Readonly<Record<'light' | 'dark', TokenMap>> = { 'bg-pattern-step': '40px', 'color-scheme': 'dark', 'accent-warm': '212 167 52', + 'accent-warm-text': '212 167 52', 'accent-warm-fg': '26 26 26', 'shadow-color': '0 0 0', 'shadow-strength': '1', @@ -586,12 +608,17 @@ export const BUILT_IN_TOKENS: Readonly<Record<'light' | 'dark', TokenMap>> = { 'fg-faint': '111 111 111', 'line-default': '229 229 229', 'line-strong': '208 208 208', + 'line-field': '146 146 146', accent: '10 10 10', 'accent-hover': '31 31 31', 'accent-fg': '255 255 255', online: '21 128 61', warning: '180 83 9', + 'online-fg': '255 255 255', + 'warning-fg': '255 255 255', + 'info-fg': '255 255 255', danger: '185 28 28', + 'danger-fg': '255 255 255', info: '3 105 161', 'focus-ring': '176 133 24', 'chart-1': '59 130 246', @@ -610,6 +637,7 @@ export const BUILT_IN_TOKENS: Readonly<Record<'light' | 'dark', TokenMap>> = { 'bg-pattern-step': '40px', 'color-scheme': 'light', 'accent-warm': '176 133 24', + 'accent-warm-text': '143 104 8', 'accent-warm-fg': '26 26 26', 'shadow-color': '0 0 0', 'shadow-strength': '1', @@ -835,6 +863,27 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [ // the site, was checked against neither surface it sits on. { fg: 'fg-default', bg: 'bg-elevated', what: 'text typed in a field' }, { fg: 'fg-faint', bg: 'bg-elevated', what: 'field placeholder' }, + + /* + * Les paires ajoutées après la revue d'interface de septembre 2026. + * + * Toutes les trois disaient la même chose : la paire que personne n'a + * déclarée est celle qui part cassée. L'or était mesuré comme REMPLISSAGE + * (`accent-warm-fg` posé dessus) et jamais comme encre, alors que la console + * l'utilise en couleur de texte à plus de cent endroits ; l'encre du bouton + * de danger était un `#fff` en dur hors du système ; et la bordure d'un champ + * n'était mesurée nulle part, au motif écrit que le fond du champ suffisait à + * l'identifier — ce qui cesse d'être vrai dès que `bg-elevated` et + * `bg-surface` se rejoignent, comme en thème clair. + */ + { fg: 'accent-warm-text', bg: 'bg-base', what: 'gold text' }, + { fg: 'accent-warm-text', bg: 'bg-surface', what: 'gold text on cards' }, + { fg: 'accent-warm-text', bg: 'bg-inset', what: 'gold text on inset panels' }, + { fg: 'online-fg', bg: 'online', what: 'text on a success fill' }, + { fg: 'warning-fg', bg: 'warning', what: 'text on a warning fill' }, + { fg: 'info-fg', bg: 'info', what: 'text on an info fill' }, + { fg: 'danger-fg', bg: 'danger', what: 'danger button' }, + { fg: 'line-field', bg: 'bg-elevated', what: 'field border', nonText: true }, // `bg-inset` is the recessed panel — 36 components use it. In the dark theme // it sits between base and surface, but in light it is the DARKEST surface // (245 against 250/255), so it is the worst case there rather than a middle From 61eae1652e07d95b4900959b9f04cbf241732990 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Wed, 2 Sep 2026 22:24:24 +0200 Subject: [PATCH 65/96] docs: three pages promised protection the code does not provide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panic mode said "instantly encrypt all sensitive data" and listed "Private messages | Encrypted". Measured on the compiled stack: after encryption `torrents.name` becomes `iv:ciphertext:tag` and the size goes to zero, while `messages.body` stays perfectly readable — as do tickets, TOTP secrets, the federation signing key, login history and the audit log. An operator could have triggered it believing otherwise, which is the direction that gets people hurt. Both halves of the boundary are now listed, and they are the same two lists the running instance returns from `POST /api/admin/panic/encrypt`, so the answer can be checked against the code rather than against this page. The IV line said 16 bytes; it is 12, per field rather than per session. The scrypt cost is documented with its versions, so a database encrypted by an older build is visibly still restorable. `ADMIN_API_KEY` was listed among the secrets "the app refuses to start without". It is not in that check — the running stack prints "All 2 required secrets validated" — and no route calls the header gate it belongs to. Three pages corrected. `.env.example` loses two dead references and gains `WEBHOOK_ALLOW_HOSTS`. Its claim that `sslmode=require` is added automatically in `nuxt.config.ts` was wrong twice over: it is `apps/api/nitro.config.ts`, and the value it sets is read nowhere. The high-availability page credited postgres.js for adapting to PgBouncer; PgBouncer 1.25 changed the default. And `prepare: true` is not what prevents SQL injection — parameter binding is, and it works either way. Saying otherwise discouraged the very change that would settle the pooling question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .env.example | 50 +++++++++++++++--- README.md | 4 +- doc/Dockerfile | 22 +++++--- doc/guide/architecture.md | 9 ++-- doc/guide/configuration.md | 6 ++- doc/guide/getting-started.md | 13 +++-- doc/guide/high-availability.md | 30 ++++++++--- doc/guide/kubernetes.md | 25 ++++++++- doc/guide/panic-mode.md | 94 +++++++++++++++++++++++++++++----- doc/guide/security.md | 2 +- doc/index.md | 2 +- 11 files changed, 211 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 34896c8c..28b3a5a8 100644 --- a/.env.example +++ b/.env.example @@ -33,12 +33,22 @@ IMAGE_TAG=latest # Development (SSL disabled) DATABASE_URL=postgres://tracker:tracker@localhost:5432/trackarr -# Production (SSL enforced - sslmode=require automatically added in nuxt.config.ts) -# DATABASE_URL=postgres://tracker:STRONG_PASSWORD@pgbouncer:6432/trackarr +# En production, `docker-compose.prod.yml` construit `DATABASE_URL` lui-même à +# partir de DB_USER / DB_PASSWORD / DB_NAME : ne pas la renseigner ici. +# +# Ce bloc annonçait « sslmode=require automatically added in nuxt.config.ts ». +# Deux erreurs : le calcul est dans `apps/api/nitro.config.ts`, et la valeur +# qu'il produit (`runtimeConfig.databaseUrl`) N'EST LUE NULLE PART — +# `packages/db/src/index.ts` lit `process.env.DATABASE_URL` directement. Rien +# n'ajoute donc `sslmode=require` automatiquement. Sur le réseau interne de +# Docker, TLS est délibérément coupé (`-c ssl=off` côté Postgres, +# `sslmode=disable` dans la DSN) ; pour une base externe, mettre `DB_SSL=true` +# et l'écrire dans la DSN soi-même. # Production database settings DB_USER=tracker -# ⚠️ PRODUCTION: Generate with: openssl rand -base64 32 +# ⚠️ PRODUCTION: Generate with: openssl rand -hex 32 +# (hex, pas base64 : un « / » dans le mot de passe casse la DSN) # Leave empty and let the stack refuse to start rather than ship a guessable # default. Generate one with the command block at the bottom of this file. DB_PASSWORD= @@ -49,9 +59,15 @@ DB_PORT=5432 DB_POOL_MAX=20 # SSL/TLS for database connections -# Development: SSL disabled for local testing -# Production: SSL automatically enforced (see documentation/ssl-setup.md) -# Generate certificates with: ./scripts/generate-ssl-certs.sh +# +# Coupé par défaut, y compris en production : la base n'est joignable que depuis +# le réseau interne de Docker et n'est pas publiée. Pour une base EXTERNE ou +# gérée, mettre `DB_SSL=true` et ajouter `?sslmode=require` à la DSN. +# +# (Les deux renvois qui figuraient ici — `documentation/ssl-setup.md` et +# `./scripts/generate-ssl-certs.sh` — ne correspondent à aucun fichier du +# dépôt.) +DB_SSL=false # Debug mode (never enable in production) DB_DEBUG=false @@ -214,10 +230,28 @@ MESSAGING_SERVICE_URL=/messaging # echo "NUXT_SESSION_SECRET=$(openssl rand -hex 32)" >> .env # echo "ADMIN_API_KEY=$(openssl rand -hex 32)" >> .env # echo "IP_HASH_SECRET=$(openssl rand -hex 32)" >> .env -# echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env -# echo "REDIS_PASSWORD=$(openssl rand -base64 24)" >> .env +# `-hex`, pas `-base64` : l'alphabet base64 contient « / », qui termine +# l'autorité d'une URL. Le mot de passe est injecté dans +# `postgres://tracker:<pass>@pgbouncer:6432/…`, donc deux mots de passe sur +# cinq (mesuré : 0,395) produisaient une DSN illisible et une pile qui ne +# joignait pas sa base, avec une erreur « Invalid URL » qu'aucune page de doc +# ne relie au générateur. `doc/guide/local-production.md` employait déjà la +# bonne forme. +# echo "DB_PASSWORD=$(openssl rand -hex 32)" >> .env +# echo "REDIS_PASSWORD=$(openssl rand -hex 32)" >> .env # # Configure domains # echo "DOMAIN=your-tracker.com" >> .env # # Start # docker compose -f docker-compose.prod.yml up -d +# Hôtes autorisés pour le canal webhook, séparés par des virgules. +# +# L'URL d'un webhook est un champ de MEMBRE : chacun renseigne la sienne, sans +# revue d'administrateur. `safeFetch` écarte déjà les plages privées, la boucle +# locale et le lien-local, et re-valide chaque redirection — il reste une course +# de réattachement DNS sous-milliseconde, qui demande un serveur DNS contrôlé. +# +# Vide (le défaut) = aucune restriction, comportement inchangé. Renseigné, seuls +# ces hôtes et leurs sous-domaines sont joignables. +# WEBHOOK_ALLOW_HOSTS=discord.com,hooks.slack.com +WEBHOOK_ALLOW_HOSTS= diff --git a/README.md b/README.md index 253bf679..f255fd81 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,8 @@ NUXT_SESSION_SECRET=$(openssl rand -hex 32) ADMIN_API_KEY=$(openssl rand -hex 32) IP_HASH_SECRET=$(openssl rand -hex 32) CHANNEL_ENCRYPTION_KEY=$(openssl rand -hex 32) -DB_PASSWORD=$(openssl rand -base64 24) -REDIS_PASSWORD=$(openssl rand -base64 24) +DB_PASSWORD=$(openssl rand -hex 32) +REDIS_PASSWORD=$(openssl rand -hex 32) NUXT_PUBLIC_TRACKER_HTTP_URL=https://tracker.your-domain.com/announce NUXT_PUBLIC_TRACKER_UDP_URL=udp://tracker.your-domain.com:6969/announce diff --git a/doc/Dockerfile b/doc/Dockerfile index a9953231..bad72ee0 100644 --- a/doc/Dockerfile +++ b/doc/Dockerfile @@ -79,12 +79,22 @@ EXPOSE 8080 # before SIGKILL — plenty for a static asset response. STOPSIGNAL SIGQUIT -# Healthcheck — `nginx -t` validates the config without making a -# network round-trip, so it works even when there's no client traffic. -# The dedicated /healthz route in nginx.conf covers HTTP-level probes -# (k8s readinessProbe / docker-compose's healthcheck httpGet, etc.). -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD ["/usr/sbin/nginx", "-t"] +# Pas de HEALTHCHECK ici, et c'est délibéré. +# +# Il y en avait un : `CMD ["/usr/sbin/nginx", "-t"]`. Or `-t` valide le FICHIER +# DE CONFIGURATION — un worker bloqué, un listener fermé, un disque plein +# passent tous au vert. Un feu vert qui ne mesure rien est pire que pas de feu +# du tout : il transforme une panne en « sain » sur le tableau de bord. +# +# Le commentaire affirmait par ailleurs « we override to hit /healthz » ; la +# commande ne faisait pas cela. Les deux configurations exposent bien un vrai +# `/healthz`, mais l'image Chainguard n'a ni shell, ni wget, ni curl (vérifié : +# `/bin/sh` n'existe pas), donc rien ne peut l'interroger depuis l'intérieur du +# conteneur. +# +# La sonde revient donc à l'orchestrateur, qui sait le faire depuis l'extérieur : +# `readinessProbe: httpGet: { path: /healthz }` côté Kubernetes, ou un +# `healthcheck:` dans le compose si l'on veut la garder ici. # The Chainguard image's default ENTRYPOINT runs `nginx -g 'daemon off;'` # from the right user. No overrides needed. diff --git a/doc/guide/architecture.md b/doc/guide/architecture.md index c1dcad2a..c0c5d3a4 100644 --- a/doc/guide/architecture.md +++ b/doc/guide/architecture.md @@ -99,9 +99,12 @@ No shells, no package managers, signed images, non-root by default. last-tick timestamp ensure exactly one tick fires across the fleet per `BONUS_COLLECTION_INTERVAL`, regardless of restarts. - **Panic mode** — operator-triggered AES-256-GCM column-level - encryption of every sensitive field. The panic key never lands - on disk; recovery requires the operator to re-enter it. See - [Panic Mode](./panic-mode.md). + encryption of the `.torrent` payloads, member credentials, and + forum and comment content. Not of every sensitive field: private + messages, tickets, TOTP secrets and the audit log stay readable, + and the boundary is listed in full in the guide. The panic key + never lands on disk; recovery requires the operator to re-enter + it. See [Panic Mode](./panic-mode.md). ## Deployment topology diff --git a/doc/guide/configuration.md b/doc/guide/configuration.md index 8b173966..d58d71a8 100644 --- a/doc/guide/configuration.md +++ b/doc/guide/configuration.md @@ -104,7 +104,11 @@ If you change them, rebuild the affected container (`api` or `tracker`) and rede Before going live, verify: 1. Every secret is 32+ chars (`openssl rand -hex 32`). -2. `IP_HASH_SECRET`, `NUXT_SESSION_SECRET`, `ADMIN_API_KEY` are all set — the app refuses to start without them. +2. `IP_HASH_SECRET` and `NUXT_SESSION_SECRET` are set — the app refuses to + start without those two (`plugins/00.secrets.ts`). `ADMIN_API_KEY` is + **not** in that check and is not required to boot; set it anyway if you + intend to use the header-authenticated admin routes, and see the note in + `apps/api/utils/auth.ts` for what it currently gates. 3. `DOMAIN` + `TRACKER_DOMAIN` resolve to the VPS, port 80/443 inbound is open. 4. `TRUST_PROXY=true` (already on in `docker-compose.prod.yml`) so the rate limiter sees the real client IP through Caddy. 5. Postgres and Redis ports are **not** exposed on the host (compose `expose:` not `ports:`). diff --git a/doc/guide/getting-started.md b/doc/guide/getting-started.md index 0b031696..541daa4a 100644 --- a/doc/guide/getting-started.md +++ b/doc/guide/getting-started.md @@ -107,8 +107,8 @@ ACME_EMAIL=admin@your-domain.com NUXT_SESSION_SECRET=$(openssl rand -hex 32) ADMIN_API_KEY=$(openssl rand -hex 32) IP_HASH_SECRET=$(openssl rand -hex 32) -DB_PASSWORD=$(openssl rand -base64 24) -REDIS_PASSWORD=$(openssl rand -base64 24) +DB_PASSWORD=$(openssl rand -hex 32) +REDIS_PASSWORD=$(openssl rand -hex 32) NUXT_PUBLIC_TRACKER_HTTP_URL=https://tracker.your-domain.com/announce NUXT_PUBLIC_TRACKER_UDP_URL=udp://tracker.your-domain.com:6969/announce @@ -197,7 +197,7 @@ the minimum you must set for production: | Variable | Why | | ------------------------------- | --------------------------------------------------- | | `NUXT_SESSION_SECRET` | Encrypts user sessions (32+ chars) | -| `ADMIN_API_KEY` | Internal admin operations | +| `ADMIN_API_KEY` | Header-auth admin routes (optional — see note below) | | `IP_HASH_SECRET` | Daily-rotated salt for peer-IP hashing | | `DB_PASSWORD` | PostgreSQL password | | `REDIS_PASSWORD` | Redis password | @@ -208,6 +208,13 @@ the minimum you must set for production: | `NUXT_PUBLIC_TRACKER_UDP_URL` | UDP announce URL (informational, UDP is disabled) | | `NUXT_PUBLIC_TRACKER_WS_URL` | WS announce URL (informational, WS is disabled) | +`ADMIN_API_KEY` is the one line in that table you can leave empty. It is not +part of the boot-time secret check, and no route calls the header gate today +(`requireAdmin` in `apps/api/utils/auth.ts` has no callers) — the admin panel +authenticates by session, not by this key. Generating one costs nothing and +keeps a later admin-API route from needing a redeploy, but an empty value +removes no protection from anything that exists now. + The `docker-compose.prod.yml` file ships with `TRUST_PROXY=true` — needed so the rate limiter sees the real client IP through Caddy. Don't remove it. diff --git a/doc/guide/high-availability.md b/doc/guide/high-availability.md index 8c804564..efc52f0f 100644 --- a/doc/guide/high-availability.md +++ b/doc/guide/high-availability.md @@ -255,15 +255,29 @@ different: pgx *does* support multi-host DSNs with `target_session_attrs`, so the tracker could skip the proxy if you wanted it to. Keeping both on the same path is worth more than saving the tracker one hop. -**4. Prepared statements through the pooler: measured, and fine.** +**4. Prepared statements through the pooler: measured, and the reason matters.** `packages/db/src/index.ts` sets `prepare: true` and connects through PgBouncer in -`transaction` mode — the combination usually said to need -`max_prepared_statements > 0`, which the Compose file does not set. Tested -directly against PgBouncer 1.25.2 in transaction mode with `postgres.js` 3.4.9 -and Postgres 18.6: 80 statements reused across separate transactions, no error, -behaviour identical to connecting straight to Postgres. postgres.js accounts for -the pooling mode itself. Nothing to do — recorded so the question is not -reopened every time the pooling layer changes. +`transaction` mode. Tested directly against PgBouncer 1.25.2 with +`postgres.js` 3.4.9 and Postgres 18.6: 80 statements reused across separate +transactions, no error, behaviour identical to connecting straight to Postgres. + +The measurement was right and its earlier explanation was wrong. This page used +to say "postgres.js accounts for the pooling mode itself"; the actual reason is +that **PgBouncer 1.25 changed the default of `max_prepared_statements` from 0 to +200**. Nothing in postgres.js adapts — the pooler simply started supporting it. + +That distinction is load-bearing: mount `docker/pgbouncer/pgbouncer.ini` against +a PgBouncer *older* than 1.25 — a distribution package, an existing corporate +pooler, RDS Proxy, Supabase's pooler — and the second use of every named +prepared statement fails. Intermittent, and invisible in testing. The file now +sets `max_prepared_statements = 200` explicitly so the behaviour no longer +depends on which binary is in front, and `docker-compose.prod.yml` passes +`MAX_PREPARED_STATEMENTS` for the same reason. + +Note also that `prepare: true` is *not* what protects against SQL injection — +parameter binding is, and it happens with `prepare: false` too. The comment in +`packages/db/src/index.ts` that claimed otherwise discouraged exactly the change +that would make this question moot. **5. Decide what `synchronous_commit` means with a standby.** The tracker sets `synchronous_commit=off` on its own connections diff --git a/doc/guide/kubernetes.md b/doc/guide/kubernetes.md index 9687f670..3f7200dc 100644 --- a/doc/guide/kubernetes.md +++ b/doc/guide/kubernetes.md @@ -99,6 +99,29 @@ object store with it. See [Object storage](./object-storage). `Cluster`, kube-proxy rewrites it to a node address and every peer in the swarm is handed the wrong IP. +**And so does HTTP — on the ingress controller's own Service.** The reasoning +above was written for UDP only, but it applies just as much to `/announce` over +HTTP. ingress-nginx does set `X-Forwarded-For` itself, from `$remote_addr` — and +with the controller Service's default `externalTrafficPolicy: Cluster`, +kube-proxy has already SNAT'd that address to a node's. So every peer registers +under one of two or three node IPs: swarms fail to connect (each peer is handed +the node's address for all the others), IP bans ban the whole cluster, and rate +limiting shares one bucket across every member. + +That Service belongs to the ingress-nginx chart, not to this one, so it has to +be set there: + +```yaml +# ingress-nginx values +controller: + service: + externalTrafficPolicy: Local +``` + +With an L4 load balancer in front, `use-proxy-protocol: "true"` on the +controller is the alternative — it carries the real peer instead of relying on +the source address surviving. + ## Without the chart's ingress `ingress.enabled=false` omits the Ingress and changes nothing else, which covers @@ -131,7 +154,7 @@ Secret back before generating anything and mints them only on a first install: | --- | --- | | `NUXT_SESSION_SECRET` | signs every member out | | `IP_HASH_SECRET` | breaks IP-hash continuity, so the anti-cheat and ban history stop matching rows already written | -| `ADMIN_API_KEY` | breaks whatever calls the admin API | +| `ADMIN_API_KEY` | breaks whatever calls the header-authenticated admin routes — which, today, is nothing in this codebase (see `apps/api/utils/auth.ts`). Kept stable so that stops being true without a rotation | Migrating from Compose: copy those three out of your `.env` and pass them as `secrets.sessionSecret` / `secrets.ipHashSecret` / `secrets.adminApiKey` on the diff --git a/doc/guide/panic-mode.md b/doc/guide/panic-mode.md index 64e08939..48d15872 100644 --- a/doc/guide/panic-mode.md +++ b/doc/guide/panic-mode.md @@ -1,6 +1,20 @@ # Panic Mode -The **Panic Button** allows administrators to **instantly encrypt all sensitive data** in an emergency. Once activated, all torrent files become unusable and user data is unreadable. +The **Panic Button** lets an administrator encrypt the tracker's most +damaging data in an emergency: every `.torrent` payload, the credentials +that authenticate members, and the written content of the forum and of +torrent comments. + +::: warning It is not whole-database encryption +This page used to say "instantly encrypt all sensitive data" and "user data +is unreadable". That was wrong, and wrong in the direction that gets people +hurt — an operator could activate Panic Mode believing private messages and +2FA secrets were protected when they are not. See +[What gets encrypted](#what-gets-encrypted) for the exact boundary, both +halves of it. The live instance reports the same two lists from +`POST /api/admin/panic/encrypt`, so the answer can be checked against the +running code rather than against this page. +::: ## How It Works @@ -22,6 +36,10 @@ The **Panic Button** allows administrators to **instantly encrypt all sensitive │ • Torrent sizes → 0 │ │ • User credentials → Encrypted │ │ • Forum posts → Encrypted │ +│ • Comments → Encrypted │ +│ │ +│ • Private messages, tickets, 2FA secrets, │ +│ audit log, login history → UNTOUCHED, still readable │ └───────────────────────────────────────────────────────────────────┘ │ RESTORE (with password) @@ -54,14 +72,46 @@ Encryption begins immediately and cannot be interrupted. ## What Gets Encrypted -| Data Type | Encryption | -|-----------|------------| -| `.torrent` files | AES-256-GCM (completely unusable) | -| Torrent metadata | Names replaced with `[ENCRYPTED]` | -| File sizes | Set to 0 | -| User auth data | Encrypted | -| Forum posts | Content encrypted | -| Private messages | Encrypted | +Both halves are listed, because only the two together describe what Panic +Mode buys you. They are the lists the API itself returns. + +### Covered + +| Column | What happens | +|--------|--------------| +| `torrents.torrent_data` | AES-256-GCM — the payload becomes unusable | +| `torrents.name` | Encrypted, or `[ENCRYPTED]` when empty | +| `torrents.description` | Encrypted | +| `torrents.size` | Set to `0` | +| `torrents.category_id` | Set to `NULL` | +| `users.auth_salt`, `users.auth_verifier` | Encrypted — logins stop working | +| `users.passkey` | Encrypted — announces stop working | +| `users.rss_key`, `users.api_key` | Encrypted — feeds and Torznab stop working | +| `users.last_ip` | Encrypted | +| `forum_posts.content` | Encrypted | +| `torrent_comments.content` | Encrypted | + +### Left in cleartext + +| Column | Why it matters | +|--------|----------------| +| `messages.body`, `room_messages.body` | Private conversations, in full | +| `tickets.*`, `ticket_messages.*` | Support threads, in full | +| `users.totp_secret` | A 2FA secret is enough to mint valid codes | +| `federation_config.private_key` | The instance's own signing key | +| `users.bio`, `users.display_name` | Member-authored identity | +| `forum_topics.title` | The posts are encrypted; their titles are not | +| `notification_channels.server_config` | Webhook URLs and channel credentials | +| `login_events.*` | Who connected, from where, when | +| `anticheat_flags.ip`, `anticheat_flags.user_agent` | Member IP addresses | +| `audit_log.*` | The administrative record | +| `remote_torrents.*` | Federated catalogue | + +Extending the covered half is a schema and restore-path change, not a +setting: every encrypted column needs its decrypt on the way back, and +`restore` has to stay able to bring an instance encrypted by an older +version back. Nothing here is a suggestion that Panic Mode is broken — it +is a statement of where its boundary currently sits. ## Restoration @@ -77,10 +127,30 @@ The system will decrypt all data and return to normal operation. | Component | Algorithm | |-----------|-----------| -| Key Derivation | scrypt (32 bytes output) | +| Key Derivation | scrypt, 32-byte output, cost versioned (see below) | | Encryption | AES-256-GCM | -| IV | 16 bytes random (per encryption session) | -| Authentication | GCM tag (prevents tampering) | +| IV | 12 bytes random, **per field** — not per session | +| Authentication | GCM tag, 16 bytes (prevents tampering) | + +The IV is 96 bits because that is the size AES-GCM is built around; the +16-byte IV used before was permitted but strictly worse, since the extra +bytes get folded into the tag derivation instead of being used as a nonce. +It is drawn fresh for every field, so two identical plaintexts do not +produce identical ciphertexts. + +The scrypt cost is versioned rather than changed, so a database encrypted by +an older build stays restorable: + +| `kdf_version` | Cost | Notes | +|---------------|------|-------| +| 1, 2 | N = 2^14, r = 8, p = 1 (~16 MB) | Node's defaults | +| 3 | N = 2^17, r = 8, p = 1 (~128 MB) | What new encryptions write | + +Version 3 is what OWASP asks for a human-chosen password, and this is the +place it counts most: Panic Mode assumes the attacker already holds the salt +and the ciphertext, so the only thing standing between them and the data is +how long each guess takes. Measured on this codebase: ~50 ms per derivation +at version 1, ~490 ms at version 3. ## Use Cases diff --git a/doc/guide/security.md b/doc/guide/security.md index e252f3da..6b2765d0 100644 --- a/doc/guide/security.md +++ b/doc/guide/security.md @@ -7,7 +7,7 @@ Trackarr is built with security as a foundational principle, not an afterthought | Layer | Protection | |-------|------------| | **Authentication** | Zero-Knowledge proofs, PoW anti-abuse, session encryption, CSRF protection | -| **Database** | SCRAM-SHA-256 auth, TLS, prepared statements, connection pool limits | +| **Database** | SCRAM-SHA-256 auth, optional TLS (`DB_SSL` / `database.sslMode` — **off by default** on the internal network), parameter binding, connection pool limits | | **Redis** | Password auth, command restrictions, memory limits | | **Network** | Rate limiting, auto IP bans, attack pattern detection | | **Privacy** | SHA-256 hashed IPs, no raw IP persistence, minimal logging | diff --git a/doc/index.md b/doc/index.md index 5e4199a1..557511df 100644 --- a/doc/index.md +++ b/doc/index.md @@ -21,7 +21,7 @@ features: - title: Lightning Fast details: Redis-powered sub-millisecond peer lookups, Postgres trigram search, and a Go announce hot path designed for thousands of swarms per second. - title: Panic Mode - details: One-click AES-256-GCM encryption of every sensitive column. The data is recoverable only with your panic password — no server-side key escrow. + details: One-click AES-256-GCM encryption of the .torrent payloads, member credentials, and forum and comment content. Recoverable only with your panic password — no server-side key escrow. The guide lists what it does not cover. - title: Moderation pipeline details: Pending / accepted / changes-requested / rejected lifecycle with a per-torrent discussion thread between uploaders and moderators. - title: Bonus events From 87f3a692a31a0258c856cf5f600b9ead4c8e1408 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:27:49 +0200 Subject: [PATCH 66/96] fix(api): the freeleech pool named its top members to anonymous callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/freeleech-pool/state` answers without a session on purpose — the docstring says so, and it is right: the shop widget needs `userContribution: null` to tell "not signed in" from "signed in, hasn't contributed". What nobody weighed is that `topContributors` rode along. Measured on the compiled stack: an anonymous caller received five usernames, their contribution totals, and their INTERNAL user ids — the key every other path in the API is addressed by. On a private tracker the member list is precisely what must not leave. Nobody signed in loses anything: the only consumer is `components/shop/FreeleechPool.vue`, `/shop` requires a session, and the id there is a `v-for` key and nothing more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/freeleech-pool/state.get.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/api/routes/api/freeleech-pool/state.get.ts b/apps/api/routes/api/freeleech-pool/state.get.ts index cf48e216..949be29c 100644 --- a/apps/api/routes/api/freeleech-pool/state.get.ts +++ b/apps/api/routes/api/freeleech-pool/state.get.ts @@ -9,6 +9,20 @@ * Anonymous callers get `userContribution: null` instead of zero * so the FE can tell "not logged in" from "logged in, didn't * contribute yet". + * + * Ils n'obtiennent PAS la liste des contributeurs. + * + * L'appel anonyme était prévu — pour `userContribution` — et + * `topContributors` est parti avec, sans que personne y pense. + * Mesuré sur la pile compilée : un appelant sans session recevait + * cinq pseudonymes, leurs totaux, et surtout leurs identifiants + * INTERNES, ceux qui servent de clé dans tout le reste de l'API. + * Sur un tracker privé, la liste des membres est précisément ce qui + * ne doit pas sortir. + * + * Personne d'authentifié n'y perd : le seul consommateur est + * `components/shop/FreeleechPool.vue`, et `/shop` demande une + * session. L'identifiant n'y sert d'ailleurs que de clé de boucle. */ import { getPublicState } from '~~/utils/freeleechPool'; @@ -16,5 +30,8 @@ export default defineEventHandler(async (event) => { const session = await getUserSession(event); const userId = session?.user?.id ?? null; const state = await getPublicState(userId); + if (!userId) { + return { ...state, topContributors: [] }; + } return state; }); From 66719930300955743864b8e9a07a2e05e8e0e52f Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:27:49 +0200 Subject: [PATCH 67/96] fix(panic): encrypt was an unbounded oracle against the panic password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its sibling `restore` carries three guards and explains them: the `auth` rate bucket, a global attempt budget, and a `.max(200)` schema justified as "free CPU amplification". `encrypt` derives the SAME key from the SAME password through the same scrypt and had none of them — a bare `readBody()` and no limit at all. Two costs. A multi-megabyte `panicPassword` reaches scrypt on every call. And `verifyPassword(admin.panicPasswordHash, …)` below is an oracle: with no ceiling, a compromised admin session can try the password that decrypts the whole database as often as it likes. That the door already needs an admin session is not an answer — a stolen admin session is the scenario panic mode exists for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../routes/api/admin/panic/encrypt.post.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/api/routes/api/admin/panic/encrypt.post.ts b/apps/api/routes/api/admin/panic/encrypt.post.ts index 2b3a976a..0b1d408d 100644 --- a/apps/api/routes/api/admin/panic/encrypt.post.ts +++ b/apps/api/routes/api/admin/panic/encrypt.post.ts @@ -9,6 +9,8 @@ import { } from '@trackarr/db/schema'; import { requireAdminSession } from '~~/utils/adminAuth'; import { auditDetail } from '~~/utils/audit'; +import { z } from 'zod'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { CURRENT_KDF_VERSION, deriveKey, @@ -24,8 +26,31 @@ import { * Encrypt all sensitive database data * This is an emergency action that renders data unreadable */ +/* + * Le corps est borné, et l'appel limité — comme `restore`. + * + * `restore` porte les trois durcissements (seau `RATE_LIMITS.auth`, budget + * global, schéma `.max(200)`) et les justifie : « free CPU amplification », + * parce que la valeur atteint scrypt. `encrypt` dérive la MÊME clé depuis le + * MÊME mot de passe et n'en avait aucun — `readBody()` nu, aucune limite. + * + * Deux conséquences. Un `panicPassword` de plusieurs mégaoctets part dans + * scrypt à chaque appel ; et surtout `verifyPassword(admin.panicPasswordHash, + * …)` plus bas est un oracle : sans plafond, une session d'administration + * compromise essaie le mot de passe qui déchiffre TOUTE la base autant de fois + * qu'elle veut. Que la porte demande déjà une session admin ne change rien — + * c'est précisément le scénario contre lequel le mode panique existe. + */ +const bodySchema = z + .object({ + confirm: z.literal('ENCRYPT_ALL_DATA'), + panicPassword: z.string().min(1, 'Panic password is required').max(200), + }) + .strict(); + export default defineEventHandler(async (event) => { await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.auth); // 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. @@ -33,25 +58,11 @@ export default defineEventHandler(async (event) => { // No `changes`: the body carries the raw panic password. auditDetail(event, { action: 'panic.encrypt', targetType: 'instance' }); - const body = await readBody(event); - - if (body.confirm !== 'ENCRYPT_ALL_DATA') { - throw createError({ - statusCode: 400, - message: 'Confirmation required. Send { confirm: "ENCRYPT_ALL_DATA" }', - }); - } - // The raw panic password is required so the encryption key can be // derived from it (not from the stored hash). Deriving from the // hash would leave BOTH KDF inputs (hash + salt) inside the very // dump panic mode is meant to protect — see finding C1. - if (!body.panicPassword || typeof body.panicPassword !== 'string') { - throw createError({ - statusCode: 400, - message: 'Panic password is required to encrypt.', - }); - } + const body = await readValidatedBody(event, bodySchema.parse); const currentState = await db.query.panicState.findFirst(); if (currentState?.isEncrypted) { From 776d7461f0a7c4644901ad2b48d7b33b1968f937 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:27:49 +0200 Subject: [PATCH 68/96] fix(forum): a topic author could delete everyone else's replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `forum_posts.topic_id` cascades, so removing the `forum_topics` row takes every reply with it. Both delete paths knew half of this: the topic route refused only on a LOCKED topic, and the post route carries a comment naming the danger word for word — "cascade-wipe the entire locked thread (every other user's replies too)" — and guards, again, only the locked case. An open thread of fifty replies was therefore destructible by its author alone. Measured on the compiled stack: a topic holding three posts from three distinct authors, deleted by its non-staff author, leaves zero posts. Two other members' work gone, with nothing but an audit row to show for it. The rule is the ordinary forum one: you may withdraw what you opened for as long as nobody else has spoken in it. After that the thread is not yours to remove — only staff can. It keeps the "I posted nonsense a minute ago" case working without handing an author the power to erase other people's contributions. Proven three ways: no other voices → the author still deletes; another member replied → 403 and the posts survive; staff → still able to delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../api/routes/api/forum/posts/[id].delete.ts | 7 +++ .../routes/api/forum/topics/[id].delete.ts | 7 +++ apps/api/utils/forumDeletion.ts | 53 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 apps/api/utils/forumDeletion.ts diff --git a/apps/api/routes/api/forum/posts/[id].delete.ts b/apps/api/routes/api/forum/posts/[id].delete.ts index 7183c70d..c1a3841e 100644 --- a/apps/api/routes/api/forum/posts/[id].delete.ts +++ b/apps/api/routes/api/forum/posts/[id].delete.ts @@ -60,6 +60,13 @@ export default defineEventHandler(async (event) => { }); if (firstPost?.id === id) { + // Supprimer le premier message supprime le sujet, donc la cascade emporte + // toutes les réponses. Le garde ci-dessus ne couvrait que le fil + // VERROUILLÉ ; un fil ouvert de cinquante réponses restait destructible par + // son auteur. Voir `utils/forumDeletion.ts`. + if (!isModerator) { + await assertTopicDeletableByAuthor(post.topicId, session.user.id); + } // If it's the first post, delete the whole topic await db.delete(forumTopics).where(eq(forumTopics.id, post.topicId)); return { message: 'Topic deleted (first post removed)' }; diff --git a/apps/api/routes/api/forum/topics/[id].delete.ts b/apps/api/routes/api/forum/topics/[id].delete.ts index 6b5113bc..6a6777a9 100644 --- a/apps/api/routes/api/forum/topics/[id].delete.ts +++ b/apps/api/routes/api/forum/topics/[id].delete.ts @@ -45,6 +45,13 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 403, message: 'This topic is locked' }); } + // Ni un sujet où quelqu'un d'autre a pris la parole : la cascade sur + // `forum_posts.topic_id` emporterait ses messages avec. Voir + // `utils/forumDeletion.ts`. + if (!isModerator) { + await assertTopicDeletableByAuthor(id, session.user.id); + } + await db.delete(forumTopics).where(eq(forumTopics.id, id)); return { message: 'Topic deleted' }; diff --git a/apps/api/utils/forumDeletion.ts b/apps/api/utils/forumDeletion.ts new file mode 100644 index 00000000..244193bf --- /dev/null +++ b/apps/api/utils/forumDeletion.ts @@ -0,0 +1,53 @@ +import { db, schema } from '@trackarr/db'; +import { and, eq, ne, sql } from 'drizzle-orm'; + +/** + * L'auteur d'un sujet peut-il encore le supprimer ? + * + * `forum_posts.topic_id` porte `onDelete: 'cascade'`, donc supprimer la ligne + * `forum_topics` emporte TOUTES les réponses — y compris celles des autres + * membres. Les deux chemins qui suppriment un sujet le savaient à moitié : + * + * - `topics/[id].delete.ts` ne refusait que sur un sujet VERROUILLÉ ; + * - `posts/[id].delete.ts` porte un commentaire qui nomme le danger mot pour + * mot — « cascade-wipe the entire locked thread (every other user's + * replies too) » — et ne garde, lui aussi, que le cas verrouillé. + * + * Un fil non verrouillé de cinquante réponses restait donc destructible par son + * seul auteur. Mesuré sur la pile compilée : un sujet portant trois messages de + * trois auteurs distincts, supprimé par son auteur simple membre, laisse zéro + * message. Le travail des deux autres disparaît sans recours et sans trace + * autre qu'une ligne d'audit. + * + * La règle retenue est celle des forums : on peut retirer ce qu'on a ouvert + * tant que personne d'autre n'y a pris la parole. Au-delà, le fil ne + * t'appartient plus — seul le personnel peut le supprimer. Elle préserve le + * retrait d'une bêtise fraîche sans donner à un auteur le pouvoir d'effacer + * les contributions d'autrui. + * + * Le personnel n'est pas concerné : `isModerator` court-circuite l'appel. + */ +export async function assertTopicDeletableByAuthor( + topicId: string, + authorId: string +): Promise<void> { + const [row] = await db + .select({ others: sql<number>`count(*)::int` }) + .from(schema.forumPosts) + .where( + and( + eq(schema.forumPosts.topicId, topicId), + ne(schema.forumPosts.authorId, authorId) + ) + ); + + if ((row?.others ?? 0) > 0) { + throw createError({ + statusCode: 403, + // `data.reason` plutôt que la phrase : le front choisit sa traduction. + data: { reason: 'topic-has-replies' }, + message: + 'Another member has replied in this topic. Only staff can delete it now.', + }); + } +} From ea8ce0f747041d117141e45cd5c72f32369616f2 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:28:09 +0200 Subject: [PATCH 69/96] fix(api): the upload count contradicted the anonymous-uploads setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile counted `uploaderId = target` with no other predicate, while `users/[id]/uploads.get.ts` applies three for an ordinary reader: accepted rows only, no adult categories unless the viewer opted in, and NOTHING AT ALL when the target has anonymous uploads on. The page therefore printed "12 uploads" directly above a list saying this member publishes anonymously — and leaked the pending and rejected counts to any authenticated member along the way. The count stays whole for the member themselves and for staff, who already see the full list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/users/[id].get.ts | 34 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/api/routes/api/users/[id].get.ts b/apps/api/routes/api/users/[id].get.ts index 409aeb8d..0e62accf 100644 --- a/apps/api/routes/api/users/[id].get.ts +++ b/apps/api/routes/api/users/[id].get.ts @@ -15,6 +15,7 @@ export default defineEventHandler(async (event) => { const user = await db.query.users.findFirst({ where: eq(schema.users.id, params.id), columns: { + anonymousUploads: true, id: true, username: true, displayName: true, @@ -81,11 +82,36 @@ export default defineEventHandler(async (event) => { // parallel. The follower count is public (a number, never a list); // `viewerFollowing` is the one bit the follow toggle depends on // for its filled/outline state on first paint. + /* + * Le compte suit les MÊMES règles que la liste, sinon il la contredit. + * + * Il comptait `uploaderId = cible`, sans autre prédicat, quand + * `users/[id]/uploads.get.ts` en applique trois pour un lecteur ordinaire : + * seulement les dépôts acceptés, jamais les catégories adultes si le lecteur + * n'a pas opté, et RIEN DU TOUT quand la cible a coché « téléversements + * anonymes ». La fiche affichait donc « 12 téléversements » juste au-dessus + * d'une liste disant « ce membre publie anonymement » — et fuyait au passage + * le nombre de dépôts en attente et rejetés, à n'importe quel membre + * authentifié. + * + * Le compte reste entier pour la personne elle-même et pour le personnel, + * qui voient déjà la liste entière. + */ + const countsHidden = !isPrivileged && user.anonymousUploads; + const uploadCountWhere = isPrivileged + ? eq(schema.torrents.uploaderId, params.id) + : and( + eq(schema.torrents.uploaderId, params.id), + eq(schema.torrents.moderationStatus, 'accepted') + ); + const [uploadsCount, followersCount, viewerFollow] = await Promise.all([ - db - .select({ count: sql<number>`count(*)::int` }) - .from(schema.torrents) - .where(eq(schema.torrents.uploaderId, params.id)), + countsHidden + ? Promise.resolve([{ count: 0 }]) + : db + .select({ count: sql<number>`count(*)::int` }) + .from(schema.torrents) + .where(uploadCountWhere), db .select({ count: sql<number>`count(*)::int` }) .from(schema.userFollows) From bac5d5828039b62b4d3155630516f74fe0127380 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:28:10 +0200 Subject: [PATCH 70/96] fix(forum): the last two mutating routes without a schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lock` and `pin` read a bare body and dereferenced it. A PUT with no body, or with a JSON `null`, threw a TypeError — a 500 with a stack trace where a 400 says what is wrong. They were the only two left in the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/forum/topics/[id]/lock.put.ts | 10 +++++++++- apps/api/routes/api/forum/topics/[id]/pin.put.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/api/routes/api/forum/topics/[id]/lock.put.ts b/apps/api/routes/api/forum/topics/[id]/lock.put.ts index 4a17a148..551f6c47 100644 --- a/apps/api/routes/api/forum/topics/[id]/lock.put.ts +++ b/apps/api/routes/api/forum/topics/[id]/lock.put.ts @@ -2,11 +2,19 @@ import { db } from '@trackarr/db'; import { forumTopics } from '@trackarr/db/schema'; import { requireModeratorSession } from '~~/utils/adminAuth'; import { eq } from 'drizzle-orm'; +import { z } from 'zod'; export default defineEventHandler(async (event) => { await requireModeratorSession(event); const id = getRouterParam(event, 'id'); - const body = await readBody(event); + // Un schéma, pas `readBody()`. C'étaient les deux dernières routes + // mutantes de l'arbre sans validation : un PUT sans corps, ou avec un + // corps JSON `null`, faisait lever un TypeError sur la déréférence — un + // 500 avec trace là où un 400 suffit. + const body = await readValidatedBody( + event, + z.object({ isLocked: z.boolean() }).strict().parse + ); if (!id) { throw createError({ diff --git a/apps/api/routes/api/forum/topics/[id]/pin.put.ts b/apps/api/routes/api/forum/topics/[id]/pin.put.ts index 71ec398a..e50addaf 100644 --- a/apps/api/routes/api/forum/topics/[id]/pin.put.ts +++ b/apps/api/routes/api/forum/topics/[id]/pin.put.ts @@ -2,11 +2,19 @@ import { db } from '@trackarr/db'; import { forumTopics } from '@trackarr/db/schema'; import { requireModeratorSession } from '~~/utils/adminAuth'; import { eq } from 'drizzle-orm'; +import { z } from 'zod'; export default defineEventHandler(async (event) => { await requireModeratorSession(event); const id = getRouterParam(event, 'id'); - const body = await readBody(event); + // Un schéma, pas `readBody()`. C'étaient les deux dernières routes + // mutantes de l'arbre sans validation : un PUT sans corps, ou avec un + // corps JSON `null`, faisait lever un TypeError sur la déréférence — un + // 500 avec trace là où un 400 suffit. + const body = await readValidatedBody( + event, + z.object({ isPinned: z.boolean() }).strict().parse + ); if (!id) { throw createError({ From 3d49573c0b66015db2f9134fcdc6ff01b908986d Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:28:10 +0200 Subject: [PATCH 71/96] fix(me): the one verifyTotp call without its replay guard, and a stamp reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `utils/twoFactor.ts` only writes the single-use Redis key when the caller passes `{ userId }` — that was the fix behind finding M3. Three of the four call sites pass it. `totp/enable` did not, so a captured code stayed replayable there. The window is narrow (the route answers 409 the moment 2FA is on, so it only exists during enrolment); the asymmetry inside a security guard is the finding. `me/index.patch` set `loggedInAt: Date.now()` when refreshing the session, so editing a bio reset the login moment. Inert today — the `requireFreshAuth` window lives in Redis keyed on the h3 session id, and nothing reads this field — but a trap laid for whoever comes to rely on it, at which point an idle request would make an old session look fresh. Refreshing the session is for the navbar and the theme; the login time has no business in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/me/2fa/totp/enable.post.ts | 11 ++++++++++- apps/api/routes/api/me/index.patch.ts | 14 +++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/api/routes/api/me/2fa/totp/enable.post.ts b/apps/api/routes/api/me/2fa/totp/enable.post.ts index 323b21b4..53aad122 100644 --- a/apps/api/routes/api/me/2fa/totp/enable.post.ts +++ b/apps/api/routes/api/me/2fa/totp/enable.post.ts @@ -49,7 +49,16 @@ export default defineEventHandler(async (event) => { }); } const seed = decryptSecret(row.totpSecret); - if (!seed || !(await verifyTotp(body.code, seed))) { + // `{ userId }` compte : c'est LUI qui arme la garde anti-rejeu. + // + // `utils/twoFactor.ts` ne pose la clé Redis d'usage unique que si l'appelant + // fournit l'identifiant — c'était le correctif « finding M3 ». Les trois + // autres sites d'appel le passent ; celui-ci, seul, ne le passait pas. La + // portée réelle est étroite (la route refuse en 409 dès que la 2FA est + // active, donc un code capturé n'est rejouable que pendant l'enrôlement), + // mais une asymétrie dans une garde de sécurité est une asymétrie qu'on + // finit par croire volontaire. + if (!seed || !(await verifyTotp(body.code, seed, { userId: session.user.id }))) { throw createError({ statusCode: 400, message: 'Invalid code. Make sure your phone clock is in sync.', diff --git a/apps/api/routes/api/me/index.patch.ts b/apps/api/routes/api/me/index.patch.ts index fdcb78e8..acb39c2d 100644 --- a/apps/api/routes/api/me/index.patch.ts +++ b/apps/api/routes/api/me/index.patch.ts @@ -202,6 +202,18 @@ export default defineEventHandler(async (event) => { // read from the session, not from /api/me) reflect the new value on // the next /api/auth/status poll without waiting for a re-login. if ('displayName' in updates || 'theme' in updates || 'language' in updates) { + // `loggedInAt` est CONSERVÉ, pas réestampillé. + // + // Il valait `Date.now()` ici : éditer sa biographie remettait à zéro le + // moment de la connexion. C'est inerte aujourd'hui — la fenêtre de + // `requireFreshAuth` vit dans Redis, clefée sur l'identifiant h3, et rien + // ne lit ce champ comme signal de fraîcheur — mais c'est un piège posé + // pour le jour où quelqu'un s'y fiera : une requête anodine ferait alors + // passer une session ancienne pour fraîche. + // + // Rafraîchir la session sert à ce que la barre de navigation et le thème + // suivent le changement ; l'heure de connexion n'a rien à y voir. + const current = await getUserSession(event); await setUserSession(event, { user: { ...user, @@ -209,7 +221,7 @@ export default defineEventHandler(async (event) => { theme: updated.theme, language: updated.language, }, - loggedInAt: Date.now(), + loggedInAt: current.loggedInAt, }); } From b1d588a0f2006ee106af6cb5c5efcd5282ca209e Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:28:28 +0200 Subject: [PATCH 72/96] fix(security): a banned member kept the account routes under /api/auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The middleware skips the ban gate for all of `/api/auth/**` — it has to, login and registration are anonymous — but the skip also covers the account routes that live under that prefix. And `requireUserSession` does not read the ban status, unlike `requireAuthSession`. A banned member therefore kept rotating their passkey and changing their password: the front door shut, the service door open. Measured 200 before the ban and 403 after, on all three. Fixed per route rather than by narrowing the middleware's skip: the skip is right for what it covers, and listing routes one by one would make an authorization guard depend on a list somebody has to maintain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/auth/passkey.get.ts | 17 ++++++++++++++++- apps/api/routes/api/auth/passkey.post.ts | 17 ++++++++++++++++- apps/api/routes/api/auth/password.put.ts | 17 ++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/api/routes/api/auth/passkey.get.ts b/apps/api/routes/api/auth/passkey.get.ts index a09faf8e..5554de4e 100644 --- a/apps/api/routes/api/auth/passkey.get.ts +++ b/apps/api/routes/api/auth/passkey.get.ts @@ -2,8 +2,23 @@ * GET /api/auth/passkey * Returns the current user's passkey (private, only accessible to the user themselves) */ +/* + * `requireAuthSession`, pas `requireUserSession` : le bannissement compte ici. + * + * Le middleware saute la porte de bannissement pour tout `/api/auth/**` — il + * le doit, la connexion et l'inscription sont anonymes — mais le saut couvre + * aussi les routes de compte qui vivent sous ce préfixe. Or + * `requireUserSession` ne lit pas le statut, contrairement à + * `requireAuthSession`. Un membre banni gardait donc de quoi faire tourner son + * passkey et changer son mot de passe : la porte d'entrée était fermée, la + * porte de service ouverte. + * + * Corrigé par route plutôt qu'en resserrant le saut du middleware : le saut + * est juste pour ce qu'il vise, et le lister route par route ferait dépendre + * une garde d'autorisation d'une liste à tenir à jour. + */ export default defineEventHandler(async (event) => { - const { user } = await requireUserSession(event); + const { user } = await requireAuthSession(event); // The passkey is stored in the session, which is only accessible to the authenticated user return { diff --git a/apps/api/routes/api/auth/passkey.post.ts b/apps/api/routes/api/auth/passkey.post.ts index 8fe69af1..6ff9da51 100644 --- a/apps/api/routes/api/auth/passkey.post.ts +++ b/apps/api/routes/api/auth/passkey.post.ts @@ -39,8 +39,23 @@ const bodySchema = z.object({ }), }); +/* + * `requireAuthSession`, pas `requireUserSession` : le bannissement compte ici. + * + * Le middleware saute la porte de bannissement pour tout `/api/auth/**` — il + * le doit, la connexion et l'inscription sont anonymes — mais le saut couvre + * aussi les routes de compte qui vivent sous ce préfixe. Or + * `requireUserSession` ne lit pas le statut, contrairement à + * `requireAuthSession`. Un membre banni gardait donc de quoi faire tourner son + * passkey et changer son mot de passe : la porte d'entrée était fermée, la + * porte de service ouverte. + * + * Corrigé par route plutôt qu'en resserrant le saut du middleware : le saut + * est juste pour ce qu'il vise, et le lister route par route ferait dépendre + * une garde d'autorisation d'une liste à tenir à jour. + */ export default defineEventHandler(async (event) => { - const { user } = await requireUserSession(event); + const { user } = await requireAuthSession(event); // The same step-up `me/passkey/reset` requires, and for the reason that route // states: a borrowed session must not be able to lock the real owner's client // out of the tracker. Two doors to the same action, one of them unguarded, is diff --git a/apps/api/routes/api/auth/password.put.ts b/apps/api/routes/api/auth/password.put.ts index de07cb83..f3948aae 100644 --- a/apps/api/routes/api/auth/password.put.ts +++ b/apps/api/routes/api/auth/password.put.ts @@ -42,8 +42,23 @@ const bodySchema = z.object({ newVerifier: z.string().min(40, 'Invalid new verifier'), }); +/* + * `requireAuthSession`, pas `requireUserSession` : le bannissement compte ici. + * + * Le middleware saute la porte de bannissement pour tout `/api/auth/**` — il + * le doit, la connexion et l'inscription sont anonymes — mais le saut couvre + * aussi les routes de compte qui vivent sous ce préfixe. Or + * `requireUserSession` ne lit pas le statut, contrairement à + * `requireAuthSession`. Un membre banni gardait donc de quoi faire tourner son + * passkey et changer son mot de passe : la porte d'entrée était fermée, la + * porte de service ouverte. + * + * Corrigé par route plutôt qu'en resserrant le saut du middleware : le saut + * est juste pour ce qu'il vise, et le lister route par route ferait dépendre + * une garde d'autorisation d'une liste à tenir à jour. + */ export default defineEventHandler(async (event) => { - const { user } = await requireUserSession(event); + const { user } = await requireAuthSession(event); // Password change is a credential operation — use the strict auth // bucket (5/5min, progressive) instead of the looser mutation bucket. await rateLimit(event, RATE_LIMITS.auth); From 4113ba063c0929311e2677e39121a13cc6f666b2 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:28:28 +0200 Subject: [PATCH 73/96] fix(torrents): a takedown left the catalogue, the page and the file untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_active` is described in `apps/tracker/db/queries/torrents.sql` as an operator switch, and the Go tracker refuses to announce an inactive release. On the API side RSS, Torznab, federation, groups and the statistics all honour it. It was absent from the three surfaces that matter for a takedown: the web catalogue, the detail page, and the `.torrent` download. So an operator flipping the flag by hand for a DMCA request watched the release vanish everywhere EXCEPT where people find it and where they fetch it. Nothing in the codebase writes `false` today, which is what kept it inert — a trap waiting for the first time anyone used it. Staff and the uploader still see the row: they already see pending uploads, and hiding a release from the person who has to deal with it helps nobody. Measured: 404 on all three for a member, 200 for staff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/api/routes/api/torrents/[hash].get.ts | 6 ++++- .../api/torrents/[hash]/download.get.ts | 9 +++++++- apps/api/routes/api/torrents/index.get.ts | 23 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/api/routes/api/torrents/[hash].get.ts b/apps/api/routes/api/torrents/[hash].get.ts index 2cddfe76..f58bbc27 100644 --- a/apps/api/routes/api/torrents/[hash].get.ts +++ b/apps/api/routes/api/torrents/[hash].get.ts @@ -54,7 +54,11 @@ export default defineEventHandler(async (event) => { // changes_requested torrent). Staff see everything. Anyone else // gets a flat 404 — same response as a non-existent hash so a // probe can't even confirm a moderation thread exists. - if (torrent.moderationStatus !== 'accepted') { + // `!torrent.isActive` autant que le statut de modération : `is_active` est + // l'interrupteur de retrait d'un opérateur, honoré par le tracker, RSS, + // Torznab et la fédération — et jusqu'ici pas par la fiche, donc la release + // « retirée » restait consultable à son adresse directe. + if (torrent.moderationStatus !== 'accepted' || !torrent.isActive) { const isOwner = torrent.uploaderId === session.id; const isStaff = !!(session.isAdmin || session.isModerator); if (!isOwner && !isStaff) { diff --git a/apps/api/routes/api/torrents/[hash]/download.get.ts b/apps/api/routes/api/torrents/[hash]/download.get.ts index 32ae8899..585acd80 100644 --- a/apps/api/routes/api/torrents/[hash]/download.get.ts +++ b/apps/api/routes/api/torrents/[hash]/download.get.ts @@ -37,6 +37,7 @@ export default defineEventHandler(async (event) => { torrentData: schema.torrents.torrentData, moderationStatus: schema.torrents.moderationStatus, uploaderId: schema.torrents.uploaderId, + isActive: schema.torrents.isActive, }) .from(schema.torrents) .where(eq(schema.torrents.infoHash, infoHash)) @@ -53,8 +54,14 @@ export default defineEventHandler(async (event) => { const isStaff = !!(user.isAdmin || user.isModerator); const isOwner = torrent.uploaderId === user.id; + // `!torrent.isActive` compte autant que le statut de modération. + // + // `is_active` est l'interrupteur qu'un opérateur bascule pour retirer une + // release — le tracker Go refuse alors de l'annoncer, RSS et Torznab la + // taisent — mais le `.torrent` restait servi ici. Retirer une release et + // continuer à distribuer son fichier est le contraire d'un retrait. if ( - torrent.moderationStatus !== 'accepted' && + (torrent.moderationStatus !== 'accepted' || !torrent.isActive) && !isStaff && !isOwner ) { diff --git a/apps/api/routes/api/torrents/index.get.ts b/apps/api/routes/api/torrents/index.get.ts index 336ba4af..68120044 100644 --- a/apps/api/routes/api/torrents/index.get.ts +++ b/apps/api/routes/api/torrents/index.get.ts @@ -64,6 +64,29 @@ export default defineEventHandler(async (event) => { ); } + /* + * `is_active` est un interrupteur d'opérateur, et il ne coupait pas ici. + * + * `apps/tracker/db/queries/torrents.sql` le décrit comme tel et le tracker Go + * refuse d'annoncer une release inactive. Côté API il est honoré par RSS, par + * Torznab, par la fédération, par les groupes et par les statistiques — mais + * il était ABSENT du catalogue web, de la fiche et du téléchargement du + * `.torrent`, c'est-à-dire des trois seules surfaces qui comptent pour un + * retrait. Un opérateur qui basculait le drapeau à la main pour une demande + * DMCA voyait la release disparaître partout SAUF de l'endroit où on la + * trouve et de celui où on la récupère. + * + * Rien n'écrit `false` dans le code aujourd'hui, donc c'était inerte — un + * piège qui attendait la première fois qu'on s'en serve. + * + * Le personnel et le téléverseur continuent de voir la ligne : ils voient + * déjà les dépôts en attente, et retirer une release de la vue de celui qui + * doit la traiter n'aide personne. + */ + if (!canSeeUnapproved) { + conditions.push(eq(schema.torrents.isActive, true)); + } + // Hide adult-categorised torrents from users who haven't opted in. // Uncategorised torrents (categoryId = null) are never adult so they // pass through unconditionally. From d0f894e278f935fc4c6d9098bf944b042a505753 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:28:50 +0200 Subject: [PATCH 74/96] feat(auth): sign out everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session cookie is sealed and STATELESS: seven days, `isAdmin` and the passkey baked in at login, no server-side record. Nothing could invalidate it. `auth/password.put.ts` documents that changing the password leaves sessions open, and signing out only clears the cookie in the browser doing it. A stolen cookie was therefore seven days of ordinary access, and the only recourse was to ban the account or rotate `NUXT_SESSION_SECRET` — which signs out EVERY member for one member's problem. `users.session_epoch` is that record, in one integer. The session carries the epoch it was issued with, and `requireUserSession` compares the two on the live read it was already doing for roles — so revocation costs no extra query, cache included. An integer rather than a timestamp: two revocations in the same millisecond are still two revocations. A cookie with no epoch counts as `0`, so sessions already open when this deploys survive until the first revocation. Installing the means to sign someone out must not sign everyone out. The caller's own session goes too. That is deliberate: without a device register, "sign out everywhere" cannot know which device to spare — and it is the right answer anyway when you do not know which one is compromised. `requireFreshAuth` guards it, like the passkey reset: an action that repairs a stolen session must not be reachable BY the stolen session without its holder proving who they are again. Measured end to end: two sessions open, revoke from one, both answer 401 with `reason: session-revoked`, and a fresh login works immediately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../routes/api/auth/2fa/verify-totp.post.ts | 4 + apps/api/routes/api/auth/login.post.ts | 3 + apps/api/routes/api/auth/register.post.ts | 3 + .../routes/api/me/sessions/revoke-all.post.ts | 60 + apps/api/utils/liveRoles.ts | 39 +- apps/api/utils/session.ts | 35 + apps/web/i18n/locales/en.json | 11 +- apps/web/i18n/locales/fr.json | 11 +- packages/db/src/migrations/0070_same_xorn.sql | 1 + .../db/src/migrations/meta/0070_snapshot.json | 10527 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 21 + 12 files changed, 10713 insertions(+), 9 deletions(-) create mode 100644 apps/api/routes/api/me/sessions/revoke-all.post.ts create mode 100644 packages/db/src/migrations/0070_same_xorn.sql create mode 100644 packages/db/src/migrations/meta/0070_snapshot.json diff --git a/apps/api/routes/api/auth/2fa/verify-totp.post.ts b/apps/api/routes/api/auth/2fa/verify-totp.post.ts index 2902d4be..1dc6585b 100644 --- a/apps/api/routes/api/auth/2fa/verify-totp.post.ts +++ b/apps/api/routes/api/auth/2fa/verify-totp.post.ts @@ -64,6 +64,7 @@ export default defineEventHandler(async (event) => { isAdmin: true, isModerator: true, isOwner: true, + sessionEpoch: true, uploaded: true, downloaded: true, bonusPoints: true, @@ -160,6 +161,9 @@ export default defineEventHandler(async (event) => { isAdmin: user.isAdmin, isModerator: user.isModerator, isOwner: user.isOwner, + // L'époque de session en cours, comparée à chaque requête par + // `requireUserSession`. Voir `users.session_epoch`. + sessionEpoch: user.sessionEpoch ?? 0, uploaded: user.uploaded, downloaded: user.downloaded, bonusPoints: user.bonusPoints, diff --git a/apps/api/routes/api/auth/login.post.ts b/apps/api/routes/api/auth/login.post.ts index bf99ed14..0225eab0 100644 --- a/apps/api/routes/api/auth/login.post.ts +++ b/apps/api/routes/api/auth/login.post.ts @@ -221,6 +221,9 @@ export default defineEventHandler(async (event) => { isAdmin: user.isAdmin, isModerator: user.isModerator, isOwner: user.isOwner, + // L'époque de session en cours, comparée à chaque requête par + // `requireUserSession`. Voir `users.session_epoch`. + sessionEpoch: user.sessionEpoch ?? 0, uploaded: user.uploaded, downloaded: user.downloaded, bonusPoints: user.bonusPoints, diff --git a/apps/api/routes/api/auth/register.post.ts b/apps/api/routes/api/auth/register.post.ts index 0d09584d..214daee3 100644 --- a/apps/api/routes/api/auth/register.post.ts +++ b/apps/api/routes/api/auth/register.post.ts @@ -312,6 +312,9 @@ export default defineEventHandler(async (event) => { isAdmin: finalIsFirstUser, isModerator: false, isOwner: finalIsFirstUser, + // L'époque de session en cours, comparée à chaque requête par + // `requireUserSession`. Voir `users.session_epoch`. + sessionEpoch: 0, uploaded: starterUpload, downloaded: 0, bonusPoints: 0, diff --git a/apps/api/routes/api/me/sessions/revoke-all.post.ts b/apps/api/routes/api/me/sessions/revoke-all.post.ts new file mode 100644 index 00000000..eafe96df --- /dev/null +++ b/apps/api/routes/api/me/sessions/revoke-all.post.ts @@ -0,0 +1,60 @@ +/** + * POST /api/me/sessions/revoke-all — « Déconnecter partout ». + * + * Le cookie de session est scellé et SANS ÉTAT : sept jours, sans registre + * serveur. Jusqu'ici rien ne pouvait l'invalider. `auth/password.put.ts` + * documente d'ailleurs qu'un changement de mot de passe laisse les sessions + * ouvertes, et se déconnecter ne vide que le cookie du navigateur courant. Un + * cookie exfiltré — un poste partagé, un ordinateur volé, une extension + * curieuse — valait donc sept jours d'accès ordinaire, et le seul recours + * était de bannir le compte ou de faire tourner `NUXT_SESSION_SECRET`, ce qui + * déconnecte TOUS les membres pour le problème d'un seul. + * + * Le geste est un incrément : `users.session_epoch + 1`. La session porte + * l'époque reçue à la connexion, et `requireUserSession` compare les deux à + * chaque requête — sur la lecture d'état vivant qui avait déjà lieu, donc sans + * requête supplémentaire. + * + * CELLE DE L'APPELANT AUSSI. C'est voulu : « déconnecter partout » qui + * épargnerait l'appareil courant obligerait à savoir lequel c'est, et cette + * information n'existe pas dans un cookie sans état. Le membre se reconnecte — + * et c'est aussi la bonne réponse quand il ne sait plus quel appareil est + * compromis. + * + * `requireFreshAuth` comme sur la réinitialisation de passkey : une action qui + * répare un vol de session ne doit pas être déclenchable PAR la session volée + * sans que son porteur reprouve qui il est. + */ +import { db, schema } from '@trackarr/db'; +import { eq, sql } from 'drizzle-orm'; +import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; +import { invalidateRoleCache } from '~~/utils/liveRoles'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { auditDetail } from '~~/utils/audit'; + +export default defineEventHandler(async (event) => { + const session = await requireAuthSession(event); + await requireFreshAuth(event); + await rateLimit(event, RATE_LIMITS.mutation); + + auditDetail(event, { + action: 'session.revoke_all', + targetType: 'user', + targetId: session.user.id, + }); + + const [row] = await db + .update(schema.users) + .set({ sessionEpoch: sql`${schema.users.sessionEpoch} + 1` }) + .where(eq(schema.users.id, session.user.id)) + .returning({ sessionEpoch: schema.users.sessionEpoch }); + + // Le cache de rôles porte l'époque : sans cette purge, l'instance + // continuerait d'accepter les sessions périmées jusqu'à 60 s. + await invalidateRoleCache(session.user.id); + + // On ne vide pas le cookie ici : la prochaine requête le fera, avec le motif + // `session-revoked` que le front sait présenter. Le vider maintenant + // donnerait une déconnexion muette, impossible à distinguer d'une panne. + return { ok: true, sessionEpoch: row?.sessionEpoch ?? null }; +}); diff --git a/apps/api/utils/liveRoles.ts b/apps/api/utils/liveRoles.ts index 10448ad7..fe36d626 100644 --- a/apps/api/utils/liveRoles.ts +++ b/apps/api/utils/liveRoles.ts @@ -28,19 +28,37 @@ import { redis } from '../redis/client'; const ROLE_CACHE_TTL_S = 60; const roleCacheKey = (userId: string) => `auth:role:${userId}`; -export async function readLiveRoles( - userId: string -): Promise<{ isAdmin: boolean; isModerator: boolean; isOwner: boolean } | null> { +export async function readLiveRoles(userId: string): Promise<{ + isAdmin: boolean; + isModerator: boolean; + isOwner: boolean; + /** Voir `users.session_epoch` : la génération courante des sessions. */ + sessionEpoch: number; +} | null> { try { const cached = await redis.get(roleCacheKey(userId)); if (cached) { - const p = JSON.parse(cached) as { a: boolean; m: boolean; o?: boolean }; + const p = JSON.parse(cached) as { + a: boolean; + m: boolean; + o?: boolean; + e?: number; + }; // A payload written before `o` existed is treated as a MISS rather than // as `isOwner: false`. Otherwise the deploy that adds ownership answers // 403 to the owner for up to the cache TTL — and the one thing that // would fix it is the console they cannot reach. - if (p.o !== undefined) { - return { isAdmin: !!p.a, isModerator: !!p.m, isOwner: !!p.o }; + // Même raisonnement pour `e` que pour `o` ci-dessus : une charge écrite + // avant que l'époque existe est un ÉCHEC de cache, pas une époque zéro. + // Sans cela, le déploiement qui ajoute la révocation lirait `0` pendant + // une minute et accepterait des sessions qu'un membre vient de révoquer. + if (p.o !== undefined && p.e !== undefined) { + return { + isAdmin: !!p.a, + isModerator: !!p.m, + isOwner: !!p.o, + sessionEpoch: p.e, + }; } } } catch { @@ -51,6 +69,7 @@ export async function readLiveRoles( isAdmin: users.isAdmin, isModerator: users.isModerator, isOwner: users.isOwner, + sessionEpoch: users.sessionEpoch, }) .from(users) .where(eq(users.id, userId)) @@ -60,7 +79,12 @@ export async function readLiveRoles( await redis.setex( roleCacheKey(userId), ROLE_CACHE_TTL_S, - JSON.stringify({ a: row.isAdmin, m: row.isModerator, o: row.isOwner }) + JSON.stringify({ + a: row.isAdmin, + m: row.isModerator, + o: row.isOwner, + e: row.sessionEpoch, + }) ); } catch { /* no-op */ @@ -69,6 +93,7 @@ export async function readLiveRoles( isAdmin: row.isAdmin, isModerator: row.isModerator, isOwner: row.isOwner, + sessionEpoch: row.sessionEpoch, }; } diff --git a/apps/api/utils/session.ts b/apps/api/utils/session.ts index b47495d5..b197b17c 100644 --- a/apps/api/utils/session.ts +++ b/apps/api/utils/session.ts @@ -21,6 +21,16 @@ export interface SessionUser { * visible in the type rather than arriving through the catch-all. */ isOwner: boolean; + /** + * La génération de sessions qui avait cours à la connexion. + * + * `requireUserSession` la compare à `users.session_epoch`. Absente d'un + * cookie émis avant cette fonctionnalité : traitée comme `0`, donc les + * sessions déjà ouvertes restent valides jusqu'à la première révocation — + * un déploiement ne doit pas déconnecter tout le monde pour installer de + * quoi déconnecter quelqu'un. + */ + sessionEpoch?: number; uploaded: number; downloaded: number; [key: string]: unknown; @@ -160,6 +170,31 @@ export async function requireUserSession( if (!live) { throw createError({ statusCode: 403, message: 'Account no longer exists' }); } + + /* + * La révocation, au même endroit et pour le même prix que les rôles. + * + * Le cookie est scellé et sans état : sans cette comparaison, rien ne + * pouvait l'invalider avant sept jours. La lecture est celle qui avait + * déjà lieu — `readLiveRoles` rend l'époque avec les rôles, cache de 60 s + * compris — donc révoquer ne coûte pas une requête de plus par appel. + * + * Un cookie sans époque vaut `0` : les sessions ouvertes au moment du + * déploiement survivent, et la première révocation les emporte. + * + * La fenêtre est celle du cache : jusqu'à 60 s. `revokeAllSessions` vide + * le cache en incrémentant, donc en pratique l'effet est immédiat pour + * l'instance qui reçoit l'appel. + */ + if ((session.user.sessionEpoch ?? 0) !== live.sessionEpoch) { + await clearUserSession(event); + throw createError({ + statusCode: 401, + data: { reason: 'session-revoked' }, + message: 'This session was revoked. Sign in again.', + }); + } + session.user.isAdmin = live.isAdmin; session.user.isModerator = live.isModerator; session.user.isOwner = live.isOwner; diff --git a/apps/web/i18n/locales/en.json b/apps/web/i18n/locales/en.json index 350872bc..3baa5972 100644 --- a/apps/web/i18n/locales/en.json +++ b/apps/web/i18n/locales/en.json @@ -632,7 +632,16 @@ "success": "Signed in", "failed": "Refused" }, - "loginHistoryHide": "Hide" + "loginHistoryHide": "Hide", + "revokeAll": "Sign out everywhere", + "revokeAllHint": "Closes the sessions on every device, including this one. You will have to sign in again. Use it when you do not know which device is compromised.", + "revokeAllButton": "Sign out everywhere", + "revokingAll": "Signing out…", + "revokeAllConfirmTitle": "Sign out of every device?", + "revokeAllConfirmBody": "Every session on this account will be closed, this one included: you will be signed out immediately and will have to enter your password again. So will your other devices.", + "revokeAllConfirmAction": "Sign out everywhere", + "revokeAllRateLimited": "Too many attempts. Try again in a few minutes.", + "revokeAllError": "Signing out everywhere did not go through." }, "account": { "userId": "User ID", diff --git a/apps/web/i18n/locales/fr.json b/apps/web/i18n/locales/fr.json index 341004ab..4d990d7a 100644 --- a/apps/web/i18n/locales/fr.json +++ b/apps/web/i18n/locales/fr.json @@ -632,7 +632,16 @@ "success": "Connexion", "failed": "Refusée" }, - "loginHistoryHide": "Masquer" + "loginHistoryHide": "Masquer", + "revokeAll": "Déconnecter partout", + "revokeAllHint": "Ferme les sessions de tous vos appareils, y compris celui-ci. Vous devrez vous reconnecter. À faire si vous ne savez pas lequel de vos appareils est en cause.", + "revokeAllButton": "Déconnecter partout", + "revokingAll": "Déconnexion…", + "revokeAllConfirmTitle": "Déconnecter tous vos appareils ?", + "revokeAllConfirmBody": "Toutes les sessions ouvertes sur ce compte vont être fermées, celle-ci comprise : vous serez déconnecté immédiatement et devrez saisir à nouveau votre mot de passe. Les autres appareils devront faire de même.", + "revokeAllConfirmAction": "Tout déconnecter", + "revokeAllRateLimited": "Trop de tentatives. Réessayez dans quelques minutes.", + "revokeAllError": "La déconnexion générale n'a pas abouti." }, "account": { "userId": "ID utilisateur", diff --git a/packages/db/src/migrations/0070_same_xorn.sql b/packages/db/src/migrations/0070_same_xorn.sql new file mode 100644 index 00000000..2eddd112 --- /dev/null +++ b/packages/db/src/migrations/0070_same_xorn.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "session_epoch" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0070_snapshot.json b/packages/db/src/migrations/meta/0070_snapshot.json new file mode 100644 index 00000000..cfbfdbf2 --- /dev/null +++ b/packages/db/src/migrations/meta/0070_snapshot.json @@ -0,0 +1,10527 @@ +{ + "id": "c29e2aa3-4971-4d58-bd28-b1c44fa3b8f8", + "prevId": "911abcef-457a-49ba-8475-0c04279e9eb6", + "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_torrent_idx": { + "name": "anticheat_flags_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_ip_hash": { + "name": "actor_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_target_idx": { + "name": "audit_log_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_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": { + "catalog_records_origin_ck": { + "name": "catalog_records_origin_ck", + "value": "\"catalog_records\".\"origin\" IN ('local', 'ingested')" + }, + "catalog_records_kind_ck": { + "name": "catalog_records_kind_ck", + "value": "\"catalog_records\".\"kind\" IN ('torrent', 'tombstone', 'identity', 'revocation')" + }, + "catalog_records_hops_ck": { + "name": "catalog_records_hops_ck", + "value": "\"catalog_records\".\"hops\" BETWEEN 0 AND 2" + } + }, + "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": { + "forum_posts_topic_idx": { + "name": "forum_posts_topic_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forum_posts_author_idx": { + "name": "forum_posts_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "forum_topics_category_idx": { + "name": "forum_topics_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forum_topics_author_idx": { + "name": "forum_topics_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_user_idx": { + "name": "freeleech_pool_contributions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_downloaded_at_idx": { + "name": "hnr_downloaded_at_idx", + "columns": [ + { + "expression": "downloaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hnr_completed_at_idx": { + "name": "hnr_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_used_by_idx": { + "name": "invitations_used_by_idx", + "columns": [ + { + "expression": "used_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invitations\".\"used_by\" IS NOT NULL", + "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.login_events": { + "name": "login_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_hash": { + "name": "ip_hash", + "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()" + } + }, + "indexes": { + "login_events_user_idx": { + "name": "login_events_user_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": {} + }, + "login_events_created_idx": { + "name": "login_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_events_user_id_users_id_fk": { + "name": "login_events_user_id_users_id_fk", + "tableFrom": "login_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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_user_idx": { + "name": "message_reactions_user_idx", + "columns": [ + { + "expression": "user_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_reply_to_idx": { + "name": "messages_reply_to_idx", + "columns": [ + { + "expression": "reply_to_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_author_idx": { + "name": "messages_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + }, + "panic_password_hash": { + "name": "panic_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "panic_state_phase_ck": { + "name": "panic_state_phase_ck", + "value": "\"panic_state\".\"phase\" IN ('idle', 'encrypting', 'encrypted', 'restoring')" + } + }, + "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_user_idx": { + "name": "room_message_reactions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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.saved_searches": { + "name": "saved_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tsquery": { + "name": "tsquery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_matched_at": { + "name": "last_matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "saved_searches_category_idx": { + "name": "saved_searches_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_searches_user_idx": { + "name": "saved_searches_user_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": {} + }, + "saved_searches_notify_idx": { + "name": "saved_searches_notify_idx", + "columns": [ + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"saved_searches\".\"notify\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_searches_user_id_users_id_fk": { + "name": "saved_searches_user_id_users_id_fk", + "tableFrom": "saved_searches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_searches_category_id_categories_id_fk": { + "name": "saved_searches_category_id_categories_id_fk", + "tableFrom": "saved_searches", + "tableTo": "categories", + "columnsFrom": [ + "category_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_author_idx": { + "name": "ticket_messages_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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": { + "torrent_comments_torrent_idx": { + "name": "torrent_comments_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrent_comments_author_idx": { + "name": "torrent_comments_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_torrent_idx": { + "name": "torrent_favorites_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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": { + "torrent_stats_completed_idx": { + "name": "torrent_stats_completed_idx", + "columns": [ + { + "expression": "\"completed\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrent_stats_seeders_idx": { + "name": "torrent_stats_seeders_idx", + "columns": [ + { + "expression": "\"seeders\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "download_multiplier": { + "name": "download_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "upload_multiplier": { + "name": "upload_multiplier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "multipliers_until": { + "name": "multipliers_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_sticky": { + "name": "is_sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supersede_reason": { + "name": "supersede_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_superseded_by_idx": { + "name": "torrents_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"superseded_by_id\" IS NOT NULL", + "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_created_at_idx": { + "name": "torrents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "torrents_sticky_idx": { + "name": "torrents_sticky_idx", + "columns": [ + { + "expression": "is_sticky", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"torrents\".\"is_sticky\"", + "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_superseded_by_id_torrents_id_fk": { + "name": "torrents_superseded_by_id_torrents_id_fk", + "tableFrom": "torrents", + "tableTo": "torrents", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": { + "torrents_multipliers_sane": { + "name": "torrents_multipliers_sane", + "value": "\"torrents\".\"download_multiplier\" between 0 and 200 and \"torrents\".\"upload_multiplier\" between 0 and 1000" + } + }, + "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_torrent_idx": { + "name": "upload_request_fill_attempts_torrent_idx", + "columns": [ + { + "expression": "torrent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_request_fill_attempts_user_idx": { + "name": "upload_request_fill_attempts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_category_idx": { + "name": "upload_requests_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + }, + "rss_key": { + "name": "rss_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "session_epoch": { + "name": "session_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": {} + }, + "users_username_lower_unique": { + "name": "users_username_lower_unique", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "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" + ] + }, + "users_rss_key_unique": { + "name": "users_rss_key_unique", + "nullsNotDistinct": false, + "columns": [ + "rss_key" + ] + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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 8813b4ff..7f42e74d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -491,6 +491,13 @@ "when": 1788344630212, "tag": "0069_high_juggernaut", "breakpoints": true + }, + { + "idx": 70, + "version": "7", + "when": 1788383471618, + "tag": "0070_same_xorn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 279ce95f..c662c91d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -88,6 +88,27 @@ export const users = pgTable( * with a paralysed owner is worse than one with a different owner. */ isOwner: boolean('is_owner').default(false).notNull(), + /** + * Le numéro de génération des sessions de ce compte. + * + * Le cookie de session est scellé et SANS ÉTAT : sept jours, `isAdmin` et + * le passkey cuits dedans à la connexion. Rien ne pouvait l'invalider — + * `password.put.ts` documente d'ailleurs qu'un changement de mot de passe + * laisse les sessions ouvertes, et la déconnexion ne vide que le cookie + * courant. Un cookie exfiltré valait donc sept jours d'accès ordinaire, et + * le seul recours était de bannir le compte ou de faire tourner + * `NUXT_SESSION_SECRET`, ce qui déconnecte TOUT LE MONDE. + * + * La session porte l'époque qu'elle a reçue à la connexion ; + * `requireUserSession` la compare à celle-ci, lue avec les rôles vivants + * (donc sans requête supplémentaire, cache de 60 s compris). Incrémenter + * cette colonne périme d'un coup toutes les sessions du compte — et + * seulement celles-là. + * + * Un entier plutôt qu'un horodatage : deux révocations dans la même + * milliseconde restent deux révocations. + */ + sessionEpoch: integer('session_epoch').default(0).notNull(), isBanned: boolean('is_banned').default(false).notNull(), // userId of the staffer who issued the most recent ban. Null when // the user has never been banned, or after an unban. From 00a348272c931339c89de5730cf1444d9cc8be11 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:29:18 +0200 Subject: [PATCH 75/96] fix(relay): a race unsubscribed a channel for good, and the drain signalled nobody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults in the same shutdown-and-reconnect story. `Subscribe` and `Unsubscribe` were called OUTSIDE the lock that decides them, so the two could invert: the last reader of a channel leaves (`Remove` drops the key under the lock), a new reader arrives (`Add` sees `!existed`, puts the key back and marks the channel fresh), then `Subscribe` reaches Redis BEFORE `Unsubscribe`. go-redis 9.22 keeps no reference count — `Unsubscribe` is a plain delete — so the channel ends up present in the map and unsubscribed in Redis. The failure is STICKY: the key exists, so no later `Add` will ever mark it fresh again. On `messaging:room:general`, shared by everyone, one unlucky reconnect kills the room's live delivery for the whole node, silently, until every reader leaves. `subMu` is taken before `mu` on both paths and released after the Redis call, so a decision and its command are indivisible with respect to each other. `mu` itself is free during the round trip, so dispatch never waits. The lock order is constant, and dispatch takes only `mu`. `pubsub` is now an interface for one reason: an invariant nobody can observe is an invariant nobody can defend. The regression test injects a recorder and asserts that the last command for a channel matches the map. It took three tries to write — the first two passed against the broken code, because an instant fake closes the race window and because asserting only on the final state cannot see "present and unsubscribed" once everything has been removed. It now keeps one reader alive, and fails on the unfixed code with exactly that message. The drain was the second: `http.Server.Shutdown` does NOT cancel `r.Context()`, and the SSE loops wait on that, on `conn.Done()`, or on a heartbeat — nothing told them to leave. The ten seconds elapsed with nobody moving, `Shutdown` returned `DeadlineExceeded` into a `_ =`, and `main` returned and cut twenty thousand streams at once: the exact reconnect storm the comment says it wants to avoid. `Hub.Drain` closes each connection so every stream ends normally. `golang.org/x/sys` goes 0.30.0 to 0.44.0 while here. The advisory is a Windows path govulncheck confirms is unreachable from a Linux container, but the module had drifted far behind the tracker's because nothing ever ran `go mod tidy` against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/relay/cmd/relay/main.go | 6 +- apps/relay/go.mod | 2 +- apps/relay/go.sum | 4 +- apps/relay/internal/hub/hub.go | 111 ++++++++++++++++++--- apps/relay/internal/hub/hubsub_test.go | 132 +++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 apps/relay/internal/hub/hubsub_test.go diff --git a/apps/relay/cmd/relay/main.go b/apps/relay/cmd/relay/main.go index cc6d0767..f4b32575 100644 --- a/apps/relay/cmd/relay/main.go +++ b/apps/relay/cmd/relay/main.go @@ -182,7 +182,11 @@ func main() { }() <-ctx.Done() - log.Printf("[relay] draining") + // Prévenir les flux AVANT d'attendre : `Shutdown` n'annule pas le contexte + // des requêtes en cours, donc sans ce signal le drain n'était qu'une + // attente de dix secondes suivie d'une coupure sèche. Voir `Hub.Drain`. + drained := h.Drain() + log.Printf("[relay] draining %d stream(s)", drained) // Give open streams a moment to end on their own. A relay that cuts // twenty thousand connections at once during a rolling update creates // exactly the reconnect storm the jittered client backoff exists to diff --git a/apps/relay/go.mod b/apps/relay/go.mod index 1b908e45..f932ff78 100644 --- a/apps/relay/go.mod +++ b/apps/relay/go.mod @@ -7,5 +7,5 @@ require github.com/redis/go-redis/v9 v9.22.0 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/sys v0.44.0 // indirect ) diff --git a/apps/relay/go.sum b/apps/relay/go.sum index 2184f3ba..4f35daa9 100644 --- a/apps/relay/go.sum +++ b/apps/relay/go.sum @@ -18,5 +18,5 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/apps/relay/internal/hub/hub.go b/apps/relay/internal/hub/hub.go index d5bb8fe4..d3355202 100644 --- a/apps/relay/internal/hub/hub.go +++ b/apps/relay/internal/hub/hub.go @@ -32,15 +32,15 @@ type Conn struct { // string because the room is shared — the alternative is a second // connection per reader, and the browser caps those. Channels []string - out chan []byte - done chan struct{} - closeMu sync.Once - dropped atomic.Bool + out chan []byte + done chan struct{} + closeMu sync.Once + dropped atomic.Bool } -func (c *Conn) Out() <-chan []byte { return c.out } +func (c *Conn) Out() <-chan []byte { return c.out } func (c *Conn) Done() <-chan struct{} { return c.done } -func (c *Conn) Dropped() bool { return c.dropped.Load() } +func (c *Conn) Dropped() bool { return c.dropped.Load() } func (c *Conn) close(dropped bool) { c.closeMu.Do(func() { @@ -51,13 +51,50 @@ func (c *Conn) close(dropped bool) { }) } +// pubsub est la part de `*redis.PubSub` dont le hub se sert. +// +// Une interface plutôt que le type concret, pour UNE raison : l'ordre dans +// lequel `Subscribe` et `Unsubscribe` atteignent Redis est un invariant, et un +// invariant qu'on ne peut pas observer est un invariant qu'on ne peut pas +// défendre. Un faux enregistre les commandes et le test ci-contre vérifie que +// la dernière commande vue pour un canal correspond bien à l'état de la map. +type pubsub interface { + Subscribe(ctx context.Context, channels ...string) error + Unsubscribe(ctx context.Context, channels ...string) error + Channel(opts ...redis.ChannelOption) <-chan *redis.Message + Close() error +} + type Hub struct { rdb *redis.Client live *config.Live mu sync.RWMutex conns map[string]map[*Conn]struct{} // channel -> connections - sub *redis.PubSub + sub pubsub + + // L'ordre des commandes Redis, et rien d'autre. + // + // `mu` décide qui s'abonne et qui se désabonne ; la commande Redis + // correspondante partait ENSUITE, hors du verrou. Les deux pouvaient donc + // s'inverser : le dernier lecteur d'un canal part (`Remove` retire la clé + // de la map), un nouveau lecteur arrive (`Add` voit `!existed`, remet la + // clé, met le canal dans `fresh`), puis `Subscribe` part AVANT + // `Unsubscribe`. go-redis 9.22 n'a pas de compteur de références — + // `Unsubscribe` fait un `delete` sec — donc le canal se retrouve présent + // dans `h.conns` et désabonné côté Redis. + // + // L'effet est COLLANT : la clé existant désormais, aucun `Add` ultérieur ne + // le remettra dans `fresh`. Sur `messaging:room:general`, partagé par tout + // le monde, un seul reconnect malheureux coupe le direct du salon pour le + // nœud entier, sans une ligne de journal. + // + // `subMu` est pris AVANT `mu` dans les deux chemins et relâché après la + // commande Redis : la décision et sa commande deviennent indivisibles l'une + // par rapport à l'autre. `mu` reste libre pendant l'aller-retour Redis, donc + // `dispatch` n'attend pas. Ordre d'acquisition constant `subMu` → `mu`, + // jamais l'inverse, et `dispatch` ne prend que `mu`. + subMu sync.Mutex count atomic.Int64 @@ -65,9 +102,9 @@ type Hub struct { // that no other component can see: how many readers this node cut for // falling behind, and how many frames it wrote. Monotonic, so the // scrape only ever has to rate() them. - dropped atomic.Int64 - frames atomic.Int64 - refused atomic.Int64 + dropped atomic.Int64 + frames atomic.Int64 + refused atomic.Int64 } func New(rdb *redis.Client, live *config.Live) *Hub { @@ -161,6 +198,7 @@ func (h *Hub) Add(ctx context.Context, channels ...string) (*Conn, bool) { } var fresh []string + h.subMu.Lock() h.mu.Lock() for _, channel := range channels { set, existed := h.conns[channel] @@ -173,12 +211,18 @@ func (h *Hub) Add(ctx context.Context, channels ...string) (*Conn, bool) { } h.mu.Unlock() + var subErr error if len(fresh) > 0 { - if err := h.sub.Subscribe(ctx, fresh...); err != nil { - h.Remove(c) - // `Remove` décrémente déjà le compteur : rien à rendre ici. - return nil, false - } + subErr = h.sub.Subscribe(ctx, fresh...) + } + // Relâché AVANT `Remove`, qui reprend `subMu` — un mutex Go n'est pas + // réentrant, le garder ici serait un interblocage avec soi-même. + h.subMu.Unlock() + + if subErr != nil { + h.Remove(c) + // `Remove` décrémente déjà le compteur : rien à rendre ici. + return nil, false } return c, true } @@ -190,6 +234,9 @@ func (h *Hub) Remove(c *Conn) { var emptied []string present := false + h.subMu.Lock() + defer h.subMu.Unlock() + h.mu.Lock() for _, channel := range c.Channels { set, ok := h.conns[channel] @@ -219,6 +266,40 @@ func (h *Hub) Remove(c *Conn) { } } +// Drain ferme chaque flux ouvert, proprement. +// +// `http.Server.Shutdown` n'annule PAS `r.Context()` — il cesse d'accepter et +// attend que les gestionnaires rendent la main. Or les boucles SSE n'attendent +// que `r.Context()`, `conn.Done()` ou un battement : rien ne leur disait de +// partir. Les dix secondes de drain s'écoulaient donc sans que personne bouge, +// `Shutdown` rendait `DeadlineExceeded` — ignoré par un `_ =` — puis `main` +// retournait et coupait les vingt mille flux d'un coup. C'est exactement la +// tempête de reconnexion que le commentaire du drain dit vouloir éviter : il +// la provoquait. +// +// Fermer `conn.done` fait sortir chaque boucle par sa branche `conn.Done()`, +// donc chaque réponse SSE se termine normalement plutôt que d'être coupée au +// niveau TCP. Le client voit une fin de flux, pas une erreur réseau, et sa +// temporisation à gigue joue son rôle. +// +// `close` est protégé par un `sync.Once` par connexion : appeler Drain pendant +// que des `Remove` se produisent est sans danger. +func (h *Hub) Drain() int { + h.mu.RLock() + seen := make(map[*Conn]struct{}) + for _, set := range h.conns { + for c := range set { + seen[c] = struct{}{} + } + } + h.mu.RUnlock() + + for c := range seen { + c.close(false) + } + return len(seen) +} + // Coalesce batches whatever arrives inside one window into a single frame. // // At three messages a second in a room with fifteen hundred readers that diff --git a/apps/relay/internal/hub/hubsub_test.go b/apps/relay/internal/hub/hubsub_test.go new file mode 100644 index 00000000..9b9c9ba9 --- /dev/null +++ b/apps/relay/internal/hub/hubsub_test.go @@ -0,0 +1,132 @@ +package hub + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/florianjs/trackarr/apps/relay/internal/config" +) + +// Un faux qui retient la DERNIÈRE commande vue par canal. +type recordingSub struct { + mu sync.Mutex + last map[string]string // canal -> "sub" | "unsub" +} + +func newRecordingSub() *recordingSub { + return &recordingSub{last: map[string]string{}} +} + +// La latence est le POINT du faux. +// +// La fenêtre de course est l'intervalle entre la décision (sous `mu`) et la +// commande Redis. Un faux instantané la referme presque, et le test passait +// alors même que l'ordonnancement était retiré — un test vert pour de +// mauvaises raisons. Un vrai aller-retour Redis dure des dizaines de +// microsecondes ; on en simule autant. +func (r *recordingSub) note(kind string, channels []string) { + time.Sleep(50 * time.Microsecond) + r.mu.Lock() + defer r.mu.Unlock() + for _, c := range channels { + r.last[c] = kind + } +} + +func (r *recordingSub) Subscribe(_ context.Context, channels ...string) error { + r.note("sub", channels) + return nil +} + +func (r *recordingSub) Unsubscribe(_ context.Context, channels ...string) error { + r.note("unsub", channels) + return nil +} + +func (r *recordingSub) Channel(_ ...redis.ChannelOption) <-chan *redis.Message { + return make(chan *redis.Message) +} + +func (r *recordingSub) Close() error { return nil } + +func (r *recordingSub) lastFor(channel string) string { + r.mu.Lock() + defer r.mu.Unlock() + return r.last[channel] +} + +// L'abonnement Redis doit toujours refléter l'état de la map. +// +// `Subscribe` et `Unsubscribe` partaient hors du verrou : le dernier lecteur +// s'en allait (la clé quittait `h.conns`), un nouveau arrivait (la clé +// revenait, le canal passait dans `fresh`), puis les deux commandes Redis +// partaient dans l'ordre INVERSE. go-redis n'a pas de compteur de références, +// donc le canal restait présent dans la map et désabonné côté Redis — et +// comme la clé existait, aucun `Add` ultérieur ne le réabonnait. Mort +// silencieux, jusqu'au départ de tous les lecteurs. +// +// L'invariant que ce test défend : à la fin, si le canal est dans `h.conns`, +// la dernière commande vue doit être `sub` ; s'il n'y est plus, `unsub`. +func TestSubscribeAndUnsubscribeNeverInvert(t *testing.T) { + const channel = "messaging:room:general" + const rounds = 300 + + for attempt := 0; attempt < 20; attempt++ { + rec := newRecordingSub() + live := &config.Live{} + d := config.Defaults() + d.MaxConnections = rounds * 2 + live.Set(d) + h := &Hub{ + live: live, + conns: make(map[string]map[*Conn]struct{}), + sub: rec, + } + + var wg sync.WaitGroup + for i := 0; i < rounds; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c, ok := h.Add(context.Background(), channel) + if !ok { + return + } + h.Remove(c) + }() + } + // Une connexion SURVIT à la tempête, et c'est le point du test. + // + // Si l'on retirait tout, l'état final serait « absent + unsub » quoi + // qu'il arrive : la panne recherchée — canal PRÉSENT dans la map et + // DÉSABONNÉ côté Redis — ne peut pas s'observer là. En gardant un + // lecteur, un `Unsubscribe` resté en vol qui atterrit après le + // `Subscribe` final produit exactement cet état collant. + final, ok := h.Add(context.Background(), channel) + if !ok { + t.Fatalf("tentative %d : plafond atteint, le test ne mesure rien", attempt) + } + wg.Wait() + // Laisser retomber ce qui est encore en vol. + time.Sleep(20 * time.Millisecond) + + h.mu.RLock() + _, present := h.conns[channel] + h.mu.RUnlock() + + if !present { + t.Fatalf("tentative %d : le canal a disparu alors qu'un lecteur reste", attempt) + } + if got := rec.lastFor(channel); got != "sub" { + t.Fatalf( + "tentative %d : un lecteur est présent mais la dernière commande Redis est %q — le canal est désabonné pour de bon, et aucun Add ultérieur ne le réabonnera", + attempt, got, + ) + } + h.Remove(final) + } +} From e5168edbbabc8b329cc7ce389637c5a447c3f7df Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:29:18 +0200 Subject: [PATCH 76/96] ci(relay): the relay had no workflow at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A publicly reachable HTTP service that verifies HMAC-signed bearer tokens and fans out SSE to every connected member, with nothing enforcing that it still compiled, that its tests passed, or that it was free of known vulnerabilities. `go build` inside its Dockerfile is not a gate — it runs after a release is already published. The gap had a visible cost: `go.mod` sat on `golang.org/x/sys v0.30.0` long after the tracker had moved to v0.47.0, because no job ever ran `go mod tidy` or a vulnerability scan against it. Hence the `go mod tidy is up to date` step, which fails on any drift. A copy of `tracker-ci.yml` rather than a shared reusable workflow, on purpose: the two modules will pin different Go minors over time and have different test shapes, and one file per module is what makes a red job point straight at its module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .github/workflows/relay-ci.yml | 128 +++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/relay-ci.yml diff --git a/.github/workflows/relay-ci.yml b/.github/workflows/relay-ci.yml new file mode 100644 index 00000000..6d89d930 --- /dev/null +++ b/.github/workflows/relay-ci.yml @@ -0,0 +1,128 @@ +# Go relay CI — vet, tests, and vulnerability scanning. +# +# The relay had NO workflow at all. It is a publicly reachable HTTP service +# that verifies HMAC-signed bearer tokens and fans out SSE to every connected +# member: nothing enforced that it still compiled, that its tests passed, or +# that it was free of known vulnerabilities. `go build` inside +# apps/relay/Dockerfile is not a gate — it runs after a release is already +# published. +# +# That gap had a visible cost: `apps/relay/go.mod` sat on +# `golang.org/x/sys v0.30.0` long after the tracker had moved to v0.47.0, +# because no job ever ran `go mod tidy` or a vulnerability scan against it. +# +# Deliberately a copy of `tracker-ci.yml` rather than a shared reusable +# workflow: the two modules pin different Go minors over time and have +# different test shapes, and one file per module is what makes a red job point +# straight at its module. +name: Relay CI + +on: + push: + branches: [main] + paths: + - 'apps/relay/**' + - '.github/workflows/relay-ci.yml' + pull_request: + paths: + - 'apps/relay/**' + - '.github/workflows/relay-ci.yml' + # Advisories are published against code that hasn't changed, so the vuln + # scan can't only run on pushes. Monday morning, before anyone starts — + # staggered off the tracker's slot so the two don't queue against each other. + schedule: + - cron: '37 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: relay-ci-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: apps/relay + +jobs: + test: + name: vet + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Test-only checkout — nothing is pushed back. + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + # The minor, not `go-version-file`. The `go` directive in go.mod is a + # minimum language version (`go 1.26.0`) and setup-go treats a full + # x.y.z as an exact pin — which on the tracker built CI on go1.26.0 + # and failed govulncheck on 19 stdlib vulnerabilities fixed in + # 1.26.1. Tracking the minor matches `golang:1.26-alpine` in the + # Dockerfile. Bump alongside the Dockerfile and the go.mod directive. + go-version: '1.26' + check-latest: true + cache-dependency-path: apps/relay/go.sum + + # `gofmt -l` exits 0 even when it lists files, so the emptiness of its + # output is the assertion. `set -e` covers the other failure mode: on a + # file that does not parse, gofmt writes to stderr and exits non-zero + # while printing nothing on stdout. + - name: gofmt + run: | + set -euo pipefail + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::gofmt would rewrite these files — run 'gofmt -w .' in apps/relay" + echo "$unformatted" + gofmt -d . + exit 1 + fi + + - name: go vet + run: go vet ./... + + # -race is the point for this module: the hub subscribes and + # unsubscribes Redis channels from concurrent connection handlers while + # dispatching to a shared map of subscribers. + - name: go test -race + run: go test -race ./... + + # `go.mod` drifting behind what the build actually resolves is how the + # x/sys gap survived. `git diff --exit-code` fails the job if tidy moves + # anything. + - name: go mod tidy is up to date + run: | + set -euo pipefail + go mod tidy + git diff --exit-code -- go.mod go.sum + + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Test-only checkout — nothing is pushed back. + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: '1.26' + check-latest: true + cache-dependency-path: apps/relay/go.sum + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + # The check Dependabot cannot do. Dependabot matches advisories against + # declared version ranges; govulncheck walks the call graph and only + # reports a vulnerability when a vulnerable symbol is actually reachable. + # It exits non-zero on a finding, which is what fails the job. + - name: govulncheck + run: govulncheck ./... From 66d2b87d8e1e8fa6c3b9fa0b3d223998d157e6d8 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:29:41 +0200 Subject: [PATCH 77/96] fix(tracker): the swarm-cache ceiling counted traffic, not memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `storeBounded` incremented on every `Store`, including for a key already there, and `invalidateCounts` — called by `Set` and `Remove`, so on every announce — deleted entries without decrementing. The counter drifted towards 50 000 by counting THROUGHPUT, then triggered a full wipe of both caches. On a tracker at a thousand announces a second that wipe came round every few minutes, and each one costs a burst of `HGETALL` across every live swarm. The ceiling was turning steady load into periodic spikes. `LoadOrStore` semantics tell it whether the key was new, which is the only thing that justifies incrementing, and `deleteCounted` gives the slot back — the other half of the drift. The tests fail on the old code with "une clé réécrite 150000 fois donne un compteur de 50000, attendu 1", and still assert the ceiling fires on genuine growth so the fix cannot become a leak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/tracker/internal/peers/bounded_test.go | 60 +++++++++++++++++++++ apps/tracker/internal/peers/peers.go | 38 +++++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 apps/tracker/internal/peers/bounded_test.go diff --git a/apps/tracker/internal/peers/bounded_test.go b/apps/tracker/internal/peers/bounded_test.go new file mode 100644 index 00000000..64aec3ff --- /dev/null +++ b/apps/tracker/internal/peers/bounded_test.go @@ -0,0 +1,60 @@ +package peers + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" +) + +// Le plafond compte les ENTRÉES, pas les écritures. +// +// `storeBounded` incrémentait à chaque `Store`, y compris pour une clé déjà +// présente, et `invalidateCounts` — appelé à chaque annonce — supprimait sans +// décrémenter. Le compteur dérivait donc vers 50 000 en comptant du TRAFIC : +// sur un tracker à mille annonces par seconde, le vidage total des deux caches +// revenait toutes les quelques minutes, et chacun provoque une rafale de +// `HGETALL` sur tous les essaims vivants. +func TestStoreBoundedCountsEntriesNotWrites(t *testing.T) { + var m sync.Map + var n atomic.Int64 + + // Une seule clé, réécrite bien au-delà du plafond. + for i := 0; i < maxSwarmCache*3; i++ { + storeBounded(&m, &n, "un-seul-essaim", i) + } + if got := n.Load(); got != 1 { + t.Fatalf("une clé réécrite %d fois donne un compteur de %d, attendu 1", maxSwarmCache*3, got) + } + if _, ok := m.Load("un-seul-essaim"); !ok { + t.Fatal("la clé a disparu : le plafond s'est déclenché sur du trafic") + } + + // Poser puis retirer, en boucle : le compteur doit revenir à zéro. + for i := 0; i < maxSwarmCache*2; i++ { + k := fmt.Sprintf("essaim-%d", i) + storeBounded(&m, &n, k, i) + deleteCounted(&m, &n, k) + } + if got := n.Load(); got != 1 { + t.Fatalf("après autant de poses que de retraits, le compteur vaut %d, attendu 1 (la clé initiale)", got) + } +} + +// Le plafond agit quand il doit : autant de clés DISTINCTES que la borne. +func TestStoreBoundedStillEvictsOnRealGrowth(t *testing.T) { + var m sync.Map + var n atomic.Int64 + + for i := 0; i <= maxSwarmCache; i++ { + storeBounded(&m, &n, fmt.Sprintf("essaim-%d", i), i) + } + if got := n.Load(); got != 1 { + t.Fatalf("le vidage n'a pas eu lieu : compteur %d après %d clés distinctes", got, maxSwarmCache+1) + } + count := 0 + m.Range(func(_, _ any) bool { count++; return true }) + if count != 1 { + t.Fatalf("après vidage, %d entrées restent, attendu 1", count) + } +} diff --git a/apps/tracker/internal/peers/peers.go b/apps/tracker/internal/peers/peers.go index cc1fc7b1..4d937d90 100644 --- a/apps/tracker/internal/peers/peers.go +++ b/apps/tracker/internal/peers/peers.go @@ -143,8 +143,8 @@ type Store struct { // // Le voisinage avait déjà la réponse : `db.ipBanCache` est plafonné à // 50 000 entrées et `dedup` à 100 000, tous deux avec éviction. - countsLen atomic.Int64 - remoteLen atomic.Int64 + countsLen atomic.Int64 + remoteLen atomic.Int64 } // maxSwarmCache borne chacun des deux caches de `Store`. Plein → on vide tout : @@ -153,7 +153,26 @@ type Store struct { const maxSwarmCache = 50_000 // storeBounded pose une entrée en tenant le plafond. +// +// Le compteur suit les ENTRÉES, pas les écritures. +// +// Il faisait `n.Add(1)` à chaque `Store`, y compris pour une clé déjà +// présente, et `invalidateCounts` — appelé par `Set` et par `Remove`, donc à +// chaque annonce — supprimait l'entrée SANS décrémenter. Le compteur dérivait +// donc vers 50 000 en comptant du trafic, pas de la mémoire, puis déclenchait +// un vidage TOTAL des deux caches. Sur un tracker à mille annonces par seconde +// le vidage revenait toutes les quelques minutes, et chacun provoque une rafale +// de `HGETALL` sur tous les essaims vivants. Le plafond ne bornait pas ce qu'il +// prétendait borner : il transformait du débit en pics de charge. +// +// `LoadOrStore` dit si la clé était nouvelle — c'est la seule information qui +// justifie d'incrémenter. Une clé déjà présente est écrasée sans toucher au +// compteur. func storeBounded(m *sync.Map, n *atomic.Int64, key string, value any) { + if _, existed := m.Load(key); existed { + m.Store(key, value) + return + } if n.Add(1) > maxSwarmCache { m.Range(func(k, _ any) bool { m.Delete(k); return true }) n.Store(1) @@ -161,6 +180,16 @@ func storeBounded(m *sync.Map, n *atomic.Int64, key string, value any) { m.Store(key, value) } +// deleteCounted retire une entrée et rend sa place au compteur. +// +// Sans lui, toute suppression laissait le compteur en l'air : c'est l'autre +// moitié de la dérive décrite au-dessus. +func deleteCounted(m *sync.Map, n *atomic.Int64, key string) { + if _, existed := m.LoadAndDelete(key); existed { + n.Add(-1) + } +} + // New returns a Store. `keyPrefix` typically comes from // `REDIS_KEY_PREFIX` (defaulting to `ot:`). `ttl` is the peer expiry // window; values below `minPeerTTL` are silently clamped to that @@ -415,7 +444,10 @@ func (s *Store) Counts(ctx context.Context, infoHashHex string) (seeders, leeche // `Remove` so a write surfaces in the count without waiting out // the TTL. Cheap: a `sync.Map.Delete` is lock-free. func (s *Store) invalidateCounts(infoHashHex string) { - s.countsCache.Delete(infoHashHex) + // `deleteCounted`, pas `Delete` : voir `storeBounded`. Cette fonction est + // appelée à chaque annonce, donc c'est ELLE qui faisait dériver le + // compteur vers son plafond en comptant du trafic. + deleteCounted(&s.countsCache, &s.countsLen, infoHashHex) } // statsTTL keeps stats:* hashes alive long enough for live charts to From a4bf1fe8c4d7fc79ec90ecfe9e6e7cb5e42d5714 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:29:41 +0200 Subject: [PATCH 78/96] fix(tracker): budget spent before the credit was decided, and dead code removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TakeCreditBudget` drew the tokens, and only afterwards did `CheckAndMark` decide whether the delta would be booked at all. A dual-stack client — the very case dedup exists for — burned its budget twice for one credit, and the tokens are never returned. The error leaned the safe way (under-credit, never over) and the one-minute reserve absorbs it for an honest seeder, but the order was backwards. The dedup decision now comes first, and the clamp keeps its place ahead of the bonus multipliers. The HnR writers took a passkey and re-resolved it through `FindUserAndTorrentByPasskeyAndHash`, a `users × torrents` join, while the caller already held both ids. That is the fault `IncrementUserStats` documents having fixed by addressing rows by id: a passkey rotated between the cached resolution and the write touches NO row, and the credit vanishes without a word. One avoidable query per completed announce and per seed-time credit, gone with it. Dead code staticcheck had been reporting: `checkLocal`, `checkRedis`, `serverError`, `portStr`, `errInvalidConnectionID`, and three unused test helpers. `cache_test.go` keeps its determinism check but binds the two calls to variables. The assertion was NOT tautological — calling a function twice and comparing does test determinism, including if `passkeyKey` ever started salting — but staticcheck assumes purity and flagged it. Naming the results says the intent to both the reader and the analyser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/tracker/internal/announce/announce.go | 1 - apps/tracker/internal/config/config_test.go | 34 --------- apps/tracker/internal/db/cache_test.go | 9 ++- apps/tracker/internal/server/dedup.go | 23 ------ apps/tracker/internal/server/handler.go | 78 +++++++++++---------- apps/tracker/internal/udp/parse.go | 11 ++- 6 files changed, 55 insertions(+), 101 deletions(-) diff --git a/apps/tracker/internal/announce/announce.go b/apps/tracker/internal/announce/announce.go index a00130b4..4f3cae8c 100644 --- a/apps/tracker/internal/announce/announce.go +++ b/apps/tracker/internal/announce/announce.go @@ -137,7 +137,6 @@ func Parse(q url.Values) (*Request, error) { } r.Port = uint16(port) - r.Uploaded, _ = parseInt64(q.Get("uploaded")) r.Downloaded, _ = parseInt64(q.Get("downloaded")) // `left` defaults to 1 (unknown / leeching) when missing or invalid, to diff --git a/apps/tracker/internal/config/config_test.go b/apps/tracker/internal/config/config_test.go index 3ef34a14..45cec47e 100644 --- a/apps/tracker/internal/config/config_test.go +++ b/apps/tracker/internal/config/config_test.go @@ -5,40 +5,6 @@ import ( "time" ) -// withEnv runs f with the given env vars set; restores the previous -// values after. Keeps tests isolated from the developer's actual env. -func withEnv(t *testing.T, env map[string]string, f func()) { - t.Helper() - saved := make(map[string]string, len(env)) - for k, v := range env { - saved[k] = getRawEnv(k) - t.Setenv(k, v) - _ = v // satisfy go vet - } - defer func() { - for k, v := range saved { - if v == "" { - _ = unsetIfWritable(t, k) - continue - } - t.Setenv(k, v) - } - }() - f() -} - -func getRawEnv(k string) string { - // t.Setenv automatically restores on teardown; the helper above - // is intentional indirection in case future tests need to peek. - return "" // unused — t.Setenv handles restore -} - -func unsetIfWritable(t *testing.T, k string) error { - t.Helper() - t.Setenv(k, "") - return nil -} - // requiredEnv sets the three variables Load() refuses to start without, so a // test can then assert on the optional ones in isolation. func requiredEnv(t *testing.T) { diff --git a/apps/tracker/internal/db/cache_test.go b/apps/tracker/internal/db/cache_test.go index e4be0638..2b335602 100644 --- a/apps/tracker/internal/db/cache_test.go +++ b/apps/tracker/internal/db/cache_test.go @@ -75,7 +75,14 @@ func TestUserByPasskey_NeverStoresTheRawPasskey(t *testing.T) { func TestUserByPasskey_KeyIsStableAndDistinct(t *testing.T) { d, _ := newCacheDB(t) - if d.passkeyKey(testPasskey) != d.passkeyKey(testPasskey) { + // Liés à des variables, et non comparés en place : le test EST valide — + // appeler deux fois et comparer vérifie bien le déterminisme, y compris si + // `passkeyKey` se mettait un jour à saler — mais staticcheck y voit deux + // expressions identiques (SA4000) parce qu'il présume la pureté. Nommer les + // deux résultats dit l'intention au lecteur et à l'analyseur. + first := d.passkeyKey(testPasskey) + second := d.passkeyKey(testPasskey) + if first != second { t.Fatal("the same passkey must map to the same key") } if d.passkeyKey(testPasskey) == d.passkeyKey(testPasskey+"x") { diff --git a/apps/tracker/internal/server/dedup.go b/apps/tracker/internal/server/dedup.go index 8fe25732..fb7a7b57 100644 --- a/apps/tracker/internal/server/dedup.go +++ b/apps/tracker/internal/server/dedup.go @@ -111,13 +111,6 @@ func (d *dedup) CheckAndMarkFor(ctx context.Context, key string, window time.Dur return d.checkRedisFor(ctx, key, window) } -// checkLocal is the original in-process behaviour, unchanged. Drops the -// oldest half of entries when the map exceeds `dedupMaxEntries` to keep -// memory bounded under spam. -func (d *dedup) checkLocal(key string) bool { - return d.checkLocalFor(key, dedupWindow) -} - func (d *dedup) checkLocalFor(key string, window time.Duration) bool { now := time.Now() d.mu.Lock() @@ -132,22 +125,6 @@ func (d *dedup) checkLocalFor(key string, window time.Duration) bool { return true } -// checkRedis claims the key for `dedupWindow` across every instance. -// -// `SET key 1 NX PX <window>` is the whole mechanism: one atomic round-trip, -// self-expiring, no cleanup path, and the winner is decided by Redis rather -// than by which process happened to be asked first. -// -// On error we return TRUE — the local layer already said this key was fresh, -// so we degrade to exactly the single-instance behaviour rather than dropping -// a member's bytes because Redis hiccuped. That direction is also the only -// coherent one: the byte delta is computed from a baseline that lives in -// Redis, so a Redis outage means `prev` is nil and there is no delta to -// double-credit in the first place. -func (d *dedup) checkRedis(ctx context.Context, key string) bool { - return d.checkRedisFor(ctx, key, dedupWindow) -} - func (d *dedup) checkRedisFor(ctx context.Context, key string, window time.Duration) bool { ctx, cancel := context.WithTimeout(ctx, dedupRedisTimeout) defer cancel() diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go index 31340b34..68a57505 100644 --- a/apps/tracker/internal/server/handler.go +++ b/apps/tracker/internal/server/handler.go @@ -582,7 +582,28 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // refuser un crédit légitime parce que le cache a hoqueté est le mauvais // sens de l'erreur, et sans Redis il n'y a de toute façon pas de `prev` // donc pas de delta à créditer. - if deltaUp > 0 || deltaDown > 0 { + /* + * La déduplication décide AVANT que le seau ne soit vidé. + * + * `CheckAndMark` était consulté plus bas, une fois les jetons déjà retirés. + * Un client à double pile — le cas même pour lequel la déduplication + * existe — brûlait donc son budget deux fois pour un seul crédit, et les + * jetons ne sont jamais rendus. Le sens de l'erreur était favorable (le + * membre est sous-crédité, jamais sur-crédité) et la réserve d'une minute + * l'absorbe pour un seedeur honnête, mais l'ordre était inversé. + * + * Le marquage reste au même instant qu'avant par rapport à l'écriture : + * c'est la même requête qui marque et qui crédite. Ce qui change, c'est + * qu'un delta qui ne sera PAS porté au compte ne coûte plus de jetons. + * + * L'écrêtage garde sa place d'origine — avant les multiplicateurs de bonus, + * qui s'appliquent ensuite au delta déjà borné. + */ + creditKey := infoHashHex + ":" + peerHex + ":credit" + bookCredit := (deltaUp > 0 || deltaDown > 0) && + s.dedup.CheckAndMark(ctx, creditKey) + + if bookCredit { want := deltaUp + deltaDown if granted, err := s.peers.TakeCreditBudget( ctx, user.ID, want, maxCreditBytesPerSec, @@ -639,8 +660,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // two land on the same process or on two instances behind a load // balancer. The per-event dedup above still lets distinct events run // their own side effects (completed counter, stopped removal). - creditKey := infoHashHex + ":" + peerHex + ":credit" - if (deltaUp > 0 || deltaDown > 0) && s.dedup.CheckAndMark(ctx, creditKey) { + if bookCredit { if err := s.db.Q.IncrementUserStats(ctx, queries.IncrementUserStatsParams{ Uploaded: deltaUp, Downloaded: deltaDown, @@ -750,7 +770,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli _ = s.peers.IncrementCompleted(ctx, infoHashHex) } s.bgTasks.Add(1) - go s.recordHnrCompletion(req.Passkey, infoHashHex) + go s.recordHnrCompletion(user.ID, torrentID, infoHashHex) } // 10. Seeders contribute to seed-time tracking. Gate on an @@ -792,7 +812,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli ctx, seedKey, time.Duration(minAnnounceInterval)*time.Second, ) { s.bgTasks.Add(1) - go s.recordSeedTime(req.Passkey, infoHashHex, int32(elapsed)) + go s.recordSeedTime(user.ID, torrentID, infoHashHex, int32(elapsed)) } } @@ -879,7 +899,16 @@ func (s *Server) hnrRelease() { s.hnrSlots <- struct{}{} } -func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { +// Les identifiants sont passés, pas la passkey. +// +// Les deux écritures relançaient `FindUserAndTorrentByPasskeyAndHash` — une +// jointure croisée `users × torrents` — alors que l'appelant tenait déjà +// `user.ID` et l'identifiant du torrent. C'est exactement le défaut que +// `IncrementUserStats` documente avoir corrigé en passant par l'ID : une +// passkey rotée entre la résolution (cache de 60 s) et cette écriture ne +// touche AUCUNE ligne, et le crédit disparaît sans un mot. Coût annexe évité : +// une requête Postgres par annonce complétée et par crédit de temps de seed. +func (s *Server) recordHnrCompletion(userID, torrentID, infoHashHex string) { defer s.bgTasks.Done() // Panic guard: any panic inside this goroutine would skip the // `defer hnrRelease()` below and permanently leak a semaphore @@ -913,15 +942,6 @@ func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { return } - row, err := s.db.Q.FindUserAndTorrentByPasskeyAndHash(ctx, - queries.FindUserAndTorrentByPasskeyAndHashParams{ - Passkey: passkey, - InfoHash: infoHashHex, - }) - if err != nil { - return - } - id, err := dbpkg.NewID() if err != nil { slog.Warn("hnr id generation", "info_hash", infoHashHex, "err", err) @@ -929,8 +949,8 @@ func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { } err = s.db.Q.CreateHnrEntry(ctx, queries.CreateHnrEntryParams{ ID: id, - UserID: row.UserID, - TorrentID: row.TorrentID, + UserID: userID, + TorrentID: torrentID, RequiredSeedTime: required, }) if err != nil { @@ -938,7 +958,8 @@ func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { } } -func (s *Server) recordSeedTime(passkey, infoHashHex string, secondsToAdd int32) { +// Mêmes raisons que `recordHnrCompletion` : par les identifiants. +func (s *Server) recordSeedTime(userID, torrentID, infoHashHex string, secondsToAdd int32) { defer s.bgTasks.Done() // See `recordHnrCompletion` for the rationale — without this // recover() a panic here would leak the semaphore slot it's @@ -956,19 +977,10 @@ func (s *Server) recordSeedTime(passkey, infoHashHex string, secondsToAdd int32) } defer s.hnrRelease() - row, err := s.db.Q.FindUserAndTorrentByPasskeyAndHash(ctx, - queries.FindUserAndTorrentByPasskeyAndHashParams{ - Passkey: passkey, - InfoHash: infoHashHex, - }) - if err != nil { - return - } - - err = s.db.Q.AddSeedTime(ctx, queries.AddSeedTimeParams{ + err := s.db.Q.AddSeedTime(ctx, queries.AddSeedTimeParams{ SeedTime: secondsToAdd, - UserID: row.UserID, - TorrentID: row.TorrentID, + UserID: userID, + TorrentID: torrentID, }) if err != nil { slog.Warn("update seed time", @@ -1239,9 +1251,3 @@ func writeFailure(w http.ResponseWriter, reason string) { w.WriteHeader(http.StatusOK) // BT trackers MUST return 200 with bencode failure _, _ = w.Write(bencode.FailureResponse(reason)) } - -func (s *Server) serverError(w http.ResponseWriter, where string, err error) { - slog.Error("internal error", "where", where, "err", err) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(bencode.FailureResponse("Internal tracker error")) -} diff --git a/apps/tracker/internal/udp/parse.go b/apps/tracker/internal/udp/parse.go index 61c2e850..73b04a60 100644 --- a/apps/tracker/internal/udp/parse.go +++ b/apps/tracker/internal/udp/parse.go @@ -68,12 +68,11 @@ const maxURLDataBytes = 512 // errors are short ASCII strings and we keep them tracker-vague — but // the constants make the code testable. var ( - errPacketTooShort = errors.New("packet too short") - errBadMagic = errors.New("invalid protocol_id") - errInvalidConnectionID = errors.New("invalid connection_id") - errMissingPasskey = errors.New("missing passkey") - errInvalidPort = errors.New("invalid port") - errMalformedOptions = errors.New("malformed options") + errPacketTooShort = errors.New("packet too short") + errBadMagic = errors.New("invalid protocol_id") + errMissingPasskey = errors.New("missing passkey") + errInvalidPort = errors.New("invalid port") + errMalformedOptions = errors.New("malformed options") ) // ConnectRequest is the parsed step-1 packet. The transaction_id has From b244b8c4e4b32d18508d1974a585870b8fd87246 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:30:03 +0200 Subject: [PATCH 79/96] feat(tracker): let an operator close UDP scrape, and stop flooding the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP scrape requires a passkey, and says why: without one, a list of public info hashes reconstructs the catalogue. UDP scrape cannot require one. BEP 15 defines a scrape request as a connection id, an action, a transaction id and a run of info hashes — there is no field for authentication data, and the BEP 41 options extension that carries the passkey on announce does not apply here. Demanding a passkey would break UDP scrape for every conforming client rather than protect anything. So the asymmetry is in the protocols, not in this tracker, and what was missing was the choice. `TRACKER_UDP_SCRAPE_ENABLED` closes scrape while leaving announce working. Default true, so an existing deployment behaves the same after an upgrade. Silence rather than an error when closed: answering an unverified source is the reflector `handleConnect` already refuses to be, and BEP 15 expects a client with no answer to redo its `connect`. What it costs to leave open is real but bounded, and the guide now says so: a caller learns swarm SIZES for hashes they already know, never peer addresses, and only after a `connect` round trip from their own address. The rejected-announce log line is sampled and no longer carries an address. It wrote one line per datagram, while the three counters above it write one in ten thousand precisely because "a log line per datagram is a disk-exhaustion primitive" — this path had forgotten it, and an invalid passkey or a low ratio repeats on every announce the client makes. The HTTP path writes nothing on failure and hashes the address when it logs; the reason alone is what an operator correlates on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/tracker/cmd/tracker/main.go | 2 +- apps/tracker/internal/config/config.go | 54 ++++++++++++++------ apps/tracker/internal/udp/server.go | 69 ++++++++++++++++++-------- doc/guide/udp-tracker.md | 16 ++++++ docker-compose.prod.yml | 4 ++ 5 files changed, 106 insertions(+), 39 deletions(-) diff --git a/apps/tracker/cmd/tracker/main.go b/apps/tracker/cmd/tracker/main.go index c0951802..fbfd8581 100644 --- a/apps/tracker/cmd/tracker/main.go +++ b/apps/tracker/cmd/tracker/main.go @@ -125,7 +125,7 @@ func main() { if cfg.UDPEnabled { udpAddr := ":" + strconv.Itoa(cfg.UDPPort) var err error - udpSrv, err = udp.New(udpAddr, cfg.IPHashSecret, srv, store) + udpSrv, err = udp.New(udpAddr, cfg.IPHashSecret, srv, store, cfg.UDPScrapeEnabled) if err != nil { logger.Error("udp listen", "err", err) os.Exit(1) diff --git a/apps/tracker/internal/config/config.go b/apps/tracker/internal/config/config.go index 49b4b211..4d07b3f1 100644 --- a/apps/tracker/internal/config/config.go +++ b/apps/tracker/internal/config/config.go @@ -10,15 +10,16 @@ import ( ) type Config struct { - HTTPPort int - UDPPort int - UDPEnabled bool - DatabaseURL string - RedisURL string - RedisPassword string - RedisKeyPrefix string - IPHashSecret string - Debug bool + HTTPPort int + UDPPort int + UDPEnabled bool + UDPScrapeEnabled bool + DatabaseURL string + RedisURL string + RedisPassword string + RedisKeyPrefix string + IPHashSecret string + Debug bool // FederationSwarm gates the cross-announce peer injection (Phase 4). // Off by default — mixing partner-instance peers into responses re-opens // the private swarm isolation, so it's an explicit operator opt-in. @@ -73,13 +74,34 @@ func Load() (*Config, error) { // tweaking. UDP support is opt-in via TRACKER_UDP_ENABLED so an // operator who doesn't want to expose it (HTTPS-only deployments, // strict firewall rules, etc.) can keep the listener off. - UDPPort: getEnvInt("TRACKER_UDP_PORT", 6969), - UDPEnabled: getEnvDefault("TRACKER_UDP_ENABLED", "true") == "true", - DatabaseURL: os.Getenv("DATABASE_URL"), - RedisURL: os.Getenv("REDIS_URL"), - RedisPassword: os.Getenv("REDIS_PASSWORD"), - RedisKeyPrefix: getEnvDefault("REDIS_KEY_PREFIX", "ot:"), - IPHashSecret: os.Getenv("IP_HASH_SECRET"), + UDPPort: getEnvInt("TRACKER_UDP_PORT", 6969), + UDPEnabled: getEnvDefault("TRACKER_UDP_ENABLED", "true") == "true", + // Le scrape UDP, séparément de l'annonce. + // + // Le scrape HTTP exige une passkey (voir `handleScrape` dans + // `internal/server/handler.go`, et le pourquoi : reconstituer le + // catalogue depuis une liste de hashes publics). Le scrape UDP ne le + // peut pas — BEP 15 ne prévoit AUCUN emplacement pour une donnée + // d'authentification dans une requête de scrape ; l'extension BEP 41 + // qui porte la passkey de l'annonce ne s'applique qu'à l'annonce. + // Exiger une passkey ici reviendrait à casser le scrape UDP pour tous + // les clients conformes. + // + // L'asymétrie est donc dans les protocoles, pas dans ce code. Ce qui + // manquait, c'est le choix : un tracker privé qui ne veut pas publier + // la taille de ses essaims peut désormais fermer le scrape sans perdre + // l'annonce UDP. La contrainte réelle reste modeste — l'appelant doit + // d'abord faire un `connect` depuis sa vraie adresse, et la réponse ne + // contient que des compteurs, jamais de pairs. + // + // Défaut `true` : un déploiement existant ne change pas de + // comportement en se mettant à jour. + UDPScrapeEnabled: getEnvDefault("TRACKER_UDP_SCRAPE_ENABLED", "true") == "true", + DatabaseURL: os.Getenv("DATABASE_URL"), + RedisURL: os.Getenv("REDIS_URL"), + RedisPassword: os.Getenv("REDIS_PASSWORD"), + RedisKeyPrefix: getEnvDefault("REDIS_KEY_PREFIX", "ot:"), + IPHashSecret: os.Getenv("IP_HASH_SECRET"), // 20 is what the pool was hardcoded to before this became a // setting; keeping it as the default means an existing deployment // behaves identically after the upgrade. diff --git a/apps/tracker/internal/udp/server.go b/apps/tracker/internal/udp/server.go index d8f799e2..68765c01 100644 --- a/apps/tracker/internal/udp/server.go +++ b/apps/tracker/internal/udp/server.go @@ -6,9 +6,8 @@ import ( "errors" "log/slog" "net" - "runtime/debug" "runtime" - "strconv" + "runtime/debug" "sync" "sync/atomic" "time" @@ -80,6 +79,16 @@ type Server struct { droppedParse atomic.Uint64 droppedConnID atomic.Uint64 droppedPasskey atomic.Uint64 + droppedReject atomic.Uint64 + + // Le scrape UDP peut être fermé sans fermer l'annonce. + // + // Le scrape HTTP exige une passkey ; celui-ci ne le peut pas, BEP 15 ne + // prévoyant aucun emplacement pour une donnée d'authentification dans une + // requête de scrape. L'exploitant d'un tracker privé qui ne veut pas + // publier la taille de ses essaims ferme donc l'accès plutôt que + // l'authentifier. Voir `TRACKER_UDP_SCRAPE_ENABLED`. + scrapeEnabled bool } // New builds a UDP server bound to `addr` and ready to dispatch @@ -88,7 +97,9 @@ type Server struct { // Binding happens here (rather than in Start) so the caller can fail // fast on a port conflict at boot — same pattern as `http.Server.Listen` // happens inside `ListenAndServe`, but exposed earlier. -func New(addr string, secret string, proc *server.Server, store *peers.Store) (*Server, error) { +// New construit le serveur UDP. `scrapeEnabled` vient de +// `TRACKER_UDP_SCRAPE_ENABLED` : voir le champ du même nom. +func New(addr string, secret string, proc *server.Server, store *peers.Store, scrapeEnabled bool) (*Server, error) { udpAddr, err := net.ResolveUDPAddr("udp", addr) if err != nil { return nil, err @@ -102,13 +113,14 @@ func New(addr string, secret string, proc *server.Server, store *peers.Store) (* cpus = 1 } s := &Server{ - conn: conn, - connID: NewConnIDIssuer(secret), - addr: udpAddr, - proc: proc, - store: store, - logger: slog.Default(), - workerSem: make(chan struct{}, cpus*workerSlotsPerCPU), + scrapeEnabled: scrapeEnabled, + conn: conn, + connID: NewConnIDIssuer(secret), + addr: udpAddr, + proc: proc, + store: store, + logger: slog.Default(), + workerSem: make(chan struct{}, cpus*workerSlotsPerCPU), } s.bufPool = sync.Pool{ New: func() any { @@ -223,6 +235,13 @@ func (s *Server) handlePacket(ctx context.Context, bufp *[]byte, n int, raddr *n case action == ActionAnnounce: s.handleAnnounce(ctx, data, raddr) case action == ActionScrape: + if !s.scrapeEnabled { + // Silence plutôt qu'une erreur : répondre à une source non + // vérifiée, c'est le réflecteur que `handleConnect` refuse déjà + // d'être. BEP 15 prévoit qu'un client sans réponse refasse son + // `connect`. + return + } s.handleScrape(ctx, data, raddr) default: // Drop unknown action. We could send action=3 with "Bad @@ -323,14 +342,24 @@ func (s *Server) handleAnnounce(ctx context.Context, data []byte, raddr *net.UDP // unknown). out := s.proc.ProcessAnnounce(ctx, apiReq, clientIP, "") if out.Failure != "" { - // Failure carries the user-facing reason ("Invalid passkey", - // "Low ratio…", "Torrent not found or inactive"). Surface it - // alongside the remote so the operator can correlate with a - // specific peer. - s.logger.Info("udp announce rejected", - "remote", raddr.String(), - "reason", out.Failure, - ) + // Échantillonné, et sans adresse en clair. + // + // Une ligne par datagramme rejeté : les trois compteurs plus haut + // n'écrivent qu'une fois sur dix mille précisément parce qu'« une ligne + // de journal par datagramme est un primitif d'épuisement de disque » — + // ce chemin-ci l'avait oublié. Une passkey invalide ou un ratio bas se + // répète à chaque annonce du client, sans limite. + // + // Et `raddr.String()` écrivait l'adresse en clair, là où le chemin HTTP + // n'écrit rien en cas d'échec et hache l'adresse quand il journalise. + // Le motif suffit à l'exploitant ; l'adresse ne lui apprend rien qu'il + // ne puisse retrouver autrement. + if n := s.droppedReject.Add(1); n%10_000 == 1 { + s.logger.Info("udp announces rejected", + "count", n, + "reason", out.Failure, + ) + } _, _ = s.conn.WriteToUDP( EncodeError(nil, req.TransactionID, out.Failure), raddr, @@ -451,10 +480,6 @@ func hexBytes(b []byte) string { return string(out) } -// portStr is a small helper used by tests to materialise the bound -// address. Not used in the hot path. -func portStr(p int) string { return ":" + strconv.Itoa(p) } - // _ asserts the announce package is compiled in even if the package // imports get reorganised — we rely on `announce.InfoHashLen` and // related constants. diff --git a/doc/guide/udp-tracker.md b/doc/guide/udp-tracker.md index 19730de1..b142ec0e 100644 --- a/doc/guide/udp-tracker.md +++ b/doc/guide/udp-tracker.md @@ -88,6 +88,7 @@ it — clients won't waste announces on a dead endpoint. # Tracker (apps/tracker) TRACKER_UDP_PORT=6969 # Default. The de-facto BEP 15 port. TRACKER_UDP_ENABLED=true # Default. Set to "false" to disable the listener. +TRACKER_UDP_SCRAPE_ENABLED=true # Default. "false" closes UDP scrape, keeps announce. # Web / API (NUXT_PUBLIC_*) — surfaced in /api/runtime-config and # /api/torrents/[hash]/download @@ -126,6 +127,21 @@ fill in is **deliberately ignored** — trusting it would let any peer register on behalf of arbitrary IPv4 addresses (the classic source-spoofing attack on UDP trackers). + +### Why UDP scrape has no passkey, and what to do about it + +HTTP scrape requires one. UDP scrape cannot: BEP 15 defines a scrape request as +a connection id, an action, a transaction id and a run of info hashes — there is +no field for authentication data, and the BEP 41 options extension that carries +the passkey applies to *announce* only. Requiring a passkey here would break UDP +scrape for every conforming client rather than protect anything. + +The asymmetry is therefore in the protocols, not in this tracker. What it costs +you is real but bounded: a caller learns swarm SIZES for info hashes they +already know, never peer addresses, and only after a `connect` round trip from +their own address. If that is more than a private tracker wants to publish, +close it — `TRACKER_UDP_SCRAPE_ENABLED=false` leaves UDP announce working. + ## Observability Every UDP rejection logs once at `info` (or `warn` for parse failures) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e41fe439..f54e64f8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -256,6 +256,10 @@ services: # so it's exposed directly on the host via `ports` below. TRACKER_UDP_PORT: 6969 TRACKER_UDP_ENABLED: ${TRACKER_UDP_ENABLED:-false} + # Ferme le scrape UDP sans fermer l'annonce : le scrape HTTP exige une + # passkey, celui-ci ne le peut pas (BEP 15 n'a pas d'emplacement pour + # une donnée d'authentification dans une requête de scrape). + TRACKER_UDP_SCRAPE_ENABLED: ${TRACKER_UDP_SCRAPE_ENABLED:-true} TRACKER_DEBUG: ${TRACKER_DEBUG:-false} # Caddy proxies BitTorrent announces on `/announce` and on the # `tracker.*` subdomain. Without trusting its X-Real-IP / From 52c26dd9a1a19f5b791b716d8036b408e17f6f41 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:30:03 +0200 Subject: [PATCH 80/96] docs: /api/health publishes more than the reference admitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table said "Returns `200 { ok: true }`". It returns the status, the uptime, and per-component latency for Postgres and Redis — and it is public and unauthenticated, which three guide pages tell operators to curl. Left public deliberately: it is documented that way and orchestrators are pointed at it. But a page that understates what an endpoint reveals is how an operator ends up exposing something they never agreed to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- doc/reference/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/reference/api.md b/doc/reference/api.md index 0f2fb275..2d9d64e7 100644 --- a/doc/reference/api.md +++ b/doc/reference/api.md @@ -34,7 +34,7 @@ The tracker does not currently implement a multi-hash scrape endpoint or the Web | Method | Path | Purpose | | ------ | ----------------------------- | ---------------------------------------------------------------------- | -| GET | `/api/health` | Liveness probe. Returns `200 { ok: true }`. | +| GET | `/api/health` | Readiness probe, **public and unauthenticated**. Returns `200 {status, uptime, checks:{postgres,redis}}` with per-component latency — it reports on dependencies, so treat it as a liveness signal you are choosing to publish. | | GET | `/api/branding` | Site title, tagline, logo URL, favicon URL, feature flags. | | GET | `/api/runtime-config` | Public Nuxt runtime config (announce URLs, etc.) for the static SPA. | | GET | `/api/announcement` | Operator-set homepage banner (text + level). | From f624d474bab7b99098c06f03bc44a2d21a370773 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:30:34 +0200 Subject: [PATCH 81/96] fix(web): the sanitiser passed a phishing link and ate legitimate attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ALLOWED_URI_REGEXP` accepted `//evil.tld/login`: the relative-path branch `[#/]` saw the first slash. The link survived whole, and since the hook only adds `rel="noopener noreferrer"` and `target="_blank"` on `^https?://`, it got neither. A member writing `[voir la fiche](//evil.tld/login)` in any description produced a link that reads as internal, opens in the SAME tab, sends the full `Referer` and leaves `window.opener` reachable. `utils/safePath.ts` documents exactly this danger for notification links; it had never been applied here. The same expression was eating attributes that are not URLs. DOMPurify applies `ALLOWED_URI_REGEXP` to EVERY attribute outside its `URI_SAFE_ATTRIBUTES` list, so `width="320"` — which starts with neither `http`, nor `mailto`, nor `#`, nor `/` — was dropped. Measured before the fix: <img src="…" width="320" height="180"> -> <img src="…"> <td colspan="2" align="center"> -> <td> <p dir="rtl" lang="fr"> -> <p> Which made `[img=320x180]` and `[img width=75]` — documented in `editorFormats.ts`, used by cast thumbnails — size nothing at all, and dropped `lang` and `dir` from a bilingual site. None of those attributes can carry a script; declaring them URI-safe only lifts a check that had no business running. The rich profile now DERIVES from the strict one instead of being a twin literal. Two near-identical literals is what let them drift: the relative-path branch was added to both, but every later fix had to be made twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/app/utils/markdown.ts | 64 +++++++++++++++++++++++++++------- apps/web/test/sanitize.test.ts | 60 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/apps/web/app/utils/markdown.ts b/apps/web/app/utils/markdown.ts index 9582172b..dff67a13 100644 --- a/apps/web/app/utils/markdown.ts +++ b/apps/web/app/utils/markdown.ts @@ -33,7 +33,47 @@ const SAFE_PROFILE: DomPurifyConfig = { // cliquable. Le regexp par défaut de DOMPurify autorise les références // relatives pour cette raison exacte ; celui-ci a été resserré et a emporté // le cas légitime avec les schémas dangereux. - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[#/])/i, + // + // `\/(?!\/)` et non `\/` : une barre SEULE, jamais deux. + // + // `[#/]` acceptait aussi `//evil.tld/login`. Mesuré : le lien survivait + // intact, et comme `isExternal` teste `^https?:\/\/`, le hook plus bas ne + // lui posait NI `rel="noopener noreferrer"` NI `target="_blank"`. Un membre + // écrivant `[voir la fiche](//evil.tld/login)` dans une description obtenait + // donc un lien qui passe pour interne, s'ouvre dans le MÊME onglet, envoie + // le `Referer` complet et laisse `window.opener` accessible à la page + // d'arrivée. `utils/safePath.ts` documente exactement ce danger pour les + // liens de notification ; il n'avait pas été appliqué ici. + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|#|\/(?!\/))/i, + // + // Les attributs qui ne sont PAS des URL et qu'il ne faut pas passer au + // filtre ci-dessus. + // + // `ALLOWED_URI_REGEXP` s'applique dans DOMPurify à TOUT attribut absent de + // sa liste `URI_SAFE_ATTRIBUTES` — pas seulement `href` et `src`. La valeur + // `320` de `width` ne commence ni par `http`, ni par `mailto`, ni par `#`, + // ni par `/` : elle était donc supprimée. Mesuré, avant correction : + // + // <img src="…" width="320" height="180"> -> <img src="…"> + // <td colspan="2" align="center"> -> <td> + // <p dir="rtl" lang="fr"> -> <p> + // + // Conséquence directe : `[img=320x180]` et `[img width=75]`, documentés dans + // `editorFormats.ts` et utilisés par les listes de casting, ne dimensionnaient + // plus rien. Aucun de ces attributs ne peut porter de script — les déclarer + // sûrs pour l'URI ne relâche que le contrôle qui n'avait pas lieu d'être. + ADD_URI_SAFE_ATTR: [ + 'width', + 'height', + 'colspan', + 'rowspan', + 'align', + 'start', + 'dir', + 'lang', + 'loading', + 'decoding', + ], FORBID_TAGS: ['style', 'iframe', 'form', 'input', 'base', 'meta', 'object'], FORBID_ATTR: ['style', 'srcdoc', 'autofocus'], }; @@ -51,18 +91,18 @@ const SAFE_PROFILE: DomPurifyConfig = { * expression(), positioning, etc.). The hook is gated on `richActive` * so it never loosens the strict path used for branding/forum/markdown. */ +// +// DÉRIVÉ de `SAFE_PROFILE`, et non recopié. +// +// Les deux profils étaient deux littéraux jumeaux, et c'est ce qui les a fait +// diverger : la branche `[#/]` pour les chemins relatifs avait bien été ajoutée +// aux deux, mais chaque correction ultérieure devait l'être deux fois. Le +// profil riche ne diffère que par UN point — il tolère l'ATTRIBUT `style` +// (jamais la balise), borné par `SAFE_STYLE_PROPS` dans le hook plus bas — donc +// il l'exprime comme tel. const RICH_PROFILE: DomPurifyConfig = { - // `|\/` : la branche pour un chemin relatif, qui manquait. - // - // Sans elle, `sanitizeHtml('<a href="/x">x</a>')` rend `<a>x</a>` — mesuré. - // Donc `[voir la fiche](/torrents/42)` dans une description, ou un - // `footerText` de marque pointant sur `/rules`, s'affichaient en texte non - // cliquable. Le regexp par défaut de DOMPurify autorise les références - // relatives pour cette raison exacte ; celui-ci a été resserré et a emporté - // le cas légitime avec les schémas dangereux. - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[#/])/i, - FORBID_TAGS: ['style', 'iframe', 'form', 'input', 'base', 'meta', 'object'], - FORBID_ATTR: ['srcdoc', 'autofocus'], + ...SAFE_PROFILE, + FORBID_ATTR: SAFE_PROFILE.FORBID_ATTR!.filter((a) => a !== 'style'), }; // CSS properties safe to keep on a `style=""` — purely presentational, diff --git a/apps/web/test/sanitize.test.ts b/apps/web/test/sanitize.test.ts index 70ac6686..4d690b6b 100644 --- a/apps/web/test/sanitize.test.ts +++ b/apps/web/test/sanitize.test.ts @@ -189,3 +189,63 @@ describe('safeInAppPath', () => { expect(safeInAppPath('/\\evil.example')).toBe('/'); }); }); + +// ── Le filtre d'URI s'applique à TOUS les attributs ─────────────────── +// +// `ALLOWED_URI_REGEXP` n'est pas réservé à `href` et `src` : DOMPurify le passe +// sur tout attribut absent de sa liste `URI_SAFE_ATTRIBUTES`. La valeur `320` +// de `width` ne commençant ni par `http`, ni par `mailto`, ni par `#`, ni par +// `/`, elle était supprimée — ce qui rendait morte la fonctionnalité +// `[img=320x180]` que `editorFormats.ts` documente. `ADD_URI_SAFE_ATTR` sort +// ces attributs du filtre ; aucun d'eux ne peut porter de script. +describe("le filtre d'URI ne mange plus les attributs de présentation", () => { + it('garde les dimensions d’une image', () => { + const out = sanitizeHtml('<img src="https://x/y.png" width="320" height="180">'); + expect(out).toContain('width="320"'); + expect(out).toContain('height="180"'); + }); + + it('garde la structure d’un tableau et la langue d’un paragraphe', () => { + expect(sanitizeHtml('<table><tr><td colspan="2">a</td></tr></table>')).toContain( + 'colspan="2"' + ); + const p = sanitizeHtml('<p dir="rtl" lang="fr">a</p>'); + expect(p).toContain('dir="rtl"'); + expect(p).toContain('lang="fr"'); + }); + + it('vaut pour le profil riche, qui dérive du strict', () => { + expect(sanitizeRichHtml('<img src="https://x/y.png" width="320">')).toContain( + 'width="320"' + ); + }); +}); + +// ── Un lien protocole-relatif n'est pas un lien interne ─────────────── +// +// `[#/]` acceptait `//evil.tld/login` : la branche des chemins relatifs voyait +// la première barre. Le lien survivait intact, et comme le hook ne pose +// `rel="noopener noreferrer"` et `target="_blank"` que sur `^https?://`, il +// s'ouvrait dans le MÊME onglet, envoyait le `Referer` complet et laissait +// `window.opener` accessible. Écrit par un membre dans une description, c'est +// un hameçonnage qui a l'air d'un lien du site. +describe('les liens protocole-relatifs', () => { + it('ne survivent pas au profil strict', () => { + const out = sanitizeHtml('<a href="//evil.tld/login">voir</a>'); + expect(out).not.toContain('evil.tld'); + expect(out).not.toContain('href'); + }); + + it('ne survivent pas non plus au profil riche', () => { + expect(sanitizeRichHtml('<a href="//evil.tld/login">voir</a>')).not.toContain( + 'evil.tld' + ); + }); + + it('laissent passer le lien interne et le lien externe légitimes', () => { + expect(sanitizeHtml('<a href="/torrents/42">v</a>')).toContain('href="/torrents/42"'); + const ext = sanitizeHtml('<a href="https://ok.tld/p">v</a>'); + expect(ext).toContain('rel="noopener noreferrer"'); + expect(ext).toContain('target="_blank"'); + }); +}); From dd597d48870f55144a987cfd89371f6928fc28ab Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:30:34 +0200 Subject: [PATCH 82/96] fix(web): the path guard did not see %2e%2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `event.path.includes('..')` is blind to percent-encoding, while the WHATWG URL parser folds `%2e%2e`, `%2E%2E` and `.%2e` into real double-dot segments. Measured: `/api/%2e%2e/uploads/x` passed the guard and reached `http://api:4000/uploads/x`, with the session cookie attached. The reach was bounded — the API mounts only `/api` and `/uploads`, and `/uploads` is directly reachable anyway — but a guard written on purpose that does not do what it says is worse than no guard: it gives the certainty of being covered. It now normalises with the same parser that will do the folding downstream, and requires the result to still be under `/api/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/server/routes/api/[...path].ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/web/server/routes/api/[...path].ts b/apps/web/server/routes/api/[...path].ts index 5c47c389..bd835427 100644 --- a/apps/web/server/routes/api/[...path].ts +++ b/apps/web/server/routes/api/[...path].ts @@ -52,7 +52,25 @@ export default defineEventHandler(async (event) => { // `http://api:4000/uploads/x`. La portée est bornée — l'API ne monte que // `/api` et `/uploads`, et `/uploads` est joignable directement — mais cela // permettait d'atteindre `/uploads` par l'origine web, et c'est une ligne. - if (event.path.includes('..')) { + // + // Sur le chemin DÉCODÉ, pas sur la chaîne brute. `includes('..')` ne voyait + // pas `%2e%2e` — mesuré : `/api/%2e%2e/uploads/x` passait la garde, et le + // parseur d'URL WHATWG repliait ensuite l'encodage en un vrai segment + // double-point, donnant `http://api:4000/uploads/x`. Une garde écrite exprès + // qui ne fait pas ce qu'elle annonce est pire qu'aucune garde : elle donne + // la certitude d'être couvert. + // + // On normalise avec le même analyseur que celui qui fera le repli plus bas, + // puis on exige que ce qui en sort reste dans `/api/`. Un chemin qui remonte + // ailleurs — quelle que soit la façon dont il l'écrit — n'est plus un chemin + // que ce catch-all a le droit de relayer. + let normalised: string; + try { + normalised = new URL(event.path, 'http://internal').pathname; + } catch { + throw createError({ statusCode: 400, statusMessage: 'Bad Request' }); + } + if (normalised !== event.path.split('?')[0] || !normalised.startsWith('/api/')) { throw createError({ statusCode: 400, statusMessage: 'Bad Request' }); } From 8ee6dd4c1f7c2d80f8c94cc874cffc905343b1c3 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:30:56 +0200 Subject: [PATCH 83/96] fix(web): the bell died on reconnect, and three more values differed across hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useNotifications` guarded its lifecycle with a MODULE flag, but the `watch(loggedIn, …)` it guards was registered in the setup of the first consumer — `NotificationBell`, mounted by the default layout. The login page is `layout: false`, so signing out tore the layout down and killed the watch while the flag stayed true. Signing back in is a client navigation with no reload, so `start()` was never called again: no SSE, no catch-up, a bell silent until F5. It now lives in a detached `effectScope`, so the flag and the watch have the same lifetime. Verified through a full sign-out/sign-in cycle with no reload: both the fetch and the stream come back. Three hydration mismatches with the same shape as the clocks fixed earlier: `Math.random()` evaluated at setup in the moderation panel (now `useId()`, which `Modal.vue` already documents as the fix), a `typeof window` guard inside a `computed` on `/mod` — a computed is lazy, so its first client evaluation happens DURING hydration where `window` exists, guaranteeing the mismatch it was meant to avoid — and `new Date().getFullYear()` in the footer, on every page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../torrent/TorrentModerationPanel.vue | 7 +++- apps/web/app/composables/useNotifications.ts | 41 +++++++++++++++---- apps/web/app/layouts/default.vue | 11 ++++- apps/web/app/pages/mod/index.vue | 25 +++++++---- 4 files changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/web/app/components/torrent/TorrentModerationPanel.vue b/apps/web/app/components/torrent/TorrentModerationPanel.vue index ce76e9bd..6dfcd7b9 100644 --- a/apps/web/app/components/torrent/TorrentModerationPanel.vue +++ b/apps/web/app/components/torrent/TorrentModerationPanel.vue @@ -263,7 +263,12 @@ const composerError = ref<string | null>(null); // Clicking the banner flips this on; aria-expanded + aria-controls // keep keyboard / screen-reader users in the loop. const expanded = ref(false); -const bodyId = `mod-panel-body-${Math.random().toString(36).slice(2, 9)}`; +// `useId()` plutôt que `Math.random()` : ce panneau est rendu côté serveur pour +// le téléverseur et pour le staff, et l'identifiant est calculé au `setup`. Le +// serveur en tirait un, le client un autre, et l'hydratation trouvait +// `aria-controls` pointant à côté de `id`. `Modal.vue` a eu exactement le même +// défaut, corrigé de la même façon. +const bodyId = useId(); function toggle() { expanded.value = !expanded.value; } diff --git a/apps/web/app/composables/useNotifications.ts b/apps/web/app/composables/useNotifications.ts index 6afa147f..73575991 100644 --- a/apps/web/app/composables/useNotifications.ts +++ b/apps/web/app/composables/useNotifications.ts @@ -52,6 +52,21 @@ const MAX_IN_MEMORY = 50; /** Un seul cycle de vie par processus client : voir la note sur le `watch`. */ let lifecycleBound = false; +/** + * La portée qui héberge ce cycle de vie, détachée de tout composant. + * + * `effectScope(true)` — le `true` est le point entier. Un `watch()` appelé dans + * un `setup()` est collecté par la portée de ce composant : il meurt avec lui. + * Le drapeau ci-dessus, lui, est de module et survit. La combinaison des deux + * était le bug : le premier consommateur était la cloche, montée par la mise + * en page par défaut ; la page de connexion est en `layout: false`, donc à la + * déconnexion la mise en page se démontait, le `watch` était détruit, et le + * drapeau restait vrai. Une reconnexion sans rechargement ne rappelait plus + * jamais `start()` — ni SSE ni `fetchInitial()` — et la cloche restait vide + * jusqu'au prochain F5. + */ +const lifecycleScope = import.meta.client ? effectScope(true) : null; + let eventSource: EventSource | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let pollFallback: ReturnType<typeof setInterval> | null = null; @@ -273,16 +288,24 @@ export function useNotifications() { // posait un et déclenchait `start()` à son propre `setup` : N requêtes en // double à chaque chargement. `useColorMode.ts` traite déjà le même problème // avec un drapeau de module. - if (import.meta.client && !lifecycleBound) { + // + // Et il est posé DANS `lifecycleScope`, hors de la portée du composant + // appelant : le drapeau vit aussi longtemps que la page, le `watch` doit + // vivre aussi longtemps que le drapeau. Les fermetures capturées ici sont + // celles du premier consommateur, ce qui est sans conséquence côté client : + // `useState` et `useUserSession` rendent les mêmes refs à chaque appel. + if (lifecycleScope && !lifecycleBound) { lifecycleBound = true; - watch( - loggedIn, - (v) => { - if (v) start(); - else stop(); - }, - { immediate: true }, - ); + lifecycleScope.run(() => { + watch( + loggedIn, + (v) => { + if (v) start(); + else stop(); + }, + { immediate: true }, + ); + }); } return { diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index f5e16bd1..0dbf3fdd 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -827,7 +827,7 @@ v-html=" sanitizeHtml( branding?.footerText || - `© ${new Date().getFullYear()} ${branding?.siteName || 'Trackarr'}` + `© ${footerYear} ${branding?.siteName || 'Trackarr'}` ) " ></span> @@ -929,6 +929,15 @@ const appVersion = computed( // fetch the same payload 3-4 times per SSR request. const branding = await useBranding(); +// L'année du copyright de repli, calculée une seule fois et transportée dans +// la charge utile. Elle était appelée dans le rendu — donc au rendu serveur, +// puis à nouveau au rendu client. Le pied de page est sur TOUTES les pages : +// au passage d'année, ou pour un onglet ouvert de part et d'autre de minuit le +// 31 décembre, l'hydratation trouvait deux textes différents et réécrivait +// l'arbre entier. `useState` sérialise la valeur du serveur et le client la +// relit telle quelle. +const footerYear = useState('footer-year', () => new Date().getFullYear()); + // Set dynamic favicon and title template useHead({ // A plain function, not a `computed` wrapping one: `titleTemplate` takes diff --git a/apps/web/app/pages/mod/index.vue b/apps/web/app/pages/mod/index.vue index 1d6e7866..97b3dd21 100644 --- a/apps/web/app/pages/mod/index.vue +++ b/apps/web/app/pages/mod/index.vue @@ -310,14 +310,25 @@ const hasAlerts = computed(() => { // ── Greeting prefix — picks a hello based on the wall-clock hour // so a 03:00 shift feels different from a 14:00 one. -const greetingPrefix = computed(() => { - if (typeof window === 'undefined') return t('mod.dashboard.greeting.welcome') + ' '; +// +// L'heure est celle du navigateur, donc elle n'existe pas au rendu serveur. Un +// `computed` gardé par `typeof window` ne suffisait pas : un `computed` est +// paresseux, sa première évaluation cliente a lieu PENDANT l'hydratation, où +// `window` existe déjà. Le serveur écrivait « Bienvenue », le client lisait +// « Bonsoir », et l'écart était systématique, pas seulement la nuit. Même forme +// que l'horloge juste en dessous : une valeur neutre stable, remplacée dans +// `onMounted`, une fois le HTML serveur adopté. On mémorise la clé et non le +// texte pour que le salut suive encore un changement de langue. +const greetingKey = ref('welcome'); +const greetingPrefix = computed(() => t(`mod.dashboard.greeting.${greetingKey.value}`) + ' '); +onMounted(() => { const h = new Date().getHours(); - if (h < 5) return t('mod.dashboard.greeting.lateNight') + ' '; - if (h < 12) return t('mod.dashboard.greeting.morning') + ' '; - if (h < 18) return t('mod.dashboard.greeting.afternoon') + ' '; - if (h < 22) return t('mod.dashboard.greeting.evening') + ' '; - return t('mod.dashboard.greeting.night') + ' '; + greetingKey.value = + h < 5 ? 'lateNight' + : h < 12 ? 'morning' + : h < 18 ? 'afternoon' + : h < 22 ? 'evening' + : 'night'; }); // ── Live clock & today — ticks every minute so we don't waste a From 1c8515eb324c367247c940fad95523d907ddd509 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:30:56 +0200 Subject: [PATCH 84/96] fix(web): a modal with no focus trap, and a group row only a mouse could open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReportModal` carried `role="dialog"` and `aria-modal="true"` but handled only Escape: no Tab handling, no scroll lock, no focus restoration. `aria-modal` tells the browser nothing — tabbing continues into the page behind the scrim, which `Modal.vue` explains at length and implements correctly. A keyboard user reporting a torrent walked out of the dialog on the last Tab and into a page they could no longer see. It is reachable from every torrent page. The mechanism is extracted into `useModalChrome` and used by BOTH, with a shared scroll-lock counter — one implementation instead of a second copy that would drift the way the two sanitiser profiles did. Group rows opened on a bare `@click` with no `tabindex`, `role` or key handling. The chevron is now a real button carrying `aria-expanded` and a localised label. Deliberately not `role="button"` on the row itself: it already contains buttons and a link, which such a role would remove from the accessibility tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/app/components/Modal.vue | 131 ++-------------- apps/web/app/components/ReportModal.vue | 41 ++--- apps/web/app/components/torrent/GroupRow.vue | 46 +++++- .../app/components/torrent/GroupTableRow.vue | 39 ++++- apps/web/app/composables/useModalChrome.ts | 147 ++++++++++++++++++ 5 files changed, 247 insertions(+), 157 deletions(-) create mode 100644 apps/web/app/composables/useModalChrome.ts diff --git a/apps/web/app/components/Modal.vue b/apps/web/app/components/Modal.vue index c3165bc9..8c46c33f 100644 --- a/apps/web/app/components/Modal.vue +++ b/apps/web/app/components/Modal.vue @@ -135,129 +135,16 @@ function onBackdropClick() { close(); } -// ── Focus management + window-level Esc handler ───────────── -// Scoped Esc handlers (e.g. on the backdrop) only fire when the -// focus is already inside the modal. We bind on `window` so a -// keyboard user who tabs OUT of the modal can still press Esc to -// dismiss it. The panel auto-focuses on mount so the very first -// keystroke after open is captured. +// ── Focus, Échap et verrou de défilement ──────────────────── +// La mécanique est dans `useModalChrome` : elle était ici, et +// `ReportModal.vue` — habillage sur mesure, donc pas réutilisable via ce +// composant — n'en avait rien. Une seule implémentation pour les deux. const panelRef = ref<HTMLElement | null>(null); -/** L'élément qui avait le focus avant l'ouverture, pour le lui rendre. */ -let restoreTo: HTMLElement | null = null; - -/** - * Ce qui est atteignable au clavier, dans l'ordre du document. - * - * `offsetParent` écarte ce qui est masqué (`display: none`), donc les onglets - * repliés d'une modale à onglets ne piègent pas le focus dans le vide. - */ -const FOCUSABLE = - 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])'; - -function focusables(): HTMLElement[] { - const root = panelRef.value; - if (!root) return []; - return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter( - (el) => el.offsetParent !== null || el === document.activeElement, - ); -} - -function onKeydown(e: KeyboardEvent) { - if (e.key === 'Escape' && !props.persistent) { - e.preventDefault(); - close(); - return; - } - if (e.key !== 'Tab') return; - // Le piège à focus. - // - // `aria-modal="true"` dit au lecteur d'écran que le reste de la page est - // inerte ; il ne dit rien au navigateur, dont l'ordre de tabulation continuait - // droit dans la page derrière l'ombrage. Un utilisateur au clavier sortait de - // la modale dès la dernière tabulation et se retrouvait à parcourir une page - // qu'il ne voyait plus, sans savoir comment revenir. - const items = focusables(); - if (!items.length) { - e.preventDefault(); - panelRef.value?.focus(); - return; - } - const first = items[0]!; - const last = items[items.length - 1]!; - const active = document.activeElement as HTMLElement | null; - const inside = panelRef.value?.contains(active) ?? false; - if (!inside) { - e.preventDefault(); - (e.shiftKey ? last : first).focus(); - return; - } - if (e.shiftKey && active === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && active === last) { - e.preventDefault(); - first.focus(); - } -} - -/** - * Le verrou de défilement, partagé par toutes les modales. - * - * Un compteur, pas un booléen : une modale ouverte au-dessus d'une autre - * (une confirmation par-dessus un formulaire) rendait le défilement à la - * fermeture de la première alors que la seconde était toujours à l'écran. - */ -let lockedByThis = false; - -function lockScroll() { - if (lockedByThis) return; - lockedByThis = true; - const n = Number(document.body.dataset.modalLocks || '0') + 1; - document.body.dataset.modalLocks = String(n); - if (n === 1) document.body.style.overflow = 'hidden'; -} - -function unlockScroll() { - if (!lockedByThis) return; - lockedByThis = false; - const n = Math.max(0, Number(document.body.dataset.modalLocks || '1') - 1); - document.body.dataset.modalLocks = String(n); - if (n === 0) document.body.style.overflow = ''; -} - -watch( - () => props.modelValue, - (open) => { - if (typeof window === 'undefined') return; - if (open) { - restoreTo = document.activeElement as HTMLElement | null; - window.addEventListener('keydown', onKeydown); - lockScroll(); - // Wait one tick so the teleport mounts before we steal focus. - nextTick(() => panelRef.value?.focus()); - } else { - window.removeEventListener('keydown', onKeydown); - unlockScroll(); - // Rendre le focus au bouton qui a ouvert la modale. Sans cela il retombe - // sur `<body>` et la tabulation suivante repart du haut de la page. - restoreTo?.focus?.(); - restoreTo = null; - } - }, - // `immediate` pour le cas d'une modale montée DÉJÀ ouverte : aucun appelant - // ne le fait aujourd'hui (vérifié sur les 27 usages de `<Modal v-model>`), - // mais sans cela le premier changement d'état ne serait jamais observé et la - // modale s'afficherait sans verrou de défilement, sans piège à focus et sans - // gestionnaire Échap. La branche « fermée » est inoffensive : le verrou n'est - // pas pris, et la restitution de focus porte sur `null`. - { immediate: true }, -); - -onBeforeUnmount(() => { - if (typeof window !== 'undefined') { - window.removeEventListener('keydown', onKeydown); - unlockScroll(); - } +useModalChrome({ + isOpen: () => props.modelValue, + panel: panelRef, + onEscape: close, + escapable: () => !props.persistent, }); </script> diff --git a/apps/web/app/components/ReportModal.vue b/apps/web/app/components/ReportModal.vue index 218b667d..11e22ecc 100644 --- a/apps/web/app/components/ReportModal.vue +++ b/apps/web/app/components/ReportModal.vue @@ -34,7 +34,6 @@ v-if="isOpen" class="slip-backdrop" @click.self="close" - @keydown.esc="close" > <div ref="slipRef" @@ -274,35 +273,21 @@ async function submitReport() { } } -// Focus + Esc management. The window-level listener fires even -// when the user has tabbed outside the slip, and the panel auto- -// focuses on open so the very first keystroke is captured. +// Focus, Échap, verrou de défilement et restitution du focus. +// +// Il n'y avait que Échap. `role="dialog"` + `aria-modal="true"` annoncent une +// modale au lecteur d'écran, mais ne rendent pas la page inerte pour le +// navigateur : la tabulation sortait du bordereau et repartait dans la page +// masquée par l'ombrage, sans moyen évident de revenir, et la fermeture rendait +// le focus à `<body>`. La mécanique est celle de `Modal.vue`, désormais +// partagée — ce composant ne peut pas réutiliser `Modal.vue` lui-même, son +// habillage (bord dentelé, en-tête à drapeau) lui est propre. const slipRef = ref<HTMLElement | null>(null); -function handleEsc(e: KeyboardEvent) { - if (e.key === 'Escape' && props.isOpen) { - e.preventDefault(); - close(); - } -} - -watch( - () => props.isOpen, - (open) => { - if (typeof window === 'undefined') return; - if (open) { - window.addEventListener('keydown', handleEsc); - nextTick(() => slipRef.value?.focus()); - } else { - window.removeEventListener('keydown', handleEsc); - } - } -); - -onBeforeUnmount(() => { - if (typeof window !== 'undefined') { - window.removeEventListener('keydown', handleEsc); - } +useModalChrome({ + isOpen: () => props.isOpen, + panel: slipRef, + onEscape: close, }); </script> diff --git a/apps/web/app/components/torrent/GroupRow.vue b/apps/web/app/components/torrent/GroupRow.vue index 36a56a1f..605a3ed0 100644 --- a/apps/web/app/components/torrent/GroupRow.vue +++ b/apps/web/app/components/torrent/GroupRow.vue @@ -103,10 +103,32 @@ </span> </div> - <Icon - :name="open ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" - class="grp-chev" - /> + <!-- + Le chevron est un vrai bouton, pas une décoration. + + L'ouverture était à la souris seule : `.grp-head` est un `<div>` avec un + `@click`, donc absent de l'ordre de tabulation et sourd à Entrée. On ne + peut pas lui donner `role="button"` pour autant — il contient déjà des + boutons (les puces de portée), et un rôle `button` autour d'eux les + retirerait de l'arbre d'accessibilité. Le contrôle explicite est donc + ici, et le clic sur la ligne reste un raccourci pour la souris. + --> + <button + type="button" + class="grp-chev-btn" + :aria-expanded="open" + :aria-label=" + open + ? $t('search.group.collapseAria', { title }) + : $t('search.group.expandAria', { title }) + " + @click.stop="toggle()" + > + <Icon + :name="open ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" + class="grp-chev" + /> + </button> </div> <TorrentGroupTree @@ -502,6 +524,20 @@ const age = computed(() => formatAge(props.group.latest)); color: rgb(var(--fg-faint)); } +.grp-chev-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: 0; + background: none; + cursor: pointer; + color: inherit; + border-radius: var(--radius-sm); +} +.grp-chev-btn:hover .grp-chev { + color: rgb(var(--fg-muted)); +} .grp-chev { width: 0.9rem; height: 0.9rem; @@ -545,7 +581,7 @@ const age = computed(() => formatAge(props.group.latest)); .grp-body { grid-area: body; } - .grp-chev { + .grp-chev-btn { grid-area: chev; } .grp-facts { diff --git a/apps/web/app/components/torrent/GroupTableRow.vue b/apps/web/app/components/torrent/GroupTableRow.vue index 78ed00a8..1949cee9 100644 --- a/apps/web/app/components/torrent/GroupTableRow.vue +++ b/apps/web/app/components/torrent/GroupTableRow.vue @@ -15,7 +15,6 @@ <tr class="gtr" :class="{ 'gtr--open': open }" - :aria-expanded="open" @click="toggle()" > <td> @@ -111,8 +110,30 @@ </td> <td class="gtr-size">{{ sizeSpan }}</td> <td class="gtr-age">{{ age }}</td> + <!-- + Le chevron est un vrai bouton, pas une décoration. + + L'ouverture était à la souris seule : un `<tr>` avec un `@click` n'est ni + dans l'ordre de tabulation ni sensible à Entrée. Rendre la ligne elle-même + focalisable ne suffirait pas — elle contient déjà des boutons et un lien, + et `role="button"` autour d'eux les retirerait de l'arbre + d'accessibilité. Le clic sur la ligne reste un raccourci pour la souris ; + c'est ce bouton qui porte l'état pour tout le monde. + --> <td class="gtr-chev"> - <Icon :name="open ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" /> + <button + type="button" + class="gtr-chev-btn" + :aria-expanded="open" + :aria-label=" + open + ? $t('search.group.collapseAria', { title }) + : $t('search.group.expandAria', { title }) + " + @click.stop="toggle()" + > + <Icon :name="open ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" /> + </button> </td> </tr> @@ -428,6 +449,20 @@ const searchableTitle = computed(() => { text-align: center; color: rgb(var(--fg-faint)); } +.gtr-chev-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: 0; + background: none; + cursor: pointer; + color: inherit; + border-radius: var(--radius-sm); +} +.gtr-chev-btn:hover { + color: rgb(var(--fg-muted)); +} .gtr-expanded > td { /* The releases sit inside the row's width with no cell padding of their own: diff --git a/apps/web/app/composables/useModalChrome.ts b/apps/web/app/composables/useModalChrome.ts new file mode 100644 index 00000000..2727232c --- /dev/null +++ b/apps/web/app/composables/useModalChrome.ts @@ -0,0 +1,147 @@ +/** + * Ce qu'une boîte de dialogue doit faire en plus de s'afficher. + * + * Trois mécanismes, tous invisibles tant qu'on n'utilise pas le clavier : + * le piège à focus, le verrou de défilement et la restitution du focus. Ils + * vivaient dans `Modal.vue` uniquement, et `ReportModal.vue` — qui ne peut pas + * réutiliser ce composant, son habillage de bordereau à bord dentelé étant + * entièrement à lui — n'en avait aucun : `role="dialog"` et + * `aria-modal="true"` annonçaient une modale, mais la tabulation partait droit + * dans la page derrière l'ombrage. Les sortir ici évite qu'une troisième + * modale sur mesure reparte de zéro et les oublie à nouveau. + * + * Le verrou de défilement est un compteur porté par `document.body`, partagé + * par tous les appelants : une modale ouverte au-dessus d'une autre rendait le + * défilement à la fermeture de la première alors que la seconde était toujours + * à l'écran. + */ +import type { Ref } from 'vue'; + +/** + * Ce qui est atteignable au clavier, dans l'ordre du document. + * + * `offsetParent` écarte ce qui est masqué (`display: none`), donc les onglets + * repliés d'une modale à onglets ne piègent pas le focus dans le vide. + */ +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])'; + +export interface ModalChromeOptions { + /** L'état ouvert/fermé, en getter pour rester réactif. */ + isOpen: () => boolean; + /** Le panneau lui-même : la racine du piège à focus. */ + panel: Ref<HTMLElement | null>; + /** Ce que « fermer » veut dire pour l'appelant. */ + onEscape: () => void; + /** Faux pour une modale persistante : Échap ne ferme pas. Le piège à focus + * et le verrou de défilement restent actifs — ils ne dépendent pas d'elle. */ + escapable?: () => boolean; +} + +export function useModalChrome(opts: ModalChromeOptions) { + const escapable = opts.escapable ?? (() => true); + + function focusables(): HTMLElement[] { + const root = opts.panel.value; + if (!root) return []; + return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + } + + function onKeydown(e: KeyboardEvent) { + // Un gestionnaire porté par le panneau ne se déclenche que si le focus est + // déjà dedans. On écoute sur `window` pour qu'un utilisateur au clavier + // sorti de la modale puisse encore appuyer sur Échap. + if (e.key === 'Escape' && escapable()) { + e.preventDefault(); + opts.onEscape(); + return; + } + if (e.key !== 'Tab') return; + // Le piège à focus. + // + // `aria-modal="true"` dit au lecteur d'écran que le reste de la page est + // inerte ; il ne dit rien au navigateur, dont l'ordre de tabulation + // continuait droit dans la page derrière l'ombrage. Un utilisateur au + // clavier sortait de la modale dès la dernière tabulation et se retrouvait + // à parcourir une page qu'il ne voyait plus, sans savoir comment revenir. + const items = focusables(); + if (!items.length) { + e.preventDefault(); + opts.panel.value?.focus(); + return; + } + const first = items[0]!; + const last = items[items.length - 1]!; + const active = document.activeElement as HTMLElement | null; + const inside = opts.panel.value?.contains(active) ?? false; + if (!inside) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + return; + } + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + + /** L'élément qui avait le focus avant l'ouverture, pour le lui rendre. */ + let restoreTo: HTMLElement | null = null; + let lockedByThis = false; + + function lockScroll() { + if (lockedByThis) return; + lockedByThis = true; + const n = Number(document.body.dataset.modalLocks || '0') + 1; + document.body.dataset.modalLocks = String(n); + if (n === 1) document.body.style.overflow = 'hidden'; + } + + function unlockScroll() { + if (!lockedByThis) return; + lockedByThis = false; + const n = Math.max(0, Number(document.body.dataset.modalLocks || '1') - 1); + document.body.dataset.modalLocks = String(n); + if (n === 0) document.body.style.overflow = ''; + } + + watch( + opts.isOpen, + (open) => { + if (typeof window === 'undefined') return; + if (open) { + restoreTo = document.activeElement as HTMLElement | null; + window.addEventListener('keydown', onKeydown); + lockScroll(); + // Un tick pour laisser le téléport se monter avant de prendre le focus. + nextTick(() => opts.panel.value?.focus()); + } else { + window.removeEventListener('keydown', onKeydown); + unlockScroll(); + // Rendre le focus au bouton qui a ouvert la modale. Sans cela il + // retombe sur `<body>` et la tabulation suivante repart du haut de la + // page. + restoreTo?.focus?.(); + restoreTo = null; + } + }, + // `immediate` pour le cas d'une modale montée DÉJÀ ouverte : sans cela le + // premier changement d'état ne serait jamais observé et la modale + // s'afficherait sans verrou de défilement, sans piège à focus et sans + // gestionnaire Échap. La branche « fermée » est inoffensive : le verrou + // n'est pas pris, et la restitution de focus porte sur `null`. + { immediate: true }, + ); + + onBeforeUnmount(() => { + if (typeof window !== 'undefined') { + window.removeEventListener('keydown', onKeydown); + unlockScroll(); + } + }); +} From 49eff95004f8acb23fdf3ed0e789843aea8b1062 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:31:16 +0200 Subject: [PATCH 85/96] fix(web): 49 form controls had no accessible name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dominant shape was `<label class="…">Text</label>` followed by an `<input>`, with neither `for` nor nesting — a styled paragraph, not a label. 130 controls across 49 files; the worst two sat on the sign-in path, the TOTP code and the recovery code. `SettingsGroup` was the real lever: its `<label>` named nothing across 35 settings. It now takes a `control-id`, and renders a `<label>` only when it has something to point at — otherwise a `<span>`, rather than a `for` dangling at nothing. That one component paired 24 controls across twelve admin files. `SearchBar` gets a short `aria-label` rather than leaning on a sixty-character placeholder. The freeleech day grid becomes a `role="group"` with `aria-labelledby`: a `<label>` over a set of buttons names nothing. `useFieldIds` exists because the cost of one `useId()` per field is what stopped the `for` attributes being added in the first place. 81 controls across 37 files are still unnamed — `ThemeEditor` (10), `BonusEvents` (5), `TwoFactorSection` (4) lead the rest — and the helper is in place for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- apps/web/app/components/SearchBar.vue | 22 +++++ apps/web/app/components/SettingsGroup.vue | 24 ++++- .../app/components/admin/Announcements.vue | 6 ++ .../app/components/admin/FreeleechPool.vue | 96 ++++++++++--------- .../app/components/admin/HomepageContent.vue | 6 ++ .../components/admin/MessagingBroadcast.vue | 6 ++ .../components/admin/MessagingSettings.vue | 16 ++++ .../admin/NotificationRetention.vue | 8 ++ .../web/app/components/admin/Registration.vue | 10 ++ .../app/components/admin/RequestSettings.vue | 8 ++ .../app/components/admin/SearchSettings.vue | 9 +- .../app/components/admin/TemplateSettings.vue | 6 ++ apps/web/app/components/admin/Themes.vue | 7 +- .../app/components/admin/TorznabConfig.vue | 6 ++ .../components/admin/TorznabRateLimiting.vue | 10 ++ .../security/TwoFactorLoginStep.vue | 20 +++- .../settings/NotificationsSection.vue | 11 +++ apps/web/app/composables/useFieldIds.ts | 27 ++++++ 18 files changed, 248 insertions(+), 50 deletions(-) create mode 100644 apps/web/app/composables/useFieldIds.ts diff --git a/apps/web/app/components/SearchBar.vue b/apps/web/app/components/SearchBar.vue index 91ca30af..139676d5 100644 --- a/apps/web/app/components/SearchBar.vue +++ b/apps/web/app/components/SearchBar.vue @@ -29,6 +29,7 @@ ref="inputRef" :value="modelValue" type="text" + :aria-label="effectiveLabel" :placeholder="effectivePlaceholder" class="search-input w-full bg-bg-secondary border border-border text-text-primary placeholder-text-muted focus:bg-bg-tertiary transition-all" :class="[ @@ -105,6 +106,9 @@ const { t } = useI18n(); const props = defineProps<{ modelValue: string; placeholder?: string; + /** Nom accessible du champ, quand l'appelant sait mieux que « rechercher » + * — la barre du forum, celle du magasin. */ + label?: string; loading?: boolean; size?: 'sm' | 'lg'; }>(); @@ -128,6 +132,24 @@ const effectivePlaceholder = computed(() => { return props.placeholder ?? t('components.searchBar.placeholder'); }); +/** + * Le nom accessible du champ. + * + * Il n'y en avait aucun : ni `<label>`, ni `aria-label`, seulement un + * `placeholder`. Un placeholder n'est pas un nom — il disparaît à la première + * frappe, et `effectivePlaceholder` le vide DÉJÀ de lui-même dès qu'un + * identifiant média est reconnu. Le champ de recherche principal du site + * s'annonçait donc « saisie de texte », et sans rien du tout une fois rempli. + * + * Court, et surtout pas le placeholder par défaut : celui de la page des + * torrents fait soixante caractères, et un lecteur d'écran relit le nom d'un + * champ en entier chaque fois qu'on y entre. L'indication reste dans le + * placeholder, où elle est une indication. + */ +const effectiveLabel = computed( + () => props.label ?? t('components.searchBar.ariaLabel'), +); + function onInput(event: Event) { const value = (event.target as HTMLInputElement).value; emit('update:modelValue', value); diff --git a/apps/web/app/components/SettingsGroup.vue b/apps/web/app/components/SettingsGroup.vue index f2fcb6e4..78d22f20 100644 --- a/apps/web/app/components/SettingsGroup.vue +++ b/apps/web/app/components/SettingsGroup.vue @@ -1,9 +1,25 @@ <template> <div class="md:grid md:grid-cols-3 md:gap-6 py-4 border-b border-border/50 last:border-0"> <div class="md:col-span-1 space-y-1"> - <label class="text-[10px] font-bold uppercase tracking-widest text-text-muted block"> + <!-- + Un `<label>` seulement quand il désigne quelque chose. + + C'était toujours un `<label>`, et jamais avec un `for` : le composant ne + voit pas ce que l'appelant met dans son emplacement, donc il ne pouvait + pas le désigner. Trente-cinq réglages d'administration s'annonçaient + ainsi « liste » ou « saisie de texte » sans nom, et cliquer sur le + libellé ne donnait pas le focus. L'appelant passe désormais + l'identifiant du champ qu'il rend — voir `useFieldIds()` — et sans lui + c'est un `<span>`, parce qu'un `<label>` qui ne désigne rien est un + paragraphe stylé qui se fait passer pour un libellé. + --> + <component + :is="controlId ? 'label' : 'span'" + :for="controlId" + class="text-[10px] font-bold uppercase tracking-widest text-text-muted block" + > {{ label }} - </label> + </component> <p v-if="description" class="text-xs text-text-muted leading-relaxed"> {{ description }} </p> @@ -18,5 +34,9 @@ defineProps<{ label: string; description?: string; + /** L'`id` du champ que cet emplacement rend, quand il y en a exactement un + * et qu'il est natif. Laisser vide pour un groupe de contrôles ou pour un + * emplacement qui n'en contient aucun. */ + controlId?: string; }>(); </script> diff --git a/apps/web/app/components/admin/Announcements.vue b/apps/web/app/components/admin/Announcements.vue index 909bc185..9cec8e97 100644 --- a/apps/web/app/components/admin/Announcements.vue +++ b/apps/web/app/components/admin/Announcements.vue @@ -39,11 +39,13 @@ <!-- Message --> <SettingsGroup + :control-id="fid('messageLabel')" :label="$t('admin.announcements.messageLabel')" :description="$t('admin.announcements.messageDescription')" > <div class="relative"> <textarea + :id="fid('messageLabel')" v-model="message" rows="3" maxlength="500" @@ -129,6 +131,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const { t } = useI18n(); const enabled = ref(false); diff --git a/apps/web/app/components/admin/FreeleechPool.vue b/apps/web/app/components/admin/FreeleechPool.vue index 5a4b80d3..835f10df 100644 --- a/apps/web/app/components/admin/FreeleechPool.vue +++ b/apps/web/app/components/admin/FreeleechPool.vue @@ -226,9 +226,10 @@ <!-- Calibration grid --> <div class="cal-grid"> <div class="field field--xl"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.target') }}</label> + <label class="field-label" :for="fid('target')">{{ $t('admin.freeleechPool.fields.target') }}</label> <div class="field-input field-input--unit"> <input + :id="fid('target')" v-model.number="config.pointsTarget" type="number" min="0" @@ -239,9 +240,10 @@ </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.durationDays') }}</label> + <label class="field-label" :for="fid('durationDays')">{{ $t('admin.freeleechPool.fields.durationDays') }}</label> <div class="field-input field-input--unit"> <input + :id="fid('durationDays')" v-model.number="config.freeleechDurationDays" type="number" min="1" @@ -253,17 +255,18 @@ </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.contributionMin') }}</label> + <label class="field-label" :for="fid('contributionMin')">{{ $t('admin.freeleechPool.fields.contributionMin') }}</label> <div class="field-input field-input--unit"> - <input v-model.number="config.contributionMin" type="number" min="1" class="input"> + <input :id="fid('contributionMin')" v-model.number="config.contributionMin" type="number" min="1" class="input"> <span class="unit">pts</span> </div> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.maxPerUserPct') }}</label> + <label class="field-label" :for="fid('maxPerUserPct')">{{ $t('admin.freeleechPool.fields.maxPerUserPct') }}</label> <div class="field-input field-input--unit"> <input + :id="fid('maxPerUserPct')" v-model.number="maxPerUserPct" type="number" min="0" @@ -277,8 +280,9 @@ </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.presetAmounts') }}</label> + <label class="field-label" :for="fid('presetAmounts')">{{ $t('admin.freeleechPool.fields.presetAmounts') }}</label> <input + :id="fid('presetAmounts')" v-model="presetAmountsInput" type="text" :placeholder="$t('admin.freeleechPool.fields.presetAmountsPlaceholder')" @@ -288,13 +292,14 @@ </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.eventTitle') }}</label> - <input v-model="config.eventTitleTemplate" type="text" maxlength="120" class="input"> + <label class="field-label" :for="fid('eventTitle')">{{ $t('admin.freeleechPool.fields.eventTitle') }}</label> + <input :id="fid('eventTitle')" v-model="config.eventTitleTemplate" type="text" maxlength="120" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.eventDescription') }}</label> + <label class="field-label" :for="fid('eventDescription')">{{ $t('admin.freeleechPool.fields.eventDescription') }}</label> <textarea + :id="fid('eventDescription')" v-model="config.eventDescriptionTemplate" rows="2" maxlength="500" @@ -353,16 +358,16 @@ <div v-if="scheduleTab === 'oneoff'" class="form"> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.from') }}</label> - <input v-model="newOneoff.startsAt" type="datetime-local" class="input"> + <label class="field-label" :for="fid('from')">{{ $t('admin.freeleechPool.fields.from') }}</label> + <input :id="fid('from')" v-model="newOneoff.startsAt" type="datetime-local" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.to') }}</label> - <input v-model="newOneoff.endsAt" type="datetime-local" class="input"> + <label class="field-label" :for="fid('to')">{{ $t('admin.freeleechPool.fields.to') }}</label> + <input :id="fid('to')" v-model="newOneoff.endsAt" type="datetime-local" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newOneoff.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label')" v-model="newOneoff.label" type="text" maxlength="60" class="input"> </div> </div> <button @@ -380,32 +385,32 @@ <div v-else-if="scheduleTab === 'weekly'" class="form"> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromDay') }}</label> - <select v-model.number="newWeekly.weekdayStart" class="input"> + <label class="field-label" :for="fid('fromDay')">{{ $t('admin.freeleechPool.fields.fromDay') }}</label> + <select :id="fid('fromDay')" v-model.number="newWeekly.weekdayStart" class="input"> <option v-for="d in weekdays" :key="`s-${d.value}`" :value="d.value"> {{ d.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> - <input v-model="newWeekly.timeStart" type="time" class="input"> + <label class="field-label" :for="fid('fromTime')">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> + <input :id="fid('fromTime')" v-model="newWeekly.timeStart" type="time" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toDay') }}</label> - <select v-model.number="newWeekly.weekdayEnd" class="input"> + <label class="field-label" :for="fid('toDay')">{{ $t('admin.freeleechPool.fields.toDay') }}</label> + <select :id="fid('toDay')" v-model.number="newWeekly.weekdayEnd" class="input"> <option v-for="d in weekdays" :key="`e-${d.value}`" :value="d.value"> {{ d.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toTime') }}</label> - <input v-model="newWeekly.timeEnd" type="time" class="input"> + <label class="field-label" :for="fid('toTime')">{{ $t('admin.freeleechPool.fields.toTime') }}</label> + <input :id="fid('toTime')" v-model="newWeekly.timeEnd" type="time" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newWeekly.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label2')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label2')" v-model="newWeekly.label" type="text" maxlength="60" class="input"> </div> </div> <p class="form-hint">{{ $t('admin.freeleechPool.fields.utcHint') }}</p> @@ -423,8 +428,8 @@ <!-- ─── Monthly ─── --> <div v-else-if="scheduleTab === 'monthly'" class="form"> <div class="field field--full"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.daysOfMonth') }}</label> - <div class="day-grid"> + <span :id="fid('daysOfMonth')" class="field-label">{{ $t('admin.freeleechPool.fields.daysOfMonth') }}</span> + <div class="day-grid" role="group" :aria-labelledby="fid('daysOfMonth')"> <button v-for="d in 31" :key="d" @@ -440,16 +445,16 @@ </div> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> - <input v-model="newMonthly.timeStart" type="time" class="input"> + <label class="field-label" :for="fid('fromTime2')">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> + <input :id="fid('fromTime2')" v-model="newMonthly.timeStart" type="time" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toTime') }}</label> - <input v-model="newMonthly.timeEnd" type="time" class="input"> + <label class="field-label" :for="fid('toTime2')">{{ $t('admin.freeleechPool.fields.toTime') }}</label> + <input :id="fid('toTime2')" v-model="newMonthly.timeEnd" type="time" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newMonthly.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label3')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label3')" v-model="newMonthly.label" type="text" maxlength="60" class="input"> </div> </div> <p class="form-hint">{{ $t('admin.freeleechPool.fields.utcHint') }}</p> @@ -468,32 +473,32 @@ <div v-else-if="scheduleTab === 'yearly'" class="form"> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromMonth') }}</label> - <select v-model.number="newYearly.monthStart" class="input"> + <label class="field-label" :for="fid('fromMonth')">{{ $t('admin.freeleechPool.fields.fromMonth') }}</label> + <select :id="fid('fromMonth')" v-model.number="newYearly.monthStart" class="input"> <option v-for="m in months" :key="`ms-${m.value}`" :value="m.value"> {{ m.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromDayOfMonth') }}</label> - <input v-model.number="newYearly.dayStart" type="number" min="1" max="31" class="input"> + <label class="field-label" :for="fid('fromDayOfMonth')">{{ $t('admin.freeleechPool.fields.fromDayOfMonth') }}</label> + <input :id="fid('fromDayOfMonth')" v-model.number="newYearly.dayStart" type="number" min="1" max="31" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toMonth') }}</label> - <select v-model.number="newYearly.monthEnd" class="input"> + <label class="field-label" :for="fid('toMonth')">{{ $t('admin.freeleechPool.fields.toMonth') }}</label> + <select :id="fid('toMonth')" v-model.number="newYearly.monthEnd" class="input"> <option v-for="m in months" :key="`me-${m.value}`" :value="m.value"> {{ m.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toDayOfMonth') }}</label> - <input v-model.number="newYearly.dayEnd" type="number" min="1" max="31" class="input"> + <label class="field-label" :for="fid('toDayOfMonth')">{{ $t('admin.freeleechPool.fields.toDayOfMonth') }}</label> + <input :id="fid('toDayOfMonth')" v-model.number="newYearly.dayEnd" type="number" min="1" max="31" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newYearly.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label4')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label4')" v-model="newYearly.label" type="text" maxlength="60" class="input"> </div> </div> <p class="form-hint">{{ $t('admin.freeleechPool.fields.yearlyHint') }}</p> @@ -610,6 +615,11 @@ const { t } = useI18n(); const notifications = useNotificationStore(); const confirm = useConfirm(); +// Les libellés de ce formulaire ne désignaient rien : ni `for`, ni imbrication. +// Vingt-trois champs sans nom accessible, dans le panneau qui décide de la +// cagnotte freeleech. +const fid = useFieldIds(); + interface PoolConfig { id: number; enabled: boolean; diff --git a/apps/web/app/components/admin/HomepageContent.vue b/apps/web/app/components/admin/HomepageContent.vue index 3a0293c1..124987ce 100644 --- a/apps/web/app/components/admin/HomepageContent.vue +++ b/apps/web/app/components/admin/HomepageContent.vue @@ -42,10 +42,12 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('statusBadge')" :label="$t('admin.homepage.statusBadge')" :description="$t('admin.homepage.statusBadgeDescription')" > <input + :id="fid('statusBadge')" v-model="statusBadgeText" type="text" maxlength="100" @@ -116,6 +118,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const heroTitle = ref('Trackarr'); const heroSubtitle = ref( 'High-performance, minimalist P2P tracking engine. Search through our indexed database of verified torrents.' diff --git a/apps/web/app/components/admin/MessagingBroadcast.vue b/apps/web/app/components/admin/MessagingBroadcast.vue index 1a192a0e..306632f1 100644 --- a/apps/web/app/components/admin/MessagingBroadcast.vue +++ b/apps/web/app/components/admin/MessagingBroadcast.vue @@ -64,10 +64,12 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('message')" :label="$t('admin.broadcast.message')" :description="$t('admin.broadcast.messageHint')" > <textarea + :id="fid('message')" v-model="body" rows="5" maxlength="4000" @@ -114,6 +116,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + interface Row { id: string; audience: string; diff --git a/apps/web/app/components/admin/MessagingSettings.vue b/apps/web/app/components/admin/MessagingSettings.vue index 38338fd5..1cf168d3 100644 --- a/apps/web/app/components/admin/MessagingSettings.vue +++ b/apps/web/app/components/admin/MessagingSettings.vue @@ -27,10 +27,12 @@ <div class="space-y-5"> <SettingsGroup + :control-id="fid('dmScope')" :label="$t('admin.messaging.dmScope')" :description="$t('admin.messaging.dmScopeHint')" > <select + :id="fid('dmScope')" v-model="dmScope" class="w-full md:w-64 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > @@ -41,10 +43,12 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('roomScope')" :label="$t('admin.messaging.roomScope')" :description="$t('admin.messaging.roomScopeHint')" > <select + :id="fid('roomScope')" v-model="roomScope" class="w-full md:w-64 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > @@ -60,10 +64,12 @@ nothing new arrives. A plain on/off forces the choice between drowning and abandoning people mid-conversation. --> <SettingsGroup + :control-id="fid('ticketsMode')" :label="$t('admin.messaging.ticketsMode')" :description="$t('admin.messaging.ticketsModeHint')" > <select + :id="fid('ticketsMode')" v-model="ticketsMode" class="w-full md:w-64 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > @@ -74,11 +80,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('retentionDays')" :label="$t('admin.messaging.retentionDays')" :description="$t('admin.messaging.retentionHint')" > <div class="flex items-center gap-3"> <input + :id="fid('retentionDays')" v-model.number="retentionDays" type="number" min="1" @@ -90,11 +98,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('dmRetentionDays')" :label="$t('admin.messaging.dmRetentionDays')" :description="$t('admin.messaging.dmRetentionHint')" > <div class="flex items-center gap-3"> <input + :id="fid('dmRetentionDays')" v-model.number="dmRetentionDays" type="number" min="0" @@ -113,11 +123,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('slowMode')" :label="$t('admin.messaging.slowMode')" :description="$t('admin.messaging.slowModeHint')" > <div class="flex items-center gap-3"> <input + :id="fid('slowMode')" v-model.number="slowModeSeconds" type="number" min="0" @@ -163,6 +175,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + type Scope = 'off' | 'staff' | 'all'; type TicketsMode = 'off' | 'suspended' | 'on'; diff --git a/apps/web/app/components/admin/NotificationRetention.vue b/apps/web/app/components/admin/NotificationRetention.vue index 8fc35248..076b5216 100644 --- a/apps/web/app/components/admin/NotificationRetention.vue +++ b/apps/web/app/components/admin/NotificationRetention.vue @@ -26,11 +26,13 @@ <div class="space-y-5"> <SettingsGroup + :control-id="fid('readDays')" :label="$t('admin.notifications.retention.readDays')" :description="$t('admin.notifications.retention.hint')" > <div class="flex items-center gap-3"> <input + :id="fid('readDays')" v-model.number="readDays" type="number" min="1" @@ -42,11 +44,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('unreadDays')" :label="$t('admin.notifications.retention.unreadDays')" :description="$t('admin.notifications.retention.hint')" > <div class="flex items-center gap-3"> <input + :id="fid('unreadDays')" v-model.number="unreadDays" type="number" min="1" @@ -87,6 +91,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const readDays = ref(90); const unreadDays = ref(90); const loading = ref(false); diff --git a/apps/web/app/components/admin/Registration.vue b/apps/web/app/components/admin/Registration.vue index 7ff8168b..76bd01b8 100644 --- a/apps/web/app/components/admin/Registration.vue +++ b/apps/web/app/components/admin/Registration.vue @@ -56,12 +56,14 @@ <div class="space-y-0"> <!-- Default Invites Per User --> <SettingsGroup + :control-id="fid('defaultInvitesLabel')" v-if="mode !== 'closed'" :label="$t('admin.registration.defaultInvitesLabel')" :description="$t('admin.registration.defaultInvitesDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('defaultInvitesLabel')" v-model.number="defaultInvites" type="number" min="0" @@ -74,11 +76,13 @@ <!-- Minimum Ratio --> <SettingsGroup + :control-id="fid('minRatioLabel')" :label="$t('admin.registration.minRatioLabel')" :description="$t('admin.registration.minRatioDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('minRatioLabel')" v-model.number="minRatio" type="number" step="0.1" @@ -97,11 +101,13 @@ <!-- Starter Credit --> <SettingsGroup + :control-id="fid('starterCreditLabel')" :label="$t('admin.registration.starterCreditLabel')" :description="$t('admin.registration.starterCreditDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('starterCreditLabel')" v-model.number="starterUploadGB" type="number" min="0" @@ -138,6 +144,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const { t } = useI18n(); type RegistrationMode = 'closed' | 'invite-only' | 'open'; diff --git a/apps/web/app/components/admin/RequestSettings.vue b/apps/web/app/components/admin/RequestSettings.vue index 3c8b542b..85e4ae27 100644 --- a/apps/web/app/components/admin/RequestSettings.vue +++ b/apps/web/app/components/admin/RequestSettings.vue @@ -29,11 +29,13 @@ <div class="space-y-5"> <SettingsGroup + :control-id="fid('autoValidateHours')" :label="$t('admin.requests.autoValidateHours')" :description="$t('admin.requests.autoValidateHint')" > <div class="flex items-center gap-3"> <input + :id="fid('autoValidateHours')" v-model.number="autoValidateHours" type="number" min="1" @@ -47,11 +49,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('maxFills')" :label="$t('admin.requests.maxFills')" :description="$t('admin.requests.maxFillsHint')" > <div class="flex items-center gap-3"> <input + :id="fid('maxFills')" v-model.number="maxFillsPerUser" type="number" min="1" @@ -94,6 +98,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const autoValidateHours = ref(168); const maxFillsPerUser = ref(3); const loading = ref(false); diff --git a/apps/web/app/components/admin/SearchSettings.vue b/apps/web/app/components/admin/SearchSettings.vue index c97e3df4..a77dd466 100644 --- a/apps/web/app/components/admin/SearchSettings.vue +++ b/apps/web/app/components/admin/SearchSettings.vue @@ -26,6 +26,7 @@ </p> <SettingsGroup + :control-id="fid('fields')" :label="$t('admin.search.fields')" :description="$t('admin.search.fieldsHint')" > @@ -36,6 +37,7 @@ class="flex items-start gap-3 cursor-pointer group" > <input + :id="fid('fields')" v-model="fields" type="checkbox" :value="f" @@ -58,11 +60,12 @@ </p> <SettingsGroup + :control-id="fid('fuzzy')" :label="$t('admin.search.fuzzy')" :description="$t('admin.search.fuzzyHint')" > <label class="flex items-start gap-3 cursor-pointer"> - <input v-model="fuzzy" type="checkbox" class="mt-0.5 accent-text-primary" /> + <input :id="fid('fuzzy')" v-model="fuzzy" type="checkbox" class="mt-0.5 accent-text-primary" /> <span class="text-sm text-text-primary"> {{ $t('admin.search.fuzzyLabel') }} </span> @@ -94,6 +97,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + /** Same order as on the server side, most to least obvious to enable. */ const FIELDS = ['name', 'description', 'nfo', 'tags'] as const; type Field = (typeof FIELDS)[number]; diff --git a/apps/web/app/components/admin/TemplateSettings.vue b/apps/web/app/components/admin/TemplateSettings.vue index 06a93368..4c616ddc 100644 --- a/apps/web/app/components/admin/TemplateSettings.vue +++ b/apps/web/app/components/admin/TemplateSettings.vue @@ -26,6 +26,7 @@ </p> <SettingsGroup + :control-id="fid('quotaPerUser')" :label="$t('admin.templates.quotaPerUser')" :description="$t('admin.templates.quotaHint')" > @@ -40,6 +41,7 @@ it with a 20%-opacity border left the field with no perceptible focus state at all. --> <input + :id="fid('quotaPerUser')" v-model.number="quotaPerUser" type="number" min="1" @@ -78,6 +80,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const QUOTA_DEFAULT = 5; const QUOTA_MIN = 1; const QUOTA_MAX = 100; diff --git a/apps/web/app/components/admin/Themes.vue b/apps/web/app/components/admin/Themes.vue index a2e8ab5a..b668b352 100644 --- a/apps/web/app/components/admin/Themes.vue +++ b/apps/web/app/components/admin/Themes.vue @@ -12,10 +12,11 @@ </div> <div class="card-body"> <SettingsGroup + :control-id="fid('defaultLabel')" :label="$t('admin.themes.defaultLabel')" :description="$t('admin.themes.defaultDescription')" > - <select v-model="settings.themeDefault" class="input"> + <select :id="fid('defaultLabel')" v-model="settings.themeDefault" class="input"> <option value="system">{{ $t('admin.themes.systemOption') }}</option> <option v-for="o in pickable" :key="o.slug" :value="o.slug"> {{ o.name }} @@ -186,6 +187,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + /** * The theme list, and the two settings that point at one. * diff --git a/apps/web/app/components/admin/TorznabConfig.vue b/apps/web/app/components/admin/TorznabConfig.vue index 16204123..4e5d753c 100644 --- a/apps/web/app/components/admin/TorznabConfig.vue +++ b/apps/web/app/components/admin/TorznabConfig.vue @@ -114,11 +114,13 @@ <!-- API URL Info --> <SettingsGroup + :control-id="fid('endpointLabel')" :label="$t('admin.torznab.config.endpointLabel')" :description="$t('admin.torznab.config.endpointDescription')" > <div class="flex items-center gap-2"> <input + :id="fid('endpointLabel')" :value="apiUrl" readonly class="flex-1 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm font-mono text-text-primary" @@ -140,6 +142,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const branding = await useBranding(); const federationEnabled = computed(() => Boolean(branding.value?.federationEnabled), diff --git a/apps/web/app/components/admin/TorznabRateLimiting.vue b/apps/web/app/components/admin/TorznabRateLimiting.vue index 4ba1af0e..1e9b891a 100644 --- a/apps/web/app/components/admin/TorznabRateLimiting.vue +++ b/apps/web/app/components/admin/TorznabRateLimiting.vue @@ -17,11 +17,13 @@ <!-- Time Window --> <SettingsGroup + :control-id="fid('windowLabel')" :label="$t('admin.torznab.rateLimiting.windowLabel')" :description="$t('admin.torznab.rateLimiting.windowDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('windowLabel')" v-model.number="localWindow" type="number" min="10" @@ -38,11 +40,13 @@ <!-- Search Rate Limit --> <SettingsGroup + :control-id="fid('searchLimitLabel')" :label="$t('admin.torznab.rateLimiting.searchLimitLabel')" :description="$t('admin.torznab.rateLimiting.searchLimitDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('searchLimitLabel')" v-model.number="localSearchLimit" type="number" min="1" @@ -71,11 +75,13 @@ <!-- Download Rate Limit --> <SettingsGroup + :control-id="fid('downloadLimitLabel')" :label="$t('admin.torznab.rateLimiting.downloadLimitLabel')" :description="$t('admin.torznab.rateLimiting.downloadLimitDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('downloadLimitLabel')" v-model.number="localDownloadLimit" type="number" min="1" @@ -143,6 +149,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + interface TorznabConfig { enabled: boolean; rateLimitSearch: number; diff --git a/apps/web/app/components/security/TwoFactorLoginStep.vue b/apps/web/app/components/security/TwoFactorLoginStep.vue index 78f4a98f..3a9ae658 100644 --- a/apps/web/app/components/security/TwoFactorLoginStep.vue +++ b/apps/web/app/components/security/TwoFactorLoginStep.vue @@ -31,8 +31,13 @@ <!-- TOTP panel ----------------------------------------------- --> <div v-if="active === 'totp'" class="step-panel"> - <label class="step-label">{{ $t('security.twoFactorLogin.totpLabel') }}</label> + <!-- `for`/`id`, sinon le libellé n'est qu'un paragraphe stylé : rien ne + le relie au champ, le lecteur d'écran annonce « saisie de texte » + sans nom, et un clic dessus ne donne pas le focus. Sur le chemin de + connexion, avec un code à six chiffres qui expire. --> + <label class="step-label" :for="totpId">{{ $t('security.twoFactorLogin.totpLabel') }}</label> <input + :id="totpId" v-model="totpCode" inputmode="numeric" maxlength="6" @@ -45,14 +50,16 @@ <!-- Recovery panel ------------------------------------------- --> <div v-else-if="active === 'recovery'" class="step-panel"> - <label class="step-label">{{ $t('security.twoFactorLogin.recoveryLabel') }}</label> + <label class="step-label" :for="recoveryId">{{ $t('security.twoFactorLogin.recoveryLabel') }}</label> <input + :id="recoveryId" v-model="recoveryCode" class="step-input" placeholder="XXXX-XXXXXX" + :aria-describedby="recoveryHintId" @keydown.enter="submitTotp" /> - <p class="step-hint"> + <p :id="recoveryHintId" class="step-hint"> <Icon name="ph:info-bold" /> {{ $t('security.twoFactorLogin.recoveryHint') }} </p> @@ -129,6 +136,13 @@ const emit = defineEmits<{ const { t } = useI18n(); +// `useId()` et non un compteur ou `Math.random()` : l'écran est rendu côté +// serveur, et deux valeurs différentes casseraient l'hydratation du lien +// `for` / `id` — le défaut corrigé dans `Modal.vue`. +const totpId = useId(); +const recoveryId = useId(); +const recoveryHintId = useId(); + const active = ref<Method>(props.methods[0] ?? 'totp'); const totpCode = ref(''); const recoveryCode = ref(''); diff --git a/apps/web/app/components/settings/NotificationsSection.vue b/apps/web/app/components/settings/NotificationsSection.vue index 978be149..cbf2df59 100644 --- a/apps/web/app/components/settings/NotificationsSection.vue +++ b/apps/web/app/components/settings/NotificationsSection.vue @@ -360,8 +360,16 @@ </span> </div> <span class="cat-bulk-wrap" @click.stop> + <!-- `aria-label` et non `for`/`id` : ces listes sont rendues + dans une boucle, et leur nom n'est pas un libellé fixe mais + la ligne qui les porte. Sans lui, un lecteur d'écran + parcourant ce panneau annonçait une trentaine de « listes » + indiscernables. --> <select class="cat-bulk" + :aria-label="$t('settings.notifications.bulkAria', { + category: $t(`settings.notifications.categories.${cat.key}`), + })" :value="catBulkValue(cat)" @change="catBulkApply(cat, ($event.target as HTMLSelectElement).value)" > @@ -396,6 +404,9 @@ <span>{{ routing[type] ? $t(channelLabelOf(routing[type])) : $t('settings.notifications.routeNone') }}</span> </span> <select + :aria-label="$t('settings.notifications.routeAria', { + event: $t(`notifications.types.${type}.title`), + })" :value="routing[type] ?? ''" @change="setRoute(type, ($event.target as HTMLSelectElement).value || null)" > diff --git a/apps/web/app/composables/useFieldIds.ts b/apps/web/app/composables/useFieldIds.ts new file mode 100644 index 00000000..e3a72035 --- /dev/null +++ b/apps/web/app/composables/useFieldIds.ts @@ -0,0 +1,27 @@ +/** + * Des identifiants de champ stables, un seul appel par composant. + * + * Ce qui manquait à une bonne partie des formulaires, c'est le `for` : le motif + * dominant était `<label class="…">Texte</label>` suivi d'un `<input>`, sans + * `for` ni imbrication. Un `<label>` qui ne désigne rien n'est qu'un paragraphe + * stylé — le lecteur d'écran annonce « saisie de texte » sans nom, et un clic + * sur le libellé ne donne pas le focus au champ. + * + * La raison pour laquelle personne ne l'ajoutait est le coût : un formulaire de + * vingt champs demandait vingt `useId()` et vingt constantes. Ici, un seul + * appel donne un préfixe, et chaque champ se nomme par son sens. + * + * `useId()` et non un compteur ou `Math.random()` : les formulaires sont rendus + * côté serveur, et deux valeurs différentes casseraient le lien `for` / `id` + * pendant l'hydratation — le défaut corrigé dans `Modal.vue`. + * + * ```vue + * const fid = useFieldIds(); + * <label :for="fid('title')">…</label> + * <input :id="fid('title')"> + * ``` + */ +export function useFieldIds(): (name: string) => string { + const base = useId(); + return (name: string) => `${base}-${name}`; +} From 40199bd2602c221553939982ddf992101d752517 Mon Sep 17 00:00:00 2001 From: dim145 <dimitri.bleach@gmail.com> Date: Thu, 3 Sep 2026 00:31:16 +0200 Subject: [PATCH 86/96] fix(web): document.write on the recovery-codes screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title, intro and the codes themselves were concatenated into raw HTML for a `window.open` document. Not exploitable today — the i18n catalogue ships with the build and the codes come from the API — but it was the application's only raw-HTML sink, on its most sensitive screen, with no barrier if translations ever became operator-editable. `createElement` and `textContent` cost five lines. The print-stylesheet test asserted the old shape; it now checks the new one AND forbids `document.write` coming back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../components/security/RecoveryCodesView.vue | 69 ++++++++++++++----- apps/web/test/printStylesheet.test.ts | 41 +++++++---- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/apps/web/app/components/security/RecoveryCodesView.vue b/apps/web/app/components/security/RecoveryCodesView.vue index faff4370..5c4492d4 100644 --- a/apps/web/app/components/security/RecoveryCodesView.vue +++ b/apps/web/app/components/security/RecoveryCodesView.vue @@ -68,10 +68,33 @@ function print() { // font, easy to drop in a paper safe. const w = window.open('', 'rc-print'); if (!w) return; - const list = props.codes.map((c) => `<li><code>${c}</code></li>`).join(''); + const doc = w.document; const title = t('security.recoveryCodes.printTitle'); const intro = t('security.recoveryCodes.printIntro'); + // `createElement` + `textContent`, pas `document.write`. + // + // C'était le seul endroit de l'application qui composait du HTML par + // concaténation, et il le faisait avec trois valeurs interpolées — le titre + // traduit, l'intro traduite, et les codes eux-mêmes. Rien n'est exploitable + // aujourd'hui (les traductions sont dans le dépôt, les codes sont + // alphanumériques), mais la garantie ne tient qu'à ces deux faits, sur + // l'écran où une injection coûterait le plus cher. Un nœud de texte ne se + // parse pas : la garantie devient structurelle. + // + // La fenêtre est nommée, donc réutilisée d'une impression à l'autre : on + // vide ce qu'une précédente y a laissé plutôt que d'empiler deux jeux de + // codes. + doc.head.replaceChildren(); + doc.body.replaceChildren(); + + const meta = doc.createElement('meta'); + meta.setAttribute('charset', 'utf-8'); + doc.head.append(meta); + // Passe par le nœud `<title>`, jamais par du balisage. + doc.title = title; + + const style = doc.createElement('style'); // The nonce, because this document inherits the OPENER's CSP. // // `window.open('')` yields an `about:blank` that carries the policy of the @@ -85,25 +108,33 @@ function print() { // content ATTRIBUTE, so `getAttribute('nonce')` returns nothing while the // `.nonce` property still holds the value. const nonce = document.querySelector<HTMLScriptElement>('script[nonce]')?.nonce ?? ''; - const nonceAttr = nonce ? ` nonce="${nonce}"` : ''; + if (nonce) style.setAttribute('nonce', nonce); + style.textContent = + 'body{font-family:ui-monospace,monospace;padding:2rem;color:#111}' + + 'h1{font-size:1.2rem}ul{list-style:none;padding:0;display:grid;grid-template-columns:repeat(2,1fr);gap:.5rem 1.5rem}' + + // A literal, and it has to be: this stylesheet goes into a document + // opened by `window.open`, where none of the application's custom + // properties exist. `calc(.06em * var(--tracking-scale))` there is + // invalid at computed-value time, so the tracking silently became + // `normal`. The scale substitution reached this string because it looks + // like CSS; the print window is the one place it must not. + 'code{font-size:1.05rem;letter-spacing:.06em}'; + doc.head.append(style); + + const h1 = doc.createElement('h1'); + h1.textContent = title; + const p = doc.createElement('p'); + p.textContent = intro; + const ul = doc.createElement('ul'); + for (const c of props.codes) { + const li = doc.createElement('li'); + const code = doc.createElement('code'); + code.textContent = c; + li.append(code); + ul.append(li); + } + doc.body.append(h1, p, ul); - w.document.write( - `<!doctype html><meta charset="utf-8"><title>${title}` + - `body{font-family:ui-monospace,monospace;padding:2rem;color:#111}` + - `h1{font-size:1.2rem}ul{list-style:none;padding:0;display:grid;grid-template-columns:repeat(2,1fr);gap:.5rem 1.5rem}` + - // A literal, and it has to be: this stylesheet goes into a document - // opened by `window.open`, where none of the application's custom - // properties exist. `calc(.06em * var(--tracking-scale))` there is - // invalid at computed-value time, so the tracking silently became - // `normal`. The scale substitution reached this string because it looks - // like CSS; the print window is the one place it must not. - `code{font-size:1.05rem;letter-spacing:.06em}` + - `` + - `

${title}

` + - `

${intro}

` + - `
    ${list}
` - ); - w.document.close(); w.focus(); w.print(); } diff --git a/apps/web/test/printStylesheet.test.ts b/apps/web/test/printStylesheet.test.ts index fc19ffdb..df36f660 100644 --- a/apps/web/test/printStylesheet.test.ts +++ b/apps/web/test/printStylesheet.test.ts @@ -30,26 +30,25 @@ const SOURCE = readFileSync( ); /** - * The `` written into the popup, extracted from the JS string - * rather than from the SFC — the file has both. + * The stylesheet handed to the popup, extracted from the JS string rather than + * from the SFC — the file has both. + * + * It is no longer wrapped in a literal ``: the document is + * built with `createElement` now, so the sheet is whatever is assigned to the + * element's `textContent`. That removed the very ambiguity described above — + * the string no longer looks like a single-file-component style block — but + * the property it must hold is unchanged, so the guard stays. */ function printStylesheet(): string { // Comments stripped first. The concatenation carries a `//` note explaining // why these values must stay literal, and that note necessarily QUOTES the // broken form — so a guard reading the raw text flags the explanation of the // bug as the bug. Which it duly did. - const call = SOURCE.slice(SOURCE.indexOf('w.document.write(')) - .replace(/^\s*\/\/.*$/gm, ''); - expect(call, 'the print window call moved or was renamed').not.toHaveLength(0); - // ``: the tag carries the CSP nonce now, because the - // window `window.open` returns inherits this page's policy and the tightened - // `style-src-elem` blocks an unnonced stylesheet outright. So the opening tag - // reads `` in source and a literal match finds nothing. - const open = call.indexOf('', open); - expect(open, 'no