Compare commits
1
Commits
rebuild
...
monetization
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1322e42fb7 |
No files matched your search
@@ -19,6 +19,11 @@ DB_USER=usenet_indexer
|
|||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_DATABASE=usenet_rebuild
|
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)
|
# CORS (configure allowed origins for API access)
|
||||||
#--------------------------------------------------------------------
|
#--------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import User from '#models/user'
|
import User from '#models/user'
|
||||||
|
import db from '@adonisjs/lucid/services/db'
|
||||||
import { signupValidator } from '#validators/user'
|
import { signupValidator } from '#validators/user'
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
import UserTransformer from '#transformers/user_transformer'
|
import UserTransformer from '#transformers/user_transformer'
|
||||||
|
import AccessService from '#services/access_service'
|
||||||
|
|
||||||
|
class InviteGone extends Error {}
|
||||||
|
|
||||||
export default class NewAccountController {
|
export default class NewAccountController {
|
||||||
async store({ request, serialize }: HttpContext) {
|
async store({ request, response, serialize }: HttpContext) {
|
||||||
const { fullName, email, password } = await request.validateUsing(signupValidator)
|
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)
|
const token = await User.accessTokens.create(user)
|
||||||
|
|
||||||
return serialize({
|
return serialize({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { HttpContext } from '@adonisjs/core/http'
|
|||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
import NzbService from '#services/nzb_service'
|
import NzbService from '#services/nzb_service'
|
||||||
import SearchService from '#services/search_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'
|
import { capsXml, errorXml, expandCategories, resultsXml, type NewznabServer } from '#lib/newznab/index'
|
||||||
|
|
||||||
const LIMITS = { max: 100, default: 50 }
|
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.
|
* 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 {
|
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 qs = request.qs()
|
||||||
const t = String(qs.t ?? '')
|
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()
|
// With accounts the key is a user's (AccessService); else the one configured, if any.
|
||||||
if (apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials'))
|
const grant = await AccessService.authorize(ctx, qs.apikey)
|
||||||
if (t === 'caps') return xml(capsXml(server, LIMITS))
|
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') {
|
if (t === 'get') {
|
||||||
const id = int(qs.id)
|
const id = int(qs.id)
|
||||||
if (id === undefined) return xml(errorXml(200, 'Missing parameter (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)
|
const nzb = await NzbService.build(id)
|
||||||
if (!nzb) return xml(errorXml(300, 'No such item'))
|
if (!nzb) return xml(errorXml(300, 'No such item'))
|
||||||
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
|
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'})`))
|
if (!(t in SEARCH_FUNCTIONS)) return xml(errorXml(202, `No such function (${t || 'none'})`))
|
||||||
|
await AccessService.charge(grant, 'api')
|
||||||
|
|
||||||
const requested = String(qs.cat ?? '')
|
const requested = String(qs.cat ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -79,7 +96,7 @@ export default class NewznabController {
|
|||||||
categories,
|
categories,
|
||||||
readable: true,
|
readable: true,
|
||||||
notIncomplete: true,
|
notIncomplete: true,
|
||||||
maxAgeDays: int(qs.maxage),
|
maxAgeDays: AccessService.maxAgeDays(grant, int(qs.maxage)),
|
||||||
season: int(qs.season),
|
season: int(qs.season),
|
||||||
episode: int(qs.season) !== undefined ? int(qs.ep) : undefined,
|
episode: int(qs.season) !== undefined ? int(qs.ep) : undefined,
|
||||||
sort: 'newest',
|
sort: 'newest',
|
||||||
@@ -105,12 +122,20 @@ export default class NewznabController {
|
|||||||
episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined,
|
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 proto = request.header('x-forwarded-proto') ?? 'http'
|
||||||
const host = request.header('x-forwarded-host') ?? request.header('host') ?? 'localhost'
|
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 UserTransformer from '#transformers/user_transformer'
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
import AccessService from '#services/access_service'
|
||||||
|
|
||||||
export default class ProfileController {
|
export default class ProfileController {
|
||||||
|
/** The user, their tier, Newznab key and what they used today. */
|
||||||
async show({ auth, serialize }: HttpContext) {
|
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 type { HttpContext } from '@adonisjs/core/http'
|
||||||
import NzbService from '#services/nzb_service'
|
import NzbService from '#services/nzb_service'
|
||||||
|
import AccessService from '#services/access_service'
|
||||||
import SearchService, { type SortOrder } from '#services/search_service'
|
import SearchService, { type SortOrder } from '#services/search_service'
|
||||||
import { expandCategories } from '#lib/newznab/index'
|
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). */
|
/** The catalog for the web interface (REBUILD.md, part 9). */
|
||||||
export default class ReleasesController {
|
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= */
|
/** 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) {
|
async index(ctx: HttpContext) {
|
||||||
const qs = request.qs()
|
const qs = ctx.request.qs()
|
||||||
|
const grant = await AccessService.authorize(ctx, qs.apikey)
|
||||||
|
await AccessService.charge(grant, 'api')
|
||||||
const categories = String(qs.cat ?? '')
|
const categories = String(qs.cat ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((c) => Number(c.trim()))
|
.map((c) => Number(c.trim()))
|
||||||
.filter((c) => Number.isInteger(c) && c > 0)
|
.filter((c) => Number.isInteger(c) && c > 0)
|
||||||
return SearchService.search({
|
return SearchService.search({
|
||||||
|
maxAgeDays: AccessService.maxAgeDays(grant),
|
||||||
q: typeof qs.q === 'string' ? qs.q : undefined,
|
q: typeof qs.q === 'string' ? qs.q : undefined,
|
||||||
categories: categories.length ? expandCategories(categories) : undefined,
|
categories: categories.length ? expandCategories(categories) : undefined,
|
||||||
readable: flag(qs.readable),
|
readable: flag(qs.readable),
|
||||||
@@ -39,13 +43,20 @@ export default class ReleasesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/v1/releases/:id, with its files */
|
/** GET /api/v1/releases/:id, with its files */
|
||||||
async show({ params, response }: HttpContext) {
|
async show(ctx: HttpContext) {
|
||||||
const release = await SearchService.release(Number(params.id))
|
const grant = await AccessService.authorize(ctx, ctx.request.qs().apikey)
|
||||||
return release ?? response.notFound({ error: 'Release not found' })
|
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 */
|
/** GET /api/v1/releases/:id/nzb (?apikey= for a plain link, as the web interface's) */
|
||||||
async nzb({ params, response }: HttpContext) {
|
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))
|
const nzb = await NzbService.build(Number(params.id))
|
||||||
if (!nzb) return response.notFound({ error: 'Release not found' })
|
if (!nzb) return response.notFound({ error: 'Release not found' })
|
||||||
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
|
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)
|
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() {
|
async stats() {
|
||||||
return SearchService.stats()
|
return SearchService.stats()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export default class HttpExceptionHandler extends ExceptionHandler {
|
|||||||
*/
|
*/
|
||||||
protected debug = !app.inProduction
|
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
|
* The method is used for handling errors and returning
|
||||||
* response to the client
|
* response to the client
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
import { UserSchema } from '#database/schema'
|
import { UserSchema } from '#database/schema'
|
||||||
|
import { beforeCreate } from '@adonisjs/lucid/orm'
|
||||||
import hash from '@adonisjs/core/services/hash'
|
import hash from '@adonisjs/core/services/hash'
|
||||||
import { compose } from '@adonisjs/core/helpers'
|
import { compose } from '@adonisjs/core/helpers'
|
||||||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
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)
|
static accessTokens = DbAccessTokensProvider.forModel(User)
|
||||||
declare currentAccessToken?: AccessToken
|
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() {
|
get initials() {
|
||||||
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
|
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
|
||||||
if (first && last) {
|
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' }),
|
email: email().unique({ table: 'users', column: 'email' }),
|
||||||
password: password(),
|
password: password(),
|
||||||
passwordConfirmation: password().sameAs('password'),
|
passwordConfirmation: password().sameAs('password'),
|
||||||
|
// Needed when SIGNUP is `invite` (config/access.ts).
|
||||||
|
invite: vine.string().trim().maxLength(32).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, TierLimits> = {
|
||||||
|
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 },
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-1
@@ -87,6 +87,25 @@ export class GroupSchema extends BaseModel {
|
|||||||
declare tail: boolean
|
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 {
|
export class NodeAccessTokenSchema extends BaseModel {
|
||||||
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
|
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
|
||||||
$columns = NodeAccessTokenSchema.$columns
|
$columns = NodeAccessTokenSchema.$columns
|
||||||
@@ -399,9 +418,24 @@ export class RuleSetSchema extends BaseModel {
|
|||||||
declare version: number
|
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 {
|
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
|
$columns = UserSchema.$columns
|
||||||
|
@column()
|
||||||
|
declare apiKey: string
|
||||||
@column.dateTime({ autoCreate: true })
|
@column.dateTime({ autoCreate: true })
|
||||||
declare createdAt: DateTime
|
declare createdAt: DateTime
|
||||||
@column()
|
@column()
|
||||||
@@ -412,6 +446,12 @@ export class UserSchema extends BaseModel {
|
|||||||
declare id: number
|
declare id: number
|
||||||
@column({ serializeAs: null })
|
@column({ serializeAs: null })
|
||||||
declare password: string
|
declare password: string
|
||||||
|
@column()
|
||||||
|
declare suspended: boolean
|
||||||
|
@column()
|
||||||
|
declare tier: string
|
||||||
|
@column.dateTime()
|
||||||
|
declare tierUntil: DateTime | null
|
||||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||||
declare updatedAt: DateTime | null
|
declare updatedAt: DateTime | null
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
* Newznab API formatting, independent of AdonisJS. From voyager 1 (its CONCEPT.md D23).
|
* Newznab API formatting, independent of AdonisJS. From voyager 1 (its CONCEPT.md D23).
|
||||||
*/
|
*/
|
||||||
export { CATEGORIES, categoryLabel, expandCategories, parentCategory } from './categories.js'
|
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'
|
||||||
+24
-4
@@ -27,7 +27,7 @@ export interface NewznabServer {
|
|||||||
apiKey?: string
|
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. */
|
/** 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 {
|
export function ubbToText(ubb: string): string {
|
||||||
return ubb
|
return ubb
|
||||||
@@ -44,11 +44,23 @@ export function errorXml(code: number, description: string): string {
|
|||||||
.end({ prettyPrint: true })
|
.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')
|
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('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('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')
|
const searching = caps.ele('searching')
|
||||||
searching.ele('search', { available: 'yes', supportedParams: 'q' })
|
searching.ele('search', { available: 'yes', supportedParams: 'q' })
|
||||||
searching.ele('tv-search', { available: 'yes', supportedParams: 'q,season,ep' })
|
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 })
|
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', {
|
const rss = create({ version: '1.0', encoding: 'UTF-8' }).ele('rss', {
|
||||||
'version': '2.0',
|
'version': '2.0',
|
||||||
'xmlns:atom': 'http://www.w3.org/2005/Atom',
|
'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('description').txt(`${server.title} API results`)
|
||||||
channel.ele('link').txt(`${server.baseUrl}/`)
|
channel.ele('link').txt(`${server.baseUrl}/`)
|
||||||
channel.ele('newznab:response', { offset: String(offset), total: String(total) })
|
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)}` : ''
|
const key = server.apiKey ? `&apikey=${encodeURIComponent(server.apiKey)}` : ''
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
+126
@@ -891,3 +891,129 @@ kbd {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: var(--faint);
|
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);
|
||||||
|
}
|
||||||
+189
-7
@@ -51,6 +51,26 @@ const state = {
|
|||||||
let requestId = 0
|
let requestId = 0
|
||||||
let searchAbort = null
|
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 ------------------------------------------------------------
|
// --- Formatting ------------------------------------------------------------
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
@@ -147,12 +167,26 @@ function syncControls() {
|
|||||||
|
|
||||||
// --- API -------------------------------------------------------------------
|
// --- API -------------------------------------------------------------------
|
||||||
|
|
||||||
async function api(path, { signal } = {}) {
|
async function api(path, { signal, method = 'GET', body } = {}) {
|
||||||
const response = await fetch(path, { headers: { accept: 'application/json' }, signal })
|
const headers = { accept: 'application/json' }
|
||||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
|
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()
|
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) {
|
function searchParams(page) {
|
||||||
const params = new URLSearchParams({ sort: state.sort, page: String(page), per_page: '50' })
|
const params = new URLSearchParams({ sort: state.sort, page: String(page), per_page: '50' })
|
||||||
if (state.q) params.set('q', state.q)
|
if (state.q) params.set('q', state.q)
|
||||||
@@ -229,7 +263,7 @@ function releaseRow(release) {
|
|||||||
|
|
||||||
row.querySelector('.release-size').textContent = formatSize(release.size)
|
row.querySelector('.release-size').textContent = formatSize(release.size)
|
||||||
const nzb = row.querySelector('.nzb')
|
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))
|
row.querySelector('.release-main').addEventListener('click', () => openRelease(release.id, true))
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
@@ -264,6 +298,8 @@ async function loadResults({ append = false } = {}) {
|
|||||||
(shown < result.total ? ` · showing ${number.format(shown)}` : '')
|
(shown < result.total ? ` · showing ${number.format(shown)}` : '')
|
||||||
$('#empty').hidden = result.total !== 0
|
$('#empty').hidden = result.total !== 0
|
||||||
$('#more').hidden = shown >= result.total && !result.totalCapped
|
$('#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) {
|
} catch (error) {
|
||||||
if (id !== requestId) return
|
if (id !== requestId) return
|
||||||
$('#list').classList.remove('stale')
|
$('#list').classList.remove('stale')
|
||||||
@@ -299,7 +335,7 @@ function renderDetail(release) {
|
|||||||
const actions = el(
|
const actions = el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'actions' },
|
{ 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('span', { text: 'Download NZB' })),
|
||||||
el('button', {
|
el('button', {
|
||||||
class: 'button',
|
class: 'button',
|
||||||
@@ -418,8 +454,151 @@ function closeRelease(push = false) {
|
|||||||
document.querySelector(`.release[data-id="${id}"] .release-main`)?.focus()
|
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 ----------------------------------------------------------------
|
// --- Wiring ----------------------------------------------------------------
|
||||||
|
|
||||||
|
$('#account').addEventListener('click', () => openAccount(account.access ? 'profile' : 'signin'))
|
||||||
|
$('#account-close').addEventListener('click', () => $('#account-dialog').close())
|
||||||
|
|
||||||
$('#q').addEventListener('input', (event) => {
|
$('#q').addEventListener('input', (event) => {
|
||||||
state.q = event.target.value.trim()
|
state.q = event.target.value.trim()
|
||||||
scheduleSearch()
|
scheduleSearch()
|
||||||
@@ -495,5 +674,8 @@ window.addEventListener('popstate', () => {
|
|||||||
readUrl()
|
readUrl()
|
||||||
syncControls()
|
syncControls()
|
||||||
loadStats()
|
loadStats()
|
||||||
loadResults()
|
startAccess().then((ready) => {
|
||||||
if (state.release) openRelease(state.release)
|
if (!ready) return
|
||||||
|
loadResults()
|
||||||
|
if (state.release) openRelease(state.release)
|
||||||
|
})
|
||||||
@@ -14,8 +14,19 @@
|
|||||||
<span>Voyager 2</span>
|
<span>Voyager 2</span>
|
||||||
</a>
|
</a>
|
||||||
<dl class="stats" id="stats" aria-label="Index statistics"></dl>
|
<dl class="stats" id="stats" aria-label="Index statistics"></dl>
|
||||||
|
<button type="button" class="button account-button" id="account" hidden></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<dialog class="dialog" id="account-dialog" aria-labelledby="account-title">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<h2 id="account-title"></h2>
|
||||||
|
<button type="button" class="icon-button" id="account-close" aria-label="Close">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body" id="account-body"></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<section class="search" aria-label="Search">
|
<section class="search" aria-label="Search">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export default await Env.create(new URL('../', import.meta.url), {
|
|||||||
// Newznab API key (empty: no key needed)
|
// Newznab API key (empty: no key needed)
|
||||||
NEWZNAB_API_KEY: Env.schema.secret.optional(),
|
NEWZNAB_API_KEY: Env.schema.secret.optional(),
|
||||||
|
|
||||||
|
// Access (config/access.ts): open (one key, no limits) or accounts (a key per user, tiers with daily limits)
|
||||||
|
ACCESS: Env.schema.enum.optional(['open', 'accounts'] as const),
|
||||||
|
SIGNUP: Env.schema.enum.optional(['open', 'invite', 'closed'] as const),
|
||||||
|
|
||||||
// Coordinator
|
// Coordinator
|
||||||
RANGE_ARTICLES: Env.schema.number.optional(),
|
RANGE_ARTICLES: Env.schema.number.optional(),
|
||||||
TAIL_EVERY_SECONDS: Env.schema.number.optional(),
|
TAIL_EVERY_SECONDS: Env.schema.number.optional(),
|
||||||
|
|||||||
+6
-1
@@ -45,13 +45,18 @@ router
|
|||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.get('profile', [controllers.Profile, 'show'])
|
router.get('profile', [controllers.Profile, 'show'])
|
||||||
|
router.post('api-key', [controllers.Profile, 'resetApiKey'])
|
||||||
router.post('logout', [controllers.AccessTokens, 'destroy'])
|
router.post('logout', [controllers.AccessTokens, 'destroy'])
|
||||||
})
|
})
|
||||||
.prefix('account')
|
.prefix('account')
|
||||||
.as('profile')
|
.as('profile')
|
||||||
.use(middleware.auth())
|
.use(middleware.auth())
|
||||||
|
|
||||||
// The catalog (REBUILD.md, part 9): open, as voyager 1's (the web interface uses it); Newznab has its key.
|
// How the web interface signs in: open, or accounts with limits (config/access.ts).
|
||||||
|
router.get('access', [controllers.Releases, 'access'])
|
||||||
|
|
||||||
|
// The catalog (REBUILD.md, part 9): open, as voyager 1's (the web interface uses it), unless ACCESS
|
||||||
|
// is `accounts`: then a user's token or apikey, counted as Newznab's are (AccessService).
|
||||||
router.get('releases', [controllers.Releases, 'index'])
|
router.get('releases', [controllers.Releases, 'index'])
|
||||||
router.get('releases/:id', [controllers.Releases, 'show']).where('id', router.matchers.number())
|
router.get('releases/:id', [controllers.Releases, 'show']).where('id', router.matchers.number())
|
||||||
router.get('releases/:id/nzb', [controllers.Releases, 'nzb']).where('id', router.matchers.number())
|
router.get('releases/:id/nzb', [controllers.Releases, 'nzb']).where('id', router.matchers.number())
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import db from '@adonisjs/lucid/services/db'
|
||||||
|
import testUtils from '@adonisjs/core/services/test_utils'
|
||||||
|
import User from '#models/user'
|
||||||
|
import BatchStore from '#services/batch_store'
|
||||||
|
import AccessService from '#services/access_service'
|
||||||
|
import { TIERS } from '#config/access'
|
||||||
|
import { RangeIndexer } from '#lib/edge/index'
|
||||||
|
import { DEFAULT_RULES } from '#lib/rules/index'
|
||||||
|
import type { Overview } from '#lib/nntp/index'
|
||||||
|
|
||||||
|
const GROUP = 'alt.binaries.test'
|
||||||
|
|
||||||
|
function file(name: string, counter: string, parts: number, at: number): Overview[] {
|
||||||
|
return Array.from({ length: parts }, (_, i) => [at + i, {
|
||||||
|
'subject': `${counter} - "${name}" yEnc (${i + 1}/${parts})`, 'from': 'p <p@x>', 'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
|
||||||
|
'message-id': `<${name}-${i + 1}@test>`, ':bytes': String(700000 + i), 'xref': `news.example ${GROUP}:${at + i}`,
|
||||||
|
}] as Overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two releases: a show (recent) and a movie posted two years ago. */
|
||||||
|
async function catalog() {
|
||||||
|
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||||
|
const [n] = await db.table('nodes').insert({ name: 'access-node' }).returning('id')
|
||||||
|
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: n.id }).returning('id')
|
||||||
|
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
|
||||||
|
indexer.add([...file('Some.Show.S01E01.1080p.WEB.mkv', 'Some.Show.S01E01.1080p.WEB [1/1]', 2, 100), ...file('Old.Movie.2024.1080p.BluRay.mkv', 'Old.Movie.2024.1080p.BluRay [1/1]', 1, 300)], 1001)
|
||||||
|
indexer.finish()
|
||||||
|
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
|
||||||
|
const show = await db.from('releases').where('name', 'ilike', '%show%').firstOrFail()
|
||||||
|
const movie = await db.from('releases').where('name', 'ilike', '%movie%').firstOrFail()
|
||||||
|
await db.from('releases').where('id', movie.id).update({ posted_at: DateTime.now().minus({ years: 2 }).toJSDate() })
|
||||||
|
return { show: Number(show.id), movie: Number(movie.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = (email = 'someone@example.com') => User.create({ email, password: 'secret-password' })
|
||||||
|
|
||||||
|
test.group('Access with accounts', (group) => {
|
||||||
|
const free = { ...TIERS.free }
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
group.each.setup(() => {
|
||||||
|
AccessService.mode = 'accounts'
|
||||||
|
AccessService.signup = 'invite'
|
||||||
|
Object.assign(TIERS.free, { apiHits: 3, grabs: 1, retentionDays: 365 })
|
||||||
|
return () => {
|
||||||
|
AccessService.mode = 'open'
|
||||||
|
AccessService.signup = 'open'
|
||||||
|
Object.assign(TIERS.free, free)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Newznab takes a user's key, counts searches and grabs per day and keeps to the tier's retention", async ({ client, assert }) => {
|
||||||
|
const { show, movie } = await catalog()
|
||||||
|
const someone = await user()
|
||||||
|
|
||||||
|
assert.include((await client.get('/api?t=search&q=')).text(), 'code="100"')
|
||||||
|
assert.include((await client.get('/api?t=search&q=&apikey=0123456789abcdef0123456789abcdef')).text(), 'code="100"')
|
||||||
|
// caps isn't counted.
|
||||||
|
assert.include((await client.get(`/api?t=caps&apikey=${someone.apiKey}`)).text(), '<registration available="yes" open="no"/>')
|
||||||
|
|
||||||
|
const feed = (await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)).text()
|
||||||
|
// The free tier sees a year back: the movie isn't found.
|
||||||
|
assert.include(feed, 'Some Show S01E01')
|
||||||
|
assert.notInclude(feed, 'Old Movie')
|
||||||
|
assert.include(feed, `t=get&id=${show}&apikey=${someone.apiKey}`)
|
||||||
|
assert.include(feed, '<newznab:apilimits apicurrent="1" apimax="3" grabcurrent="0" grabmax="1"/>')
|
||||||
|
|
||||||
|
// Past the retention: refused, and not counted as a grab.
|
||||||
|
assert.include((await client.get(`/api?t=get&id=${movie}&apikey=${someone.apiKey}`)).text(), 'code="102"')
|
||||||
|
assert.include((await client.get(`/api?t=get&id=999999&apikey=${someone.apiKey}`)).text(), 'code="300"')
|
||||||
|
const nzb = await client.get(`/api?t=get&id=${show}&apikey=${someone.apiKey}`)
|
||||||
|
assert.include(nzb.header('content-type'), 'application/x-nzb')
|
||||||
|
assert.include((await client.get(`/api?t=get&id=${show}&apikey=${someone.apiKey}`)).text(), 'code="501"')
|
||||||
|
|
||||||
|
await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)
|
||||||
|
await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)
|
||||||
|
assert.include((await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)).text(), 'code="500"')
|
||||||
|
assert.deepEqual(await AccessService.usage(someone.id), { apiHits: 3, grabs: 1 })
|
||||||
|
|
||||||
|
// VIP sees everything; a tier that ran out is free again.
|
||||||
|
AccessService.grantTier(someone, 'vip', 30)
|
||||||
|
await someone.save()
|
||||||
|
assert.include((await client.get(`/api?t=movie&q=old movie&apikey=${someone.apiKey}`)).text(), 'Old Movie 2024')
|
||||||
|
someone.tierUntil = DateTime.now().minus({ days: 1 })
|
||||||
|
await someone.save()
|
||||||
|
assert.include((await client.get(`/api?t=movie&q=old movie&apikey=${someone.apiKey}`)).text(), 'code="500"')
|
||||||
|
|
||||||
|
someone.suspended = true
|
||||||
|
await someone.save()
|
||||||
|
assert.include((await client.get(`/api?t=caps&apikey=${someone.apiKey}`)).text(), 'code="101"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the catalog needs a token or a key, and counts as Newznab does', async ({ client, assert }) => {
|
||||||
|
const { show, movie } = await catalog()
|
||||||
|
const someone = await user()
|
||||||
|
|
||||||
|
;(await client.get('/api/v1/releases')).assertStatus(401)
|
||||||
|
;(await client.get(`/api/v1/releases/${show}/nzb`)).assertStatus(401)
|
||||||
|
// Stats and how to sign in stay open.
|
||||||
|
;(await client.get('/api/v1/stats')).assertStatus(200)
|
||||||
|
assert.deepEqual((await client.get('/api/v1/access')).body(), { mode: 'accounts', signup: 'invite' })
|
||||||
|
|
||||||
|
const found = await client.get('/api/v1/releases').loginAs(someone)
|
||||||
|
found.assertStatus(200)
|
||||||
|
assert.deepEqual((found.body() as { releases: { id: number }[] }).releases.map((r) => r.id), [show])
|
||||||
|
;(await client.get(`/api/v1/releases/${movie}`).loginAs(someone)).assertStatus(403)
|
||||||
|
;(await client.get(`/api/v1/releases/${show}`).loginAs(someone)).assertStatus(200)
|
||||||
|
|
||||||
|
// A plain link (the web interface's download button) carries the key.
|
||||||
|
const nzb = await client.get(`/api/v1/releases/${show}/nzb?apikey=${someone.apiKey}`)
|
||||||
|
nzb.assertStatus(200)
|
||||||
|
const over = await client.get(`/api/v1/releases/${show}/nzb?apikey=${someone.apiKey}`)
|
||||||
|
over.assertStatus(429)
|
||||||
|
assert.deepEqual(over.body(), { error: 'Download limit reached', code: 501 })
|
||||||
|
// The refused movie wasn't counted: a third search is left.
|
||||||
|
;(await client.get('/api/v1/releases').loginAs(someone)).assertStatus(200)
|
||||||
|
;(await client.get('/api/v1/releases').loginAs(someone)).assertStatus(429)
|
||||||
|
|
||||||
|
const profile = ((await client.get('/api/v1/account/profile').loginAs(someone)).body() as any).data
|
||||||
|
assert.equal(profile.access.tier, 'free')
|
||||||
|
assert.equal(profile.access.apiKey, someone.apiKey)
|
||||||
|
assert.deepEqual(profile.access.usage, { apiHits: 3, grabs: 1 })
|
||||||
|
|
||||||
|
const reset = (await client.post('/api/v1/account/api-key').loginAs(someone)).body() as { apiKey: string }
|
||||||
|
assert.notEqual(reset.apiKey, someone.apiKey)
|
||||||
|
assert.include((await client.get(`/api?t=caps&apikey=${someone.apiKey}`)).text(), 'code="100"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('signup takes an unused invite, which may give a tier', async ({ client, assert }) => {
|
||||||
|
const form = (email: string, invite?: string) => ({ email, password: 'secret-password', passwordConfirmation: 'secret-password', fullName: null, invite })
|
||||||
|
await db.table('invites').insert([{ code: 'plain-invite' }, { code: 'vip-invite', tier: 'vip', tier_days: 365 }])
|
||||||
|
|
||||||
|
;(await client.post('/api/v1/auth/signup').json(form('a@example.com'))).assertStatus(403)
|
||||||
|
;(await client.post('/api/v1/auth/signup').json(form('a@example.com', 'no-such-code'))).assertStatus(403)
|
||||||
|
// A refused invite leaves no account behind.
|
||||||
|
assert.isNull(await User.findBy('email', 'a@example.com'))
|
||||||
|
|
||||||
|
;(await client.post('/api/v1/auth/signup').json(form('a@example.com', 'plain-invite'))).assertStatus(200)
|
||||||
|
;(await client.post('/api/v1/auth/signup').json(form('b@example.com', 'plain-invite'))).assertStatus(403)
|
||||||
|
const vip = await client.post('/api/v1/auth/signup').json(form('c@example.com', 'vip-invite'))
|
||||||
|
vip.assertStatus(200)
|
||||||
|
const c = await User.findByOrFail('email', 'c@example.com')
|
||||||
|
assert.equal(c.tier, 'vip')
|
||||||
|
assert.closeTo(c.tierUntil!.diffNow('days').days, 365, 1)
|
||||||
|
assert.match(c.apiKey, /^[0-9a-f]{32}$/)
|
||||||
|
|
||||||
|
// Renewed early, the year is added to what's left.
|
||||||
|
AccessService.grantTier(c, 'vip', 365)
|
||||||
|
assert.closeTo(c.tierUntil!.diffNow('days').days, 730, 1)
|
||||||
|
|
||||||
|
AccessService.signup = 'closed'
|
||||||
|
;(await client.post('/api/v1/auth/signup').json(form('d@example.com', 'vip-invite'))).assertStatus(403)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user