Accounts with limits: a Newznab key and a tier per user, daily API and NZB counts, invites
ACCESS=accounts (config/access.ts; open stays the default and behaves as before): Newznab and the catalog's releases routes name a user (apikey or the web interface's token), whose tier sets searches and NZBs per UTC day (Newznab 500/501, the catalog 429) and how far back they see (102/403). Counted in one upsert per use, so the last one can't be taken twice; caps, stats and refused requests aren't counted. Signup takes an invite (SIGNUP), which may give a tier; ace access:invite, access:tier (extends a running tier), access:suspend. The web interface signs in and shows the day's usage and the Newznab key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c55918350e
commit
1322e42fb7
23 files changed
+1023
-39
No files matched your search
+126
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user