Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
21ab158
test(e2e): the stack had no tracker, so every swarm rendered empty
Dim145 Sep 4, 2026
22e9fdd
feat(web): rebuild the torrent detail page around the reader's questions
Dim145 Sep 4, 2026
bbd076b
fix(torrents): the tag filter never reached the grouped view
Dim145 Sep 4, 2026
53821e8
style(web): the torrent page had no scale of its own
Dim145 Sep 4, 2026
e3a5598
refactor(web): a full-width row for one checkbox, next to a back link…
Dim145 Sep 4, 2026
d22d1b1
refactor(web): a grid track does not close when its content ends, so …
Dim145 Sep 4, 2026
7ffef79
fix(web): a flex line breaks on what an item asks for, not on what it…
Dim145 Sep 4, 2026
5b99589
fix(web): four pages scrolled sideways on a phone, and the footer's p…
Dim145 Sep 4, 2026
11b29a7
fix(web/torrent): the section head could not wrap, so its trailing ac…
Dim145 Sep 4, 2026
6f0e43a
fix(api): the abuse counter is per address, and the web container has…
Dim145 Sep 5, 2026
caf0091
feat(web/torrent): a hero band, two columns with the decision pinned,…
Dim145 Sep 5, 2026
6c931a1
fix(web/torrent): without metadata the hero was 480px of nothing — a …
Dim145 Sep 5, 2026
acf9fb2
feat(admin): the Hit & Run thresholds were read by the tracker and th…
Dim145 Sep 5, 2026
a75c80b
feat(web/torrent): the page said what the release was 700px below the…
Dim145 Sep 6, 2026
179a97d
fix(web): the site forced classic scrollbars on every system — 8px of…
Dim145 Sep 6, 2026
c679398
fix(web/torrent): an uploader's "# Title" was a second h1, "No source…
Dim145 Sep 6, 2026
698c6ee
feat(web/torrent): the page takes the colour of the work, and its onl…
Dim145 Sep 6, 2026
51ccf32
fix(web/torrent): the poster relay took any Sec-Fetch-less client, an…
Dim145 Sep 6, 2026
b39f44c
fix(api/metadata): a timed-out lookup was cached as "no such record" …
Dim145 Sep 6, 2026
8100181
feat(web/catalogue): the search page was a table behind a search box …
Dim145 Sep 6, 2026
661782d
fix(search): "frieren" found one release out of nine — a dotted relea…
Dim145 Sep 6, 2026
5b1a55e
feat(api): a text search could only be sorted by age — relevance rank…
Dim145 Sep 7, 2026
94ca8ae
feat(web/catalogue): the whole card opens a work — a chevron, seasons…
Dim145 Sep 7, 2026
6fb64ca
fix(web): backdrop-filter written before its -webkit twin was dropped…
Dim145 Sep 7, 2026
7d60548
feat(api): the catalogue had no time window, and the review found six…
Dim145 Sep 7, 2026
a1ff305
feat(web): a menu on Torrents, and the work cards stop reading as one…
Dim145 Sep 7, 2026
2f21a51
test: the catalogue's predicates were covered by nothing, and the har…
Dim145 Sep 7, 2026
984c95b
fix: the rate limits the last commit claimed were never written, and …
Dim145 Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions apps/api/middleware/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
* Implements request validation, suspicious activity detection, and security headers
*/

import { detectDDoS, isBlacklisted, getClientIP } from '~~/utils/rateLimit';
import {
detectDDoS,
isBlacklisted,
getClientIP,
isInternalOrigin,
} from '~~/utils/rateLimit';
import { eq } from 'drizzle-orm';
import { db } from '@trackarr/db';
import { users, webauthnCredentials } from '@trackarr/db/schema';
Expand Down Expand Up @@ -173,6 +178,24 @@ export default defineEventHandler(async (event) => {
const ip = getClientIP(event);
const userAgent = getHeader(event, 'user-agent') || '';

/*
* Notre propre rendu serveur n'est pas un client.
*
* Le compteur anti-abus est par adresse, et le conteneur web n'en a qu'une
* pour tout le site : QUATRE requêtes par page vue s'y accumulaient
* (mesuré), donc vingt-cinq pages en dix secondes suffisaient à bannir
* l'instance entière. Le filtre est décrit dans `isInternalOrigin` — pair
* socket privé ET aucun en-tête de transfert, deux conditions qu'un client
* d'Internet ne peut pas réunir.
*
* Ce que ça n'exempte PAS : le filtre d'agent, la validation de chemin et de
* paramètres, les bannissements d'adresse, les limites par route et toute
* la suite de l'authentification. Uniquement le compteur grossier et la
* liste noire qu'il alimente — c'est-à-dire exactement ce qui n'a aucun sens
* pour une instance qui se parle à elle-même.
*/
const interne = isInternalOrigin(event);

// Order matters. Everything below is sorted by cost, cheapest first, so a
// flood is dropped as early as possible:
//
Expand Down Expand Up @@ -208,15 +231,16 @@ export default defineEventHandler(async (event) => {
}

// 2. Redis — temporary blacklist, then the abuse counter itself.
if (await isBlacklisted(ip)) {
if (!interne && (await isBlacklisted(ip))) {
console.warn(`[Security] Blocked blacklisted IP: ${ip.slice(0, 8)}...`);
throw createError({ statusCode: 403, message: 'Access denied' });
}

if (
path.startsWith('/api/') ||
path.includes('/announce') ||
path.includes('/scrape')
!interne &&
(path.startsWith('/api/') ||
path.includes('/announce') ||
path.includes('/scrape'))
) {
await detectDDoS(event);
}
Expand Down
114 changes: 114 additions & 0 deletions apps/api/plugins/metadata-warmer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { db, schema } from '@trackarr/db';
import { and, desc, eq, isNotNull, or } from 'drizzle-orm';
import { redis } from '~~/utils/server';
import { withCronLock } from '~~/utils/cronLock';
import {
isMetadataEnabled,
isSourceEnabled,
lookupMetadata,
normalizeSourceId,
type LookupSource,
} from '~~/utils/metadata';
import type { MediaTypeHint } from '~~/utils/metadata/types';

/**
* Préchauffer le cache des œuvres.
*
* Le catalogue ne lit que le cache des métadonnées : une œuvre dont personne
* n'a ouvert la fiche s'affiche par son nom de fichier, sans affiche ni titre.
* Cette tâche comble le trou à petite cadence — UNE recherche amont par tic,
* vingt secondes entre deux par défaut — en partant des torrents les plus
* récents. Ce qu'elle a tenté (trouvé ou non) est marqué sept jours dans un
* ensemble Redis, pour ne pas retaper à la même porte.
*
* Elle respecte les mêmes gardes que la fiche : pas de fournisseur configuré,
* pas d'appel ; une panne amont est déjà mise en cache court par `guarded`.
*/
const INTERVAL_MS = Math.max(5000, parseInt(process.env.METADATA_WARM_INTERVAL_MS || '20000', 10) || 20000);
const FIRST_RUN_DELAY_MS = 45_000;
const CANDIDATES = 300;
/*
* Un marqueur PAR ŒUVRE, et non un ensemble.
*
* `EXPIRE` porte sur la clé entière : avec un `SADD` suivi d'un `EXPIRE`, chaque
* nouveau marquage repoussait le délai de TOUT l'ensemble. Le collecteur
* marquant une œuvre toutes les vingt secondes, l'ensemble n'expirait jamais et
* une œuvre en échec n'était jamais reprise — le défaut qu'on croyait corriger.
*/
const MARK_PREFIX = 'meta:warm:v2:';
const markKey = (key: string) => `${MARK_PREFIX}${key}`;
const DONE_TTL_S = 7 * 86400;
/** Les œuvres tentées sans réponse (amont en panne, dépassement) : on y revient dans l'heure, pas dans la semaine. */
const RETRY_TTL_S = 3600;

type Ref = { source: LookupSource; id: string; hint: MediaTypeHint | undefined };

function refOf(row: { tmdbId: string | null; igdbId: string | null; openlibraryId: string | null }): Ref | null {
if (row.tmdbId) {
const hint = row.tmdbId.startsWith('tv/') ? 'tv' : row.tmdbId.startsWith('movie/') ? 'movie' : undefined;
return { source: 'tmdb', id: row.tmdbId, hint };
}
if (row.igdbId) return { source: 'igdb', id: row.igdbId, hint: 'game' };
if (row.openlibraryId) return { source: 'openlibrary', id: row.openlibraryId, hint: 'book' };
return null;
}

async function tick(): Promise<void> {
if (!isMetadataEnabled()) return;
const rows = await db
.select({ tmdbId: schema.torrents.tmdbId, igdbId: schema.torrents.igdbId, openlibraryId: schema.torrents.openlibraryId })
.from(schema.torrents)
.where(
and(
eq(schema.torrents.moderationStatus, 'accepted'),
eq(schema.torrents.isActive, true),
or(isNotNull(schema.torrents.tmdbId), isNotNull(schema.torrents.igdbId), isNotNull(schema.torrents.openlibraryId)),
),
)
.orderBy(desc(schema.torrents.createdAt))
.limit(CANDIDATES);
const candidates = rows
.map((row) => refOf(row))
.filter((ref): ref is Ref => !!ref)
.map((ref) => ({ ref, key: `${ref.source}:${ref.id}` }));
if (candidates.length === 0) return;
// Une lecture pour toute la fenêtre : chaque marqueur porte son propre délai.
const marks = await redis.mget(...candidates.map((c) => markKey(c.key)));
const mark = (key: string, ttl: number) => redis.set(markKey(key), '1', 'EX', ttl);
for (const [i, { ref, key }] of candidates.entries()) {
if (marks[i]) continue;
if (!isSourceEnabled(ref.source)) {
await mark(key, DONE_TTL_S);
continue;
}
const canonical = await normalizeSourceId(ref.source, ref.id);
if (!canonical) {
await mark(key, DONE_TTL_S);
continue;
}
// « Fait » seulement sur une réponse ; une panne amont marquait l'œuvre
// faite pour sept jours, et rien ne la redemandait avant qu'un membre
// n'ouvre sa fiche. `lookupMetadata` rend null sur amont indisponible
// comme sur 404 : dans le doute, on repasse dans l'heure.
let meta: unknown = null;
try {
meta = await lookupMetadata(ref.source, canonical, ref.hint);
} catch (err) {
console.warn('[MetadataWarmer] lookup failed for', key, ':', (err as Error).message);
}
await mark(key, meta ? DONE_TTL_S : RETRY_TTL_S);
return; // une œuvre par tic : la cadence est la protection des quotas
}
}

export default defineNitroPlugin(() => {
const run = async () => {
try {
await withCronLock('metadata_warmer:lock', 60, tick);
} catch (err) {
console.warn('[MetadataWarmer] tick failed:', (err as Error).message);
}
};
setTimeout(run, FIRST_RUN_DELAY_MS).unref?.();
setInterval(run, INTERVAL_MS).unref?.();
});
22 changes: 22 additions & 0 deletions apps/api/plugins/stats-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ async function writeTorrentStats(
SET seeders = 0, leechers = 0, updated_at = now()
WHERE (seeders <> 0 OR leechers <> 0)
AND updated_at < ${passStartedAt}::timestamptz`);

// L'historique : un point par torrent et par jour, la dernière valeur du
// jour l'emporte. Lu depuis `torrent_stats` et non depuis `perTorrent`,
// pour que les rangées que le balayage ci-dessus vient de METTRE À ZÉRO
// soient enregistrées elles aussi — un essaim qui meurt est précisément
// ce que la courbe doit montrer. `updated_at >= passStartedAt` borne la
// copie à ce que cette passe a touché.
//
// Seulement après une passe COMPLÈTE : un balayage tronqué a vu un
// sous-ensemble arbitraire, et un jour manquant vaut mieux qu'un jour
// faux.
await db.execute(sql`
INSERT INTO torrent_stats_history (info_hash, day, seeders, leechers)
SELECT info_hash, current_date, seeders, leechers
FROM torrent_stats
WHERE updated_at >= ${passStartedAt}::timestamptz
ON CONFLICT (info_hash, day) DO UPDATE
SET seeders = excluded.seeders,
leechers = excluded.leechers`);
await db.execute(sql`
DELETE FROM torrent_stats_history
WHERE day < current_date - 30`);
}
} catch (err) {
// A stale snapshot degrades a range on a collapsed row; it must never
Expand Down
98 changes: 98 additions & 0 deletions apps/api/routes/api/admin/categories/[id]/merge.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, schema } from '@trackarr/db';
import { requireAdminSession } from '~~/utils/adminAuth';
import { validateBody } from '~~/utils/schemas';
import { auditDetail } from '~~/utils/audit';

/**
* POST /api/admin/categories/:id/merge — fondre une catégorie dans une autre.
*
* Un import laisse « Films » et « Movies », « Jeux » et « Games » : deux
* facettes pour une seule chose, et un catalogue qui compte double. Tout ce
* qui pointait la source pointe la cible — torrents, sous-catégories,
* recherches enregistrées, demandes, motifs des règles d'envoi, table de
* correspondance fédérée — puis la source disparaît. Une transaction : pas de
* catalogue à moitié déplacé.
*/
const bodySchema = z.object({ into: z.string().uuid() });

export default defineEventHandler(async (event) => {
await requireAdminSession(event);
const id = getRouterParam(event, 'id');
if (!id || !z.string().uuid().safeParse(id).success) throw createError({ statusCode: 400, message: 'Category ID is required' });
const { into } = await validateBody(event, bodySchema);
if (into === id) throw createError({ statusCode: 400, message: 'A category cannot be merged into itself' });

const [source, target] = await Promise.all([
db.query.categories.findFirst({ where: eq(schema.categories.id, id) }),
db.query.categories.findFirst({ where: eq(schema.categories.id, into) }),
]);
if (!source || !target) throw createError({ statusCode: 404, message: 'Category not found' });
if (target.parentId === id) {
throw createError({ statusCode: 400, message: 'The target is a child of the source; merge the other way round' });
}
// Le drapeau adulte suit la catégorie : fondre une catégorie adulte dans une
// catégorie ordinaire montrerait ses torrents à qui a coupé ce contenu.
if (source.isAdult !== target.isAdult) {
throw createError({ statusCode: 400, message: 'The adult flag differs between the two categories' });
}
// La cible ne descend pas de la source (à toute profondeur : sinon un cycle),
// et une source qui a des enfants ne peut fondre que dans une racine, pour
// que l'arbre garde ses deux niveaux — le listing ne déplie qu'un niveau.
const children = await db.query.categories.findMany({ where: eq(schema.categories.parentId, id), columns: { id: true } });
if (children.length > 0 && target.parentId) {
throw createError({ statusCode: 400, message: 'A category with sub-categories can only be merged into a root category' });
}
// Toute la chaîne, jusqu'à la racine — un compteur de bonds laissait passer
// un arbre plus profond que la limite. Le jeu des visités arrête un cycle
// préexistant sans borne arbitraire.
const walked = new Set<string>();
for (let cursor = target.parentId; cursor && !walked.has(cursor); ) {
if (cursor === id) throw createError({ statusCode: 400, message: 'The target descends from the source' });
walked.add(cursor);
const parent = await db.query.categories.findFirst({ where: eq(schema.categories.id, cursor), columns: { parentId: true } });
cursor = parent?.parentId ?? null;
}

const moved = await db.transaction(async (tx) => {
const torrents = await tx
.update(schema.torrents)
.set({ categoryId: into })
.where(eq(schema.torrents.categoryId, id))
.returning({ id: schema.torrents.id });
await tx.update(schema.categories).set({ parentId: into }).where(eq(schema.categories.parentId, id));
await tx.update(schema.savedSearches).set({ categoryId: into }).where(eq(schema.savedSearches.categoryId, id));
await tx.update(schema.uploadRequests).set({ categoryId: into }).where(eq(schema.uploadRequests.categoryId, id));
// Un motif d'envoi par catégorie (clé primaire) : si la cible a déjà le
// sien, il l'emporte et celui de la source part avec elle ; sinon il suit.
const [targetPattern] = await tx
.select({ categoryId: schema.uploadRuleCategoryPatterns.categoryId })
.from(schema.uploadRuleCategoryPatterns)
.where(eq(schema.uploadRuleCategoryPatterns.categoryId, into))
.limit(1);
if (targetPattern) {
await tx.delete(schema.uploadRuleCategoryPatterns).where(eq(schema.uploadRuleCategoryPatterns.categoryId, id));
} else {
await tx
.update(schema.uploadRuleCategoryPatterns)
.set({ categoryId: into })
.where(eq(schema.uploadRuleCategoryPatterns.categoryId, id));
}
await tx
.update(schema.remoteCategoryMap)
.set({ localCategoryId: into })
.where(eq(schema.remoteCategoryMap.localCategoryId, id));
await tx.delete(schema.categories).where(eq(schema.categories.id, id));
return torrents.length;
});

invalidateAdultCategoryCache();
auditDetail(event, {
action: 'categories.merge',
targetType: 'category',
targetId: id,
changes: { from: source.name, into: target.name, torrentsMoved: moved },
});
return { success: true, moved };
});
30 changes: 30 additions & 0 deletions apps/api/routes/api/admin/search-misses/index.delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, schema } from '@trackarr/db';
import { requireAdminSession } from '~~/utils/adminAuth';
import { auditDetail } from '~~/utils/audit';

/**
* DELETE /api/admin/search-misses — effacer une ligne, ou tout.
*
* Une recherche qu'on a traitée (la release est arrivée, ou n'existe pas)
* sort de la liste ; sans corps, la liste repart de zéro.
*/
const bodySchema = z.object({ query: z.string().trim().min(1).max(200).optional() });

export default defineEventHandler(async (event) => {
await requireAdminSession(event);
const raw = await readBody(event).catch(() => ({}));
const parsed = bodySchema.safeParse(raw ?? {});
if (!parsed.success) throw createError({ statusCode: 400, message: 'query: 1 to 200 characters' });
const body = parsed.data;
const deleted = body.query
? await db.delete(schema.searchMisses).where(eq(schema.searchMisses.query, body.query.toLowerCase())).returning({ q: schema.searchMisses.query })
: await db.delete(schema.searchMisses).returning({ q: schema.searchMisses.query });
auditDetail(event, {
action: 'search_misses.clear',
targetType: 'search_misses',
changes: { query: body.query ?? '*', deleted: deleted.length },
});
return { success: true, deleted: deleted.length };
});
26 changes: 26 additions & 0 deletions apps/api/routes/api/admin/search-misses/index.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { desc } from 'drizzle-orm';
import { z } from 'zod';
import { db, schema } from '@trackarr/db';
import { requireAdminSession } from '~~/utils/adminAuth';
import { validateQuery } from '~~/utils/schemas';

/**
* GET /api/admin/search-misses — ce que les membres cherchent en vain.
*
* Les plus fréquentes d'abord, puis les plus récentes : c'est une liste
* d'acquisition, et le haut de la liste est ce qui manque le plus.
*/
const querySchema = z.object({
limit: z.coerce.number().int().min(1).max(500).default(100),
});

export default defineEventHandler(async (event) => {
await requireAdminSession(event);
const { limit } = validateQuery(event, querySchema);
const items = await db
.select()
.from(schema.searchMisses)
.orderBy(desc(schema.searchMisses.count), desc(schema.searchMisses.lastAt))
.limit(limit);
return { items };
});
Loading