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
@@ -1,13 +1,36 @@
|
||||
import User from '#models/user'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { signupValidator } from '#validators/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import UserTransformer from '#transformers/user_transformer'
|
||||
import AccessService from '#services/access_service'
|
||||
|
||||
class InviteGone extends Error {}
|
||||
|
||||
export default class NewAccountController {
|
||||
async store({ request, serialize }: HttpContext) {
|
||||
const { fullName, email, password } = await request.validateUsing(signupValidator)
|
||||
async store({ request, response, serialize }: HttpContext) {
|
||||
const { fullName, email, password, invite } = await request.validateUsing(signupValidator)
|
||||
if (AccessService.signup === 'closed') return response.forbidden({ error: 'Signup is closed' })
|
||||
if (AccessService.signup === 'invite' && !invite) return response.forbidden({ error: 'An invite code is needed to sign up' })
|
||||
|
||||
const user = await User.create({ fullName, email, password })
|
||||
// The user and their invite together: an invite that's gone rolls the signup back.
|
||||
const user = await db
|
||||
.transaction(async (trx) => {
|
||||
const created = await User.create({ fullName, email, password }, { client: trx })
|
||||
if (AccessService.signup !== 'invite') return created
|
||||
const gives = await AccessService.redeem(invite!, created.id, trx)
|
||||
if (!gives) throw new InviteGone()
|
||||
if (gives.tier) {
|
||||
AccessService.grantTier(created, gives.tier, gives.tier_days)
|
||||
await created.useTransaction(trx).save()
|
||||
}
|
||||
return created
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof InviteGone) return null
|
||||
throw error
|
||||
})
|
||||
if (!user) return response.forbidden({ error: 'Unknown or used invite code' })
|
||||
const token = await User.accessTokens.create(user)
|
||||
|
||||
return serialize({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { HttpContext } from '@adonisjs/core/http'
|
||||
import env from '#start/env'
|
||||
import NzbService from '#services/nzb_service'
|
||||
import SearchService from '#services/search_service'
|
||||
import AccessService, { AccessDenied } from '#services/access_service'
|
||||
import { capsXml, errorXml, expandCategories, resultsXml, type NewznabServer } from '#lib/newznab/index'
|
||||
|
||||
const LIMITS = { max: 100, default: 50 }
|
||||
@@ -35,22 +36,37 @@ function int(value: unknown): number | undefined {
|
||||
* Releases with readable names (or an inner name from the prober, which then is the title) that aren't known to miss files.
|
||||
*/
|
||||
export default class NewznabController {
|
||||
async handle({ request, response }: HttpContext) {
|
||||
async handle(ctx: HttpContext) {
|
||||
const xml = (body: string, type = 'application/xml') => {
|
||||
ctx.response.header('Content-Type', `${type}; charset=utf-8`)
|
||||
return ctx.response.send(body)
|
||||
}
|
||||
try {
|
||||
return await this.answer(ctx, xml)
|
||||
} catch (error) {
|
||||
if (error instanceof AccessDenied) return xml(errorXml(error.newznabCode, error.message))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async answer(ctx: HttpContext, xml: (body: string, type?: string) => void) {
|
||||
const { request, response } = ctx
|
||||
const qs = request.qs()
|
||||
const t = String(qs.t ?? '')
|
||||
const server = this.server(request)
|
||||
const xml = (body: string, type = 'application/xml') => {
|
||||
response.header('Content-Type', `${type}; charset=utf-8`)
|
||||
return response.send(body)
|
||||
}
|
||||
|
||||
const apiKey = configuredApiKey()
|
||||
if (apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials'))
|
||||
if (t === 'caps') return xml(capsXml(server, LIMITS))
|
||||
// With accounts the key is a user's (AccessService); else the one configured, if any.
|
||||
const grant = await AccessService.authorize(ctx, qs.apikey)
|
||||
const apiKey = grant ? grant.user.apiKey : configuredApiKey()
|
||||
if (!grant && apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials'))
|
||||
const server = this.server(request, apiKey)
|
||||
if (t === 'caps') return xml(capsXml(server, LIMITS, { available: AccessService.accounts && AccessService.signup !== 'closed', open: AccessService.accounts && AccessService.signup === 'open' }))
|
||||
|
||||
if (t === 'get') {
|
||||
const id = int(qs.id)
|
||||
if (id === undefined) return xml(errorXml(200, 'Missing parameter (id)'))
|
||||
// Unknown ids and releases past the tier's retention aren't counted.
|
||||
if (!(await AccessService.checkRelease(grant, id))) return xml(errorXml(300, 'No such item'))
|
||||
await AccessService.charge(grant, 'grab')
|
||||
const nzb = await NzbService.build(id)
|
||||
if (!nzb) return xml(errorXml(300, 'No such item'))
|
||||
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
|
||||
@@ -59,6 +75,7 @@ export default class NewznabController {
|
||||
}
|
||||
|
||||
if (!(t in SEARCH_FUNCTIONS)) return xml(errorXml(202, `No such function (${t || 'none'})`))
|
||||
await AccessService.charge(grant, 'api')
|
||||
|
||||
const requested = String(qs.cat ?? '')
|
||||
.split(',')
|
||||
@@ -79,7 +96,7 @@ export default class NewznabController {
|
||||
categories,
|
||||
readable: true,
|
||||
notIncomplete: true,
|
||||
maxAgeDays: int(qs.maxage),
|
||||
maxAgeDays: AccessService.maxAgeDays(grant, int(qs.maxage)),
|
||||
season: int(qs.season),
|
||||
episode: int(qs.season) !== undefined ? int(qs.ep) : undefined,
|
||||
sort: 'newest',
|
||||
@@ -105,12 +122,20 @@ export default class NewznabController {
|
||||
episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined,
|
||||
}
|
||||
})
|
||||
return xml(resultsXml(server, items, offset, result?.total ?? 0), 'application/rss+xml')
|
||||
// What's left of the day, for clients that show it (newznab:apilimits).
|
||||
const limits = grant && {
|
||||
apiCurrent: grant.usage.apiHits,
|
||||
apiMax: grant.limits.apiHits,
|
||||
grabCurrent: grant.usage.grabs,
|
||||
grabMax: grant.limits.grabs,
|
||||
}
|
||||
return xml(resultsXml(server, items, offset, result?.total ?? 0, limits || undefined), 'application/rss+xml')
|
||||
}
|
||||
|
||||
private server(request: HttpContext['request']): NewznabServer {
|
||||
/** Download links carry the caller's key. */
|
||||
private server(request: HttpContext['request'], apiKey: string | undefined): NewznabServer {
|
||||
const proto = request.header('x-forwarded-proto') ?? 'http'
|
||||
const host = request.header('x-forwarded-host') ?? request.header('host') ?? 'localhost'
|
||||
return { title: 'Voyager 2', baseUrl: `${proto}://${host}`, apiKey: configuredApiKey() }
|
||||
return { title: 'Voyager 2', baseUrl: `${proto}://${host}`, apiKey }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import UserTransformer from '#transformers/user_transformer'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import AccessService from '#services/access_service'
|
||||
|
||||
export default class ProfileController {
|
||||
/** The user, their tier, Newznab key and what they used today. */
|
||||
async show({ auth, serialize }: HttpContext) {
|
||||
return serialize(UserTransformer.transform(auth.use('api').getUserOrFail()))
|
||||
const user = auth.use('api').getUserOrFail()
|
||||
return serialize({ user: UserTransformer.transform(user), access: await AccessService.summary(user) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/account/api-key: a new Newznab key (the old one stops working). */
|
||||
async resetApiKey({ auth }: HttpContext) {
|
||||
const user = auth.use('api').getUserOrFail()
|
||||
user.apiKey = AccessService.newKey()
|
||||
await user.save()
|
||||
return { apiKey: user.apiKey }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import NzbService from '#services/nzb_service'
|
||||
import AccessService from '#services/access_service'
|
||||
import SearchService, { type SortOrder } from '#services/search_service'
|
||||
import { expandCategories } from '#lib/newznab/index'
|
||||
|
||||
@@ -14,13 +15,16 @@ function megabytes(value: unknown): number | undefined {
|
||||
/** The catalog for the web interface (REBUILD.md, part 9). */
|
||||
export default class ReleasesController {
|
||||
/** GET /api/v1/releases?q=&cat=5000,2040&readable=1&complete=1&unlocked=1&min_size=&max_size=(MB)&source=&poster=&sort=&page=&per_page= */
|
||||
async index({ request }: HttpContext) {
|
||||
const qs = request.qs()
|
||||
async index(ctx: HttpContext) {
|
||||
const qs = ctx.request.qs()
|
||||
const grant = await AccessService.authorize(ctx, qs.apikey)
|
||||
await AccessService.charge(grant, 'api')
|
||||
const categories = String(qs.cat ?? '')
|
||||
.split(',')
|
||||
.map((c) => Number(c.trim()))
|
||||
.filter((c) => Number.isInteger(c) && c > 0)
|
||||
return SearchService.search({
|
||||
maxAgeDays: AccessService.maxAgeDays(grant),
|
||||
q: typeof qs.q === 'string' ? qs.q : undefined,
|
||||
categories: categories.length ? expandCategories(categories) : undefined,
|
||||
readable: flag(qs.readable),
|
||||
@@ -39,13 +43,20 @@ export default class ReleasesController {
|
||||
}
|
||||
|
||||
/** GET /api/v1/releases/:id, with its files */
|
||||
async show({ params, response }: HttpContext) {
|
||||
const release = await SearchService.release(Number(params.id))
|
||||
return release ?? response.notFound({ error: 'Release not found' })
|
||||
async show(ctx: HttpContext) {
|
||||
const grant = await AccessService.authorize(ctx, ctx.request.qs().apikey)
|
||||
if (!(await AccessService.checkRelease(grant, Number(ctx.params.id)))) return ctx.response.notFound({ error: 'Release not found' })
|
||||
await AccessService.charge(grant, 'api')
|
||||
const release = await SearchService.release(Number(ctx.params.id))
|
||||
return release ?? ctx.response.notFound({ error: 'Release not found' })
|
||||
}
|
||||
|
||||
/** GET /api/v1/releases/:id/nzb */
|
||||
async nzb({ params, response }: HttpContext) {
|
||||
/** GET /api/v1/releases/:id/nzb (?apikey= for a plain link, as the web interface's) */
|
||||
async nzb(ctx: HttpContext) {
|
||||
const { params, response } = ctx
|
||||
const grant = await AccessService.authorize(ctx, ctx.request.qs().apikey)
|
||||
if (!(await AccessService.checkRelease(grant, Number(params.id)))) return response.notFound({ error: 'Release not found' })
|
||||
await AccessService.charge(grant, 'grab')
|
||||
const nzb = await NzbService.build(Number(params.id))
|
||||
if (!nzb) return response.notFound({ error: 'Release not found' })
|
||||
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
|
||||
@@ -54,7 +65,12 @@ export default class ReleasesController {
|
||||
return response.send(nzb.xml)
|
||||
}
|
||||
|
||||
/** GET /api/v1/stats */
|
||||
/** GET /api/v1/access: whether the web interface needs an account, and how one is made. */
|
||||
async access() {
|
||||
return { mode: AccessService.mode, signup: AccessService.signup }
|
||||
}
|
||||
|
||||
/** GET /api/v1/stats (open: what the index holds, not what's in it) */
|
||||
async stats() {
|
||||
return SearchService.stats()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ export default class HttpExceptionHandler extends ExceptionHandler {
|
||||
*/
|
||||
protected debug = !app.inProduction
|
||||
|
||||
/** A refused request (AccessService: no key, a limit reached) is an answer, not a fault. */
|
||||
protected ignoreCodes = ['E_ACCESS_DENIED']
|
||||
|
||||
/**
|
||||
* The method is used for handling errors and returning
|
||||
* response to the client
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { UserSchema } from '#database/schema'
|
||||
import { beforeCreate } from '@adonisjs/lucid/orm'
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { compose } from '@adonisjs/core/helpers'
|
||||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
||||
@@ -8,6 +10,12 @@ export default class User extends compose(UserSchema, withAuthFinder(hash)) {
|
||||
static accessTokens = DbAccessTokensProvider.forModel(User)
|
||||
declare currentAccessToken?: AccessToken
|
||||
|
||||
/** The Newznab key (AccessService), known on the model right away rather than from the column's default. */
|
||||
@beforeCreate()
|
||||
static assignApiKey(user: User) {
|
||||
user.apiKey ??= randomBytes(16).toString('hex')
|
||||
}
|
||||
|
||||
get initials() {
|
||||
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
|
||||
if (first && last) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export const signupValidator = vine.create({
|
||||
email: email().unique({ table: 'users', column: 'email' }),
|
||||
password: password(),
|
||||
passwordConfirmation: password().sameAs('password'),
|
||||
// Needed when SIGNUP is `invite` (config/access.ts).
|
||||
invite: vine.string().trim().maxLength(32).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user