import { randomBytes } from 'node:crypto' import { DateTime } from 'luxon' import db from '@adonisjs/lucid/services/db' import { Exception } from '@adonisjs/core/exceptions' import type { HttpContext } from '@adonisjs/core/http' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import User from '#models/user' import { ACCESS, SIGNUP, TIERS, type TierLimits } from '#config/access' export type Use = 'api' | 'grab' /** Newznab's error codes for access (the catalog answers with the HTTP status). */ const STATUS = { 100: 401, 101: 403, 102: 403, 500: 429, 501: 429 } as const /** Why a request is refused; the catalog's answer (Newznab catches it for its own). */ export class AccessDenied extends Exception { constructor( readonly newznabCode: keyof typeof STATUS, message: string ) { super(message, { status: STATUS[newznabCode], code: 'E_ACCESS_DENIED' }) } async handle(error: this, ctx: HttpContext) { ctx.response.status(error.status).send({ error: error.message, code: error.newznabCode }) } } export interface Usage { apiHits: number grabs: number } /** A caller let in: who, their tier as it is now, and what they used today. */ export interface Grant { user: User tier: string limits: TierLimits usage: Usage } const COLUMNS: Record = { api: 'api_hits', grab: 'grabs' } const TODAY = `(now() AT TIME ZONE 'UTC')::date` const DAY_MS = 86_400_000 /** Accounts, tiers and their daily limits (config/access.ts). */ export default class AccessService { /** From config/access.ts; tests switch it. */ static mode: 'open' | 'accounts' = ACCESS static signup: 'open' | 'invite' | 'closed' = SIGNUP static get accounts() { return this.mode === 'accounts' } static newKey() { return randomBytes(16).toString('hex') } /** The user's tier as it is now: one that ran out (or isn't configured) is free. */ static tierOf(user: User): { tier: string; limits: TierLimits } { const expired = user.tierUntil !== null && user.tierUntil.toMillis() <= Date.now() const tier = !expired && TIERS[user.tier] ? user.tier : 'free' return { tier, limits: TIERS[tier] } } /** The user a request names: the web interface's token, else an apikey (Newznab, the catalog's NZB links). */ static async caller(ctx: HttpContext, apikey?: unknown): Promise { const guard = ctx.auth.use('api') if (guard.isAuthenticated || (await guard.check())) return guard.user! if (typeof apikey === 'string' && /^[0-9a-f]{32}$/.test(apikey)) return User.findBy('apiKey', apikey) return null } /** Lets a request in (counting nothing yet); null when access is open. */ static async authorize(ctx: HttpContext, apikey?: unknown): Promise { if (!this.accounts) return null const user = await this.caller(ctx, apikey) if (!user) throw new AccessDenied(100, 'Incorrect user credentials') if (user.suspended) throw new AccessDenied(101, 'Account suspended') return { user, ...this.tierOf(user), usage: await this.usage(user.id) } } /** Counts one use against the grant's tier, or refuses it once the day's limit is reached. */ static async charge(grant: Grant | null, use: Use) { if (!grant) return const usage = await this.count(grant.user.id, use, use === 'api' ? grant.limits.apiHits : grant.limits.grabs) if (!usage) throw use === 'api' ? new AccessDenied(500, 'Request limit reached') : new AccessDenied(501, 'Download limit reached') grant.usage = usage } /** * Adds one use to today's row unless the limit is reached (null then): one * statement, so concurrent requests can't both take the last one. */ static async count(userId: number, use: Use, limit: number | null): Promise { if (limit !== null && limit <= 0) return null const column = COLUMNS[use] const { rows } = await db.rawQuery( `INSERT INTO usage (user_id, day, ${column}) VALUES (?, ${TODAY}, 1) ON CONFLICT (user_id, day) DO UPDATE SET ${column} = usage.${column} + 1 ${limit === null ? '' : `WHERE usage.${column} < ?`} RETURNING api_hits, grabs`, limit === null ? [userId] : [userId, limit] ) return rows[0] ? { apiHits: rows[0].api_hits, grabs: rows[0].grabs } : null } static async usage(userId: number): Promise { const { rows } = await db.rawQuery(`SELECT api_hits, grabs FROM usage WHERE user_id = ? AND day = ${TODAY}`, [userId]) return { apiHits: rows[0]?.api_hits ?? 0, grabs: rows[0]?.grabs ?? 0 } } /** A search's age bound: the asked one within the tier's retention. */ static maxAgeDays(grant: Grant | null, requested?: number): number | undefined { const retention = grant?.limits.retentionDays if (!retention) return requested return requested ? Math.min(requested, retention) : retention } /** Refuses a release posted before the tier's retention (one without a date is let through). */ static checkPosted(grant: Grant | null, postedAt: Date | string | null) { const retention = grant?.limits.retentionDays if (!retention || !postedAt) return if (new Date(postedAt).getTime() < Date.now() - retention * DAY_MS) { throw new AccessDenied(102, `Older than your tier's retention (${retention} days)`) } } /** Whether a release exists, refusing it when it's older than the tier sees; asked before an NZB is counted. */ static async checkRelease(grant: Grant | null, id: number): Promise { const release = await db.from('releases').where('id', id).select('posted_at').first() if (!release) return false this.checkPosted(grant, release.posted_at) return true } /** Takes an unused invite for a new user: what it gives, or null when it's unknown or used. */ static async redeem(code: string, userId: number, trx: TransactionClientContract) { const { rows } = await trx.rawQuery( `UPDATE invites SET used_by = ?, used_at = now() WHERE code = ? AND used_by IS NULL AND used_at IS NULL RETURNING tier, tier_days`, [userId, code] ) return (rows[0] as { tier: string | null; tier_days: number | null } | undefined) ?? null } /** * Gives a user a tier for `days` (null: without end). The same tier still * running is extended from where it ends, so a renewal paid early loses nothing. */ static grantTier(user: User, tier: string, days: number | null) { if (!TIERS[tier]) throw new Error(`No such tier: ${tier} (config/access.ts has ${Object.keys(TIERS).join(', ')})`) const now = DateTime.utc() const running = user.tier === tier && user.tierUntil && user.tierUntil > now ? user.tierUntil : now user.tier = tier user.tierUntil = days === null ? null : running.plus({ days }) } /** What a profile shows of the account's access. */ static async summary(user: User) { const { tier, limits } = this.tierOf(user) return { mode: this.mode, tier, tierUntil: tier === user.tier ? user.tierUntil : null, apiKey: user.apiKey, limits, usage: await this.usage(user.id), } } }