mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-28 04:06:52 -05:00
Add an Access-protected admin panel and local interactive preview
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"delay": "^4.4.0",
|
||||
"he": "^1.1.1",
|
||||
"ics": "^3.12.0",
|
||||
"jose": "^6.2.12",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"lodash": "^4.18.1",
|
||||
@@ -9841,6 +9842,15 @@
|
||||
"@sideway/pinpoint": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.12",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-message": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"updater:deploy": "wrangler deploy --config workers/updater/wrangler.jsonc",
|
||||
"updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc",
|
||||
"social": "node src/app/index.js social",
|
||||
"social:test": "node src/app/index.js socialTest"
|
||||
"social:test": "node src/app/index.js socialTest",
|
||||
"admin:preview": "node workers/updater/preview/server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.20.42",
|
||||
@@ -52,6 +53,7 @@
|
||||
"delay": "^4.4.0",
|
||||
"he": "^1.1.1",
|
||||
"ics": "^3.12.0",
|
||||
"jose": "^6.2.12",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"lodash": "^4.18.1",
|
||||
|
||||
28
test/admin/access.test.mjs
Normal file
28
test/admin/access.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPair, SignJWT } from 'jose';
|
||||
import { verifyAccess } from '../../workers/updater/src/admin/access.mjs';
|
||||
|
||||
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
||||
const env = { ADMIN_HOSTNAME: 'admin.example.test', ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', ACCESS_AUD: 'admin-audience' };
|
||||
async function token({ audience = env.ACCESS_AUD, issuer = 'https://' + env.ACCESS_TEAM_DOMAIN, expires = '5m' } = {}) {
|
||||
return new SignJWT({ email: 'admin@example.test' }).setProtectedHeader({ alg: 'RS256' }).setSubject('user-id').setIssuer(issuer).setAudience(audience).setExpirationTime(expires).sign(privateKey);
|
||||
}
|
||||
function request(jwt, host = env.ADMIN_HOSTNAME) {
|
||||
return new Request(`https://${host}/admin/`, { headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {} });
|
||||
}
|
||||
|
||||
test('validates Access signature, issuer, audience, expiration and configured hostname', async () => {
|
||||
assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), { email: 'admin@example.test' });
|
||||
for (const options of [{ audience: 'different-app' }, { issuer: 'https://other.cloudflareaccess.com' }, { expires: '0s' }])
|
||||
assert.equal(await verifyAccess(request(await token(options)), env, publicKey), null);
|
||||
assert.equal(await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), null);
|
||||
const wrong = await generateKeyPair('RS256');
|
||||
assert.equal(await verifyAccess(request(await token()), env, wrong.publicKey), null);
|
||||
});
|
||||
|
||||
test('fails closed without configuration or a valid assertion', async () => {
|
||||
assert.equal(await verifyAccess(request(), env, publicKey), null);
|
||||
assert.equal(await verifyAccess(request('forged'), env, publicKey), null);
|
||||
assert.equal(await verifyAccess(request(await token()), {}, publicKey), null);
|
||||
});
|
||||
@@ -117,6 +117,39 @@ persists the pause and deletes the alarm. `/arm` resumes scheduling; a saved
|
||||
overdue run executes immediately. Do not pause
|
||||
in the middle of a run: a busy pause request returns 409 so the caller can retry.
|
||||
|
||||
## Admin panel
|
||||
|
||||
`/admin/` provides a mobile-friendly panel for data-only, social-only, and full
|
||||
manual runs. The authenticated browser starts a persisted request and polls its
|
||||
status; closing the tab does not cancel the job. One manual request can be
|
||||
pending at a time, and it shares the hourly scheduler's lock. Social runs keep
|
||||
normal checkpoints and the published-data check. An interrupted manual run is
|
||||
reported as failed rather than automatically replaying an uncertain social send.
|
||||
The hourly schedule is preserved. Paused scheduling also blocks manual runs.
|
||||
|
||||
Preview the actual panel with simulated results using `npm run admin:preview`,
|
||||
then open `http://127.0.0.1:8788/admin/`. This standalone preview server listens
|
||||
only on loopback and has no production credentials or bindings. The production
|
||||
Worker has no local-authentication bypass.
|
||||
|
||||
Before exposing the panel in production:
|
||||
|
||||
1. Create a Cloudflare Access self-hosted application protecting the entire
|
||||
chosen admin hostname (for example `admin.splatoon2.ink`). Allow only the
|
||||
owner's identity, with email one-time codes or their preferred provider.
|
||||
2. Configure the updater with `ADMIN_HOSTNAME`, `ACCESS_TEAM_DOMAIN` (the bare
|
||||
`<team>.cloudflareaccess.com` hostname), and `ACCESS_AUD` (the application's
|
||||
audience tag). These settings are deliberately absent until Access is ready;
|
||||
all admin routes fail closed without them.
|
||||
3. Attach the admin hostname to this Worker and deploy. Open `/admin/` and verify
|
||||
login, status, and a deliberate test run. No Access application or production
|
||||
admin domain is created by the local preview.
|
||||
|
||||
The Worker verifies JWT signature, issuer, audience, expiration, and hostname.
|
||||
Mutating browser requests also require a matching Origin and JSON content type.
|
||||
The existing bearer-token API remains available for scripts, independently of
|
||||
Access. The panel does not expose force-repost, pause, or resume controls.
|
||||
|
||||
## Configuration and local commands
|
||||
|
||||
Secrets: `NINTENDO_SESSION_ID_NA`, `NINTENDO_SESSION_ID_EU`,
|
||||
|
||||
37
workers/updater/admin.spec.mjs
Normal file
37
workers/updater/admin.spec.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, it, vi, afterEach } from 'vitest';
|
||||
import { adminRequest } from './src/admin/routes.mjs';
|
||||
import { verifyAccess } from './src/admin/access.mjs';
|
||||
vi.mock('./src/admin/access.mjs', () => ({ verifyAccess: vi.fn() }));
|
||||
afterEach(() => vi.resetAllMocks());
|
||||
const url = 'https://admin.example.test';
|
||||
const post = (mode = 'both', origin = url) => new Request(url + '/admin/api/run', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) });
|
||||
|
||||
it('requires Access before exposing the panel, status or actions', async () => {
|
||||
verifyAccess.mockResolvedValue(null);
|
||||
const get = vi.fn();
|
||||
for (const request of [new Request(url + '/admin/'), new Request(url + '/admin/api/status'), post()])
|
||||
expect((await adminRequest(request, {}, get)).status).toBe(401);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
it('rejects cross-origin requests and unknown modes before scheduling work', async () => {
|
||||
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
|
||||
const get = vi.fn();
|
||||
expect((await adminRequest(post('both', 'https://other.test'), {}, get)).status).toBe(403);
|
||||
expect((await adminRequest(post('force'), {}, get)).status).toBe(400);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
it('returns an accepted run immediately and reports overlap without starting another', async () => {
|
||||
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
|
||||
const startManual = vi.fn(async mode => ({ ok: true, run: { id: 'one', mode, status: 'queued' } }));
|
||||
expect((await adminRequest(post('social'), {}, () => ({ startManual }))).status).toBe(202);
|
||||
expect(startManual).toHaveBeenCalledWith('social');
|
||||
startManual.mockResolvedValue({ ok: false, busy: true });
|
||||
expect((await adminRequest(post(), {}, () => ({ startManual }))).status).toBe(409);
|
||||
});
|
||||
it('serves the mobile panel with no-store and a nonce-based content policy', async () => {
|
||||
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
|
||||
const response = await adminRequest(new Request(url + '/admin/'), {}, vi.fn());
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(response.headers.get('content-security-policy')).toContain('frame-ancestors \'none\'');
|
||||
expect(await response.text()).not.toContain('__NONCE__');
|
||||
});
|
||||
45
workers/updater/preview/server.mjs
Normal file
45
workers/updater/preview/server.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
// UI preview only. No Worker bindings, credentials, or production network requests.
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const port = Number(process.env.ADMIN_PREVIEW_PORT || 8788);
|
||||
const hour = Math.floor(Date.now() / 3600000) * 3600000;
|
||||
const state = {
|
||||
preview: true, user: { email: 'Local preview' }, paused: false, busy: false,
|
||||
hourlyAt: hour + 3600000 + 10000, retryAt: null, pendingManual: null,
|
||||
lastManualRun: { id: 'previous', mode: 'social', ok: false, status: 'failed', startedAt: hour - 1800000, runMs: 42300, error: 'Browser screenshot timed out. The post was not sent.' },
|
||||
lastRun: { mode: 'both', ok: true, startedAt: hour + 10000, runMs: 18200, updaters: { ok: true }, social: { ok: true } },
|
||||
};
|
||||
createServer(async (request, response) => {
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
const url = new URL(request.url, `http://127.0.0.1:${port}`);
|
||||
function json(body, status = 200) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); }
|
||||
try {
|
||||
if (request.method === 'GET' && ['/', '/admin', '/admin/'].includes(url.pathname)) {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
return response.end((await readFile(new URL('../src/admin/page.html', import.meta.url), 'utf8')).replaceAll('__NONCE__', 'preview'));
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/admin/api/status') return json(state);
|
||||
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
|
||||
if (request.headers.origin !== `http://${request.headers.host}`) return json({ error: 'Invalid origin.' }, 403);
|
||||
if (state.busy) return json({ error: 'A run is already active.' }, 409);
|
||||
let body = '';
|
||||
for await (const chunk of request) { body += chunk; if (body.length > 1024) return json({ error: 'Request too large.' }, 413); }
|
||||
const { mode } = JSON.parse(body);
|
||||
if (!['data', 'social', 'both'].includes(mode)) return json({ error: 'Unknown mode.' }, 400);
|
||||
const run = { id: randomUUID(), mode, status: 'queued', requestedAt: Date.now() };
|
||||
state.busy = true; state.pendingManual = run;
|
||||
setTimeout(() => { run.status = 'running'; run.startedAt = Date.now(); }, 700);
|
||||
setTimeout(() => {
|
||||
state.lastManualRun = { ...run, ok: true, status: 'succeeded', finishedAt: Date.now(), runMs: Date.now() - run.startedAt,
|
||||
updaters: mode === 'social' ? { ok: true, skipped: true } : { ok: true, updaters: ['Schedules', 'Timeline', 'CoopSchedules', 'Merchandises'].map(name => ({ name, ok: true })) },
|
||||
social: mode === 'data' ? { ok: true, skipped: true } : { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] },
|
||||
};
|
||||
state.busy = false; state.pendingManual = null;
|
||||
}, 6000);
|
||||
return json({ ok: true, run }, 202);
|
||||
}
|
||||
json({ error: 'Not found.' }, 404);
|
||||
} catch { json({ error: 'Invalid preview request.' }, 400); }
|
||||
}).listen(port, '127.0.0.1', () => console.log(`Admin preview: http://127.0.0.1:${port}/admin/ (simulated runs only)`));
|
||||
28
workers/updater/src/admin/access.mjs
Normal file
28
workers/updater/src/admin/access.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
|
||||
let keySets = new Map();
|
||||
|
||||
export async function verifyAccess(request, env, resolveKey) {
|
||||
let domain = env.ACCESS_TEAM_DOMAIN;
|
||||
if (!domain || !env.ACCESS_AUD || new URL(request.url).hostname !== env.ADMIN_HOSTNAME)
|
||||
return null;
|
||||
let token = request.headers.get('Cf-Access-Jwt-Assertion');
|
||||
if (!token)
|
||||
return null;
|
||||
try {
|
||||
let issuer = new URL(`https://${domain}`);
|
||||
if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com'))
|
||||
return null;
|
||||
if (!resolveKey) {
|
||||
if (!keySets.has(domain))
|
||||
keySets.set(domain, createRemoteJWKSet(new URL('/cdn-cgi/access/certs', issuer), { timeoutDuration: 5000 }));
|
||||
resolveKey = keySets.get(domain);
|
||||
}
|
||||
let { payload } = await jwtVerify(token, resolveKey, {
|
||||
issuer: issuer.origin, audience: env.ACCESS_AUD, algorithms: ['RS256'], requiredClaims: ['exp', 'sub'],
|
||||
});
|
||||
return { email: payload.email ?? 'Authenticated administrator' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
101
workers/updater/src/admin/page.html
Normal file
101
workers/updater/src/admin/page.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Splatoon2.ink · Control room</title>
|
||||
<style nonce="__NONCE__">
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#f3f2fa;background:#101116;font-synthesis:none;--muted:#aaaaba;--line:#30313d;--purple:#b1a0ff;--green:#91ddb6}
|
||||
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 85% 0%,#242037 0,transparent 48%);min-height:100vh}button,a{-webkit-tap-highlight-color:transparent}button{font:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.45}a{color:var(--purple)}button:focus-visible,a:focus-visible{outline:3px solid var(--purple);outline-offset:4px}.shell{width:min(920px,100% - 48px);margin:auto;padding:38px 0 30px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:30px;border-bottom:1px solid var(--line)}.brand{font-weight:750;font-size:18px;letter-spacing:-.5px}.brand span{color:var(--purple)}.tag{font-size:11px;letter-spacing:1.4px;text-transform:uppercase;color:var(--muted);padding:6px 10px;border:1px solid var(--line);border-radius:5px}.intro{padding:38px 0 26px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:2px;color:var(--purple);text-transform:uppercase}h1{font-size:38px;letter-spacing:-1.5px;line-height:1.15;margin:10px 0 12px}p{color:var(--muted);font-size:14px;line-height:1.65;margin:0}.preview{display:none;padding:12px 16px;border:1px solid #54476b;background:#251f32;border-radius:9px;color:#d0bee9;margin-bottom:20px;font-size:13px}.status{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:20px 23px;border:1px solid var(--line);border-radius:12px;background:#1b1c25;margin-bottom:30px}.state-line{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:650}.dot{height:8px;width:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px #91ddb612}.dot.busy{background:var(--purple)}.dot.warning{background:#edb685}.sub{font-size:12px;color:var(--muted);margin-top:7px}.next{text-align:right}.next strong{font-size:14px;font-weight:500}.next small{display:block;color:var(--muted);font-size:11px;margin-bottom:7px}.section-label{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}h2{font-size:15px;margin:0;font-weight:650}.hint{font-size:12px;color:var(--muted)}.actions{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:17px}.action{display:flex;flex-direction:column;background:#191a23;border:1px solid var(--line);border-radius:12px;padding:23px 21px}.icon{width:38px;height:38px;display:grid;place-items:center;border-radius:10px;background:#2c2640;color:var(--purple);margin-bottom:22px;font-size:19px}.action:nth-child(2) .icon{background:#20352e;color:#91ddb6}.action:nth-child(3) .icon{background:#333024;color:#e6d595}h3{font-size:16px;margin:0 0 9px;font-weight:650}.action p{font-size:13px;line-height:1.65;flex:1;margin-bottom:24px}.run{width:100%;border:1px solid #454050;border-radius:7px;background:#272431;color:#e6defc;padding:11px 8px;font-size:13px;font-weight:650;min-height:44px}.run:hover:not(:disabled){background:#373046;border-color:#8c75b7}.action:first-child .run{background:#b6a4fa;color:#191226;border-color:#b6a4fa}.action:first-child .run:hover:not(:disabled){background:#cbbbff}.note{font-size:12px;color:var(--muted);margin-bottom:32px;display:flex;gap:8px}.notice{border-radius:8px;background:#272333;border:1px solid #615277;padding:13px 16px;font-size:13px;margin:0 0 20px;line-height:1.6}.notice:empty{display:none}.history{border:1px solid var(--line);border-radius:12px;overflow:hidden}.record{padding:20px 23px;background:#191a23}.record+.record{border-top:1px solid var(--line)}.record-top{display:flex;justify-content:space-between;align-items:center;gap:16px}.record-name{font-weight:550;font-size:14px}.badge{font-size:11px;padding:4px 8px;border-radius:5px;color:var(--green);background:#20352b;white-space:nowrap}.badge.failed{color:#edbd9b;background:#392b27}.badge.neutral{color:var(--muted);background:#292a35}.detail{font-size:12px;color:var(--muted);line-height:1.6;margin-top:8px}.error{color:#edbd9b;margin-top:8px;font-size:12px;overflow-wrap:anywhere}details{margin-top:12px;font-size:12px;color:var(--muted)}summary{cursor:pointer;min-height:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:12px;background:#111218;border-radius:7px;font-size:11px;line-height:1.6}.footer{display:flex;justify-content:space-between;gap:16px;color:#898a9d;font-size:11px;padding-top:26px;line-height:1.7}.refresh{background:transparent;border:0;color:var(--purple);font-size:12px;padding:8px;min-height:36px}#sign-in{display:none;margin-left:8px}.identity{overflow-wrap:anywhere}
|
||||
@media(max-width:640px){.shell{width:calc(100% - 32px);padding-top:22px}.top{padding-bottom:22px}.intro{padding:28px 0 24px}h1{font-size:32px}.actions{grid-template-columns:1fr}.action{padding:20px;display:grid;grid-template-columns:40px 1fr;column-gap:16px}.icon{grid-row:1/3;margin:0}.action p{margin:0 0 16px}.run{grid-column:2}.status{padding:18px 17px;align-items:flex-start}.next strong{font-size:12px}.sub{max-width:180px;line-height:1.5}.record{padding:18px 17px}.footer{flex-direction:column;gap:5px}.hint{font-size:11px}.tag{font-size:10px}h3{margin-top:2px}.note{line-height:1.6}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="top"><div class="brand">splatoon<span>2</span>.ink</div><span class="tag">Administration</span></header>
|
||||
<section class="intro"><div class="eyebrow">Control room</div><h1>A little maintenance.</h1><p>Check the latest runs, refresh the data, or catch up on social posts.</p></section>
|
||||
<div class="preview" id="preview">Local preview · All runs are simulated. No production data or social accounts are touched.</div>
|
||||
<section class="status" aria-label="Scheduler status"><div><div class="state-line"><span class="dot" id="dot"></span><span id="state">Connecting…</span></div><div class="sub" id="state-detail">Checking the updater</div></div><div class="next"><small>Next scheduled run</small><strong id="next">—</strong></div></section>
|
||||
<div class="section-label"><h2>Start a run</h2><span class="hint">One run at a time</span></div>
|
||||
<section class="actions" aria-label="Manual actions">
|
||||
<article class="action"><div class="icon" aria-hidden="true">↻</div><h3>Update game data</h3><p>Refresh schedules, gear, and the rest of the SplatNet data.</p><button class="run" data-mode="data" disabled>Update data <span aria-hidden="true">→</span></button></article>
|
||||
<article class="action"><div class="icon" aria-hidden="true">↗</div><h3>Catch up on social</h3><p>Send any due Bluesky posts using the latest published data.</p><button class="run" data-mode="social" disabled>Run social cycle <span aria-hidden="true">→</span></button></article>
|
||||
<article class="action"><div class="icon" aria-hidden="true">⇄</div><h3>Run the full cycle</h3><p>Update game data, then send any social posts that are due.</p><button class="run" data-mode="both" disabled>Update & post <span aria-hidden="true">→</span></button></article>
|
||||
</section>
|
||||
<p class="note"><span aria-hidden="true">✓</span> Successful posts are remembered. Retrying a cycle skips posts already sent.</p>
|
||||
<div id="notice" class="notice" role="status" aria-live="polite"></div><a id="sign-in" href="/admin/">Sign in again</a>
|
||||
<section><div class="section-label"><h2>Recent activity</h2><button class="refresh" id="refresh">Refresh status ↻</button></div><div class="history" id="history"><div class="record"><p>Loading recent runs…</p></div></div></section>
|
||||
<footer class="footer"><div class="identity" id="identity">Protected by Cloudflare Access</div><div id="checked">Times shown in your local timezone.</div></footer>
|
||||
</main>
|
||||
<script nonce="__NONCE__">
|
||||
const names = { data: 'Data update', social: 'Social cycle', both: 'Full cycle' };
|
||||
const buttons = [...document.querySelectorAll('[data-mode]')];
|
||||
const $ = id => document.getElementById(id);
|
||||
let state, sending = false, polling = false, timer, lastRunId;
|
||||
function date(value) { return value ? new Date(value).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—'; }
|
||||
function controls() { for (const button of buttons) button.disabled = sending || !state || state.busy || state.paused; }
|
||||
function record(label, run) {
|
||||
const row = document.createElement('div'); row.className = 'record';
|
||||
const top = document.createElement('div'); top.className = 'record-top';
|
||||
const title = document.createElement('span'); title.className = 'record-name'; title.textContent = label;
|
||||
const badge = document.createElement('span'); badge.className = 'badge' + (!run ? ' neutral' : run.ok ? '' : ' failed'); badge.textContent = !run ? 'No runs yet' : run.ok ? 'Completed' : 'Needs attention';
|
||||
top.append(title, badge); row.append(top);
|
||||
const detail = document.createElement('div'); detail.className = 'detail';
|
||||
detail.textContent = run ? `${names[run.mode] || 'Full cycle'} · ${date(run.startedAt)} · ${Math.max(1, Math.round((run.runMs || 0) / 1000))}s` : 'Results will appear here after a run.'; row.append(detail);
|
||||
if (run) {
|
||||
const messages = [run.error, ...(run.updaters?.updaters || []).filter(item => !item.ok).map(item => item.error || `${item.name} failed`), ...(run.social?.posts || []).filter(item => item && !item.ok).map(item => item.error || 'A social post failed')].filter(Boolean);
|
||||
if (messages.length) { const error = document.createElement('div'); error.className = 'error'; error.textContent = messages.join(' · '); row.append(error); }
|
||||
const details = document.createElement('details'), summary = document.createElement('summary'), pre = document.createElement('pre'); summary.textContent = 'View run details'; pre.textContent = JSON.stringify(run, null, 2); details.append(summary, pre); row.append(details);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
function render() {
|
||||
$('preview').style.display = state.preview ? 'block' : 'none';
|
||||
$('state').textContent = state.busy ? 'Run in progress' : state.paused ? 'Scheduler paused' : 'Ready when you are';
|
||||
$('dot').className = 'dot' + (state.busy ? ' busy' : state.paused ? ' warning' : '');
|
||||
$('state-detail').textContent = state.pendingManual ? `${names[state.pendingManual.mode]} · ${state.pendingManual.status === 'queued' ? 'waiting to start' : 'running'}` : state.busy ? 'A scheduled or API run is active.' : state.paused ? 'Manual runs are disabled while paused.' : 'The hourly schedule is running automatically.';
|
||||
$('next').textContent = state.paused ? 'Paused' : date(state.retryAt || state.hourlyAt);
|
||||
// Keep expanded details open during polling when the records have not changed.
|
||||
const signature = JSON.stringify([state.lastManualRun, state.lastRun]);
|
||||
if ($('history').dataset.signature !== signature) {
|
||||
$('history').replaceChildren(record('Last manual run', state.lastManualRun), record('Last scheduled run', state.lastRun));
|
||||
$('history').dataset.signature = signature;
|
||||
}
|
||||
if (lastRunId && state.lastManualRun?.id === lastRunId) { $('notice').textContent = state.lastManualRun.ok ? 'Your run finished successfully.' : 'Your run needs attention. See the result below before retrying.'; lastRunId = null; }
|
||||
$('identity').textContent = state.preview ? 'Preview session · Simulated results' : `Signed in as ${state.user.email}`;
|
||||
$('checked').textContent = `Checked ${new Date().toLocaleTimeString([], {hour: 'numeric', minute: '2-digit', second: '2-digit'})} · Local time`;
|
||||
controls();
|
||||
}
|
||||
async function api(path, options) {
|
||||
const response = await fetch('/admin/api/' + path, { ...options, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
||||
if (response.status === 401 || response.status === 403) { $('sign-in').style.display = 'inline-block'; throw new Error('Your session may have expired. Sign in again to continue.'); }
|
||||
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Could not read status. Reload to sign in again.');
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'The request failed.');
|
||||
return result;
|
||||
}
|
||||
async function poll() {
|
||||
clearTimeout(timer);
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
try { state = await api('status'); render(); }
|
||||
catch (error) { state = null; controls(); $('state').textContent = 'Connection unavailable'; $('state-detail').textContent = 'Reconnect before starting a run.'; $('dot').className = 'dot warning'; $('notice').textContent = error.message; }
|
||||
finally { polling = false; if (!document.hidden) timer = setTimeout(poll, state?.busy ? 2000 : 15000); }
|
||||
}
|
||||
for (const button of buttons) button.addEventListener('click', async () => {
|
||||
sending = true; controls(); $('notice').textContent = 'Starting your run…';
|
||||
try {
|
||||
const result = await api('run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: button.dataset.mode }) });
|
||||
lastRunId = result.run.id;
|
||||
if (state) { state.busy = true; state.pendingManual = result.run; render(); }
|
||||
$('notice').textContent = 'Run accepted. You can close this page and check back later.';
|
||||
} catch (error) { $('notice').textContent = `${error.message} Check status before trying again; the run may already have started.`; }
|
||||
finally { sending = false; await poll(); controls(); }
|
||||
});
|
||||
$('refresh').addEventListener('click', poll);
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) clearTimeout(timer); else poll(); });
|
||||
poll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
36
workers/updater/src/admin/routes.mjs
Normal file
36
workers/updater/src/admin/routes.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import page from './page.html';
|
||||
import { verifyAccess } from './access.mjs';
|
||||
import { MANUAL_MODES } from '../Scheduler.mjs';
|
||||
|
||||
const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
||||
const json = (body, status = 200) => Response.json(body, { status, headers });
|
||||
|
||||
export async function adminRequest(request, env, getScheduler) {
|
||||
let user = await verifyAccess(request, env);
|
||||
if (!user)
|
||||
return json({ error: 'Sign in through Cloudflare Access to continue.' }, 401);
|
||||
let url = new URL(request.url);
|
||||
if (request.method === 'GET' && ['/admin', '/admin/'].includes(url.pathname)) {
|
||||
let nonce = crypto.randomUUID();
|
||||
return new Response(page.replaceAll('__NONCE__', nonce), { headers: {
|
||||
...headers,
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`,
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
} });
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/admin/api/status')
|
||||
return json({ ...await getScheduler().status(), user, preview: false });
|
||||
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
|
||||
// Access cookies authenticate the user; require a same-origin JSON request as well.
|
||||
if (request.headers.get('Origin') !== url.origin || request.headers.get('Content-Type') !== 'application/json')
|
||||
return json({ error: 'A same-origin JSON request is required.' }, 403);
|
||||
let mode;
|
||||
try { ({ mode } = await request.json()); } catch { return json({ error: 'Invalid request.' }, 400); }
|
||||
if (!MANUAL_MODES.includes(mode))
|
||||
return json({ error: 'Unknown run mode.' }, 400);
|
||||
let result = await getScheduler().startManual(mode);
|
||||
return json(result, result.ok ? 202 : result.busy || result.paused ? 409 : 400);
|
||||
}
|
||||
return json({ error: 'Not found.' }, 404);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { adminRequest } from './admin/routes.mjs';
|
||||
import { withSentry, instrumentDurableObjectWithSentry } from '@sentry/cloudflare';
|
||||
import { Scheduler as SchedulerClass } from './Scheduler.mjs';
|
||||
import { createLogger, describeError } from './log.mjs';
|
||||
@@ -44,6 +45,14 @@ export default withSentry(sentryOptions, {
|
||||
// Manual runs use the same owner as alarms. Targeted runs refresh data only.
|
||||
async fetch(request, env, ctx) {
|
||||
let url = new URL(request.url);
|
||||
if (url.pathname === '/admin' || url.pathname.startsWith('/admin/')) {
|
||||
try {
|
||||
return await adminRequest(request, env, () => scheduler(env));
|
||||
} catch (error) {
|
||||
createLogger('admin').error('Admin request failed', describeError(error));
|
||||
return Response.json({ error: 'The request failed. Refresh status before retrying.' }, { status: 500, headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
}
|
||||
let route = `${request.method} ${url.pathname}`;
|
||||
if (!['POST /run', 'POST /arm', 'POST /pause', 'GET /status', 'GET /list'].includes(route))
|
||||
return new Response('Not found', { status: 404 });
|
||||
|
||||
Reference in New Issue
Block a user