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>
172 lines
7.0 KiB
TypeScript
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),
|
|
}
|
|
}
|
|
}
|