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
+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)
|
||||
})
|
||||
Reference in new issue
Block a user