next-site: admin auth, admin UI, override/channel write endpoints
This commit is contained in:
parent
98999b57f3
commit
ff775d8d97
4 changed files with 204 additions and 0 deletions
6
worker/src/admin/auth.ts
Normal file
6
worker/src/admin/auth.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { Env } from '../env'
|
||||
|
||||
export function checkAuth(request: Request, env: Env): boolean {
|
||||
const auth = request.headers.get('authorization')
|
||||
return auth === `Bearer ${env.ADMIN_TOKEN}`
|
||||
}
|
||||
100
worker/src/admin/routes.ts
Normal file
100
worker/src/admin/routes.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { setChannel, upsertOverride } from '../db'
|
||||
import { checkAuth } from './auth'
|
||||
import type { Env } from '../env'
|
||||
import type { BaseIndex } from '../publicIndex'
|
||||
|
||||
const PLUGIN_ID_REGEX = /^[a-zA-Z0-9.-]+$/ // matches generate-index.ts's own validation exactly
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const DEFAULT_MAIN_JS = `export default plugin({\n\tstart() {},\n})\n`
|
||||
|
||||
/** Returns null if `url.pathname` isn't an admin API route, so the caller can fall through. */
|
||||
export async function handleAdminApi(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith('/api/admin/')) return null
|
||||
|
||||
if (!checkAuth(request, env)) return json({ error: 'unauthorized' }, 401)
|
||||
|
||||
const overrideMatch = url.pathname.match(
|
||||
/^\/api\/admin\/overrides\/([\w.-]+)$/,
|
||||
)
|
||||
if (overrideMatch && request.method === 'PUT') {
|
||||
const id = overrideMatch[1]
|
||||
if (!PLUGIN_ID_REGEX.test(id)) return json({ error: 'invalid id' }, 400)
|
||||
|
||||
const body = await request.json<Record<string, unknown>>().catch(() => null)
|
||||
if (!body) return json({ error: 'invalid body' }, 400)
|
||||
|
||||
const patch: Parameters<typeof upsertOverride>[2] = {
|
||||
name: body.name as string | undefined,
|
||||
description: body.description as string | undefined,
|
||||
tagline: body.tagline as string | undefined,
|
||||
note: body.note as string | undefined,
|
||||
category: body.category as string | undefined,
|
||||
accent: body.accent as string | undefined,
|
||||
status: body.status as string | undefined,
|
||||
howItWorks: body.howItWorks as string | undefined,
|
||||
features: body.features as string[] | undefined,
|
||||
commands: body.commands as { cmd: string; desc: string }[] | undefined,
|
||||
limitations: body.limitations as string | undefined,
|
||||
hidden: body.hidden as boolean | undefined,
|
||||
isDraft: body.isDraft as boolean | undefined,
|
||||
}
|
||||
|
||||
if (patch.isDraft) {
|
||||
const mainJs = (body.mainJs as string | undefined) ?? DEFAULT_MAIN_JS
|
||||
patch.mainJs = mainJs
|
||||
patch.manifest = JSON.stringify({
|
||||
format: 1,
|
||||
id,
|
||||
name: patch.name ?? id,
|
||||
description: patch.description ?? '',
|
||||
author: (body.author as string | undefined) ?? '',
|
||||
version: (body.version as string | undefined) ?? '0.0.1',
|
||||
dependencies: {
|
||||
'revenge.api': { version: '>=1 <2' },
|
||||
discord: { version: '*' },
|
||||
},
|
||||
dist: { script: 'index.js' },
|
||||
})
|
||||
}
|
||||
|
||||
await upsertOverride(env, id, patch)
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
const channelMatch = url.pathname.match(
|
||||
/^\/api\/admin\/channels\/([\w.-]+)\/([\w-]+)$/,
|
||||
)
|
||||
if (channelMatch && request.method === 'PUT') {
|
||||
const [, id, channel] = channelMatch
|
||||
const body = await request.json<{ version?: string }>().catch(() => null)
|
||||
if (!body?.version) return json({ error: 'version required' }, 400)
|
||||
|
||||
// Mirrors generate-index.ts's own "Channel override points at unpublished version" check -
|
||||
// a channel can only ever point at a version that's actually in the built index.
|
||||
const baseRes = await env.ASSETS.fetch('https://internal/index.json')
|
||||
const base = await baseRes.json<BaseIndex>()
|
||||
const plugin = base.plugins[id]
|
||||
if (!plugin || !(body.version in plugin.versions)) {
|
||||
return json(
|
||||
{ error: `version '${body.version}' is not published for '${id}'` },
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
await setChannel(env, id, channel, body.version)
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
return json({ error: 'not found' }, 404)
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { handleAdminApi } from './admin/routes'
|
||||
import { getAllChannels, getAllOverrides } from './db'
|
||||
import { renderAdminPage } from './pages/admin'
|
||||
import { renderBrowsePage } from './pages/browse'
|
||||
import { renderDetailPage } from './pages/detail'
|
||||
import { buildPublicIndex } from './publicIndex'
|
||||
|
|
@ -64,6 +66,15 @@ export default {
|
|||
})
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin') {
|
||||
return new Response(renderAdminPage(), {
|
||||
headers: { 'content-type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
const adminApiRes = await handleAdminApi(request, env, url)
|
||||
if (adminApiRes) return adminApiRes
|
||||
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
87
worker/src/pages/admin.ts
Normal file
87
worker/src/pages/admin.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { htmlShell } from '../theme'
|
||||
|
||||
/**
|
||||
* Single-page admin UI: a token field gates everything else client-side (the real gate is
|
||||
* server-side, in handleAdminApi - this is just UX, not security). Once a token is entered, it
|
||||
* fetches /index.json for the plugin list and lets the admin PUT overrides/channels directly.
|
||||
*/
|
||||
export function renderAdminPage(): string {
|
||||
const body = `
|
||||
<header>
|
||||
<div class="brand">
|
||||
<a href="/" class="brand-mark" style="text-decoration:none;">N</a>
|
||||
<div class="brand-name">Admin</div>
|
||||
</div>
|
||||
<nav><a href="/">Back to site</a></nav>
|
||||
</header>
|
||||
<div class="wrap" style="padding-top:22px;">
|
||||
<div id="gate">
|
||||
<div class="page-sub">Admin token</div>
|
||||
<input id="token" type="password" style="background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:8px 10px;border-radius:6px;width:280px;">
|
||||
<button id="unlock" class="btn" style="border:none;cursor:pointer;margin-left:8px;">Unlock</button>
|
||||
</div>
|
||||
<div id="panel" style="display:none;"></div>
|
||||
</div>
|
||||
<script>
|
||||
const gate = document.getElementById('gate')
|
||||
const panel = document.getElementById('panel')
|
||||
let token = ''
|
||||
|
||||
async function loadPlugins() {
|
||||
const res = await fetch('/index.json')
|
||||
const data = await res.json()
|
||||
panel.innerHTML = Object.entries(data.plugins).map(([id, p]) => \`
|
||||
<div class="card" style="margin-bottom:12px;">
|
||||
<div class="card-name">\${p.name} <span class="card-meta">(\${id})</span></div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<input placeholder="tagline" data-id="\${id}" data-field="tagline" style="background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:6px 8px;border-radius:6px;flex:1;">
|
||||
<label style="font-size:11px;display:flex;align-items:center;gap:4px;"><input type="checkbox" data-id="\${id}" data-field="hidden"> hidden</label>
|
||||
<button data-id="\${id}" class="btn save" style="border:none;cursor:pointer;">Save</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
<input placeholder="version" data-id="\${id}" data-channel="stable" style="background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:6px 8px;border-radius:6px;width:100px;">
|
||||
<button data-id="\${id}" data-channel="stable" class="btn promote" style="border:none;cursor:pointer;">Promote to stable</button>
|
||||
</div>
|
||||
</div>
|
||||
\`).join('')
|
||||
|
||||
panel.querySelectorAll('.save').forEach(btn => btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id
|
||||
const tagline = panel.querySelector(\`[data-id="\${id}"][data-field="tagline"]\`).value
|
||||
const hidden = panel.querySelector(\`[data-id="\${id}"][data-field="hidden"]\`).checked
|
||||
await fetch(\`/api/admin/overrides/\${id}\`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
|
||||
body: JSON.stringify({ tagline, hidden }),
|
||||
})
|
||||
alert('Saved ' + id)
|
||||
}))
|
||||
|
||||
panel.querySelectorAll('.promote').forEach(btn => btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id
|
||||
const channel = btn.dataset.channel
|
||||
const version = panel.querySelector(\`[data-id="\${id}"][data-channel="\${channel}"]\`).value
|
||||
const res = await fetch(\`/api/admin/channels/\${id}/\${channel}\`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
|
||||
body: JSON.stringify({ version }),
|
||||
})
|
||||
const data = await res.json()
|
||||
alert(res.ok ? \`Promoted \${id}@\${version} to \${channel}\` : data.error)
|
||||
}))
|
||||
}
|
||||
|
||||
document.getElementById('unlock').addEventListener('click', () => {
|
||||
token = document.getElementById('token').value
|
||||
gate.style.display = 'none'
|
||||
panel.style.display = 'block'
|
||||
loadPlugins()
|
||||
})
|
||||
</script>`
|
||||
|
||||
return htmlShell({
|
||||
title: 'Admin - Revenge Next',
|
||||
description: 'Admin panel',
|
||||
body,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue