diff --git a/worker/src/admin/auth.ts b/worker/src/admin/auth.ts new file mode 100644 index 0000000..7b11245 --- /dev/null +++ b/worker/src/admin/auth.ts @@ -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}` +} diff --git a/worker/src/admin/routes.ts b/worker/src/admin/routes.ts new file mode 100644 index 0000000..f013dfc --- /dev/null +++ b/worker/src/admin/routes.ts @@ -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 { + 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>().catch(() => null) + if (!body) return json({ error: 'invalid body' }, 400) + + const patch: Parameters[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() + 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) +} diff --git a/worker/src/index.ts b/worker/src/index.ts index d0edbf0..35b1b03 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -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) }, } diff --git a/worker/src/pages/admin.ts b/worker/src/pages/admin.ts new file mode 100644 index 0000000..75eb5c6 --- /dev/null +++ b/worker/src/pages/admin.ts @@ -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 = ` +
+
+ N +
Admin
+
+ +
+
+
+
Admin token
+ + +
+ +
+ ` + + return htmlShell({ + title: 'Admin - Revenge Next', + description: 'Admin panel', + body, + }) +}