diff --git a/.env.example b/.env.example index ee3ebe3..b295edc 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ DB_USER=usenet_indexer DB_PASSWORD= DB_DATABASE=usenet_rebuild +# Access (config/access.ts): open (NEWZNAB_API_KEY or none, no limits) or accounts +# (a Newznab key per user, tiers with daily limits; ace access:invite, access:tier, access:suspend) +# ACCESS=open +# SIGNUP=open|invite|closed (with accounts: invite unless set) + #-------------------------------------------------------------------- # CORS (configure allowed origins for API access) #-------------------------------------------------------------------- diff --git a/app/controllers/new_account_controller.ts b/app/controllers/new_account_controller.ts index 1251fb7..70d82af 100644 --- a/app/controllers/new_account_controller.ts +++ b/app/controllers/new_account_controller.ts @@ -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({ diff --git a/app/controllers/newznab_controller.ts b/app/controllers/newznab_controller.ts index 92f6720..a9acf46 100644 --- a/app/controllers/newznab_controller.ts +++ b/app/controllers/newznab_controller.ts @@ -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 } } } diff --git a/app/controllers/profile_controller.ts b/app/controllers/profile_controller.ts index d0c8208..3620bdf 100644 --- a/app/controllers/profile_controller.ts +++ b/app/controllers/profile_controller.ts @@ -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 } } } diff --git a/app/controllers/releases_controller.ts b/app/controllers/releases_controller.ts index 0dda7c5..b3a1573 100644 --- a/app/controllers/releases_controller.ts +++ b/app/controllers/releases_controller.ts @@ -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() } diff --git a/app/exceptions/handler.ts b/app/exceptions/handler.ts index 30ddc51..8ec169a 100644 --- a/app/exceptions/handler.ts +++ b/app/exceptions/handler.ts @@ -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 diff --git a/app/models/user.ts b/app/models/user.ts index 7bc5a08..44b84c1 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -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) { diff --git a/app/services/access_service.ts b/app/services/access_service.ts new file mode 100644 index 0000000..fffe51c --- /dev/null +++ b/app/services/access_service.ts @@ -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 = { 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), + } + } +} diff --git a/app/validators/user.ts b/app/validators/user.ts index 5c4ff9a..0b22dfd 100644 --- a/app/validators/user.ts +++ b/app/validators/user.ts @@ -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(), }) /** diff --git a/commands/access_invite.ts b/commands/access_invite.ts new file mode 100644 index 0000000..106e1bc --- /dev/null +++ b/commands/access_invite.ts @@ -0,0 +1,37 @@ +import { BaseCommand, flags } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' +import db from '@adonisjs/lucid/services/db' +import AccessService from '#services/access_service' +import { TIERS } from '#config/access' + +/** Makes invite codes (SIGNUP=invite), optionally with a tier for the new account, e.g. a sold VIP year. */ +export default class AccessInvite extends BaseCommand { + static commandName = 'access:invite' + static description = 'Makes invite codes, optionally giving a tier (e.g. --tier=vip --days=365)' + static options: CommandOptions = { startApp: true } + + @flags.number({ description: 'How many codes', default: 1 }) + declare count: number + + @flags.string({ description: `Tier the new account gets (${Object.keys(TIERS).join(', ')})` }) + declare tier?: string + + @flags.number({ description: 'For how many days the tier holds (without: no end)' }) + declare days?: number + + @flags.string({ description: 'A note, e.g. who it is for or the payment' }) + declare note?: string + + async run() { + if (this.tier && !TIERS[this.tier]) { + this.logger.error(`No such tier: ${this.tier}`) + this.exitCode = 1 + return + } + const codes = Array.from({ length: this.count }, () => AccessService.newKey()) + await db.table('invites').multiInsert( + codes.map((code) => ({ code, tier: this.tier ?? null, tier_days: this.tier ? (this.days ?? null) : null, note: this.note ?? null })) + ) + for (const code of codes) this.logger.log(code) + } +} diff --git a/commands/access_suspend.ts b/commands/access_suspend.ts new file mode 100644 index 0000000..9361790 --- /dev/null +++ b/commands/access_suspend.ts @@ -0,0 +1,28 @@ +import { BaseCommand, args, flags } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' +import User from '#models/user' + +/** Suspends a user (Newznab error 101, the catalog 403), e.g. for a shared key; --undo lifts it. */ +export default class AccessSuspend extends BaseCommand { + static commandName = 'access:suspend' + static description = 'Suspends a user (or lifts it with --undo)' + static options: CommandOptions = { startApp: true } + + @args.string({ description: 'The user’s email' }) + declare email: string + + @flags.boolean({ description: 'Lift the suspension' }) + declare undo: boolean + + async run() { + const user = await User.findBy('email', this.email) + if (!user) { + this.logger.error(`No user ${this.email}`) + this.exitCode = 1 + return + } + user.suspended = !this.undo + await user.save() + this.logger.info(`${user.email}: ${user.suspended ? 'suspended' : 'active'}`) + } +} diff --git a/commands/access_tier.ts b/commands/access_tier.ts new file mode 100644 index 0000000..1e92799 --- /dev/null +++ b/commands/access_tier.ts @@ -0,0 +1,32 @@ +import { BaseCommand, args, flags } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' +import User from '#models/user' +import AccessService from '#services/access_service' + +/** Gives a user a tier (a payment, by hand until one is taken on the site); a running one is extended. */ +export default class AccessTier extends BaseCommand { + static commandName = 'access:tier' + static description = "Sets a user's tier, e.g. access:tier someone@example.com vip --days=365" + static options: CommandOptions = { startApp: true } + + @args.string({ description: 'The user’s email' }) + declare email: string + + @args.string({ description: 'Tier (config/access.ts)' }) + declare tier: string + + @flags.number({ description: 'For how many days (without: no end); the same tier still running is extended' }) + declare days?: number + + async run() { + const user = await User.findBy('email', this.email) + if (!user) { + this.logger.error(`No user ${this.email}`) + this.exitCode = 1 + return + } + AccessService.grantTier(user, this.tier, this.days ?? null) + await user.save() + this.logger.info(`${user.email}: ${user.tier} ${user.tierUntil ? `until ${user.tierUntil.toISO()}` : 'without end'}`) + } +} diff --git a/config/access.ts b/config/access.ts new file mode 100644 index 0000000..f7ee146 --- /dev/null +++ b/config/access.ts @@ -0,0 +1,31 @@ +import env from '#start/env' + +/** + * Who may use the catalog and the Newznab API. + * + * `open` (the default): as before, one key for Newznab (NEWZNAB_API_KEY, or + * none) and an open web catalog, no limits. `accounts`: every request names a + * user (a Newznab `apikey`, or the web interface's token), whose tier sets what + * they may do per UTC day and how far back they see. + */ +export const ACCESS = env.get('ACCESS', 'open') + +/** Who may sign up: anyone, with an unused invite code, or no one. With accounts, invites unless set. */ +export const SIGNUP = env.get('SIGNUP', ACCESS === 'accounts' ? 'invite' : 'open') + +export interface TierLimits { + /** Searches and release details per day (Newznab t=search & co., the catalog); null: no limit. */ + apiHits: number | null + /** NZBs per day; null: no limit. */ + grabs: number | null + /** How many days back releases are found and fetched; null: everything. */ + retentionDays: number | null +} + +/** A tier that runs out (users.tier_until) falls back to `free`. */ +export const TIERS: Record = { + free: { apiHits: 100, grabs: 10, retentionDays: 365 }, + vip: { apiHits: 10_000, grabs: 1_000, retentionDays: null }, + // Staff and friends. + unlimited: { apiHits: null, grabs: null, retentionDays: null }, +} diff --git a/database/migrations/1770000003300_access.ts b/database/migrations/1770000003300_access.ts new file mode 100644 index 0000000..ced082a --- /dev/null +++ b/database/migrations/1770000003300_access.ts @@ -0,0 +1,49 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Accounts with limits (config/access.ts): a Newznab key and a tier per user, + * invites, and what each user used per day. + */ +export default class extends BaseSchema { + async up() { + this.schema.alterTable('users', (table) => { + // Existing users get a key of their own (a volatile default is evaluated per row). + table.string('api_key', 32).notNullable().unique().defaultTo(this.raw(`md5(random()::text || clock_timestamp()::text)`)) + table.string('tier', 16).notNullable().defaultTo('free') + // Null: the tier doesn't run out. + table.timestamp('tier_until', { useTz: true }).nullable() + table.boolean('suspended').notNullable().defaultTo(false) + }) + + this.schema.createTable('invites', (table) => { + table.string('code', 32).primary() + // What the invite gives (e.g. a sold VIP invite); null: the free tier. + table.string('tier', 16).nullable() + table.integer('tier_days').nullable() + table.string('note').nullable() + table.integer('used_by').nullable().references('id').inTable('users').onDelete('SET NULL') + table.timestamp('used_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + + this.schema.createTable('usage', (table) => { + table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE') + // A UTC day. + table.date('day').notNullable() + table.integer('api_hits').notNullable().defaultTo(0) + table.integer('grabs').notNullable().defaultTo(0) + table.primary(['user_id', 'day']) + }) + } + + async down() { + this.schema.dropTable('usage') + this.schema.dropTable('invites') + this.schema.alterTable('users', (table) => { + table.dropColumn('api_key') + table.dropColumn('tier') + table.dropColumn('tier_until') + table.dropColumn('suspended') + }) + } +} diff --git a/database/schema.ts b/database/schema.ts index f836854..c1e67eb 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -87,6 +87,25 @@ export class GroupSchema extends BaseModel { declare tail: boolean } +export class InviteSchema extends BaseModel { + static $columns = ['code', 'createdAt', 'note', 'tier', 'tierDays', 'usedAt', 'usedBy'] as const + $columns = InviteSchema.$columns + @column({ isPrimary: true }) + declare code: string + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column() + declare note: string | null + @column() + declare tier: string | null + @column() + declare tierDays: number | null + @column.dateTime() + declare usedAt: DateTime | null + @column() + declare usedBy: number | null +} + export class NodeAccessTokenSchema extends BaseModel { static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const $columns = NodeAccessTokenSchema.$columns @@ -399,9 +418,24 @@ export class RuleSetSchema extends BaseModel { declare version: number } +export class UsageSchema extends BaseModel { + static $columns = ['apiHits', 'day', 'grabs', 'userId'] as const + $columns = UsageSchema.$columns + @column() + declare apiHits: number + @column.date() + declare day: DateTime + @column() + declare grabs: number + @column({ isPrimary: true }) + declare userId: number +} + export class UserSchema extends BaseModel { - static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const + static $columns = ['apiKey', 'createdAt', 'email', 'fullName', 'id', 'password', 'suspended', 'tier', 'tierUntil', 'updatedAt'] as const $columns = UserSchema.$columns + @column() + declare apiKey: string @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column() @@ -412,6 +446,12 @@ export class UserSchema extends BaseModel { declare id: number @column({ serializeAs: null }) declare password: string + @column() + declare suspended: boolean + @column() + declare tier: string + @column.dateTime() + declare tierUntil: DateTime | null @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null } diff --git a/lib/newznab/index.ts b/lib/newznab/index.ts index e28069d..494e716 100644 --- a/lib/newznab/index.ts +++ b/lib/newznab/index.ts @@ -2,4 +2,4 @@ * Newznab API formatting, independent of AdonisJS. From voyager 1 (its CONCEPT.md D23). */ export { CATEGORIES, categoryLabel, expandCategories, parentCategory } from './categories.js' -export { capsXml, errorXml, resultsXml, type NewznabItem, type NewznabServer } from './xml.js' +export { capsXml, errorXml, resultsXml, type ApiLimits, type NewznabItem, type NewznabServer } from './xml.js' diff --git a/lib/newznab/xml.ts b/lib/newznab/xml.ts index ff73d4a..92d209e 100644 --- a/lib/newznab/xml.ts +++ b/lib/newznab/xml.ts @@ -27,7 +27,7 @@ export interface NewznabServer { apiKey?: string } -/** Newznab error codes: 100 credentials, 200/201 parameters, 202 function, 300 item. */ +/** Newznab error codes: 100 credentials, 101 suspended, 102 privileges, 200/201 parameters, 202 function, 300 item, 500/501 request/download limit. */ /** Spotnet UBB markup (or the HTML of old spots) as plain text: [br] as line breaks, other tags dropped. */ export function ubbToText(ubb: string): string { return ubb @@ -44,11 +44,23 @@ export function errorXml(code: number, description: string): string { .end({ prettyPrint: true }) } -export function capsXml(server: NewznabServer, limits: { max: number; default: number }): string { +/** A caller's day so far and its limits (null: none), as nZEDb-style indexers report them. */ +export interface ApiLimits { + apiCurrent: number + apiMax: number | null + grabCurrent: number + grabMax: number | null +} + +export function capsXml( + server: NewznabServer, + limits: { max: number; default: number }, + registration = { available: false, open: false } +): string { const caps = create({ version: '1.0', encoding: 'UTF-8' }).ele('caps') caps.ele('server', { version: '1.0', title: server.title, strapline: 'Usenet indexer', url: `${server.baseUrl}/` }) caps.ele('limits', { max: String(limits.max), default: String(limits.default) }) - caps.ele('registration', { available: 'no', open: 'no' }) + caps.ele('registration', { available: registration.available ? 'yes' : 'no', open: registration.open ? 'yes' : 'no' }) const searching = caps.ele('searching') searching.ele('search', { available: 'yes', supportedParams: 'q' }) searching.ele('tv-search', { available: 'yes', supportedParams: 'q,season,ep' }) @@ -63,7 +75,7 @@ export function capsXml(server: NewznabServer, limits: { max: number; default: n return caps.end({ prettyPrint: true }) } -export function resultsXml(server: NewznabServer, items: NewznabItem[], offset: number, total: number): string { +export function resultsXml(server: NewznabServer, items: NewznabItem[], offset: number, total: number, limits?: ApiLimits): string { const rss = create({ version: '1.0', encoding: 'UTF-8' }).ele('rss', { 'version': '2.0', 'xmlns:atom': 'http://www.w3.org/2005/Atom', @@ -74,6 +86,14 @@ export function resultsXml(server: NewznabServer, items: NewznabItem[], offset: channel.ele('description').txt(`${server.title} API results`) channel.ele('link').txt(`${server.baseUrl}/`) channel.ele('newznab:response', { offset: String(offset), total: String(total) }) + if (limits) { + channel.ele('newznab:apilimits', { + apicurrent: String(limits.apiCurrent), + ...(limits.apiMax !== null && { apimax: String(limits.apiMax) }), + grabcurrent: String(limits.grabCurrent), + ...(limits.grabMax !== null && { grabmax: String(limits.grabMax) }), + }) + } const key = server.apiKey ? `&apikey=${encodeURIComponent(server.apiKey)}` : '' for (const item of items) { diff --git a/public/app.css b/public/app.css index 5185aec..4ebe578 100644 --- a/public/app.css +++ b/public/app.css @@ -891,3 +891,129 @@ kbd { margin-top: 4px; color: var(--faint); } + +/* --- Account (ACCESS=accounts) ------------------------------------------ */ + +.account-button { + height: 32px; + padding: 0 12px; + font-size: 13px; +} + +.account-button .tier { + color: var(--accent); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.04em; +} + +.dialog { + width: min(420px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} + +.dialog::backdrop { + background: rgba(10, 14, 20, 0.35); +} + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} + +.dialog-head h2 { + margin: 0; + font-size: 16px; +} + +.dialog-body { + padding: 18px; +} + +.form { + display: grid; + gap: 12px; +} + +.form label { + display: grid; + gap: 4px; + font-size: 13px; + color: var(--muted); +} + +.form input { + height: 38px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-size: 14px; +} + +.form input:focus { + outline: 2px solid var(--accent-soft); + border-color: var(--accent); +} + +.form .button { + justify-content: center; +} + +.form-switch { + margin: 4px 0 0; + font-size: 13px; + color: var(--muted); + text-align: center; +} + +.form-error { + margin: 0; + color: var(--bad); + font-size: 13px; +} + +.api-key { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 18px; +} + +.api-key code { + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg); + font-family: var(--mono); + font-size: 12.5px; + overflow-wrap: anywhere; +} + +.meter { + height: 6px; + margin-top: 4px; + border-radius: 3px; + background: var(--border); + overflow: hidden; +} + +.meter span { + display: block; + height: 100%; + background: var(--accent); +} + +.meter-full span { + background: var(--bad); +} diff --git a/public/app.js b/public/app.js index eca044a..e5c49cb 100644 --- a/public/app.js +++ b/public/app.js @@ -51,6 +51,26 @@ const state = { let requestId = 0 let searchAbort = null +/** Signing in (GET /api/v1/access): `open` needs none; with `accounts` the catalog takes a token. */ +const account = { + mode: 'open', + signup: 'open', + token: storage('get', 'token'), + /** The profile's access: tier, limits, today's usage, the Newznab key. */ + access: null, +} + +/** localStorage, which a private window may refuse. */ +function storage(action, key, value) { + try { + if (action === 'get') return localStorage.getItem(key) + if (action === 'set') localStorage.setItem(key, value) + else localStorage.removeItem(key) + } catch { + return null + } +} + // --- Formatting ------------------------------------------------------------ function formatSize(bytes) { @@ -147,12 +167,26 @@ function syncControls() { // --- API ------------------------------------------------------------------- -async function api(path, { signal } = {}) { - const response = await fetch(path, { headers: { accept: 'application/json' }, signal }) - if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) +async function api(path, { signal, method = 'GET', body } = {}) { + const headers = { accept: 'application/json' } + if (account.token) headers.authorization = `Bearer ${account.token}` + if (body) headers['content-type'] = 'application/json' + const response = await fetch(path, { method, headers, signal, body: body && JSON.stringify(body) }) + if (!response.ok) { + const error = await response.json().catch(() => ({})) + // A token that no longer works: sign in again. + if (response.status === 401 && account.mode === 'accounts' && !path.startsWith('/api/v1/auth/')) signOut() + throw new Error(error.error ?? error.errors?.[0]?.message ?? `${response.status} ${response.statusText}`) + } return response.json() } +/** A release's NZB as a plain link: with accounts it carries the key (a link can't send the token). */ +function nzbHref(id) { + const key = account.mode === 'accounts' && account.access?.apiKey + return `/api/v1/releases/${id}/nzb${key ? `?apikey=${encodeURIComponent(key)}` : ''}` +} + function searchParams(page) { const params = new URLSearchParams({ sort: state.sort, page: String(page), per_page: '50' }) if (state.q) params.set('q', state.q) @@ -229,7 +263,7 @@ function releaseRow(release) { row.querySelector('.release-size').textContent = formatSize(release.size) const nzb = row.querySelector('.nzb') - nzb.href = `/api/v1/releases/${release.id}/nzb` + nzb.href = nzbHref(release.id) row.querySelector('.release-main').addEventListener('click', () => openRelease(release.id, true)) return row } @@ -264,6 +298,8 @@ async function loadResults({ append = false } = {}) { (shown < result.total ? ` · showing ${number.format(shown)}` : '') $('#empty').hidden = result.total !== 0 $('#more').hidden = shown >= result.total && !result.totalCapped + // The day's count in the account button (the profile isn't counted). + if (account.access) loadProfile().catch(() => {}) } catch (error) { if (id !== requestId) return $('#list').classList.remove('stale') @@ -299,7 +335,7 @@ function renderDetail(release) { const actions = el( 'div', { class: 'actions' }, - el('a', { class: 'button button-primary', href: `/api/v1/releases/${release.id}/nzb` }, + el('a', { class: 'button button-primary', href: nzbHref(release.id) }, el('span', { text: 'Download NZB' })), el('button', { class: 'button', @@ -418,8 +454,151 @@ function closeRelease(push = false) { document.querySelector(`.release[data-id="${id}"] .release-main`)?.focus() } +// --- Account (ACCESS=accounts) --------------------------------------------- + +function renderAccountButton() { + const button = $('#account') + button.hidden = account.mode !== 'accounts' + if (!account.access) return button.replaceChildren('Sign in') + const { tier, usage, limits } = account.access + const of = (used, max) => (max === null ? number.format(used) : `${number.format(used)}/${number.format(max)}`) + button.title = 'Searches and NZBs today' + button.replaceChildren(el('span', { class: 'tier', text: tier }), ` ${of(usage.apiHits, limits.apiHits)} · ${of(usage.grabs, limits.grabs)} NZB`) +} + +async function loadProfile() { + const { data } = await api('/api/v1/account/profile') + account.access = data.access + renderAccountButton() +} + +/** Signed in with a new token: the profile, then the search that was waiting. */ +async function signedIn(token) { + account.token = token + storage('set', 'token', token) + await loadProfile() + $('#account-dialog').close() + loadResults() + if (state.release) openRelease(state.release) +} + +function signOut() { + if (account.token) api('/api/v1/account/logout', { method: 'POST' }).catch(() => {}) + account.token = null + account.access = null + storage('remove', 'token') + renderAccountButton() + closeRelease() + $('#list').replaceChildren() + $('#more').hidden = true + $('#empty').hidden = true + $('#summary').textContent = 'Sign in to search the index.' + openAccount('signin') +} + +function field(label, props) { + return el('label', {}, label, el('input', props)) +} + +function accountForm(view) { + const signup = view === 'signup' + const error = el('p', { class: 'form-error', role: 'alert', hidden: true }) + const form = el( + 'form', + { class: 'form' }, + field('Email', { name: 'email', type: 'email', autocomplete: 'email', required: true }), + field('Password', { name: 'password', type: 'password', autocomplete: signup ? 'new-password' : 'current-password', required: true, minlength: signup ? '8' : null }), + signup ? field('Password again', { name: 'passwordConfirmation', type: 'password', autocomplete: 'new-password', required: true }) : null, + signup && account.signup === 'invite' ? field('Invite code', { name: 'invite', autocomplete: 'off', spellcheck: 'false', required: true }) : null, + error, + el('button', { class: 'button button-primary', type: 'submit', text: signup ? 'Create account' : 'Sign in' }) + ) + form.addEventListener('submit', async (event) => { + event.preventDefault() + const values = Object.fromEntries(new FormData(form)) + error.hidden = true + try { + const path = signup ? '/api/v1/auth/signup' : '/api/v1/auth/login' + const { data } = await api(path, { method: 'POST', body: signup ? { fullName: null, ...values } : values }) + await signedIn(data.token) + } catch (failure) { + error.textContent = failure.message + error.hidden = false + } + }) + const other = signup + ? el('p', { class: 'form-switch' }, 'Have an account? ', el('button', { class: 'link-button', type: 'button', text: 'Sign in', onclick: () => openAccount('signin') })) + : account.signup !== 'closed' + ? el('p', { class: 'form-switch' }, 'New here? ', el('button', { class: 'link-button', type: 'button', text: 'Create an account', onclick: () => openAccount('signup') })) + : null + return [form, other] +} + +function meter(used, max) { + if (max === null) return null + const share = Math.min(1, used / Math.max(1, max)) + return el('div', { class: `meter${used >= max ? ' meter-full' : ''}` }, el('span', { style: `width: ${Math.round(share * 100)}%` })) +} + +function profileView() { + const { tier, tierUntil, limits, usage, apiKey } = account.access + const of = (used, max) => (max === null ? `${number.format(used)} (no limit)` : `${number.format(used)} of ${number.format(max)}`) + const key = el('code', { text: apiKey }) + return [ + el('dl', { class: 'facts' }, + fact('Tier', tierUntil ? `${tier} until ${new Date(tierUntil).toLocaleDateString()}` : tier), + fact('Searches today', el('div', {}, of(usage.apiHits, limits.apiHits), meter(usage.apiHits, limits.apiHits))), + fact('NZBs today', el('div', {}, of(usage.grabs, limits.grabs), meter(usage.grabs, limits.grabs))), + fact('Retention', limits.retentionDays ? `${number.format(limits.retentionDays)} days` : 'Everything')), + ...section('Newznab API key', 'for Prowlarr, Sonarr, Radarr', el('div', { class: 'api-key' }, + key, + el('button', { class: 'button', type: 'button', text: 'Copy', onclick: async (event) => { + await navigator.clipboard?.writeText(account.access.apiKey) + event.target.textContent = 'Copied' + setTimeout(() => (event.target.textContent = 'Copy'), 1200) + } }), + el('button', { class: 'button', type: 'button', text: 'New key', title: 'The old key stops working', onclick: async () => { + if (!confirm('Make a new key? Apps using the old one stop working until they get the new one.')) return + const { apiKey: fresh } = await api('/api/v1/account/api-key', { method: 'POST' }) + account.access.apiKey = fresh + key.textContent = fresh + } }))), + el('p', { class: 'form-switch' }, `URL for your apps: ${location.origin}`), + el('button', { class: 'button', type: 'button', text: 'Sign out', onclick: () => signOut() }), + ] +} + +async function openAccount(view) { + const dialog = $('#account-dialog') + if (view === 'profile') await loadProfile().catch(() => {}) + if (view === 'profile' && !account.access) return + $('#account-title').textContent = { signin: 'Sign in', signup: 'Create an account', profile: 'Your account' }[view] + $('#account-body').replaceChildren(...(view === 'profile' ? profileView() : accountForm(view))) + if (!dialog.open) dialog.showModal() + dialog.querySelector('input')?.focus() +} + +/** With accounts, nothing is searched before signing in. */ +async function startAccess() { + try { + Object.assign(account, await api('/api/v1/access')) + } catch { + // An older server: open. + } + if (account.mode !== 'accounts') return true + if (account.token) await loadProfile().catch(() => {}) + renderAccountButton() + if (account.access) return true + $('#summary').textContent = 'Sign in to search the index.' + if (!$('#account-dialog').open) openAccount('signin') + return false +} + // --- Wiring ---------------------------------------------------------------- +$('#account').addEventListener('click', () => openAccount(account.access ? 'profile' : 'signin')) +$('#account-close').addEventListener('click', () => $('#account-dialog').close()) + $('#q').addEventListener('input', (event) => { state.q = event.target.value.trim() scheduleSearch() @@ -495,5 +674,8 @@ window.addEventListener('popstate', () => { readUrl() syncControls() loadStats() -loadResults() -if (state.release) openRelease(state.release) +startAccess().then((ready) => { + if (!ready) return + loadResults() + if (state.release) openRelease(state.release) +}) diff --git a/public/index.html b/public/index.html index 6b73f85..0fb9ef5 100644 --- a/public/index.html +++ b/public/index.html @@ -14,8 +14,19 @@ Voyager 2
+ + +
+

+ +
+
+
+