diff --git a/app/services/nzb_service.ts b/app/services/nzb_service.ts index 90c1b19..450ad19 100644 --- a/app/services/nzb_service.ts +++ b/app/services/nzb_service.ts @@ -3,13 +3,24 @@ import db from '@adonisjs/lucid/services/db' import { decodeChunk, type ChunkFile } from '#lib/chunks/index' import { buildNzb } from '#lib/nzb/index' +/** Where a known password came from, the most trusted first: the spot's NZB meta, its title, its text. */ +const PASSWORD_SOURCES = ['nzb', 'spot-title', 'spot-text'] + /** * A release's files and NZB (REBUILD.md, part 9), from its chunks in the * chunk store: every chunk decoded, its files in name order. A file stored * twice (the same name and first article, e.g. a range read again) is taken - * once. + * once. A known password (release_passwords) goes into the NZB's meta, where + * SABnzbd and NZBGet take it for unpacking. */ class NzbService { + /** The release's known passwords, the most trusted first. */ + async passwords(releaseId: number): Promise<{ password: string; source: string }[]> { + const rows = await db.from('release_passwords').where('release_id', releaseId).orderBy('created_at').select('password', 'source') + const rank = (source: string) => (PASSWORD_SOURCES.indexOf(source) + PASSWORD_SOURCES.length + 1) % (PASSWORD_SOURCES.length + 1) + return rows.sort((a, b) => rank(a.source) - rank(b.source)).map((r) => ({ password: r.password, source: r.source })) + } + async files(releaseId: number): Promise { const chunks = await db.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload') const files = new Map() @@ -22,10 +33,11 @@ class NzbService { async build(releaseId: number): Promise<{ name: string; xml: string } | null> { const release = await db.from('releases').where('id', releaseId).select('name', 'source').first() if (!release) return null + const password = (await this.passwords(releaseId))[0]?.password // A release a spot made of its own (no files matched the header pass's): the spot's NZB as read. if (release.source === 'spot') { const spot = await db.from('spots').where('release_id', releaseId).whereNotNull('nzb').select('nzb').first() - if (spot) return { name: release.name, xml: gunzipSync(spot.nzb).toString('utf8') } + if (spot) return { name: release.name, xml: withPassword(gunzipSync(spot.nzb).toString('utf8'), password) } } const xml = buildNzb( (await this.files(releaseId)).map((f) => ({ @@ -35,10 +47,18 @@ class NzbService { groups: f.groups, segments: f.segments.map(([number, id, size]) => ({ number, id, size })), })), - { title: release.name } + { title: release.name, password } ) return { name: release.name, xml } } } +/** A read NZB with the password in its meta, when it has none (the spot's title or text named it, not its NZB). */ +export function withPassword(xml: string, password: string | undefined): string { + if (!password || /${password.replace(/&/g, '&').replace(//g, '>')}` + if (/]*>/i.test(xml)) return xml.replace(/]*>/i, (head) => `${head}${meta}`) + return xml.replace(/]*>/i, (nzb) => `${nzb}${meta}`) +} + export default new NzbService() diff --git a/app/services/search_service.ts b/app/services/search_service.ts index 598a005..d493212 100644 --- a/app/services/search_service.ts +++ b/app/services/search_service.ts @@ -57,6 +57,8 @@ export interface ReleaseSummary { settled: boolean /** The prober found encrypted archive entries or headers; null: not probed (yet). */ passworded: boolean | null + /** A password is known (release_passwords): it's in the NZB. */ + passwordKnown: boolean /** What the prober found inside (an archive entry, a par2 set's file, a yEnc name), when it says more than the name. */ innerName: string | null } @@ -163,6 +165,7 @@ class SearchService { const [{ rows }, { rows: counted }] = await Promise.all([ db.rawQuery( `SELECT r.id, r.name, r.source, r.poster, r.groups, r.posted_at, r.file_total, r.files, r.bytes, r.readable, r.complete, r.category, r.settled_at, r.passworded, r.inner_name, + EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id) AS password_known, release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete) AS final FROM ${source.from} WHERE ${source.where} ORDER BY ${order} LIMIT ? OFFSET ?`, [...source.bindings, ...bindings, perPage, offset] @@ -192,6 +195,7 @@ class SearchService { category: r.category, settled: r.settled_at !== null, passworded: r.passworded, + passwordKnown: r.password_known, innerName: r.inner_name, })), } @@ -201,10 +205,11 @@ class SearchService { async release(id: number) { const r = await db.from('releases').where('id', id).first() if (!r) return null - const [files, { rows: inspections }, { rows: finality }] = await Promise.all([ + const [files, { rows: inspections }, { rows: finality }, passwords] = await Promise.all([ NzbService.files(id), db.rawQuery(`SELECT kind, result, created_at FROM release_inspections WHERE release_id = ? ORDER BY candidate_id`, [id]), db.rawQuery(`SELECT release_final(?, ?, ?, ?, ?, ?) AS final`, [r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete]), + NzbService.passwords(id), ]) return { id: Number(r.id), @@ -226,6 +231,8 @@ class SearchService { lastSeenAt: r.last_seen_at, settledAt: r.settled_at, passworded: r.passworded, + /** Known passwords (spots, their NZBs), the most trusted first; the first is in the NZB. */ + passwords, innerName: r.inner_name, inspectedAt: r.inspected_at, /** What the prober read from the release's articles (lib/probe Inspection), by probe kind. */ diff --git a/public/app.js b/public/app.js index eca044a..300c4ae 100644 --- a/public/app.js +++ b/public/app.js @@ -17,6 +17,12 @@ const SOURCE_LABELS = { subject: 'Subject', post: 'Post id', } +/** Where a known password came from (release_passwords.source). */ +const PASSWORD_SOURCES = { + nzb: "from the spot's NZB", + 'spot-title': "from the spot's title", + 'spot-text': "from the spot's text", +} /** Newznab categories (lib/newznab/categories.ts). */ const CATEGORIES = [ [2000, 'Movies', [[2030, 'SD'], [2040, 'HD'], [2045, 'UHD']]], @@ -220,7 +226,8 @@ function releaseRow(release) { if (release.complete) badges.append(badge(files, 'good', 'Complete')) else if (release.fileTotal) badges.append(badge(`${release.fileCount}/${release.fileTotal}`, 'warn', 'Files present / announced')) else badges.append(badge(files)) - if (release.passworded) badges.append(badge('Password', 'bad', 'Its archive is encrypted: a password is needed')) + if (release.passworded && release.passwordKnown) badges.append(badge('Password known', 'warn', 'Its archive is encrypted; the password is in the NZB')) + else if (release.passworded) badges.append(badge('Password', 'bad', 'Its archive is encrypted: a password is needed')) if (!release.settled) badges.append(badge('Collecting', 'accent', 'Recently seen: more files may still arrive')) if (release.category && release.category !== 8010) badges.append(badge(categoryLabel(release.category), '', 'Category (from the name)')) if (release.source && release.source !== 'stem' && release.source !== 'subject') { @@ -347,7 +354,12 @@ function renderDetail(release) { if (!release.settledAt) { notices.push(el('div', { class: 'notice notice-warn', text: 'Recently seen: more files of this release may still arrive. The NZB has what is indexed so far.' })) } - if (release.passworded) { + const passwords = release.passwords ?? [] + if (passwords.length) { + notices.push(el('div', { class: 'notice notice-warn' }, + el('span', { text: `${passwords.length === 1 ? 'Password' : 'Passwords'} (the first is in the NZB): ` }), + ...passwords.map((p, i) => el('span', {}, i ? ', ' : '', el('code', { text: p.password }), ` (${PASSWORD_SOURCES[p.source] ?? p.source})`)))) + } else if (release.passworded) { notices.push(el('div', { class: 'notice notice-bad', text: 'Passworded: its archive has encrypted entries or headers. The password is not in the index.' })) } if (!release.readable && !release.innerName) { diff --git a/tests/functional/releases_api.spec.ts b/tests/functional/releases_api.spec.ts index eb0f23f..e8f5c63 100644 --- a/tests/functional/releases_api.spec.ts +++ b/tests/functional/releases_api.spec.ts @@ -113,4 +113,27 @@ test.group('Releases API', (group) => { assert.include(feed, 'Hidden.Gem.2023') assert.match(feed, /Other Movie 2024[^]*?name="password" value="1"/) }) + + test('a known password: in the NZB (the most trusted source first), the details and the list', async ({ client, assert }) => { + const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id') + const [n] = await db.table('nodes').insert({ name: 'api-node' }).returning('id') + const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: n.id }).returning('id') + const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } }) + indexer.add(file('Locked.Movie.2024.1080p.BluRay.x264.part1.rar', 'Locked.Movie [1/1]', 2, 100), 1001) + indexer.finish() + await BatchStore.store(Number(r.id), Number(n.id), indexer.take()) + const movie = await db.from('releases').firstOrFail() + await db.from('releases').where('id', movie.id).update({ passworded: true }) + await db.table('release_passwords').multiInsert([ + { release_id: movie.id, password: 'from-text', source: 'spot-text' }, + { release_id: movie.id, password: 'from-nzb', source: 'nzb' }, + ]) + + const nzb = parseNzb((await client.get(`/api/v1/releases/${movie.id}/nzb`)).text())! + assert.equal(nzb.meta.password, 'from-nzb') + const detail = (await client.get(`/api/v1/releases/${movie.id}`)).body() as any + assert.deepEqual(detail.passwords, [{ password: 'from-nzb', source: 'nzb' }, { password: 'from-text', source: 'spot-text' }]) + const list = (await client.get('/api/v1/releases?q=locked movie')).body() as { releases: { passwordKnown: boolean }[] } + assert.isTrue(list.releases[0].passwordKnown) + }) }) diff --git a/tests/unit/nzb/with_password.spec.ts b/tests/unit/nzb/with_password.spec.ts new file mode 100644 index 0000000..55586ae --- /dev/null +++ b/tests/unit/nzb/with_password.spec.ts @@ -0,0 +1,15 @@ +import { test } from '@japa/runner' +import { withPassword } from '#services/nzb_service' +import { parseNzb } from '#lib/nzb/index' + +const NZB = (head: string) => + `${head}a.b.xa@x` + +test.group('nzb / a read NZB with the password', () => { + test('added into its head, or a head made; one it has already is kept', ({ assert }) => { + assert.equal(parseNzb(withPassword(NZB('x'), 'p<&>w'))!.meta.password, 'p<&>w') + assert.equal(parseNzb(withPassword(NZB(''), 'pw'))!.meta.password, 'pw') + assert.equal(parseNzb(withPassword(NZB('own'), 'pw'))!.meta.password, 'own') + assert.equal(withPassword(NZB(''), undefined), NZB('')) + }) +})