Accounts with limits: a Newznab key and a tier per user, daily API and NZB counts, invites
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m44s
Test and deploy / deploy (push) Skipped

ACCESS=accounts (config/access.ts; open stays the default and behaves as
before): Newznab and the catalog's releases routes name a user (apikey or
the web interface's token), whose tier sets searches and NZBs per UTC day
(Newznab 500/501, the catalog 429) and how far back they see (102/403).
Counted in one upsert per use, so the last one can't be taken twice;
caps, stats and refused requests aren't counted. Signup takes an invite
(SIGNUP), which may give a tier; ace access:invite, access:tier (extends a
running tier), access:suspend. The web interface signs in and shows the
day's usage and the Newznab key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-17 00:16:45 +02:00
1 parent c55918350e
commit 1322e42fb7
23 files changed
+1023 -39

No files matched your search

+126
View File
@@ -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);
}
+189 -7
View File
@@ -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)
})
+11
View File
@@ -14,8 +14,19 @@
<span>Voyager 2</span>
</a>
<dl class="stats" id="stats" aria-label="Index statistics"></dl>
<button type="button" class="button account-button" id="account" hidden></button>
</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">
<section class="search" aria-label="Search">
<div class="search-row">