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>
This commit is contained in:
1 parent
c55918350e
commit
1322e42fb7
23 files changed
+1023
-39
No files matched your search
@@ -0,0 +1,171 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user