implemented a lot of features

This commit is contained in:
Daan Meijer committed 2026-09-29 00:32:01 +02:00
1 parent 62f1767df3
commit bcb9be5da9
141 files changed
+10705 -385

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
/**
* Content recognition by magic bytes, independent of AdonisJS.
*/
export { sniffContentType, type ContentType } from './sniff.js'
+72
View File
@@ -0,0 +1,72 @@
/**
* Recognises a file type from its first bytes, independent of AdonisJS.
* `nzbe` is an encrypted-NZB container seen on Usenet (LOG.md 2026-09-28 (11)):
* "nzbe", 16 random bytes (salt/IV), then ciphertext.
*/
export type ContentType =
| 'nzbe'
| 'par2'
| 'rar4'
| 'rar5'
| '7z'
| 'zip'
| 'zstd'
| 'gzip'
| 'xz'
| 'matroska'
| 'mp4'
| 'rclone'
| 'iso'
| 'udf'
| 'pdf'
| 'nzb'
| 'jpeg'
| 'png'
| 'mp3'
| 'flac'
| 'avi'
| 'wav'
| 'ogg'
| 'bzip2'
const SIGNATURES: [ContentType, number, number[]][] = [
['nzbe', 0, [0x6e, 0x7a, 0x62, 0x65]],
['par2', 0, [0x50, 0x41, 0x52, 0x32, 0x00, 0x50, 0x4b, 0x54]],
['rar5', 0, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]],
['rar4', 0, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]],
['7z', 0, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]],
['zip', 0, [0x50, 0x4b, 0x03, 0x04]],
['zstd', 0, [0x28, 0xb5, 0x2f, 0xfd]],
['gzip', 0, [0x1f, 0x8b]],
['xz', 0, [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]],
['matroska', 0, [0x1a, 0x45, 0xdf, 0xa3]],
['mp4', 4, [0x66, 0x74, 0x79, 0x70]], // "ftyp" box
['rclone', 0, [0x52, 0x43, 0x4c, 0x4f, 0x4e, 0x45]], // rclone crypt
['pdf', 0, [0x25, 0x50, 0x44, 0x46, 0x2d]],
['jpeg', 0, [0xff, 0xd8, 0xff]],
['png', 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
['mp3', 0, [0x49, 0x44, 0x33]], // "ID3" tag
['flac', 0, [0x66, 0x4c, 0x61, 0x43]], // "fLaC"
['ogg', 0, [0x4f, 0x67, 0x67, 0x53]], // "OggS"
['bzip2', 0, [0x42, 0x5a, 0x68]], // "BZh"
]
export function sniffContentType(data: Buffer): ContentType | null {
for (const [type, offset, bytes] of SIGNATURES) {
if (data.length >= offset + bytes.length && bytes.every((b, i) => data[offset + i] === b)) return type
}
// Disc images, visible when the first part is big enough (sector 16 at 32 KiB):
// ISO 9660 has "CD001"; UDF (DVD/Blu-ray) has BEA01 followed by NSR02/NSR03.
if (data.length >= 0x8006 && data.subarray(0x8001, 0x8006).toString('latin1') === 'CD001') return 'iso'
if (data.length >= 0x8806 && data.subarray(0x8001, 0x8006).toString('latin1') === 'BEA01' &&
/^NSR0[23]$/.test(data.subarray(0x8801, 0x8806).toString('latin1'))) return 'udf'
// RIFF container: AVI or WAV by form type.
if (data.length >= 12 && data.subarray(0, 4).toString('latin1') === 'RIFF') {
const form = data.subarray(8, 12).toString('latin1')
if (form === 'AVI ') return 'avi'
if (form === 'WAVE') return 'wav'
}
const head = data.subarray(0, 512).toString('latin1')
if (/^\s*(<\?xml[^>]*>\s*)?(<!DOCTYPE nzb|<nzb[\s>])/.test(head)) return 'nzb'
return null
}
+40
View File
@@ -0,0 +1,40 @@
/** Newznab categories this indexer assigns (see release_category() in SQL). */
export const CATEGORIES: { id: number; name: string; subcats: { id: number; name: string }[] }[] = [
{ id: 2000, name: 'Movies', subcats: [{ id: 2030, name: 'SD' }, { id: 2040, name: 'HD' }, { id: 2045, name: 'UHD' }] },
{ id: 3000, name: 'Audio', subcats: [{ id: 3010, name: 'MP3' }, { id: 3040, name: 'Lossless' }] },
{ id: 4000, name: 'PC', subcats: [] },
{ id: 5000, name: 'TV', subcats: [{ id: 5030, name: 'SD' }, { id: 5040, name: 'HD' }, { id: 5045, name: 'UHD' }] },
{ id: 7000, name: 'Books', subcats: [{ id: 7020, name: 'Ebook' }] },
{ id: 8000, name: 'Other', subcats: [{ id: 8010, name: 'Misc' }] },
]
/** Parent category of a (sub)category, e.g. 5040 → 5000. */
export function parentCategory(id: number): number {
return Math.floor(id / 1000) * 1000
}
/** "TV > HD" style label. */
export function categoryLabel(id: number): string {
const parent = CATEGORIES.find((c) => c.id === parentCategory(id))
if (!parent) return 'Other'
const sub = parent.subcats.find((s) => s.id === id)
return sub ? `${parent.name} > ${sub.name}` : parent.name
}
/**
* Expands requested categories (`cat=5000,2040`) to the concrete ones to
* match: a parent includes all of its subcategories.
*/
export function expandCategories(requested: number[]): number[] {
const result = new Set<number>()
for (const id of requested) {
const parent = CATEGORIES.find((c) => c.id === id)
if (parent) {
result.add(parent.id)
for (const sub of parent.subcats) result.add(sub.id)
} else {
result.add(id)
}
}
return [...result]
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Newznab API formatting, independent of AdonisJS. See CONCEPT.md (D23).
*/
export { CATEGORIES, categoryLabel, expandCategories, parentCategory } from './categories.js'
export { capsXml, errorXml, resultsXml, type NewznabItem, type NewznabServer } from './xml.js'
+95
View File
@@ -0,0 +1,95 @@
import { create } from 'xmlbuilder2'
import { CATEGORIES, categoryLabel, parentCategory } from './categories.js'
const NEWZNAB_NS = 'http://www.newznab.com/DTD/2010/feeds/attributes/'
export interface NewznabItem {
id: number
title: string
category: number
size: number
files: number
postedAt: Date | null
poster: string | null
groups: string[]
password: boolean
season?: string
episode?: string
}
export interface NewznabServer {
title: string
/** Absolute base URL, e.g. http://homelab:3333 */
baseUrl: string
/** Appended to download links so a client can fetch without extra config. */
apiKey?: string
}
/** Newznab error codes: 100 credentials, 200/201 parameters, 202 function, 300 item. */
export function errorXml(code: number, description: string): string {
return create({ version: '1.0', encoding: 'UTF-8' })
.ele('error', { code: String(code), description })
.end({ prettyPrint: true })
}
export function capsXml(server: NewznabServer, limits: { max: number; default: number }): string {
const caps = create({ version: '1.0', encoding: 'UTF-8' }).ele('caps')
caps.ele('server', { version: '1.0', title: server.title, strapline: 'Usenet indexer', url: `${server.baseUrl}/` })
caps.ele('limits', { max: String(limits.max), default: String(limits.default) })
caps.ele('registration', { available: 'no', open: 'no' })
const searching = caps.ele('searching')
searching.ele('search', { available: 'yes', supportedParams: 'q' })
searching.ele('tv-search', { available: 'yes', supportedParams: 'q,season,ep' })
searching.ele('movie-search', { available: 'yes', supportedParams: 'q' })
searching.ele('audio-search', { available: 'yes', supportedParams: 'q' })
searching.ele('book-search', { available: 'yes', supportedParams: 'q' })
const categories = caps.ele('categories')
for (const category of CATEGORIES) {
const element = categories.ele('category', { id: String(category.id), name: category.name })
for (const sub of category.subcats) element.ele('subcat', { id: String(sub.id), name: sub.name })
}
return caps.end({ prettyPrint: true })
}
export function resultsXml(server: NewznabServer, items: NewznabItem[], offset: number, total: number): string {
const rss = create({ version: '1.0', encoding: 'UTF-8' }).ele('rss', {
'version': '2.0',
'xmlns:atom': 'http://www.w3.org/2005/Atom',
'xmlns:newznab': NEWZNAB_NS,
})
const channel = rss.ele('channel')
channel.ele('title').txt(server.title)
channel.ele('description').txt(`${server.title} API results`)
channel.ele('link').txt(`${server.baseUrl}/`)
channel.ele('newznab:response', { offset: String(offset), total: String(total) })
const key = server.apiKey ? `&apikey=${encodeURIComponent(server.apiKey)}` : ''
for (const item of items) {
const getUrl = `${server.baseUrl}/api?t=get&id=${item.id}${key}`
const pubDate = (item.postedAt ?? new Date()).toUTCString()
const element = channel.ele('item')
element.ele('title').txt(item.title)
element.ele('guid', { isPermaLink: 'false' }).txt(String(item.id))
element.ele('link').txt(getUrl)
element.ele('comments').txt(`${server.baseUrl}/?release=${item.id}`)
element.ele('pubDate').txt(pubDate)
element.ele('category').txt(categoryLabel(item.category))
element.ele('description').txt(item.title)
element.ele('enclosure', { url: getUrl, length: String(item.size), type: 'application/x-nzb' })
const attr = (name: string, value: string | number) =>
element.ele('newznab:attr', { name, value: String(value) })
attr('category', parentCategory(item.category))
if (item.category !== parentCategory(item.category)) attr('category', item.category)
attr('size', item.size)
attr('files', item.files)
attr('grabs', 0)
attr('password', item.password ? 1 : 0)
if (item.poster) attr('poster', item.poster)
if (item.groups.length) attr('group', item.groups[0])
attr('usenetdate', pubDate)
if (item.season) attr('season', item.season)
if (item.episode) attr('episode', item.episode)
}
return rss.end({ prettyPrint: true })
}
+23
View File
@@ -0,0 +1,23 @@
export interface GroupInfo {
count: bigint
first: bigint
last: bigint
name: string
}
/**
* Parses a `211 count first last name` GROUP reply (RFC 3977 §6.1.1).
*
* nntp-js's own `group()` destructures this reply without skipping the status
* code, so its `first`/`last` are off by one field. Pass it `result.resp`.
*/
export function parseGroupResponse(resp: string): GroupInfo {
const match = /^211\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/.exec(resp.trim())
if (!match) throw new Error(`Unexpected GROUP response: ${resp}`)
return {
count: BigInt(match[1]),
first: BigInt(match[2]),
last: BigInt(match[3]),
name: match[4],
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Returns the group names from an Xref header,
* e.g. `news.eweka.nl alt.binaries.test:123 alt.binaries.x:456`.
*/
export function parseXrefGroups(xref: string | undefined): string[] {
if (!xref) return []
return xref
.trim()
.split(/\s+/)
.slice(1)
.map((entry) => entry.slice(0, entry.lastIndexOf(':')))
.filter(Boolean)
}
+54
View File
@@ -0,0 +1,54 @@
import { create } from 'xmlbuilder2'
export interface NzbFile {
/** Name the downloader should use; the real name when it is known. */
name: string
poster: string
/** Posting time in milliseconds. */
date: number
groups: string[]
/** Segment number → message-id (without angle brackets) and article size. */
segments: Record<string, { id: string; size: number }>
}
export interface NzbOptions {
/** Shown by downloaders as the job name. */
title?: string
/** Archive password, when known. */
password?: string
}
/**
* Builds an NZB 1.1 document (newzbin DTD). The subject follows the common
* `"name" yEnc (1/N)` form, which downloaders use to name the file.
*/
export function buildNzb(files: NzbFile[], options: NzbOptions = {}): string {
const root = create({ version: '1.0', encoding: 'UTF-8' })
.dtd({ pubID: '-//newzBin//DTD NZB 1.1//EN', sysID: 'http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd' })
.ele('nzb', { xmlns: 'http://www.newzbin.com/DTD/2003/nzb' })
if (options.title || options.password) {
const head = root.ele('head')
if (options.title) head.ele('meta', { type: 'name' }).txt(options.title)
if (options.password) head.ele('meta', { type: 'password' }).txt(options.password)
}
for (const file of files) {
const numbers = Object.keys(file.segments)
.map(Number)
.sort((a, b) => a - b)
const element = root.ele('file', {
poster: file.poster,
date: String(Math.floor(file.date / 1000)),
subject: `"${file.name}" yEnc (1/${numbers.length})`,
})
const groups = element.ele('groups')
for (const group of file.groups) groups.ele('group').txt(group)
const segments = element.ele('segments')
for (const number of numbers) {
const segment = file.segments[String(number)]
segments.ele('segment', { bytes: String(segment.size), number: String(number) }).txt(segment.id)
}
}
return root.end({ prettyPrint: true })
}
+5
View File
@@ -0,0 +1,5 @@
/**
* NZB generation, independent of AdonisJS.
*/
export { buildNzb, type NzbFile, type NzbOptions } from './build.js'
export { parseNzb, type ParsedNzb, type ParsedNzbFile } from './parse.js'
+72
View File
@@ -0,0 +1,72 @@
export interface ParsedNzbFile {
poster: string
/** Posting time in seconds (as in the NZB). */
date: number
subject: string
groups: string[]
segments: { number: number; bytes: number; id: string }[]
}
export interface ParsedNzb {
/** <head><meta type="..."> values, e.g. name, password, category. */
meta: Record<string, string>
files: ParsedNzbFile[]
}
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
function decode(text: string): string {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
if (entity[0] === '#') {
const code = entity[1].toLowerCase() === 'x' ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10)
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
}
return ENTITIES[entity.toLowerCase()] ?? match
})
}
function attributes(tag: string): Record<string, string> {
const result: Record<string, string> = {}
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) result[m[1]] = decode(m[3] ?? m[4] ?? '')
return result
}
/**
* Parses an NZB (1.0/1.1 and the older 0.9 DTD). A tolerant scanner rather
* than a full XML parser: NZBs are flat, and this never resolves DTDs or
* external entities. Returns null when there is no <nzb> root.
*/
export function parseNzb(xml: string, limits = { maxFiles: 20_000 }): ParsedNzb | null {
if (!/<nzb[\s>]/i.test(xml)) return null
const meta: Record<string, string> = {}
const head = /<head>([\s\S]*?)<\/head>/i.exec(xml)?.[1] ?? ''
for (const m of head.matchAll(/<meta\s([^>]*)>([\s\S]*?)<\/meta>/gi)) {
const type = attributes(m[1]).type
if (type) meta[type.toLowerCase()] = decode(m[2].trim())
}
const files: ParsedNzbFile[] = []
for (const m of xml.matchAll(/<file\s([^>]*)>([\s\S]*?)<\/file>/gi)) {
if (files.length >= limits.maxFiles) break
const attrs = attributes(m[1])
const body = m[2]
const groups = [...body.matchAll(/<group>([^<]*)<\/group>/gi)].map((g) => decode(g[1].trim())).filter(Boolean)
const segments = [...body.matchAll(/<segment\s([^>]*)>([^<]*)<\/segment>/gi)]
.map((s) => {
const a = attributes(s[1])
return { number: Number(a.number), bytes: Number(a.bytes) || 0, id: decode(s[2].trim()).replace(/^<|>$/g, '') }
})
.filter((s) => Number.isInteger(s.number) && s.number > 0 && s.id)
.sort((x, y) => x.number - y.number)
if (segments.length === 0) continue
files.push({
poster: attrs.poster ?? '',
date: Number(attrs.date) || 0,
subject: attrs.subject ?? '',
groups,
segments,
})
}
return { meta, files }
}
+5
View File
@@ -0,0 +1,5 @@
/**
* PAR2 parsing, independent of AdonisJS. Used to recover the real filenames
* and sizes of obfuscated posts. See CONCEPT.md (D16).
*/
export { parsePar2, isPar2, type Par2File, type Par2Set } from './parse.js'
+74
View File
@@ -0,0 +1,74 @@
import { createHash } from 'node:crypto'
export interface Par2File {
/** File ID (hex): MD5 of md5_16k, length and name, as defined by PAR2. */
fileId: string
/** MD5 of the whole file (hex). */
md5: string
/** MD5 of the first 16 KiB (hex). */
md5_16k: string
length: number
name: string
}
export interface Par2Set {
/** Recovery set ID (hex), shared by every packet of one par2 set. */
recoverySetId: string
files: Par2File[]
}
const MAGIC = Buffer.from('PAR2\0PKT', 'latin1')
const HEADER_SIZE = 64
const FILE_DESC = Buffer.from('PAR 2.0\0FileDesc', 'latin1')
export function isPar2(data: Buffer): boolean {
return data.subarray(0, MAGIC.length).equals(MAGIC)
}
/**
* Reads the FileDesc packets of a par2 file (index or recovery volume).
* Packets with a bad length or checksum are skipped; parsing resumes at the
* next magic sequence. Returns null when no valid FileDesc packet is found.
*/
export function parsePar2(data: Buffer): Par2Set | null {
const files = new Map<string, Par2File>()
let recoverySetId: string | undefined
let offset = data.indexOf(MAGIC)
while (offset !== -1 && offset + HEADER_SIZE <= data.length) {
const length = Number(data.readBigUInt64LE(offset + 8))
const valid =
length >= HEADER_SIZE &&
length % 4 === 0 &&
offset + length <= data.length &&
createHash('md5')
.update(data.subarray(offset + 32, offset + length))
.digest()
.equals(data.subarray(offset + 16, offset + 32))
if (!valid) {
offset = data.indexOf(MAGIC, offset + 1)
continue
}
const setId = data.subarray(offset + 32, offset + 48).toString('hex')
const type = data.subarray(offset + 48, offset + 64)
if (type.equals(FILE_DESC) && (!recoverySetId || recoverySetId === setId)) {
recoverySetId = setId
const body = data.subarray(offset + HEADER_SIZE, offset + length)
const fileId = body.subarray(0, 16).toString('hex')
files.set(fileId, {
fileId,
md5: body.subarray(16, 32).toString('hex'),
md5_16k: body.subarray(32, 48).toString('hex'),
length: Number(body.readBigUInt64LE(48)),
// The name is padded with NULs to a multiple of 4 bytes.
name: body.subarray(56).toString('utf8').replace(/\0+$/, ''),
})
}
offset += length
}
if (!recoverySetId || files.size === 0) return null
return { recoverySetId, files: [...files.values()] }
}
+5
View File
@@ -0,0 +1,5 @@
/**
* RAR (4.x and 5.0) header parsing, independent of AdonisJS. Lists archive
* contents from the first segment of a first volume. See CONCEPT.md (D19).
*/
export { parseRarHeaders, rarFormat, decodeRar4Name, storedEntryData, type RarInfo, type RarEntry } from './parse.js'
+333
View File
@@ -0,0 +1,333 @@
import { crc32 } from 'node:zlib'
export interface RarEntry {
name: string
/** Unpacked size in bytes (null when the archiver did not record it). */
size: number | null
packedSize: number
directory: boolean
/** The file's data is password protected. */
encrypted: boolean
/** Continues from the previous volume. */
splitBefore: boolean
/** Continues in the next volume. */
splitAfter: boolean
/** Stored without compression, so the data can be copied out as is. */
stored: boolean
/** Offset of the entry's (packed) data in the parsed buffer. */
dataOffset: number
}
export interface RarInfo {
format: 'rar4' | 'rar5'
/** Part of a multi-volume set. */
volume: boolean
/** First volume of the set (null when the format version does not say). */
firstVolume: boolean | null
solid: boolean
/** File names are encrypted too; nothing can be listed without the password. */
headersEncrypted: boolean
entries: RarEntry[]
/** The buffer ended before the end-of-archive header (normal for one segment). */
truncated: boolean
}
const RAR4_MARKER = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00])
const RAR5_MARKER = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00])
export function rarFormat(data: Buffer): 'rar4' | 'rar5' | null {
if (data.subarray(0, RAR5_MARKER.length).equals(RAR5_MARKER)) return 'rar5'
if (data.subarray(0, RAR4_MARKER.length).equals(RAR4_MARKER)) return 'rar4'
return null
}
/**
* Lists a RAR archive from its headers only, so the first segment of a first
* volume is enough. Returns null when the data is not a RAR archive.
*/
export function parseRarHeaders(data: Buffer): RarInfo | null {
const format = rarFormat(data)
if (format === 'rar4') return parseRar4(data)
if (format === 'rar5') return parseRar5(data)
return null
}
// --- RAR 4.x (RAR 1.5 – 4.x archive format) -------------------------------
const R4_MAIN = 0x73
const R4_FILE = 0x74
const R4_END = 0x7b
const R4_LONG_BLOCK = 0x8000
function parseRar4(data: Buffer): RarInfo {
const info: RarInfo = {
format: 'rar4',
volume: false,
firstVolume: null,
solid: false,
headersEncrypted: false,
entries: [],
truncated: true,
}
let p = RAR4_MARKER.length
while (p + 7 <= data.length) {
const headCrc = data.readUInt16LE(p)
const type = data[p + 2]
const flags = data.readUInt16LE(p + 3)
const size = data.readUInt16LE(p + 5)
if (size < 7 || p + size > data.length) break
if (type === R4_END) {
info.truncated = false
break
}
let next = p + size + (flags & R4_LONG_BLOCK && size >= 11 ? data.readUInt32LE(p + 7) : 0)
if (type === R4_MAIN) {
info.volume = Boolean(flags & 0x0001)
info.solid = Boolean(flags & 0x0008)
info.firstVolume = info.volume ? Boolean(flags & 0x0100) : true
if (flags & 0x0080) {
info.headersEncrypted = true
break
}
} else if (type === R4_FILE) {
if (size < 32 || (crc32(data.subarray(p + 2, p + size)) & 0xffff) !== headCrc) break
const large = Boolean(flags & 0x0100)
let packed = data.readUInt32LE(p + 7)
let unpacked = data.readUInt32LE(p + 11)
const nameSize = data.readUInt16LE(p + 26)
let nameStart = p + 32
if (large) {
packed += data.readUInt32LE(p + 32) * 2 ** 32
unpacked += data.readUInt32LE(p + 36) * 2 ** 32
nameStart = p + 40
}
const rawName = data.subarray(nameStart, nameStart + nameSize)
info.entries.push({
name: flags & 0x0200 ? decodeRar4Name(rawName) : rawName.toString('latin1'),
// 0xffffffff with LHD_LARGE unset means "unknown" in some archivers.
size: unpacked === 0xffffffff && !large ? null : unpacked,
packedSize: packed,
directory: (flags & 0x00e0) === 0x00e0,
encrypted: Boolean(flags & 0x0004),
splitBefore: Boolean(flags & 0x0001),
splitAfter: Boolean(flags & 0x0002),
stored: data[p + 25] === 0x30,
dataOffset: p + size,
})
next = p + size + packed
}
if (next <= p) break
p = next
}
// Archivers before RAR 3.0 don't set the first-volume flag; a first
// volume is one whose first file does not continue from a previous one.
if (info.volume && !info.firstVolume && info.entries.length > 0 && !info.entries[0].splitBefore) {
info.firstVolume = true
}
return info
}
/**
* RAR 4 stores Unicode names as "ASCII\0<compressed UTF-16>", where the
* compressed part refers back to the ASCII bytes (unrar's DecodeFileName).
*/
export function decodeRar4Name(raw: Buffer): string {
const zero = raw.indexOf(0)
if (zero === -1) return raw.toString('utf8')
const ascii = raw.subarray(0, zero)
const enc = raw.subarray(zero + 1)
if (enc.length === 0) return ascii.toString('latin1')
const out: number[] = []
const highByte = enc[0]
let encPos = 1
let flags = 0
let flagBits = 0
while (encPos < enc.length) {
if (flagBits === 0) {
flags = enc[encPos++]
flagBits = 8
}
switch (flags >> 6) {
case 0:
out.push(enc[encPos++])
break
case 1:
out.push(enc[encPos++] + (highByte << 8))
break
case 2:
out.push(enc[encPos] + (enc[encPos + 1] << 8))
encPos += 2
break
case 3: {
let length = enc[encPos++]
if (length & 0x80) {
const correction = enc[encPos++]
for (length = (length & 0x7f) + 2; length > 0 && out.length < ascii.length; length--) {
out.push(((ascii[out.length] + correction) & 0xff) + (highByte << 8))
}
} else {
for (length += 2; length > 0 && out.length < ascii.length; length--) {
out.push(ascii[out.length])
}
}
break
}
}
flags = (flags << 2) & 0xff
flagBits -= 2
}
return String.fromCharCode(...out)
}
// --- RAR 5.0 ----------------------------------------------------------------
const R5_MAIN = 1
const R5_FILE = 2
const R5_ENCRYPTION = 4
const R5_END = 5
/** Reads a RAR5 variable-length integer: 7 bits per byte, little-endian. */
function vint(data: Buffer, offset: number): { value: number; next: number } | null {
let value = 0
let shift = 1
for (let i = offset; i < data.length && i < offset + 10; i++) {
value += (data[i] & 0x7f) * shift
shift *= 128
if (!(data[i] & 0x80)) return { value, next: i + 1 }
}
return null
}
function parseRar5(data: Buffer): RarInfo {
const info: RarInfo = {
format: 'rar5',
volume: false,
firstVolume: null,
solid: false,
headersEncrypted: false,
entries: [],
truncated: true,
}
let p = RAR5_MARKER.length
while (p + 5 <= data.length) {
const headCrc = data.readUInt32LE(p)
const headerSize = vint(data, p + 4)
if (!headerSize || headerSize.value === 0) break
const headerEnd = headerSize.next + headerSize.value
if (headerEnd > data.length) break
if ((crc32(data.subarray(p + 4, headerEnd)) >>> 0) !== headCrc) break
const type = vint(data, headerSize.next)
const flags = type && vint(data, type.next)
if (!type || !flags) break
let cursor = flags.next
let extraSize = 0
let dataSize = 0
if (flags.value & 0x0001) {
const v = vint(data, cursor)
if (!v) break
extraSize = v.value
cursor = v.next
}
if (flags.value & 0x0002) {
const v = vint(data, cursor)
if (!v) break
dataSize = v.value
cursor = v.next
}
if (type.value === R5_END) {
info.truncated = false
break
}
if (type.value === R5_ENCRYPTION) {
info.headersEncrypted = true
break
}
if (type.value === R5_MAIN) {
const archiveFlags = vint(data, cursor)
if (!archiveFlags) break
info.volume = Boolean(archiveFlags.value & 0x0001)
// A volume number is only stored for volumes after the first.
info.firstVolume = info.volume ? !(archiveFlags.value & 0x0002) : true
info.solid = Boolean(archiveFlags.value & 0x0004)
} else if (type.value === R5_FILE) {
const entry = parseRar5File(data, cursor, headerEnd - extraSize, headerEnd, flags.value, dataSize)
if (!entry) break
info.entries.push(entry)
}
const next = headerEnd + dataSize
if (next <= p) break
p = next
}
return info
}
function parseRar5File(
data: Buffer,
start: number,
extraStart: number,
headerEnd: number,
headerFlags: number,
dataSize: number
): RarEntry | null {
const fileFlags = vint(data, start)
const unpacked = fileFlags && vint(data, fileFlags.next)
const attributes = unpacked && vint(data, unpacked.next)
if (!fileFlags || !unpacked || !attributes) return null
let cursor = attributes.next
if (fileFlags.value & 0x0002) cursor += 4 // mtime
if (fileFlags.value & 0x0004) cursor += 4 // data CRC32
const compression = vint(data, cursor)
const hostOs = compression && vint(data, compression.next)
const nameLength = hostOs && vint(data, hostOs.next)
if (!compression || !hostOs || !nameLength) return null
const name = data.subarray(nameLength.next, nameLength.next + nameLength.value).toString('utf8')
// Compression information: bits 7-9 are the method, 0 is "store".
const method = Math.floor(compression.value / 128) & 0x7
// Extra area records: size, type, data. Type 1 is file encryption.
let encrypted = false
let e = extraStart
while (e < headerEnd) {
const recordSize = vint(data, e)
const recordType = recordSize && vint(data, recordSize.next)
if (!recordSize || !recordType || recordSize.value === 0) break
if (recordType.value === 0x01) encrypted = true
e = recordSize.next + recordSize.value
}
return {
name,
size: fileFlags.value & 0x0008 ? null : unpacked.value,
packedSize: dataSize,
directory: Boolean(fileFlags.value & 0x0001),
encrypted,
splitBefore: Boolean(headerFlags & 0x0008),
splitAfter: Boolean(headerFlags & 0x0010),
stored: method === 0,
dataOffset: headerEnd,
}
}
/**
* The data of an entry stored without compression, copied out of the
* archive buffer. Null when it is compressed, encrypted, split over volumes
* or not completely in the buffer.
*/
export function storedEntryData(data: Buffer, entry: RarEntry): Buffer | null {
if (!entry.stored || entry.encrypted || entry.directory || entry.splitBefore || entry.splitAfter) return null
if (entry.size === null || entry.size !== entry.packedSize) return null
const end = entry.dataOffset + entry.packedSize
if (end > data.length) return null
return data.subarray(entry.dataOffset, end)
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Grouping of files into releases (downloads), independent of AdonisJS.
*/
export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, fileStem, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './release_key.js'
+211
View File
@@ -0,0 +1,211 @@
import { createHash } from 'node:crypto'
import { looksLikeFilename } from '../subject/file_key.js'
export interface ReleaseIdentity {
key: string
/** Human-readable name: the subject text or the shared filename stem. */
name: string
/** Number of files announced by the `[i/M]` counter, when present. */
fileTotal?: number
/** Which rule recognised the release. */
source: 'subject' | 'post' | 'stem' | 'weak-stem' | 'par2' | 'rar' | 'nzb' | 'spot'
}
export interface ReleaseInput {
poster: string
/** Subject without segment counter (the binary's `subject`). */
subject: string
/** Filename of the binary. */
name: string
/** True when the identity came from the yEnc body (obfuscated post). */
weak: boolean
}
// Suffixes that differ between the files of one release. Stripped repeatedly,
// so "x.mkv.vol03+04.par2" and "x.part07.rar" both reduce to "x".
const SUFFIXES = [
/\.vol\d+[+-]\d+\.par2$/i,
/\.par2$/i,
/\.part\d+\.rar$/i,
/\.r\d{2,3}$/i,
/\.rar$/i,
/\.7z(\.\d{3})?$/i,
/\.zip(\.\d{3})?$/i,
// Split volumes (x.mkv.001), not codec names (H.264, H.265).
/(?<=\.(7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.\d{3}$/i,
/\.(nfo|sfv|nzb|srr|srs|txt|jpg|png|md5|sha1|srt|sub|idx|ass|ssa|m3u|m3u8|cue|log)$/i,
/\.tar$/i,
/\.(zst|gz|xz|bz2)$/i,
/\.(mkv|mp4|avi|m4v|ts|iso|img|bin|flac|mp3|epub|pdf)$/i,
]
/** Strips per-file suffixes (volume numbers, archive and par2 extensions) from a filename. */
export function fileStem(name: string): string {
let stem = name.trim()
let changed = true
while (changed) {
changed = false
for (const suffix of SUFFIXES) {
const next = stem.replace(suffix, '')
if (next !== stem && next.length > 0) {
stem = next
changed = true
}
}
}
return stem
}
/**
* What remains of a subject once the filename, counters, "yEnc" and sizes are
* removed. Posters that name the release outside the quotes put it here.
*/
export function subjectRemainder(subject: string): string {
return subject
.replace(/"[^"]*"/g, ' ')
.replace(/\[\d+\/\d+\]|\(\d+\/\d+\)/g, ' ')
// File sizes follow "yEnc" at the end; other numbers (years, post ids) stay.
.replace(/\byEnc\b(\s+\d+)?\s*$/i, ' ')
.replace(/\byEnc\b/gi, ' ')
.replace(/[\s\-–.:|]+/g, ' ')
.trim()
}
/**
* Posting tools put random per-file tokens and poster IDs in subjects (e.g.
* `elys-de81bcabef5faf6a - [Nyh5Q07Y]`); only text that still reads like a
* name once those are removed identifies a release.
*/
function isRandomToken(token: string): boolean {
const t = token.replace(/^\[|\]$/g, '')
if (t.length < 6) return false
if (/^[0-9a-f]+$/i.test(t) && /\d/.test(t)) return true
return /\d/.test(t) && /[a-z]/.test(t) && /[A-Z]/.test(t) && !/[._-]/.test(t)
}
/**
* A stable per-post token: one short run of letters/digits with at least one
* digit. Requiring a digit keeps constant labels like `[PRiVATE]` from
* grouping different posts of the same poster.
*/
export function isPostToken(remainder: string): boolean {
const compact = remainder.replace(/[\s\[\]()_-]+/g, '')
return compact.length >= 6 && compact.length <= 40 && /\d/.test(compact) && /^[A-Za-z0-9]+$/.test(compact)
}
export function namesRelease(remainder: string): boolean {
const words = remainder
.split(/\s+/)
.filter((w) => w && !isRandomToken(w))
.join(' ')
// At least two words of letters, joined the way release names are.
return /[A-Za-z]{2,}[._ -]+[A-Za-z0-9]{2,}/.test(words)
}
function hash(...parts: (string | number | undefined)[]) {
return createHash('sha1').update(parts.map((p) => p ?? '').join('\n')).digest('hex')
}
/**
* Decides which release (download) a file belongs to. Returns null when the
* file cannot be grouped from its name alone (random extensionless names in
* obfuscated posts; those need par2 matching). See CONCEPT.md (D16).
*/
export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
const counter = /\[(\d+)\/(\d+)\]/.exec(input.subject)
const fileTotal = counter ? Number.parseInt(counter[2], 10) : undefined
const stem = fileStem(input.name)
if (input.weak) {
// Obfuscating posters randomise the poster per article; a real filename
// stem is specific enough on its own.
if (!looksLikeFilename(input.name)) return null
return { key: hash('weak', stem), name: stem, source: 'weak-stem' }
}
// Text outside the quotes that names the release, e.g. "Some.Release [01/45] - "x.rar"".
const remainder = subjectRemainder(input.subject)
if (namesRelease(remainder)) {
return {
key: hash('subject', input.poster, remainder),
name: remainder,
fileTotal,
source: 'subject',
}
}
// Not a name, but a post id (e.g. `83110629-n [10/18] - "random"`): with the
// [i/M] counter it groups the files of one post whose names are random.
if (fileTotal && isPostToken(remainder)) {
return { key: hash('post', input.poster, remainder, fileTotal), name: remainder, fileTotal, source: 'post' }
}
// The [i/M] total is informational, not part of the key: posters often
// upload the par2 index without a counter, and it belongs to the same post.
return { key: hash('stem', input.poster, stem), name: stem, fileTotal, source: 'stem' }
}
/** Release of a par2 recovery set; every file listed in it belongs to it. */
export function par2ReleaseIdentity(recoverySetId: string, fileNames: string[]): ReleaseIdentity {
return { key: hash('par2', recoverySetId), name: par2ReleaseName(fileNames) ?? recoverySetId, source: 'par2' }
}
const SEPARATOR = /[._ -]/
/**
* A release name from the files a par2 set lists. Files that differ only in
* the middle (episodes S03E01…S03E20, parts) give their shared name, with
* the episode reduced to the season: SEAL.Team.S03E01.1080p…-nitrato.mkv to
* SEAL.Team.S03E20.1080p…-nitrato.mkv give SEAL.Team.S03.1080p…-nitrato.
* Otherwise the longest stem, which tends to be the most descriptive (the
* video file).
*/
export function par2ReleaseName(fileNames: string[]): string | undefined {
const stems = [...new Set(fileNames.map((n) => fileStem(n.split(/[\\/]/).pop() ?? n)))].filter(Boolean)
if (stems.length === 0) return undefined
const longest = [...stems].sort((a, b) => b.length - a.length)[0]
if (stems.length < 2) return longest
let prefix = stems.reduce((p, s) => {
let i = 0
while (i < p.length && i < s.length && p[i] === s[i]) i++
return p.slice(0, i)
})
let suffix = stems.reduce((p, s) => {
let i = 0
while (i < p.length && i < s.length && p[p.length - 1 - i] === s[s.length - 1 - i]) i++
return p.slice(p.length - i)
})
const shortest = Math.min(...stems.map((s) => s.length))
if (prefix.length + suffix.length > shortest) suffix = suffix.slice(prefix.length + suffix.length - shortest)
// Season packs: "Show.S03E" → "Show.S03". Otherwise back to a whole token.
const season = /^(.*[._ -]S\d{1,2})E\d*$/i.exec(prefix)
if (season) prefix = season[1]
else if (prefix.length < stems[0].length && !SEPARATOR.test(stems[0][prefix.length] ?? '.')) {
prefix = prefix.replace(/[^._ -]*$/, '')
}
suffix = SEPARATOR.test(suffix[0] ?? '') ? suffix : suffix.replace(/^[^._ -]*/, '')
prefix = prefix.replace(/[._ -]+$/, '')
const name = suffix ? prefix + suffix : prefix
// Only when the shared part carries most of the name (not just "The.").
return name.length >= 8 && name.length >= 0.6 * shortest ? name : longest
}
/**
* Release of a RAR volume set, from what its headers list: the shared
* top-level directory if there is one, otherwise the first entry. For
* volumes after the first, the first entry is the file that continues from
* the previous volume, so all volumes of a single-file archive agree.
*/
export function rarReleaseIdentity(entries: { name: string; size: number | null }[]): ReleaseIdentity | null {
if (entries.length === 0) return null
const tops = new Set(entries.map((e) => e.name.split(/[\\/]/)[0]))
const nested = entries.some((e) => /[\\/]/.test(e.name))
if (nested && tops.size === 1) {
const [top] = tops
return { key: hash('rar-dir', top), name: top, source: 'rar' }
}
const first = entries[0]
const base = first.name.split(/[\\/]/).pop() ?? first.name
return { key: hash('rar-entry', first.name, first.size ?? ''), name: fileStem(base), source: 'rar' }
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Separating equal-sized obfuscated volumes by posting order, independent of AdonisJS.
*/
export { splitInstances, splitByRank, splitVerified, verifyVolume, type SplitArticle, type SplitOptions } from './instances.js'
+178
View File
@@ -0,0 +1,178 @@
import { combineCrc32 } from '../yenc/decode.js'
export interface SplitArticle {
number: number
messageId: string
bytes: number
/** Article number in the group; posting order. */
articleNumber: number | null
/** yEnc part CRC and decoded part length, for verification. */
pcrc32?: string | null
partSize?: number | null
/** Whole-file CRC32, usually only in the last part. */
fileCrc32?: string | null
}
export interface SplitOptions {
/** Total parts per volume; a volume that has them all takes no more articles. */
totalParts: number
/**
* Maximum distance in article numbers between consecutive articles of one
* volume. Separates different posts that happen to share a size.
*/
window?: number
/**
* How far (in posting positions) an article may be from where its volume
* predicts it; covers out-of-order finishing across connections.
*/
tolerance?: number
}
interface Volume {
articles: SplitArticle[]
/** Posting rank of each part number present. */
ranks: Map<number, number>
lastArticle: number
opened: number
}
/** ~1 minute of posting in a busy group. */
const DEFAULT_WINDOW = 20_000
/**
* Separates the articles of several equal-sized files that share one
* identity (obfuscated volumes with random names, possibly from different
* posts) into one list per file. See CONCEPT.md (D20).
*
* Posting tools upload a queue (volume 1 parts 1..n, volume 2 parts 1..n,
* ...) over several connections, so within a volume part k+1 lands about one
* position after part k in posting order, give or take the connection count.
* Each article, in posting order, goes to the eligible volume (lacks this
* part, not complete, active within `window` article numbers) whose
* predicted position for it is closest: taken from that volume's article with
* the nearest part number, which keeps the prediction local and unaffected
* by missing articles elsewhere. A deviation above the tolerance starts a new
* volume. The result is recomputed from all articles, so it improves as late
* articles arrive.
*/
export function splitInstances(articles: SplitArticle[], options: SplitOptions): SplitArticle[][] {
const window = options.window ?? DEFAULT_WINDOW
const tolerance = options.tolerance ?? Math.max(16, Math.ceil(options.totalParts * 0.1))
const seen = new Set<string>()
const ordered = articles
.filter((a) => (seen.has(a.messageId) ? false : (seen.add(a.messageId), true)))
.sort(
(a, b) =>
(a.articleNumber ?? Number.MAX_SAFE_INTEGER) - (b.articleNumber ?? Number.MAX_SAFE_INTEGER) ||
a.messageId.localeCompare(b.messageId)
)
const volumes: Volume[] = []
ordered.forEach((article, rank) => {
const position = article.articleNumber ?? Number.MAX_SAFE_INTEGER
let best: Volume | undefined
let bestScore: [number, number] | undefined
for (const volume of volumes) {
if (volume.ranks.has(article.number) || volume.ranks.size >= options.totalParts) continue
if (position - volume.lastArticle > window) continue
const deviation = Math.abs(rank - predictedRank(volume, article.number))
if (deviation > tolerance) continue
const score: [number, number] = [deviation, volume.opened]
if (!bestScore || score[0] < bestScore[0] || (score[0] === bestScore[0] && score[1] < bestScore[1])) {
best = volume
bestScore = score
}
}
if (!best) {
best = { articles: [], ranks: new Map(), lastArticle: position, opened: volumes.length }
volumes.push(best)
}
best.articles.push(article)
best.ranks.set(article.number, rank)
best.lastArticle = Math.max(best.lastArticle, position)
})
return volumes.map((v) => v.articles.sort((a, b) => a.number - b.number))
}
/** Where part `number` should be in posting order, judged from the volume's nearest part. */
function predictedRank(volume: Volume, number: number): number {
let anchorPart = 0
let anchorRank = 0
let nearest = Number.MAX_SAFE_INTEGER
for (const [part, rank] of volume.ranks) {
const distance = Math.abs(part - number)
if (distance < nearest) {
nearest = distance
anchorPart = part
anchorRank = rank
}
}
return anchorRank + (number - anchorPart)
}
/**
* Simpler split: for every part number, the i-th article in posting order
* belongs to volume i. Right for a single, complete post; used as a second
* proposal for verification.
*/
export function splitByRank(articles: SplitArticle[]): SplitArticle[][] {
const byNumber = new Map<number, SplitArticle[]>()
const seen = new Set<string>()
for (const article of articles) {
if (seen.has(article.messageId)) continue
seen.add(article.messageId)
byNumber.set(article.number, [...(byNumber.get(article.number) ?? []), article])
}
const volumes: SplitArticle[][] = []
for (const group of byNumber.values()) {
group.sort(
(a, b) =>
(a.articleNumber ?? Number.MAX_SAFE_INTEGER) - (b.articleNumber ?? Number.MAX_SAFE_INTEGER) ||
a.messageId.localeCompare(b.messageId)
)
group.forEach((article, i) => (volumes[i] ??= []).push(article))
}
return volumes.map((v) => v.sort((a, b) => a.number - b.number))
}
/**
* True when a volume has every part and its combined part CRCs equal the
* whole-file CRC32 its last part announces: then it is exactly one file.
*/
export function verifyVolume(volume: SplitArticle[], totalParts: number): boolean {
if (volume.length !== totalParts) return false
const sorted = [...volume].sort((a, b) => a.number - b.number)
if (sorted.some((a, i) => a.number !== i + 1 || !a.pcrc32 || !a.partSize)) return false
const expected = sorted[sorted.length - 1].fileCrc32
if (!expected) return false
const combined = combineCrc32(sorted.map((a) => ({ crc: a.pcrc32!, length: a.partSize! })))
return combined === expected.toLowerCase().padStart(8, '0')
}
/**
* Splits and keeps only volumes that verify (verifyVolume). Proposals from
* both methods are tried; articles of verified volumes are taken out and the
* rest is tried again, until nothing new verifies. Unverified articles are
* left out: guessing wrong would produce a corrupt file.
*/
export function splitVerified(articles: SplitArticle[], options: SplitOptions): SplitArticle[][] {
const verified: SplitArticle[][] = []
let remaining = articles
for (let round = 0; round < 4 && remaining.length >= options.totalParts; round++) {
const found: SplitArticle[][] = []
const used = new Set<string>()
for (const propose of [(a: SplitArticle[]) => splitInstances(a, options), splitByRank]) {
for (const volume of propose(remaining.filter((a) => !used.has(a.messageId)))) {
if (verifyVolume(volume, options.totalParts)) {
found.push(volume)
for (const article of volume) used.add(article.messageId)
}
}
}
if (found.length === 0) break
verified.push(...found)
remaining = remaining.filter((a) => !used.has(a.messageId))
}
return verified
}
+162
View File
@@ -0,0 +1,162 @@
import { createHash, createPublicKey, verify } from 'node:crypto'
import { crc32 } from 'node:zlib'
/**
* Spotnet spot headers (free.pt), after Spotweb's Services_Format_Parsing::parseHeader.
*
* From: Nick <[PUBKEY[.USERSIGNATURE]]@[CAT][KEYID][SUBCATS].[SIZE].[x].[DATE].[..].[SIGNATURE]>
* Subject: Title | Tag
*/
export interface SpotHeader {
messageId: string
poster: string
title: string
tag: string
/** 0 image/video, 1 sound, 2 games, 3 applications. */
category: number
keyId: number
/** e.g. { a: [9], b: [3], c: [7, 10], d: [17], z: [0] } */
subcats: Record<string, number[]>
size: number
/** Unix seconds. */
stamp: number
/** Everything after the @. */
header: string
headerSign: string
selfSignedPubKey: string
userSignature?: string
}
/** Spotnet's base64 variant: `-s` for `/`, `-p` for `+`, padding left out. */
export function spotUnprepareBase64(input: string): string {
const padded = input.length % 4 ? input + '='.repeat(4 - (input.length % 4)) : input
return padded.replace(/-s/g, '/').replace(/-p/g, '+')
}
/** Parses the From/Subject of a spot. Null when it isn't one. */
export function parseSpotHeader(from: string, subject: string, messageId: string, date?: string): SpotHeader | null {
const lt = from.indexOf('<')
if (lt === -1) return null
const address = from.slice(lt + 1, from.endsWith('>') ? -1 : undefined)
const at = address.lastIndexOf('@')
if (at === -1) return null
const header = address.slice(at + 1)
const [pubKey, userSignature] = address.slice(0, at).split('.')
const fields = header.split('.')
if (fields.length < 6) return null
const category = Number.parseInt(fields[0][0], 10) - 1
const keyId = Number.parseInt(fields[0][1], 10)
if (!(category >= 0) || !(keyId >= 0)) return null
// a9b4c0d5 (FTD) or a09b04c00d05 (newer): a letter, then digits.
const subcats: Record<string, number[]> = {}
for (const [, letter, digits] of fields[0].slice(2).toLowerCase().matchAll(/([a-z])(\d+)/g)) {
if ('abcdz'.includes(letter)) (subcats[letter] ??= []).push(Number.parseInt(digits, 10))
}
// Newer keys: "Title | Tag". Key 1 (oldest): the tag is the last field.
const parts = subject.split('|')
let title: string
let tag = ''
if (keyId !== 1) {
title = parts[0].trim()
tag = parts.length > 1 ? parts[1].trim() : ''
} else {
tag = parts.length > 1 ? parts[parts.length - 1].trim() : subject.trim()
title = parts.length > 1 ? parts.slice(0, -2).join('|').trim() : ''
}
const parsedDate = date ? Date.parse(date) : Number.NaN
return {
messageId: messageId.replace(/^<|>$/g, ''),
poster: from.slice(0, lt).trim(),
title,
tag,
category,
keyId,
subcats,
size: Number.parseInt(fields[1], 10) || 0,
stamp: Number.isNaN(parsedDate) ? Number.parseInt(fields[3], 10) || 0 : Math.floor(parsedDate / 1000),
header,
headerSign: fields[fields.length - 1],
selfSignedPubKey: spotUnprepareBase64(pubKey ?? ''),
userSignature: userSignature ? spotUnprepareBase64(userSignature) : undefined,
}
}
/** Spotnet's own signing keys (Spotweb settings `rsa_keys`), base64 modulus. */
export const SPOTNET_KEYS: Record<number, string> = {
2: 'ys8WSlqonQMWT8ubG0tAA2Q07P36E+CJmb875wSR1XH7IFhEi0CCwlUzNqBFhC+P',
3: 'uiyChPV23eguLAJNttC/o0nAsxXgdjtvUvidV2JL+hjNzc4Tc/PPo2JdYvsqUsat',
4: '1k6RNDVD6yBYWR6kHmwzmSud7JkNV4SMigBrs+jFgOK5Ldzwl17mKXJhl+su/GR9',
}
const base64url = (b64: string) => Buffer.from(b64, 'base64').toString('base64url')
/** RSA PKCS#1 v1.5 with SHA-1, as Spotweb's checkRsaSignature (exponent AQAB). */
export function checkRsaSignature(message: Buffer | string, signatureB64: string, modulusB64: string): boolean {
try {
const key = createPublicKey({ key: { kty: 'RSA', n: base64url(modulusB64), e: 'AQAB' }, format: 'jwk' })
const data = typeof message === 'string' ? toBytes(message) : message
return verify('sha1', data, key, Buffer.from(signatureB64, 'base64'))
} catch {
return false
}
}
/** Spots are signed over their ISO-8859-1 bytes. */
function toBytes(text: string): Buffer {
return /^[\u0000-ÿ]*$/.test(text) ? Buffer.from(text, 'latin1') : Buffer.from(text, 'utf8')
}
/** SPOTSIGN_V2 and self-signed messages carry a hashcash: sha1('<msgid>') starts with 0000. */
function hashcash(messageId: string): boolean {
return createHash('sha1').update(`<${messageId}>`).digest('hex').startsWith('0000')
}
/**
* Verifies a spot header like Spotweb does: key 7 (self-signed) needs a
* hashcash and the poster's key, keys 2–4 Spotnet's keys; spots that also
* carry a user signature must have it match the message id.
*/
export function verifySpotHeader(spot: SpotHeader, keys: Record<number, string> = SPOTNET_KEYS): boolean {
if (!spot.title || !spot.poster) return false
// Old key 1 spots from before 2011 weren't signed.
if (spot.keyId === 1 && spot.stamp <= 1293870080) return true
if (!spot.headerSign) return false
const signature = spotUnprepareBase64(spot.headerSign)
const toCheck = spot.title + spot.header.slice(0, spot.header.length - spot.headerSign.length - 1) + spot.poster
let verified: boolean
if (spot.keyId === 7 || (spot.keyId === 2 && spot.selfSignedPubKey.length > 50)) {
// Self-signed (and key-2 "dispose" messages): the hashcash is the gate, as in Spotweb.
verified = hashcash(spot.messageId)
} else {
verified = keys[spot.keyId] !== undefined && checkRsaSignature(toCheck, signature, keys[spot.keyId])
}
if (verified && spot.userSignature && spot.selfSignedPubKey) {
verified = checkRsaSignature(`<${spot.messageId}>`, spot.userSignature, spot.selfSignedPubKey)
}
return verified
}
/** Spotweb's spotter id: base64 of the CRC32 (little-endian) of the poster's public key. */
export function spotterId(pubKeyB64: string): string {
if (!pubKeyB64) return ''
const crc = Buffer.alloc(4)
crc.writeUInt32LE(crc32(Buffer.from(pubKeyB64, 'base64')) >>> 0)
return crc.toString('base64').replace(/[/+=]/g, '')
}
/**
* Moderation: key-2 spots titled "delete|dispose|remove <message-id>".
* From Spotnet's key they are moderator actions; with the poster's own key
* (a spotter id) a personal dispose of their own spot.
*/
export function moderationTarget(spot: Pick<SpotHeader, 'keyId' | 'title'>): string | null {
if (spot.keyId !== 2) return null
const [command, target] = spot.title.split(' ')
if (!['delete', 'dispose', 'remove'].includes(command?.toLowerCase()) || !target) return null
return target.replace(/^<|>$/g, '')
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Spotnet (free.pt) spots, independent of AdonisJS: header parsing and
* signature checks after Spotweb, the spot XML and its compressed NZB.
* See CONCEPT.md (D29).
*/
export { parseSpotHeader, verifySpotHeader, spotterId, moderationTarget, checkRsaSignature, spotUnprepareBase64, SPOTNET_KEYS, type SpotHeader } from './header.js'
export { parseSpotXml, parseSpotArticleHeaders, decodeSpotBinary, type SpotXml } from './xml.js'
+84
View File
@@ -0,0 +1,84 @@
import { inflateRawSync } from 'node:zlib'
export interface SpotXml {
title: string
category: number | null
subcats: string[]
size: number
website: string
/** Message ids (without <>) of the compressed NZB in alt.binaries.ftd. */
nzbSegments: string[]
created: number | null
}
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
function text(value: string | undefined): string {
if (value === undefined) return ''
const cdata = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/.exec(value)
if (cdata) return cdata[1]
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
if (entity[0] === '#') {
const code = entity[1].toLowerCase() === 'x' ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10)
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
}
return ENTITIES[entity.toLowerCase()] ?? match
})
}
const element = (xml: string, name: string) => new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, 'i').exec(xml)?.[1]
/** The spot XML from the X-XML header lines (a tolerant scanner, as spots are often invalid XML). */
export function parseSpotXml(xml: string): SpotXml | null {
const posting = element(xml, 'Posting')
if (!posting) return null
const categoryXml = element(posting, 'Category') ?? ''
const key = Number.parseInt(element(posting, 'Key') ?? '', 10)
const rawCategory = Number.parseInt(categoryXml.replace(/<[\s\S]*$/, ''), 10)
const nzb = element(posting, 'NZB') ?? ''
return {
title: text(element(posting, 'Title')).trim(),
// Newer keys number categories from 1.
category: Number.isNaN(rawCategory) ? null : key !== 1 ? rawCategory - 1 : rawCategory,
subcats: [...categoryXml.matchAll(/<(?:Sub|SubCat)>([^<]*)<\/(?:Sub|SubCat)>/gi)].map((m) => m[1].trim()),
size: Number.parseInt(element(posting, 'Size') ?? '', 10) || 0,
website: text(element(posting, 'Website')).trim(),
nzbSegments: [...nzb.matchAll(/<Segment>([^<>]*)<\/Segment>/gi)].map((m) => m[1].trim()).filter(Boolean),
created: Number.parseInt(element(posting, 'Created') ?? '', 10) || null,
}
}
/** Header lines of a raw HEAD response; X-XML (split over many lines) joined. */
export function parseSpotArticleHeaders(raw: string): { headers: Record<string, string>; xml: string } {
const headers: Record<string, string> = {}
let xml = ''
for (const line of raw.split(/\r?\n/)) {
const colon = line.indexOf(':')
if (colon <= 0) continue
const name = line.slice(0, colon).toLowerCase()
const value = line.slice(colon + 1).replace(/^ /, '')
if (name === 'x-xml') xml += value
else headers[name] ??= value
}
return { headers, xml }
}
/**
* Spot binaries (the NZB, images): article bodies joined without line
* breaks, Spotnet's escapes undone (=A NUL, =B CR, =C LF, =D =), then raw
* deflate (PHP gzinflate).
*/
export function decodeSpotBinary(bodies: Buffer[], compressed = true): Buffer {
const joined = bodies
.map((body) =>
body
.toString('latin1')
.split('\r\n')
// NNTP dot-stuffing.
.map((line) => (line.startsWith('..') ? line.slice(1) : line))
.join('')
)
.join('')
const unescaped = Buffer.from(joined.replace(/=([ABCD])/g, (_, c: string) => ({ A: '\0', B: '\r', C: '\n', D: '=' })[c]!), 'latin1')
return compressed ? inflateRawSync(unescaped) : unescaped
}
+30
View File
@@ -0,0 +1,30 @@
import { createHash } from 'node:crypto'
/**
* Identity of one uploaded file: every segment of it has the same poster, the
* same subject apart from the segment counter, and the same segment count.
* The group is deliberately left out so crossposts resolve to one file.
* See CONCEPT.md (D12).
*/
export function fileKey(poster: string, base: string, totalSegments: number): string {
return createHash('sha1').update(`${poster}\n${base}\n${totalSegments}`).digest('hex')
}
/** True when a yEnc name looks like a real filename (it has an extension), not a random token. */
export function looksLikeFilename(name: string): boolean {
return /\.[A-Za-z0-9]{1,5}$/.test(name) && !/^[0-9a-f]{32,}$/i.test(name)
}
/**
* Identity of a file known only from its yEnc header (obfuscated posts).
* The poster is left out: obfuscating posters randomise it per article.
* - A real filename is stable across the articles of a file, and needed to
* tell apart the equal-sized volumes of one archive.
* - A random, extensionless name can change per article too; the exact
* byte size and part count are what stays the same.
* Collisions are caught by the conflict check in IngestService.
*/
export function weakFileKey(name: string, size: number, totalParts: number): string {
const identity = looksLikeFilename(name) ? `name\n${name}` : 'shape'
return createHash('sha1').update(`weak\n${identity}\n${size}\n${totalParts}`).digest('hex')
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Conventions of binary posts (subject lines, file identity), independent of AdonisJS.
*/
export { parseSubject, type ParsedSubject } from './parse.js'
export { fileKey, weakFileKey, looksLikeFilename } from './file_key.js'
+59
View File
@@ -0,0 +1,59 @@
export interface ParsedSubject {
/** Filename from the quoted part of the subject. */
name: string
/** Segment number `k` from `(k/N)`. */
segment: number
/** Segment count `N` from `(k/N)`. */
totalSegments: number
/** File counter `[i/M]`, when present. */
fileIndex?: number
fileTotal?: number
/**
* The subject with the segment counter removed. Identical for every segment
* of one uploaded file, so it is part of the file identity (see fileKey).
*/
base: string
}
const SEGMENT_COUNTER = /\((\d+)\/(\d+)\)/g
const FILE_COUNTER = /\[(\d+)\/(\d+)\]/
/**
* Parses a binary post subject such as
* `[07/45] - "Some.Release.part06.rar" yEnc (12/137) 51315352`.
*
* Returns null for subjects without a quoted filename and segment counter
* (obfuscated posts), which need their yEnc header read from the body instead.
*/
export function parseSubject(subject: string): ParsedSubject | null {
const nameEnd = subject.lastIndexOf('"')
const nameStart = nameEnd > 0 ? subject.lastIndexOf('"', nameEnd - 1) : -1
if (nameStart === -1) return null
const name = subject.slice(nameStart + 1, nameEnd)
if (!name) return null
// The segment counter is the last "(k/N)" after the filename; a file size may follow it.
let counter: RegExpExecArray | undefined
for (const match of subject.matchAll(SEGMENT_COUNTER)) {
if (match.index > nameEnd) counter = match
}
if (!counter) return null
const segment = Number.parseInt(counter[1], 10)
const totalSegments = Number.parseInt(counter[2], 10)
if (totalSegments < 1 || segment < 1 || segment > totalSegments) return null
const base = (subject.slice(0, counter.index) + subject.slice(counter.index + counter[0].length))
.replace(/\s+/g, ' ')
.trim()
const fileCounter = FILE_COUNTER.exec(subject.slice(0, nameStart))
return {
name,
segment,
totalSegments,
fileIndex: fileCounter ? Number.parseInt(fileCounter[1], 10) : undefined,
fileTotal: fileCounter ? Number.parseInt(fileCounter[2], 10) : undefined,
base,
}
}
+76
View File
@@ -0,0 +1,76 @@
import yencode from 'yencode'
import { YencCrcError, YencError } from './errors.js'
import { parseYencMeta, type YencMeta } from './meta.js'
export interface DecodedSegment {
meta: YencMeta
/** Decoded bytes of this segment. */
data: Buffer
/** 0-based offset of `data` within the complete file. */
offset: number
}
export interface DecodeOptions {
/**
* Undo NNTP dot-stuffing (`..` at line start). nntp-js hands back raw
* bodies, so this defaults to true.
*/
stripDots?: boolean
/** Verify the part (or whole-file) CRC32 from the `=yend` line. Default true. */
verifyCrc?: boolean
/**
* Accept a decoded length that differs from the declared size (the CRC is
* still checked). Some posting tools declare a size that leaves out a
* header, e.g. the 20-byte header of `nzbe` containers.
*/
lenientSize?: boolean
}
export function crc32Hex(data: Buffer): string {
return yencode.crc32(data).toString('hex')
}
/**
* Whole-file CRC32 from the CRCs and decoded lengths of its parts, in order,
* without the data itself.
*/
export function combineCrc32(parts: { crc: string; length: number }[]): string {
let combined: Buffer | undefined
for (const part of parts) {
const crc = Buffer.from(part.crc.padStart(8, '0'), 'hex')
combined = combined ? yencode.crc32_combine(combined, crc, part.length) : crc
}
return (combined ?? Buffer.alloc(4)).toString('hex')
}
/**
* Decodes one yEnc article body and checks its size and CRC32 against the
* `=yend` trailer.
*/
export function decodeSegment(body: Buffer, options: DecodeOptions = {}): DecodedSegment {
const { stripDots = true, verifyCrc = true, lenientSize = false } = options
const meta = parseYencMeta(body)
const data = yencode.decode(body.subarray(meta.dataStart, meta.dataEnd), stripDots)
if (lenientSize) {
// Only the CRC below decides.
} else if (data.length !== meta.end.size) {
throw new YencError(`Decoded size ${data.length} does not match =yend size ${meta.end.size}`)
}
if (!lenientSize && meta.part && meta.part.end - meta.part.begin + 1 !== data.length) {
throw new YencError(
`Decoded size ${data.length} does not match =ypart range ${meta.part.begin}-${meta.part.end}`
)
}
if (verifyCrc) {
// Multi-part posts carry pcrc32 per part; single-part posts only crc32.
const expected = meta.end.pcrc32 ?? (meta.part ? undefined : meta.end.crc32)
if (expected) {
const actual = crc32Hex(data)
if (actual !== expected) throw new YencCrcError(expected, actual)
}
}
return { meta, data, offset: meta.part ? meta.part.begin - 1 : 0 }
}
+16
View File
@@ -0,0 +1,16 @@
export class YencError extends Error {
constructor(message: string) {
super(message)
this.name = 'YencError'
}
}
export class YencCrcError extends YencError {
constructor(
public readonly expected: string,
public readonly actual: string
) {
super(`CRC32 mismatch: expected ${expected}, got ${actual}`)
this.name = 'YencCrcError'
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* yEnc decoding, independent of AdonisJS. See CONCEPT.md (D4, D10).
*/
export { parseYencMeta, inferTotalParts, type YencMeta, type YencBegin, type YencPart, type YencEnd } from './meta.js'
export { decodeSegment, crc32Hex, combineCrc32, type DecodedSegment, type DecodeOptions } from './decode.js'
export { YencFile } from './yenc_file.js'
export { YencError, YencCrcError } from './errors.js'
+140
View File
@@ -0,0 +1,140 @@
import { YencError } from './errors.js'
/** Fields of the `=ybegin` line. `part`/`total` are absent in single-part posts. */
export interface YencBegin {
line: number
size: number
name: string
part?: number
total?: number
}
/** Fields of the `=ypart` line. Offsets are 1-based and `end` is inclusive. */
export interface YencPart {
begin: number
end: number
}
/** Fields of the `=yend` line. CRCs are lowercase 8-digit hex. */
export interface YencEnd {
size: number
part?: number
pcrc32?: string
crc32?: string
}
export interface YencMeta {
begin: YencBegin
part?: YencPart
end: YencEnd
/** Byte range of the encoded data inside the article body. */
dataStart: number
dataEnd: number
}
const CRLF = Buffer.from('\r\n')
const BEGIN = Buffer.from('=ybegin ')
const PART = Buffer.from('=ypart ')
const END = Buffer.from('\r\n=yend ')
/**
* Parses `key=value` pairs from a yEnc header line. `name` is always the last
* field and runs to the end of the line, because filenames may contain spaces
* and `=`.
*/
function parseFields(line: string): Record<string, string> {
const fields: Record<string, string> = {}
const nameIndex = line.search(/(^|\s)name=/)
let rest = line
if (nameIndex !== -1) {
const nameStart = line.indexOf('name=', nameIndex) + 'name='.length
fields.name = line.slice(nameStart).trim()
rest = line.slice(0, nameIndex)
}
for (const match of rest.matchAll(/(\w+)=(\S+)/g)) {
fields[match[1]] = match[2]
}
return fields
}
function toInt(fields: Record<string, string>, key: string, required: true): number
function toInt(fields: Record<string, string>, key: string, required?: false): number | undefined
function toInt(fields: Record<string, string>, key: string, required = false) {
const raw = fields[key]
if (raw === undefined) {
if (required) throw new YencError(`Missing "${key}" in yEnc header`)
return undefined
}
const value = Number.parseInt(raw, 10)
if (!Number.isFinite(value)) throw new YencError(`Invalid "${key}" in yEnc header: ${raw}`)
return value
}
function toCrc(fields: Record<string, string>, key: string) {
const raw = fields[key]
if (raw === undefined) return undefined
if (!/^[0-9a-fA-F]{1,8}$/.test(raw)) throw new YencError(`Invalid "${key}" in yEnc trailer: ${raw}`)
return raw.toLowerCase().padStart(8, '0')
}
/**
* Number of parts in the file. Many posters leave `total=` out of `=ybegin`;
* then it follows from the part size, since all parts except the last one
* are the same size.
*/
export function inferTotalParts(meta: Pick<YencMeta, 'begin' | 'part'>): number {
const { begin, part } = meta
if (begin.total) return begin.total
if (!part || begin.part === undefined) return 1
if (part.end >= begin.size) return begin.part
return Math.ceil(begin.size / (part.end - part.begin + 1))
}
function lineAt(buffer: Buffer, start: number) {
const eol = buffer.indexOf(CRLF, start)
const end = eol === -1 ? buffer.length : eol
return { text: buffer.subarray(start, end).toString('latin1'), next: end + CRLF.length }
}
/**
* Locates and parses the yEnc header/trailer lines of one article body,
* without decoding the data itself.
*/
export function parseYencMeta(body: Buffer): YencMeta {
const beginIndex = body.indexOf(BEGIN)
if (beginIndex === -1) throw new YencError('No =ybegin line found')
const beginLine = lineAt(body, beginIndex)
const beginFields = parseFields(beginLine.text)
const begin: YencBegin = {
line: toInt(beginFields, 'line', true),
size: toInt(beginFields, 'size', true),
name: beginFields.name ?? '',
part: toInt(beginFields, 'part'),
total: toInt(beginFields, 'total'),
}
if (!begin.name) throw new YencError('Missing "name" in =ybegin line')
let dataStart = beginLine.next
let part: YencPart | undefined
if (body.subarray(dataStart, dataStart + PART.length).equals(PART)) {
const partLine = lineAt(body, dataStart)
const partFields = parseFields(partLine.text)
part = { begin: toInt(partFields, 'begin', true), end: toInt(partFields, 'end', true) }
dataStart = partLine.next
}
const endIndex = body.lastIndexOf(END)
if (endIndex === -1 || endIndex < dataStart - CRLF.length) {
throw new YencError('No =yend line found')
}
const endFields = parseFields(lineAt(body, endIndex + CRLF.length).text)
const end: YencEnd = {
size: toInt(endFields, 'size', true),
part: toInt(endFields, 'part'),
pcrc32: toCrc(endFields, 'pcrc32'),
crc32: toCrc(endFields, 'crc32'),
}
return { begin, part, end, dataStart, dataEnd: Math.max(dataStart, endIndex) }
}
+86
View File
@@ -0,0 +1,86 @@
import { crc32Hex, type DecodedSegment } from './decode.js'
import { YencCrcError, YencError } from './errors.js'
import { inferTotalParts } from './meta.js'
/**
* Assembles the decoded segments of one file into a single buffer. Segments
* may be added in any order.
*/
export class YencFile {
readonly name: string
readonly size: number
readonly totalParts: number
private readonly buffer: Buffer
private readonly received = new Set<number>()
private bytesWritten = 0
private fileCrc?: string
constructor(name: string, size: number, totalParts = 1) {
this.name = name
this.size = size
this.totalParts = totalParts
this.buffer = Buffer.alloc(size)
}
/** Creates a file sized from the metadata of any of its segments. */
static fromSegment(segment: DecodedSegment): YencFile {
const { begin } = segment.meta
// Many posters leave total= out; it follows from the part size.
return new YencFile(begin.name, begin.size, inferTotalParts(segment.meta))
}
add(segment: DecodedSegment): void {
const { meta, data, offset } = segment
// Only the size is compared: obfuscating posters can give every segment
// of one file a different random name.
if (meta.begin.size !== this.size) {
throw new YencError(
`Segment of "${meta.begin.name}" (${meta.begin.size} bytes) does not belong to "${this.name}" (${this.size} bytes)`
)
}
if (offset + data.length > this.size) {
throw new YencError(`Segment at offset ${offset} overruns file size ${this.size}`)
}
const partNumber = meta.begin.part ?? 1
if (this.received.has(partNumber)) return
data.copy(this.buffer, offset)
this.received.add(partNumber)
this.bytesWritten += data.length
if (meta.end.crc32) this.fileCrc = meta.end.crc32
}
get partsReceived(): number {
return this.received.size
}
get isComplete(): boolean {
return this.received.size === this.totalParts && this.bytesWritten === this.size
}
/** Part numbers (1-based) that have not been added yet. */
missingParts(): number[] {
const missing: number[] = []
for (let part = 1; part <= this.totalParts; part++) {
if (!this.received.has(part)) missing.push(part)
}
return missing
}
/**
* Returns the assembled file. Throws if parts are missing or, when the poster
* included a whole-file crc32, if it does not match.
*/
getBuffer(): Buffer {
if (!this.isComplete) {
throw new YencError(`File "${this.name}" is incomplete, missing parts: ${this.missingParts().join(', ')}`)
}
if (this.fileCrc) {
const actual = crc32Hex(this.buffer)
if (actual !== this.fileCrc) throw new YencCrcError(this.fileCrc, actual)
}
return this.buffer
}
}