Files
usenet-indexer/app/services/access_service.ts
Daan MeijerandClaude Opus 5.5 1322e42fb7
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m44s
Test and deploy / deploy (push) Skipped
Accounts with limits: a Newznab key and a tier per user, daily API and NZB counts, invites
ACCESS=accounts (config/access.ts; open stays the default and behaves as
before): Newznab and the catalog's releases routes name a user (apikey or
the web interface's token), whose tier sets searches and NZBs per UTC day
(Newznab 500/501, the catalog 429) and how far back they see (102/403).
Counted in one upsert per use, so the last one can't be taken twice;
caps, stats and refused requests aren't counted. Signup takes an invite
(SIGNUP), which may give a tier; ace access:invite, access:tier (extends a
running tier), access:suspend. The web interface signs in and shows the
day's usage and the Newznab key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:16:45 +02:00

172 lines
7.0 KiB
TypeScript

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<Use, string> = { 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<User | null> {
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<Grant | null> {
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<Usage | null> {
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<Usage> {
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<boolean> {
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),
}
}
}