From 60caef199d6d80dd7a8a4af8b1f7485360a0193d Mon Sep 17 00:00:00 2001 From: Ali Tleis Date: Tue, 10 Nov 2026 09:50:31 -0500 Subject: [PATCH 1/2] Move the control panel into Media Pool: alitleis.dev/mediapool is now the lock in front of it, and the panel's sign-in and API are gone --- README.md | 8 +- ask/README.md | 38 +- ask/api/ask.js | 2 +- ask/api/auth.js | 91 ---- ask/api/panel.js | 123 ----- ask/lib/owner.js | 364 --------------- ask/lib/track.js | 74 +-- ask/test/panel.test.js | 572 ------------------------ ask/test/redis.test.js | 47 -- ask/test/visit.test.js | 72 +++ src/app/components/SiteShell.tsx | 17 +- src/app/controlpanel/ControlPanel.tsx | 539 ---------------------- src/app/controlpanel/Inspector.tsx | 199 --------- src/app/controlpanel/Lock.tsx | 130 ------ src/app/controlpanel/Timeline.tsx | 155 ------- src/app/controlpanel/api.ts | 189 -------- src/app/controlpanel/comps/Asks.tsx | 127 ------ src/app/controlpanel/comps/Devices.tsx | 192 -------- src/app/controlpanel/comps/Links.tsx | 194 -------- src/app/controlpanel/comps/Overview.tsx | 180 -------- src/app/controlpanel/comps/Visitors.tsx | 132 ------ src/app/controlpanel/model.ts | 294 ------------ src/app/controlpanel/page.tsx | 12 - src/app/controlpanel/ui.tsx | 148 ------ src/app/globals.css | 88 +--- src/app/lib/askApi.ts | 8 +- src/app/mediapool/Door.tsx | 86 ++++ src/app/mediapool/page.tsx | 12 + 28 files changed, 215 insertions(+), 3878 deletions(-) delete mode 100644 ask/api/auth.js delete mode 100644 ask/api/panel.js delete mode 100644 ask/lib/owner.js delete mode 100644 ask/test/panel.test.js delete mode 100644 ask/test/redis.test.js create mode 100644 ask/test/visit.test.js delete mode 100644 src/app/controlpanel/ControlPanel.tsx delete mode 100644 src/app/controlpanel/Inspector.tsx delete mode 100644 src/app/controlpanel/Lock.tsx delete mode 100644 src/app/controlpanel/Timeline.tsx delete mode 100644 src/app/controlpanel/api.ts delete mode 100644 src/app/controlpanel/comps/Asks.tsx delete mode 100644 src/app/controlpanel/comps/Devices.tsx delete mode 100644 src/app/controlpanel/comps/Links.tsx delete mode 100644 src/app/controlpanel/comps/Overview.tsx delete mode 100644 src/app/controlpanel/comps/Visitors.tsx delete mode 100644 src/app/controlpanel/model.ts delete mode 100644 src/app/controlpanel/page.tsx delete mode 100644 src/app/controlpanel/ui.tsx create mode 100644 src/app/mediapool/Door.tsx create mode 100644 src/app/mediapool/page.tsx diff --git a/README.md b/README.md index bcf5e8f..952f1f2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Ali Tleis's portfolio. A Next.js site statically exported to GitHub Pages, with - `/` is a chooser between the two views below. - `/galaxy/` is the reading view, one scrolling page with Intro, Projects, Timeline, Stack, Off-clock, Resume and Contact. Its command palette searches the site and holds the Ask panel. - `/studio/` lays the same content out as a video editing suite, with a timeline to scrub and a reel to play. -- `/controlpanel/` is the owner's log of visits and Ask questions. It shows only a lock screen until you sign in, and it asks search engines not to index it. +- `/mediapool/` is the owner's way into Media Pool, his own app, where the log of visits and Ask questions is read. It sends a browser that can reach the app on to it, shows anyone else a lock, and asks search engines not to index it. ## Stack @@ -47,17 +47,17 @@ src/app/ page.tsx # the chooser at / galaxy/ # the reading view studio/ # the editing suite view - controlpanel/ # the visit and Ask log + mediapool/ # the lock in front of Media Pool ask-context.txt/ # plain-text profile the Ask backend reads opengraph-image.png # 1200x630 share card (+ .alt.txt for alt text) sitemap.ts / robots.ts # generated as static files on export - globals.css # design tokens, keyframes, studio and panel styles + globals.css # design tokens, keyframes, studio and door styles components/ SiteShell.tsx # nav, footer and command palette for the reading view sections/ # the reading view's sections projectCovers/ # hand-built SVG covers per project lib/ # shared hooks and helpers -ask/ # Vercel functions for Ask, visits and the control panel +ask/ # Vercel functions for Ask and visits scripts/ # image, search index, resume preview and TikTok helpers public/ resume/resume.pdf # linked from the navbar and Resume section diff --git a/ask/README.md b/ask/README.md index 0a13030..9e2d1ec 100644 --- a/ask/README.md +++ b/ask/README.md @@ -42,29 +42,21 @@ shows up by name, a home connection as its ISP, from api.ipapi.is), the city from Vercel's headers, the device, and an id that ties one browser's views together. -## The control panel - -The log is read at `https://alitleis.dev/controlpanel`, which unlocks with a -passkey: Face ID on the iPhone, Touch ID on a Mac, Windows Hello on a PC. The -page holds no data. `api/panel.js` sends the log only to a session that -`api/auth.js` started from a stored passkey. `lib/owner.js` explains how the -parts fit together. - -- The first device sets up with the `ADMIN_PASSWORD` production variable, - and only while no passkey is stored. After that the password opens nothing. -- A Mac on the same Apple ID gets the iPhone's passkey through iCloud - Keychain. Any other device pairs with a one-time code from Devices on a - signed-in one. -- Lost every device: delete the `passkeys` hash in the Upstash console, and - setup opens to the password again. -- Named links come from Links in the panel. Whoever opens one is called by - that name, on that visit and every later one from the same browser. -- Signing in stops counting that browser's visits. `?notrack` does the same - on a browser that never signs in, and `?notrack=off` undoes it. -- Local and preview runs log to `dev:` keys, apart from production, and need - `PANEL_ORIGIN` set to the page's origin, like `http://localhost:3000`. - -The tests run offline against a fake Upstash and real P-256 and Ed25519 keys: +## Reading the log + +The Site screen in Media Pool, Ali's own app on his tailnet, reads the log +straight from Upstash and keeps its notes there too: the `labels` hash of +names for visitors and the `links` hash of named links. Nothing on this site +reads them. `https://alitleis.dev/mediapool` is the way in. It sends a +browser that can reach Media Pool on to it and shows anyone else a lock. + +- Whoever opens a named link is called by that link's name, on that visit + and every later one from the same browser. +- A browser the /mediapool page sends on stops counting. `?notrack` does the + same anywhere, and `?notrack=off` undoes it. +- Local and preview runs log to `dev:` keys, apart from production. + +The tests run offline against a fake Upstash: ```bash node --test ask/test/*.test.js diff --git a/ask/api/ask.js b/ask/api/ask.js index 75d9568..ac57e1d 100644 --- a/ask/api/ask.js +++ b/ask/api/ask.js @@ -11,7 +11,7 @@ * dead backend costs answer quality rather than the whole feature. * * Every question that reaches a model is logged with its outcome for - * the control panel, through lib/track.js. + * Media Pool's Site screen, through lib/track.js. */ import { ASKS, later, record, visitor } from "../lib/track.js"; diff --git a/ask/api/auth.js b/ask/api/auth.js deleted file mode 100644 index 1ebb679..0000000 --- a/ask/api/auth.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Sign-in for the control panel at alitleis.dev/controlpanel. See lib/owner.js - * for how passkeys, pairing codes and sessions fit together. - * - * One POST endpoint with an `action`, so the site talks to a single URL and - * the Hobby plan's function count stays low. - */ -import { address, limiter } from "../lib/track.js"; -import { - endSession, - enroll, - enrollOptions, - mayEnroll, - passkeys, - preflight, - reply, - signIn, - signInOptions, - signedIn, - startPair, -} from "../lib/owner.js"; - -/** - * Per address, per warm instance. Unlocking takes two requests and a page - * load one more, so this leaves room for a lot of honest retries while - * keeping the password and pairing doors from being hammered. - */ -const throttled = limiter(40, 10 * 60 * 1000); - -export const OPTIONS = preflight; - -export async function POST(request) { - if (throttled(address(request) || "unknown")) { - return reply(request, { error: "Too many tries. Wait ten minutes." }, 429); - } - let body; - try { - body = await request.json(); - } catch { - return reply(request, { error: "Send JSON." }, 400); - } - - try { - switch (body?.action) { - case "status": - return reply(request, { - enrolled: Object.keys(await passkeys()).length > 0, - signedIn: Boolean(await signedIn(request)), - }); - - case "enroll-options": { - const kind = await mayEnroll(request, body); - if (!kind) { - return reply(request, { error: body.code ? "That code did not work." : "Wrong password." }, 403); - } - return reply(request, await enrollOptions(kind)); - } - - case "enroll": { - const token = await enroll(body.credential, body.name); - return token - ? reply(request, { token }) - : reply(request, { error: "That passkey could not be checked. Try again." }, 400); - } - - case "sign-in-options": - return reply(request, await signInOptions()); - - case "sign-in": { - const token = await signIn(body.credential); - return token - ? reply(request, { token }) - : reply(request, { error: "That passkey is not one of yours." }, 401); - } - - case "pair": - if (!(await signedIn(request))) return reply(request, { error: "Signed out." }, 401); - return reply(request, await startPair()); - - case "lock": - await endSession(request); - return reply(request, { ok: true }); - - default: - return reply(request, { error: "Unknown action." }, 400); - } - } catch (err) { - console.error("Auth failed:", err.message); - return reply(request, { error: "The database did not answer. Try again." }, 503); - } -} diff --git a/ask/api/panel.js b/ask/api/panel.js deleted file mode 100644 index 2d62cfd..0000000 --- a/ask/api/panel.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * The control panel's data, for a signed-in session only. GET returns the - * whole log with Ali's notes on it, and POST changes those notes: a name for a - * visitor, a named link to send someone, a device to forget. - * - * The page at alitleis.dev/controlpanel does all the counting. It loads the - * whole log once, up to 3,000 entries a list, and then polls with ?visits= - * and ?asks= set to the newest time it holds, for what came after. - */ -import { randomBytes } from "node:crypto"; -import { ASKS, LABELS, LINKS, VISITS, hash, idOf, must, newer, recent } from "../lib/track.js"; -import { forget, passkeys, preflight, reply, signedIn } from "../lib/owner.js"; - -export const OPTIONS = preflight; - -const clip = (v, max) => (typeof v === "string" ? v.trim().slice(0, max) : ""); - -/** The pages a named link may open, the same three the panel offers. */ -const PAGES = new Set(["/", "/galaxy/", "/studio/"]); - -/** Six characters a recipient will not read anything into, unlike ?ref=jane. */ -async function newCode() { - for (;;) { - const code = [...randomBytes(6)].map((b) => "abcdefghijkmnpqrstuvwxyz23456789"[b % 32]).join(""); - const [known] = await must([["HEXISTS", LINKS, code]]); - if (!known) return code; - } -} - -export async function GET(request) { - try { - const device = await signedIn(request); - if (!device) return reply(request, { error: "Signed out." }, 401); - const q = new URL(request.url).searchParams; - // Present but empty means the panel holds none yet, so everything is newer. - const read = (list, key) => (q.has(key) ? newer(list, q.get(key)) : recent(list)); - const [visits, asks, labels, links, keys] = await Promise.all([ - read(VISITS, "visits"), - read(ASKS, "asks"), - hash(LABELS), - hash(LINKS), - passkeys(), - ]); - return reply(request, { - visits, - asks, - labels, - links, - devices: Object.entries(keys).map(([id, k]) => ({ id, name: k.name, t: k.t, used: k.used })), - device, - // The id this browser's own views carry, so the panel can point out - // which visitor is Ali. - you: idOf(request), - }); - } catch (err) { - console.error("Panel read failed:", err.message); - return reply(request, { error: "The database did not answer." }, 503); - } -} - -export async function POST(request) { - let body; - try { - body = await request.json(); - } catch { - return reply(request, { error: "Send JSON." }, 400); - } - - try { - if (!(await signedIn(request))) return reply(request, { error: "Signed out." }, 401); - - switch (body?.action) { - case "label": { - const id = clip(body.id, 8); - if (!/^[0-9a-f]{8}$/.test(id)) return reply(request, { error: "No such visitor." }, 400); - const name = clip(body.name, 60); - const hidden = body.hidden === true; - await must([ - name || hidden - ? ["HSET", LABELS, id, JSON.stringify({ name: name || null, hidden })] - : ["HDEL", LABELS, id], - ]); - return reply(request, { ok: true }); - } - - case "link": { - const name = clip(body.name, 60); - if (!name) return reply(request, { error: "Give the link a name." }, 400); - const code = await newCode(); - await must([ - [ - "HSET", - LINKS, - code, - JSON.stringify({ - name, - note: clip(body.note, 200) || null, - path: PAGES.has(body.path) ? body.path : "/", - t: new Date().toISOString(), - }), - ], - ]); - return reply(request, { code }); - } - - case "unlink": - await must([["HDEL", LINKS, clip(body.code, 80)]]); - return reply(request, { ok: true }); - - case "forget": - // WebAuthn allows a credential id of 1023 bytes, 1364 in base64url. - return (await forget(clip(body.id, 1400))) - ? reply(request, { ok: true }) - : reply(request, { error: "Keep at least one device, or the password opens setup again." }, 400); - - default: - return reply(request, { error: "Unknown action." }, 400); - } - } catch (err) { - console.error("Panel write failed:", err.message); - return reply(request, { error: "The database did not answer. Try again." }, 503); - } -} diff --git a/ask/lib/owner.js b/ask/lib/owner.js deleted file mode 100644 index 1824060..0000000 --- a/ask/lib/owner.js +++ /dev/null @@ -1,364 +0,0 @@ -/** - * Who may open the control panel at alitleis.dev/controlpanel: whoever holds - * one of Ali's passkeys. - * - * Passkeys rather than a password, so there is nothing to guess or leak. The - * panel asks the phone or laptop for Face ID, Touch ID or Windows Hello, the - * device signs a one-time challenge with a key that never leaves it, and this - * checks the signature against the public half stored when it enrolled. A - * good signature buys a session token for the panel's API. - * - * The first device enrolls with ADMIN_PASSWORD, and only while no passkey is - * stored, so the password stops opening anything the moment it is used. Later - * devices enroll from a signed-in one with a pairing code. To start over after - * losing every device, delete the passkeys hash in Upstash. - * - * node:crypto alone. Without attestation, enrolling needs only the public key - * the browser has already parsed out of the authenticator's answer, so there - * is no CBOR to decode and no library to trust. - */ -import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto"; -import { NS, hash, must } from "./track.js"; - -/** - * The one page allowed to use this. A passkey is bound to its host, so one - * made here works nowhere else, and a session token is only handed back to a - * page on this origin. Local and preview runs name their own. - */ -export const ORIGIN = - process.env.VERCEL_ENV === "production" - ? "https://alitleis.dev" - : process.env.PANEL_ORIGIN || "http://localhost:3000"; -const RP_ID = new URL(ORIGIN).hostname; - -const PASSKEYS = `${NS}passkeys`; -const PAIR = `${NS}pair`; -const PAIR_FAILS = `${NS}pair:fails`; - -/** A day of reading the log, short enough that a lost phone does not stay signed in. */ -const SESSION_SECONDS = 12 * 60 * 60; -const CHALLENGE_SECONDS = 5 * 60; -const PAIR_SECONDS = 10 * 60; - -const sha = (data) => createHash("sha256").update(data).digest(); -const b64 = (buf) => Buffer.from(buf).toString("base64url"); -const unb64 = (s) => Buffer.from(typeof s === "string" ? s : "", "base64url"); - -/** Hashing both sides first gives timingSafeEqual the equal lengths it needs. */ -const same = (a, b) => timingSafeEqual(sha(String(a)), sha(String(b))); - -const sessionKey = (token) => `${NS}session:${sha(token).toString("hex")}`; - -/** ES256 for Apple and Android, EdDSA and RS256 for Windows Hello and security keys. */ -const ALGS = { "-7": "ec", "-8": "ed25519", "-257": "rsa" }; - -/** Every stored passkey, keyed by credential id. */ -export const passkeys = () => hash(PASSKEYS); - -/** - * Writes that depend on what Redis holds, run inside Redis so a second - * request cannot slip in between the check and the write. Each returns -1 - * when its condition fails. - */ -// Rewrites a passkey only if it still holds exactly what was read, so a -// sign-in cannot bring back a device forgotten while it was signing in. -const SWAP = `if redis.call("HGET", KEYS[1], ARGV[1]) == ARGV[2] then return redis.call("HSET", KEYS[1], ARGV[1], ARGV[3]) end return -1`; -// Stores the first passkey only while there is none. -const FIRST = `if redis.call("HLEN", KEYS[1]) == 0 then return redis.call("HSET", KEYS[1], ARGV[1], ARGV[2]) end return -1`; -// Forgets a passkey only while another is left. One already gone answers 0. -const DROP = `if redis.call("HEXISTS", KEYS[1], ARGV[1]) ~= 1 then return 0 end if redis.call("HLEN", KEYS[1]) > 1 then return redis.call("HDEL", KEYS[1], ARGV[1]) end return -1`; -// Stores a passkey made with a pairing code and spends the code, only while -// the code is the one the challenge was issued for. In one script, so a -// second device racing or a newer code cannot use it, and a write that fails -// leaves the code for another try. -const PAIRED = `if redis.call("GET", KEYS[2]) == ARGV[3] then redis.call("HSET", KEYS[1], ARGV[1], ARGV[2]) redis.call("DEL", KEYS[2]) return 1 end return -1`; - -// ── Challenges ─────────────────────────────────────────────────────────────── - -/** - * Random, single use and short lived, so a signature over one cannot be - * replayed. `kind` keeps an enrollment challenge from being spent on a sign-in, - * and marks one the password granted, which may only add the first passkey. - */ -async function challenge(kind) { - const c = b64(randomBytes(32)); - await must([["SET", `${NS}challenge:${c}`, kind, "EX", CHALLENGE_SECONDS]]); - return c; -} - -/** - * Checks what the browser says it signed: the right ceremony, on the panel's - * own origin, over a challenge this issued. Returns the kind it was issued - * for, or null. The challenge is spent whatever the outcome. - */ -async function client(json, type) { - let data; - try { - data = JSON.parse(unb64(json).toString("utf8")); - } catch { - return null; - } - if (typeof data?.challenge !== "string" || !/^[\w-]{43}$/.test(data.challenge)) return null; - const [kind] = await must([["GETDEL", `${NS}challenge:${data.challenge}`]]); - if (data.type !== type || data.origin !== ORIGIN) return null; - return kind ?? null; -} - -/** - * The authenticator's own record: for this host, and with someone present who - * passed Face ID, Touch ID or the device PIN, not just a tap on a security key. - */ -function authenticator(data) { - if (data.length < 37 || !data.subarray(0, 32).equals(sha(RP_ID))) return null; - const flags = data[32]; - if ((flags & 0x05) !== 0x05) return null; - return { flags, count: data.readUInt32BE(33) }; -} - -// ── Enrolling ──────────────────────────────────────────────────────────────── - -/** - * Whether this request may add a passkey, as the kind of challenge to issue: - * "enroll" from a signed-in device, "pair:" and the code's hash with a pairing - * code one made, "first" with the password while there are none at all, null - * otherwise. - */ -export async function mayEnroll(request, { password, code } = {}) { - if (await signedIn(request)) return "enroll"; - if (code) { - const pair = await checkPair(code); - return pair ? `pair:${pair}` : null; - } - if (typeof password !== "string" || !password || !process.env.ADMIN_PASSWORD) return null; - const [count] = await must([["HLEN", PASSKEYS]]); - return count === 0 && same(password, process.env.ADMIN_PASSWORD) ? "first" : null; -} - -export async function enrollOptions(kind) { - const keys = await passkeys(); - return { - challenge: await challenge(kind), - rp: { id: RP_ID, name: "Ali Tleis control panel" }, - user: { id: b64("ali"), name: "ali", displayName: "Ali Tleis" }, - pubKeyCredParams: Object.keys(ALGS).map((alg) => ({ type: "public-key", alg: Number(alg) })), - authenticatorSelection: { residentKey: "required", userVerification: "required" }, - // A device that already holds one, like a Mac that synced the iPhone's - // through iCloud Keychain, is told so instead of making a second. - excludeCredentials: Object.keys(keys).map((id) => ({ type: "public-key", id })), - attestation: "none", - timeout: 120000, - }; -} - -const TRANSPORTS = new Set(["ble", "hybrid", "internal", "nfc", "usb"]); - -/** Stores a new passkey from the browser's answer and signs that device in. */ -export async function enroll(cred, name) { - const kind = await client(cred?.clientDataJSON, "webauthn.create"); - const paired = typeof kind === "string" && kind.startsWith("pair:"); - if (kind !== "enroll" && kind !== "first" && !paired) return null; - - const data = unb64(cred.authenticatorData); - const auth = authenticator(data); - // Attested credential data: 16 bytes of AAGUID, a 2-byte length, the id. - if (!auth || !(auth.flags & 0x40) || data.length < 55) return null; - const id = unb64(cred.id); - const len = data.readUInt16BE(53); - if (!id.length || !data.subarray(55, 55 + len).equals(id)) return null; - - const type = ALGS[String(cred.alg)]; - let key; - try { - key = createPublicKey({ key: unb64(cred.publicKey), format: "der", type: "spki" }); - } catch { - return null; - } - if (!type || key.asymmetricKeyType !== type) return null; - if (type === "ec" && key.asymmetricKeyDetails?.namedCurve !== "prime256v1") return null; - - const transports = (Array.isArray(cred.transports) ? cred.transports : []).filter((t) => - TRANSPORTS.has(t), - ); - const record = JSON.stringify({ - key: b64(key.export({ format: "der", type: "spki" })), - alg: Number(cred.alg), - name: typeof name === "string" && name.trim() ? name.trim().slice(0, 60) : "Device", - transports, - count: auth.count, - t: new Date().toISOString(), - used: new Date().toISOString(), - }); - // Two challenges the password granted while the hash was empty must not - // both add a device. The second one finds the first already there. A - // pairing code is spent here, with the passkey, rather than when the - // options went out. Spent then, a cancelled Face ID prompt killed the code. - const [stored] = await must([ - kind === "first" - ? ["EVAL", FIRST, 1, PASSKEYS, b64(id), record] - : paired - ? ["EVAL", PAIRED, 2, PASSKEYS, PAIR, b64(id), record, kind.slice(5)] - : ["HSET", PASSKEYS, b64(id), record], - ]); - if (!(stored >= 0)) return null; - return startSession(b64(id)); -} - -// ── Signing in ─────────────────────────────────────────────────────────────── - -export async function signInOptions() { - const keys = await passkeys(); - return { - challenge: await challenge("sign-in"), - rpId: RP_ID, - allowCredentials: Object.entries(keys).map(([id, k]) => ({ - type: "public-key", - id, - ...(k.transports?.length ? { transports: k.transports } : {}), - })), - userVerification: "required", - timeout: 120000, - }; -} - -/** Checks a signature from a stored passkey, and starts a session if it holds. */ -export async function signIn(cred) { - if ((await client(cred?.clientDataJSON, "webauthn.get")) !== "sign-in") return null; - const id = b64(unb64(cred.id)); - const [raw] = await must([["HGET", PASSKEYS, id]]); - if (!raw) return null; - const pk = JSON.parse(raw); - - const data = unb64(cred.authenticatorData); - const auth = authenticator(data); - if (!auth) return null; - - let good = false; - try { - good = verify( - pk.alg === -8 ? null : "sha256", - Buffer.concat([data, sha(unb64(cred.clientDataJSON))]), - { key: unb64(pk.key), format: "der", type: "spki" }, - unb64(cred.signature), - ); - } catch { - good = false; - } - if (!good) return null; - - // A counter that did not go up means the key was copied off its device. - // Passkeys that sync between devices report 0 every time, which passes, - // but one that has counted before may not drop back to 0. - if ((auth.count || pk.count) && auth.count <= (pk.count ?? 0)) return null; - - const [swapped] = await must([ - ["EVAL", SWAP, 1, PASSKEYS, id, raw, JSON.stringify({ ...pk, count: auth.count, used: new Date().toISOString() })], - ]); - if (!(swapped >= 0)) return null; - return startSession(id); -} - -// ── Sessions ───────────────────────────────────────────────────────────────── - -/** Only the hash is stored, so reading the database does not sign anyone in. */ -async function startSession(cred) { - const token = b64(randomBytes(32)); - await must([ - ["SET", sessionKey(token), JSON.stringify({ cred, t: new Date().toISOString() }), "EX", SESSION_SECONDS], - ]); - return token; -} - -const bearer = (request) => - /^Bearer ([\w-]{43})$/.exec(request.headers.get("authorization") ?? "")?.[1] ?? null; - -/** The passkey this request's session came from, or null when signed out. */ -export async function signedIn(request) { - const token = bearer(request); - if (!token) return null; - const [raw] = await must([["GET", sessionKey(token)]]); - if (!raw) return null; - const { cred } = JSON.parse(raw); - // Forgetting a device ends its sessions with it. - const [known] = await must([["HEXISTS", PASSKEYS, cred]]); - return known ? cred : null; -} - -export async function endSession(request) { - const token = bearer(request); - if (token) await must([["DEL", sessionKey(token)]]); -} - -/** - * Forgets a device, unless it is the last one, which would reopen the - * password. One already gone counts as forgotten, as when two tabs both press - * Forget, rather than reading as the last device. - */ -export async function forget(id) { - const [removed] = await must([["EVAL", DROP, 1, PASSKEYS, String(id)]]); - return removed !== -1; -} - -// ── Pairing ────────────────────────────────────────────────────────────────── - -/** Crockford's base 32: no I, L, O or U to misread, and 32 so a byte maps evenly. */ -const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - -/** What someone typing the code means by it, whatever case and separators. */ -const normal = (code) => - String(code).toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1").replace(/[^0-9A-Z]/g, ""); - -/** - * A code that lets one more device enroll, shown on a signed-in one. Eight - * characters is 40 bits, and five wrong tries end it, so it cannot be guessed - * in the ten minutes it lives. - */ -export async function startPair() { - const code = [...randomBytes(8)].map((b) => ALPHABET[b % 32]).join(""); - await must([ - ["SET", PAIR, sha(code).toString("hex"), "EX", PAIR_SECONDS], - ["DEL", PAIR_FAILS], - ]); - return { code: `${code.slice(0, 4)}-${code.slice(4)}`, expires: Date.now() + PAIR_SECONDS * 1000 }; -} - -/** - * The stored hash when the code matches, without spending it. enroll spends - * it, so only one device gets in on it however many prompts were started. - */ -async function checkPair(code) { - const [stored] = await must([["GET", PAIR]]); - if (!stored) return null; - if (same(sha(normal(code)).toString("hex"), stored)) return stored; - const [fails] = await must([ - ["INCR", PAIR_FAILS], - ["EXPIRE", PAIR_FAILS, PAIR_SECONDS], - ]); - if (fails >= 5) await must([["DEL", PAIR]]); - return null; -} - -// ── Responses ──────────────────────────────────────────────────────────────── - -/** - * Unlike the Ask function, these answer one origin only. A page elsewhere can - * still send a request, but cannot read the log or a token out of the reply. - */ -export function reply(request, body, status = 200) { - const headers = { "Content-Type": "application/json", "Cache-Control": "no-store", Vary: "Origin" }; - if (request.headers.get("origin") === ORIGIN) headers["Access-Control-Allow-Origin"] = ORIGIN; - return new Response(JSON.stringify(body), { status, headers }); -} - -export function preflight(request) { - if (request.headers.get("origin") !== ORIGIN) return new Response(null, { status: 403 }); - return new Response(null, { - status: 204, - headers: { - "Access-Control-Allow-Origin": ORIGIN, - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Authorization, Content-Type", - "Access-Control-Max-Age": "86400", - Vary: "Origin", - }, - }); -} diff --git a/ask/lib/track.js b/ask/lib/track.js index d200e81..cfa00ca 100644 --- a/ask/lib/track.js +++ b/ask/lib/track.js @@ -1,7 +1,7 @@ /** - * The log behind the control panel at alitleis.dev/controlpanel: every - * question asked in the Ask panel with what came back, and every page view the - * site reports to /api/visit. + * The site's log, which the Site screen in Media Pool (Ali's own app) reads: + * every question asked in the Ask panel with what came back, and every page + * view the site reports to /api/visit. * * Kept in Upstash Redis on the free plan, as two capped lists of JSON, newest * first. Logging never fails a request. Without the database variables, or @@ -16,9 +16,6 @@ const DB_TOKEN = process.env.KV_REST_API_TOKEN; export const NS = process.env.VERCEL_ENV === "production" ? "" : "dev:"; export const ASKS = `${NS}asks`; export const VISITS = `${NS}visits`; -/** The panel's own notes: names Ali gave visitors, and the links he sent out. */ -export const LABELS = `${NS}labels`; -export const LINKS = `${NS}links`; /** Roughly a year of a portfolio's traffic, and well under the free 256MB. */ const KEEP = 3000; @@ -27,16 +24,15 @@ export async function redis(commands) { if (!DB_URL || !DB_TOKEN) return null; const res = await fetch(`${DB_URL}/pipeline`, { method: "POST", - // Upstash reads the body either way. The Redis stand-in the pull request - // checks run answers 400 to the text/plain a bare string body gets. + // Upstash reads the body either way, but a Redis REST stand-in answers + // 400 to the text/plain a bare string body gets. headers: { Authorization: `Bearer ${DB_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify(commands), signal: AbortSignal.timeout(3000), }); if (!res.ok) throw new Error(`Upstash returned ${res.status}`); // A pipeline answers 200 even when one of its commands failed, with that - // command's error in place of its result. Passed on as undefined, a failed - // conditional write in lib/owner.js would read as one that went through. + // command's error in place of its result, so that is a failure too. const out = await res.json(); const failed = out.find((r) => r.error); if (failed) throw new Error(`Upstash command failed: ${failed.error}`); @@ -73,64 +69,6 @@ export async function later(promise) { await promise; } -/** - * For the panel and its sign-in, where a missing database is an error to show - * rather than an entry to drop. - */ -export async function must(commands) { - const out = await redis(commands); - if (!out) throw new Error("KV_REST_API_URL and KV_REST_API_TOKEN are not set."); - return out; -} - -/** A Redis hash of JSON values, as an object. Upstash sends HGETALL flat. */ -export async function hash(key) { - const [flat] = await must([["HGETALL", key]]); - const out = {}; - for (let i = 0; i + 1 < (flat?.length ?? 0); i += 2) { - try { - out[flat[i]] = JSON.parse(flat[i + 1]); - } catch { - // A hand-edited entry that is not JSON is skipped, not fatal. - } - } - return out; -} - -const parsed = (items) => - items.flatMap((item) => { - try { - return [JSON.parse(item)]; - } catch { - return []; - } - }); - -export async function recent(list) { - const [items] = (await redis([["LRANGE", list, 0, KEEP - 1]])) ?? [[]]; - return parsed(items); -} - -/** - * Answers finish out of order, so a timestamp cannot end a list scan early. - * Filter the capped list inside Redis to keep old entries off the network - * and within the free plan's 10GB monthly transfer limit. - */ -const NEWER = ` -local out = {} -for _, item in ipairs(redis.call("LRANGE", KEYS[1], 0, tonumber(ARGV[2]) - 1)) do - local ok, entry = pcall(cjson.decode, item) - if ok and type(entry) == "table" and type(entry.t) == "string" and entry.t > ARGV[1] then - table.insert(out, item) - end -end -return out`; - -export async function newer(list, since) { - const [items] = await must([["EVAL", NEWER, 1, list, since, KEEP]]); - return parsed(items); -} - /** Crawlers and headless checks, which would otherwise read as visitors. */ export const isBot = (ua) => !ua || /bot|crawl|spider|slurp|headless|lighthouse|preview|curl|python/i.test(ua); diff --git a/ask/test/panel.test.js b/ask/test/panel.test.js deleted file mode 100644 index 16fc124..0000000 --- a/ask/test/panel.test.js +++ /dev/null @@ -1,572 +0,0 @@ -/** - * Offline checks for the control panel's sign-in and data endpoints. - * - * Upstash is replaced by an in-memory Redis behind the same REST pipeline, and - * the authenticator by real P-256 and Ed25519 keys, so every signature here is - * checked by the same node:crypto calls production makes. - * - * node --test ask/test - */ -import assert from "node:assert/strict"; -import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto"; -import { beforeEach, test } from "node:test"; - -const ORIGIN = "https://panel.test"; -process.env.PANEL_ORIGIN = ORIGIN; -process.env.ADMIN_PASSWORD = "hunter2"; -process.env.KV_REST_API_URL = "https://kv.test"; -process.env.KV_REST_API_TOKEN = "secret-token"; -delete process.env.VERCEL_ENV; - -// ── A Redis that holds strings and hashes, which is all the panel uses ────── - -let store; -/** Runs just before an EVAL, to land a write in the gap a race would open. */ -let beforeEval = null; -/** When set, every EVAL answers with an error, the way a pipeline reports one. */ -let evalFails = false; -const realFetch = globalThis.fetch; -globalThis.fetch = async (url, init) => { - if (!String(url).startsWith("https://kv.test/")) return realFetch(url, init); - const out = JSON.parse(init.body).map(([cmd, key, ...args]) => { - const h = () => (store.has(key) ? store.get(key) : store.set(key, new Map()).get(key)); - switch (cmd) { - case "SET": store.set(key, String(args[0])); return { result: "OK" }; - case "GET": return { result: store.get(key) ?? null }; - case "GETDEL": { const v = store.get(key) ?? null; store.delete(key); return { result: v }; } - case "DEL": return { result: store.delete(key) ? 1 : 0 }; - case "INCR": { const n = Number(store.get(key) ?? 0) + 1; store.set(key, String(n)); return { result: n }; } - case "EXPIRE": return { result: 1 }; - case "HSET": h().set(args[0], args[1]); return { result: 1 }; - case "HGET": return { result: h().get(args[0]) ?? null }; - case "EVAL": { - if (evalFails) return { error: "ERR script failed" }; - if (key.includes("LRANGE")) { - const [, list, since, keep] = args; - return { result: (store.get(list) ?? []).slice(0, keep).filter((item) => { - try { - return JSON.parse(item)?.t > since; - } catch { - return false; - } - }) }; - } - // The four scripts in lib/owner.js, told apart by what they check. - if (key.includes('"GET"')) { - const [, hashKey, pairKey, field, value, code] = args; - if (store.get(pairKey) !== code) return { result: -1 }; - (store.get(hashKey) ?? store.set(hashKey, new Map()).get(hashKey)).set(field, value); - store.delete(pairKey); - return { result: 1 }; - } - const [, hashKey, field, a, b] = [key, args[1], args[2], args[3], args[4]]; - beforeEval?.(); - const m = store.get(hashKey) ?? store.set(hashKey, new Map()).get(hashKey); - if (cmd === "EVAL" && key.includes("== ARGV[2]")) { - if (m.get(field) !== a) return { result: -1 }; - m.set(field, b); - return { result: 0 }; - } - if (key.includes("== 0")) { - if (m.size !== 0) return { result: -1 }; - m.set(field, a); - return { result: 1 }; - } - if (key.includes("> 1")) { - if (!m.has(field)) return { result: 0 }; - if (m.size <= 1) return { result: -1 }; - return { result: m.delete(field) ? 1 : 0 }; - } - throw new Error("fake Redis does not know this script"); - } - case "HDEL": return { result: h().delete(args[0]) ? 1 : 0 }; - case "HEXISTS": return { result: h().has(args[0]) ? 1 : 0 }; - case "HLEN": return { result: h().size }; - case "HGETALL": return { result: [...h()].flat() }; - case "LPUSH": { const l = store.get(key) ?? []; l.unshift(args[0]); store.set(key, l); return { result: l.length }; } - case "LTRIM": return { result: "OK" }; - case "LRANGE": return { result: (store.get(key) ?? []).slice(args[0], args[1] + 1) }; - default: throw new Error(`fake Redis has no ${cmd}`); - } - }); - return Response.json(out); -}; - -const auth = await import("../api/auth.js"); -const panel = await import("../api/panel.js"); -const visit = await import("../api/visit.js"); - -beforeEach(() => { - store = new Map(); - beforeEval = null; - evalFails = false; -}); - -// ── A browser on the panel's page ─────────────────────────────────────────── - -const b64 = (buf) => Buffer.from(buf).toString("base64url"); -const sha = (d) => createHash("sha256").update(d).digest(); - -let ip = 0; -function req(method, path, { body, token, origin = ORIGIN } = {}) { - ip++; - const headers = { - "content-type": "application/json", - "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) Safari/604.1", - "x-forwarded-for": `10.0.${ip >> 8}.${ip & 255}`, - origin, - }; - if (token) headers.authorization = `Bearer ${token}`; - return new Request(`https://api.test${path}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - }); -} - -async function call(handler, path, opts) { - const res = await handler(req(opts?.method ?? "POST", path, opts)); - return { status: res.status, headers: res.headers, body: await res.json() }; -} - -const authCall = (body, opts = {}) => call(auth.POST, "/api/auth", { ...opts, body }); - -/** An authenticator: a key pair, an id, and a counter, like Face ID holds. */ -function device(kind = "ec") { - const { publicKey, privateKey } = - kind === "ec" - ? generateKeyPairSync("ec", { namedCurve: "P-256" }) - : generateKeyPairSync("ed25519"); - return { - id: randomBytes(16), - alg: kind === "ec" ? -7 : -8, - publicKey, - privateKey, - count: 0, - rpId: new URL(ORIGIN).hostname, - }; -} - -function clientData(type, challenge, origin = ORIGIN) { - return Buffer.from(JSON.stringify({ type, challenge, origin, crossOrigin: false })); -} - -function authData(d, { flags = 0x05, attested = false } = {}) { - const head = Buffer.alloc(37); - sha(d.rpId).copy(head, 0); - head[32] = flags | (attested ? 0x40 : 0); - head.writeUInt32BE(d.count, 33); - if (!attested) return head; - const len = Buffer.alloc(2); - len.writeUInt16BE(d.id.length); - // The COSE key would follow here. The server reads the SPKI the browser - // parsed instead, so a placeholder stands in for it. - return Buffer.concat([head, Buffer.alloc(16), len, d.id, Buffer.from([0xa0])]); -} - -function created(d, options, overrides = {}) { - return { - id: b64(d.id), - clientDataJSON: b64(clientData("webauthn.create", options.challenge, overrides.origin)), - authenticatorData: b64(authData(d, { attested: true, flags: overrides.flags })), - publicKey: b64(d.publicKey.export({ format: "der", type: "spki" })), - alg: d.alg, - transports: ["internal", "hybrid", "bogus"], - }; -} - -function asserted(d, options, overrides = {}) { - const cd = clientData("webauthn.get", options.challenge, overrides.origin); - const ad = authData(d, { flags: overrides.flags }); - const signed = Buffer.concat([ad, sha(cd)]); - const sig = sign(d.alg === -8 ? null : "sha256", signed, overrides.key ?? d.privateKey); - return { - id: b64(d.id), - clientDataJSON: b64(cd), - authenticatorData: b64(ad), - signature: b64(sig), - }; -} - -async function enrollFirst(d = device()) { - const opts = await authCall({ action: "enroll-options", password: "hunter2" }); - assert.equal(opts.status, 200); - const done = await authCall({ action: "enroll", credential: created(d, opts.body), name: "iPhone · Safari" }); - assert.equal(done.status, 200, JSON.stringify(done.body)); - return { d, token: done.body.token }; -} - -const panelPost = (body, token) => call(panel.POST, "/api/panel", { body, token }); - -async function signInWith(d, overrides) { - const opts = await authCall({ action: "sign-in-options" }); - return authCall({ action: "sign-in", credential: asserted(d, opts.body, overrides) }); -} - -// ── Enrolling ─────────────────────────────────────────────────────────────── - -test("the first device enrolls with the password and comes back signed in", async () => { - const before = await authCall({ action: "status" }); - assert.deepEqual(before.body, { enrolled: false, signedIn: false }); - - const { token } = await enrollFirst(); - const after = await authCall({ action: "status" }, { token }); - assert.deepEqual(after.body, { enrolled: true, signedIn: true }); - - const stored = JSON.parse([...store.get("dev:passkeys").values()][0]); - assert.deepEqual(stored.transports, ["internal", "hybrid"]); - assert.equal(stored.name, "iPhone · Safari"); -}); - -test("a wrong password gets no challenge", async () => { - const res = await authCall({ action: "enroll-options", password: "hunter3" }); - assert.equal(res.status, 403); - assert.equal(res.body.challenge, undefined); -}); - -test("the password stops working once a passkey exists", async () => { - await enrollFirst(); - const res = await authCall({ action: "enroll-options", password: "hunter2" }); - assert.equal(res.status, 403); -}); - -test("an enrollment from another origin, or without Face ID, is refused", async () => { - for (const overrides of [{ origin: "https://evil.test" }, { flags: 0x01 }]) { - const d = device(); - const opts = await authCall({ action: "enroll-options", password: "hunter2" }); - const res = await authCall({ action: "enroll", credential: created(d, opts.body, overrides) }); - assert.equal(res.status, 400); - } - assert.equal(store.get("dev:passkeys")?.size ?? 0, 0); -}); - -test("an enrollment challenge works once", async () => { - const d = device(); - const opts = await authCall({ action: "enroll-options", password: "hunter2" }); - const cred = created(d, opts.body); - assert.equal((await authCall({ action: "enroll", credential: cred })).status, 200); - assert.equal((await authCall({ action: "enroll", credential: cred })).status, 400); -}); - -test("two password challenges cannot both add a device", async () => { - const first = await authCall({ action: "enroll-options", password: "hunter2" }); - const second = await authCall({ action: "enroll-options", password: "hunter2" }); - const a = device(); - const b = device(); - assert.equal((await authCall({ action: "enroll", credential: created(a, first.body) })).status, 200); - assert.equal((await authCall({ action: "enroll", credential: created(b, second.body) })).status, 400); - assert.equal(store.get("dev:passkeys").size, 1); -}); - -test("a pairing code enrolls one more device, once", async () => { - const { token } = await enrollFirst(); - const pair = await authCall({ action: "pair" }, { token }); - assert.match(pair.body.code, /^[0-9A-Z]{4}-[0-9A-Z]{4}$/); - - const typed = pair.body.code.toLowerCase().replace("-", " "); - const opts = await authCall({ action: "enroll-options", code: typed }); - assert.equal(opts.status, 200); - const laptop = device("ed25519"); - const done = await authCall({ action: "enroll", credential: created(laptop, opts.body), name: "Windows · Edge" }); - assert.equal(done.status, 200); - - const again = await authCall({ action: "enroll-options", code: pair.body.code }); - assert.equal(again.status, 403); - - // The Ed25519 device signs in too. - assert.equal((await signInWith(laptop)).status, 200); -}); - -test("a cancelled passkey prompt leaves the pairing code, which still enrolls one device", async () => { - const { token } = await enrollFirst(); - const pair = await authCall({ action: "pair" }, { token }); - // Face ID cancelled after the options went out, then tried twice more. - assert.equal((await authCall({ action: "enroll-options", code: pair.body.code })).status, 200); - const a = await authCall({ action: "enroll-options", code: pair.body.code }); - const b = await authCall({ action: "enroll-options", code: pair.body.code }); - assert.equal(b.status, 200); - - assert.equal((await authCall({ action: "enroll", credential: created(device(), a.body) })).status, 200); - assert.equal((await authCall({ action: "enroll", credential: created(device(), b.body) })).status, 400); - assert.equal(store.get("dev:passkeys").size, 2); -}); - -test("a prompt started with an old pairing code cannot spend a newer one", async () => { - const { token } = await enrollFirst(); - const first = await authCall({ action: "pair" }, { token }); - const stale = await authCall({ action: "enroll-options", code: first.body.code }); - const second = await authCall({ action: "pair" }, { token }); - - assert.equal((await authCall({ action: "enroll", credential: created(device(), stale.body) })).status, 400); - const fresh = await authCall({ action: "enroll-options", code: second.body.code }); - assert.equal((await authCall({ action: "enroll", credential: created(device(), fresh.body) })).status, 200); -}); - -test("five wrong pairing codes end the real one", async () => { - const { token } = await enrollFirst(); - const pair = await authCall({ action: "pair" }, { token }); - for (let i = 0; i < 5; i++) { - assert.equal((await authCall({ action: "enroll-options", code: "0000-0000" })).status, 403); - } - assert.equal((await authCall({ action: "enroll-options", code: pair.body.code })).status, 403); -}); - -test("a pairing code needs a signed-in device to make it", async () => { - await enrollFirst(); - assert.equal((await authCall({ action: "pair" })).status, 401); -}); - -// ── Signing in ────────────────────────────────────────────────────────────── - -test("a stored passkey signs in, and a stranger's key does not", async () => { - const { d } = await enrollFirst(); - const ok = await signInWith(d); - assert.equal(ok.status, 200); - assert.match(ok.body.token, /^[\w-]{43}$/); - - const stranger = device(); - stranger.id = d.id; - const bad = await signInWith(d, { key: stranger.privateKey }); - assert.equal(bad.status, 401); -}); - -test("a sign-in from another origin or without user verification is refused", async () => { - const { d } = await enrollFirst(); - assert.equal((await signInWith(d, { origin: "https://evil.test" })).status, 401); - assert.equal((await signInWith(d, { flags: 0x01 })).status, 401); -}); - -test("a sign-in refused for its origin still spends the challenge", async () => { - const { d } = await enrollFirst(); - const opts = await authCall({ action: "sign-in-options" }); - const elsewhere = asserted(d, opts.body, { origin: "https://evil.test" }); - assert.equal((await authCall({ action: "sign-in", credential: elsewhere })).status, 401); - assert.equal((await authCall({ action: "sign-in", credential: asserted(d, opts.body) })).status, 401); -}); - -test("a replayed sign-in is refused", async () => { - const { d } = await enrollFirst(); - const opts = await authCall({ action: "sign-in-options" }); - const cred = asserted(d, opts.body); - assert.equal((await authCall({ action: "sign-in", credential: cred })).status, 200); - assert.equal((await authCall({ action: "sign-in", credential: cred })).status, 401); -}); - -test("a counter that goes backwards is refused", async () => { - const { d } = await enrollFirst(); - d.count = 5; - assert.equal((await signInWith(d)).status, 200); - d.count = 3; - assert.equal((await signInWith(d)).status, 401); -}); - -test("a counter that drops back to zero is refused", async () => { - const { d } = await enrollFirst(); - d.count = 5; - assert.equal((await signInWith(d)).status, 200); - d.count = 0; - assert.equal((await signInWith(d)).status, 401); -}); - -test("a device forgotten while it signs in stays forgotten", async () => { - const { d, token } = await enrollFirst(); - const pair = await authCall({ action: "pair" }, { token }); - const opts = await authCall({ action: "enroll-options", code: pair.body.code }); - const other = device(); - await authCall({ action: "enroll", credential: created(other, opts.body) }); - - // The forget lands after sign-in has read the passkey, before it writes. - beforeEval = () => { - beforeEval = null; - store.get("dev:passkeys").delete(b64(d.id)); - }; - assert.equal((await signInWith(d)).status, 401); - assert.equal(store.get("dev:passkeys").has(b64(d.id)), false); -}); - -test("forgetting two devices at once leaves one", async () => { - const { d, token } = await enrollFirst(); - const pair = await authCall({ action: "pair" }, { token }); - const opts = await authCall({ action: "enroll-options", code: pair.body.code }); - const other = device(); - await authCall({ action: "enroll", credential: created(other, opts.body) }); - - const results = await Promise.all([ - panelPost({ action: "forget", id: b64(d.id) }, token), - panelPost({ action: "forget", id: b64(other.id) }, token), - ]); - assert.deepEqual(results.map((r) => r.status).sort(), [200, 400]); - assert.equal(store.get("dev:passkeys").size, 1); -}); - -test("a device with a long credential id can be forgotten, and forgetting it twice is fine", async () => { - const { token } = await enrollFirst(); - const pair = await authCall({ action: "pair" }, { token }); - const opts = await authCall({ action: "enroll-options", code: pair.body.code }); - const key = Object.assign(device(), { id: randomBytes(1023) }); - assert.equal((await authCall({ action: "enroll", credential: created(key, opts.body) })).status, 200); - - const id = b64(key.id); - assert.equal((await panelPost({ action: "forget", id }, token)).status, 200); - assert.equal(store.get("dev:passkeys").has(id), false); - assert.equal((await panelPost({ action: "forget", id }, token)).status, 200); -}); - -test("a failed Redis command ends the sign-in instead of passing", async () => { - const { d } = await enrollFirst(); - evalFails = true; - const res = await signInWith(d); - assert.equal(res.status, 503); - assert.equal(res.body.token, undefined); -}); - -test("locking ends the session", async () => { - const { token } = await enrollFirst(); - assert.equal((await call(panel.GET, "/api/panel", { method: "GET", token })).status, 200); - await authCall({ action: "lock" }, { token }); - assert.equal((await call(panel.GET, "/api/panel", { method: "GET", token })).status, 401); -}); - -// ── The panel ─────────────────────────────────────────────────────────────── - -test("the panel answers only a signed-in session", async () => { - assert.equal((await call(panel.GET, "/api/panel", { method: "GET" })).status, 401); - assert.equal((await call(panel.GET, "/api/panel", { method: "GET", token: "x".repeat(43) })).status, 401); - assert.equal((await panelPost({ action: "link", name: "Jane" })).status, 401); -}); - -test("labels and links round trip through the panel", async () => { - const { token } = await enrollFirst(); - assert.equal((await panelPost({ action: "label", id: "abcd1234", name: "Jane", hidden: false }, token)).status, 200); - assert.equal((await panelPost({ action: "label", id: "../etc", name: "x" }, token)).status, 400); - const link = await panelPost({ action: "link", name: "Jane at Google", note: "Sent after the career fair", path: "/studio/" }, token); - assert.match(link.body.code, /^[a-z2-9]{6}$/); - const odd = await panelPost({ action: "link", name: "Odd", path: "https://evil.test/" }, token); - - const data = await call(panel.GET, "/api/panel", { method: "GET", token }); - assert.deepEqual(data.body.labels.abcd1234, { name: "Jane", hidden: false }); - assert.equal(data.body.links[link.body.code].name, "Jane at Google"); - assert.equal(data.body.links[link.body.code].path, "/studio/"); - assert.equal(data.body.links[odd.body.code].path, "/", "only the site's own pages"); - assert.equal(data.body.devices.length, 1); - assert.equal(data.body.devices[0].key, undefined, "public keys stay server side"); - assert.match(data.body.you, /^[0-9a-f]{8}$/); - - // An empty name with nothing hidden removes the label. - await panelPost({ action: "label", id: "abcd1234", name: "" }, token); - assert.equal((await call(panel.GET, "/api/panel", { method: "GET", token })).body.labels.abcd1234, undefined); -}); - -test("the last device cannot be forgotten, and a forgotten one is signed out", async () => { - const { d, token } = await enrollFirst(); - const id = b64(d.id); - assert.equal((await panelPost({ action: "forget", id }, token)).status, 400); - - const pair = await authCall({ action: "pair" }, { token }); - const opts = await authCall({ action: "enroll-options", code: pair.body.code }); - const second = device(); - const done = await authCall({ action: "enroll", credential: created(second, opts.body) }); - - assert.equal((await panelPost({ action: "forget", id }, done.body.token)).status, 200); - assert.equal((await call(panel.GET, "/api/panel", { method: "GET", token })).status, 401); -}); - -test("CORS answers the panel's origin and nobody else's", async () => { - const pre = await auth.OPTIONS(req("OPTIONS", "/api/auth")); - assert.equal(pre.status, 204); - assert.equal(pre.headers.get("access-control-allow-origin"), ORIGIN); - assert.match(pre.headers.get("access-control-allow-headers"), /Authorization/); - - const other = await panel.OPTIONS(req("OPTIONS", "/api/panel", { origin: "https://evil.test" })); - assert.equal(other.status, 403); - - const res = await authCall({ action: "status" }, { origin: "https://evil.test" }); - assert.equal(res.headers.get("access-control-allow-origin"), null); -}); - -test("a poll with a time gets only what came after it, across pages", async () => { - const { token } = await enrollFirst(); - // 250 views a second apart, oldest pushed first, so the list is newest first. - const t0 = Date.parse("2026-10-01T00:00:00.000Z"); - store.set("dev:visits", []); - for (let i = 0; i < 250; i++) { - store.get("dev:visits").unshift(JSON.stringify({ t: new Date(t0 + i * 1000).toISOString(), path: `/${i}` })); - } - const all = await call(panel.GET, "/api/panel", { method: "GET", token }); - assert.equal(all.body.visits.length, 250); - - const since = new Date(t0 + 99 * 1000).toISOString(); - const res = await call(panel.GET, `/api/panel?visits=${encodeURIComponent(since)}&asks=`, { method: "GET", token }); - assert.equal(res.body.visits.length, 150); - assert.equal(res.body.visits.at(-1).path, "/100"); - assert.deepEqual(res.body.asks, []); -}); - -test("a delayed entry does not hide newer entries in the same poll page", async () => { - const { token } = await enrollFirst(); - const t0 = Date.parse("2026-10-01T00:00:00.000Z"); - const entry = (i) => JSON.stringify({ t: new Date(t0 + i * 1000).toISOString(), q: `Question ${i}` }); - // Answers finish out of order. Older questions can be pushed ahead of - // newer ones, including at the start of the next Redis page. - const entries = Array.from({ length: 250 }, (_, i) => entry(250 - i)); - entries.unshift(entry(0)); - entries.splice(100, 0, entry(0)); - store.set("dev:asks", entries); - - const since = new Date(t0 + 99 * 1000).toISOString(); - const res = await call(panel.GET, `/api/panel?asks=${encodeURIComponent(since)}`, { method: "GET", token }); - assert.equal(res.status, 200); - assert.equal(res.body.asks.length, 151); - assert.equal(res.body.asks.at(-1).q, "Question 100"); -}); - -test("a poll finds newer entries behind a full page of delayed answers", async () => { - const { token } = await enrollFirst(); - const old = JSON.stringify({ t: "2026-10-01T00:00:00.000Z", q: "Delayed answer" }); - const fresh = { t: "2026-10-01T00:00:20.000Z", q: "New question" }; - store.set("dev:asks", [...Array(100).fill(old), "invalid JSON", JSON.stringify(fresh)]); - const res = await call(panel.GET, "/api/panel?asks=2026-10-01T00%3A00%3A10.000Z", { method: "GET", token }); - assert.deepEqual(res.body.asks, [fresh]); -}); - -// ── Visits ────────────────────────────────────────────────────────────────── - -test("a visit keeps the address and the link tag", async () => { - globalThis.fetch = ((inner) => async (url, init) => - String(url).startsWith("https://api.ipapi.is/") - ? Response.json({ company: { name: "Google LLC" }, location: { city: "Boston" } }) - : inner(url, init))(globalThis.fetch); - - const res = await visit.POST( - new Request("https://api.test/api/visit", { - method: "POST", - headers: { - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) Safari/605.1.15", - "x-forwarded-for": "203.0.113.9", - }, - body: JSON.stringify({ path: "/galaxy/", first: false, tag: "k3x9qp" }), - }), - ); - assert.equal(res.status, 204); - const [entry] = store.get("dev:visits").map((v) => JSON.parse(v)); - assert.equal(entry.ip, "203.0.113.9"); - assert.equal(entry.tag, "k3x9qp"); - assert.equal(entry.org, "Google LLC"); - assert.match(entry.id, /^[0-9a-f]{8}$/); -}); - -test("a visit cuts a long referring host to what DNS allows", async () => { - const res = await visit.POST( - new Request("https://api.test/api/visit", { - method: "POST", - headers: { - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) Safari/605.1.15", - "x-forwarded-for": "203.0.113.10", - }, - body: JSON.stringify({ path: "/", first: true, ref: `https://${"a".repeat(100_000)}.com/` }), - }), - ); - assert.equal(res.status, 204); - const [entry] = store.get("dev:visits").map((v) => JSON.parse(v)); - assert.equal(entry.ref.length, 253); -}); diff --git a/ask/test/redis.test.js b/ask/test/redis.test.js deleted file mode 100644 index f01d0fa..0000000 --- a/ask/test/redis.test.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * The control panel's poll script, run in a real Redis. panel.test.js stands - * in for Upstash with a JavaScript copy of the script's filter, so a broken - * script still passed there. - * - * Skipped unless TEST_REDIS_REST_URL and TEST_REDIS_REST_TOKEN name an - * Upstash-style REST endpoint it may write a scratch list to. The pull request - * workflow points them at serverless-redis-http in front of a throwaway Redis. - * - * node --test ask/test - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -const REST_URL = process.env.TEST_REDIS_REST_URL; -const REST_TOKEN = process.env.TEST_REDIS_REST_TOKEN; - -test("the poll script filters a real Redis list by time", { skip: !REST_URL || !REST_TOKEN }, async () => { - process.env.KV_REST_API_URL = REST_URL; - process.env.KV_REST_API_TOKEN = REST_TOKEN; - const { newer, redis } = await import("../lib/track.js"); - - const list = `dev:test:newer:${process.pid}`; - const at = (s) => ({ t: `2026-10-01T00:00:${String(s).padStart(2, "0")}.000Z`, q: `Question ${s}` }); - // Newest written first. A late answer sits ahead of newer entries, beside - // entries the script has to skip, and the last entry is past the 3000 cap. - const items = [ - at(30), - at(5), - "invalid JSON", - { q: "No time" }, - { t: 20, q: "A number for a time" }, - at(20), - ...Array(2994).fill(at(1)), - at(59), - ].map((e) => (typeof e === "string" ? e : JSON.stringify(e))); - assert.equal(items.length, 3001); - - try { - await redis([["DEL", list], ["RPUSH", list, ...items]]); - assert.deepEqual(await newer(list, at(10).t), [at(30), at(20)]); - assert.equal((await newer(list, "")).length, 2997); - } finally { - // Quietly, so a cleanup failure cannot hide the failure that matters. - await redis([["DEL", list]]).catch(() => {}); - } -}); diff --git a/ask/test/visit.test.js b/ask/test/visit.test.js new file mode 100644 index 0000000..2bbf980 --- /dev/null +++ b/ask/test/visit.test.js @@ -0,0 +1,72 @@ +/** + * Offline checks for the visit log. Upstash is replaced by an in-memory list + * behind the same REST pipeline. + * + * node --test ask/test + */ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +process.env.KV_REST_API_URL = "https://kv.test"; +process.env.KV_REST_API_TOKEN = "secret-token"; +delete process.env.VERCEL_ENV; + +let store; +const realFetch = globalThis.fetch; +globalThis.fetch = async (url, init) => { + if (!String(url).startsWith("https://kv.test/")) return realFetch(url, init); + const out = JSON.parse(init.body).map(([cmd, key, ...args]) => { + switch (cmd) { + case "LPUSH": { const l = store.get(key) ?? []; l.unshift(args[0]); store.set(key, l); return { result: l.length }; } + case "LTRIM": return { result: "OK" }; + default: throw new Error(`fake Redis has no ${cmd}`); + } + }); + return Response.json(out); +}; + +const visit = await import("../api/visit.js"); + +beforeEach(() => { + store = new Map(); +}); + +test("a visit keeps the address and the link tag", async () => { + globalThis.fetch = ((inner) => async (url, init) => + String(url).startsWith("https://api.ipapi.is/") + ? Response.json({ company: { name: "Google LLC" }, location: { city: "Boston" } }) + : inner(url, init))(globalThis.fetch); + + const res = await visit.POST( + new Request("https://api.test/api/visit", { + method: "POST", + headers: { + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) Safari/605.1.15", + "x-forwarded-for": "203.0.113.9", + }, + body: JSON.stringify({ path: "/galaxy/", first: false, tag: "k3x9qp" }), + }), + ); + assert.equal(res.status, 204); + const [entry] = store.get("dev:visits").map((v) => JSON.parse(v)); + assert.equal(entry.ip, "203.0.113.9"); + assert.equal(entry.tag, "k3x9qp"); + assert.equal(entry.org, "Google LLC"); + assert.match(entry.id, /^[0-9a-f]{8}$/); +}); + +test("a visit cuts a long referring host to what DNS allows", async () => { + const res = await visit.POST( + new Request("https://api.test/api/visit", { + method: "POST", + headers: { + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) Safari/605.1.15", + "x-forwarded-for": "203.0.113.10", + }, + body: JSON.stringify({ path: "/", first: true, ref: `https://${"a".repeat(100_000)}.com/` }), + }), + ); + assert.equal(res.status, 204); + const [entry] = store.get("dev:visits").map((v) => JSON.parse(v)); + assert.equal(entry.ref.length, 253); +}); diff --git a/src/app/components/SiteShell.tsx b/src/app/components/SiteShell.tsx index ad23e8e..0464ec0 100644 --- a/src/app/components/SiteShell.tsx +++ b/src/app/components/SiteShell.tsx @@ -60,12 +60,13 @@ export default function SiteShell({ children }: { children: React.ReactNode }) { if (entering) clearSwitchArrival(); }, [entering, pathname]); - // Counts each page view for the control panel at /controlpanel. Opening the - // site once with ?notrack stops counting this browser, so Ali's own visits - // stay out of it, and ?notrack=off counts it again. Signing in to the panel - // sets the same flag. + // Counts each page view for the Site screen in Media Pool, Ali's own app, + // which reads the log. Opening the site once with ?notrack stops counting + // this browser, so Ali's own visits stay out of it, and ?notrack=off counts + // it again. The /mediapool door sets the same flag on a device that reaches + // Media Pool. useEffect(() => { - if (pathname?.startsWith("/controlpanel")) return; + if (pathname?.startsWith("/mediapool")) return; const params = new URLSearchParams(window.location.search); const flag = params.get("notrack"); const src = params.get("ref") ?? params.get("utm_source"); @@ -112,13 +113,13 @@ export default function SiteShell({ children }: { children: React.ReactNode }) { window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); - // Three routes bring their own frame. The suite and the control panel are - // full viewport applications, and the chooser is a single screen that would + // Three routes bring their own frame. The suite and the Media Pool door are + // full viewport screens, and the chooser is a single screen that would // look absurd under a nav offering to take you to the page you are choosing // between. const bare = pathname?.startsWith("/studio") || - pathname?.startsWith("/controlpanel") || + pathname?.startsWith("/mediapool") || pathname === "/" || pathname === ""; diff --git a/src/app/controlpanel/ControlPanel.tsx b/src/app/controlpanel/ControlPanel.tsx deleted file mode 100644 index 8c3edb8..0000000 --- a/src/app/controlpanel/ControlPanel.tsx +++ /dev/null @@ -1,539 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import type { IconType } from "react-icons"; -import { - LuChevronDown, - LuLayoutDashboard, - LuLink, - LuLock, - LuMessageSquare, - LuRefreshCw, - LuShieldCheck, - LuUsers, -} from "react-icons/lu"; -import BackToChooser from "../components/BackToChooser"; -import { useFocusTrap } from "../lib/useFocusTrap"; -import { SignedOut, load, lock, status, token } from "./api"; -import Inspector from "./Inspector"; -import Lock from "./Lock"; -import { - RANGES, - active, - clock, - day, - keyOf, - liveNow, - ms, - plural, - rangeOf, - visitors, - type PanelData, - type RangeId, - type Visitor, -} from "./model"; -import Timeline from "./Timeline"; -import { PanelHead } from "./ui"; -import Asks from "./comps/Asks"; -import Devices from "./comps/Devices"; -import Links from "./comps/Links"; -import Overview from "./comps/Overview"; -import Visitors from "./comps/Visitors"; - -type CompId = "overview" | "visitors" | "asks" | "links" | "devices"; -const COMPS: { id: CompId; name: string; icon: IconType }[] = [ - { id: "overview", name: "Overview", icon: LuLayoutDashboard }, - { id: "visitors", name: "Visitors", icon: LuUsers }, - { id: "asks", name: "Ask log", icon: LuMessageSquare }, - { id: "links", name: "Links", icon: LuLink }, - { id: "devices", name: "Devices", icon: LuShieldCheck }, -]; - -/** Layers the docked timeline shows, so it stays a strip under the viewer. */ -const TRACKS = 4; - -/** Often enough to watch someone move through the site, rare enough for the free tier. */ -const POLL_MS = 20_000; - -/** - * A poll asks for entries newer than the newest held, less a minute. Ask - * entries are written when the answer is done but stamped when the question - * came in, so a slow answer can land in the list behind a quicker, later one. - * The overlap catches it, and the merge drops what is already there. - */ -const OVERLAP_MS = 60_000; -const back = (t?: string) => (t ? new Date(Date.parse(t) - OVERLAP_MS).toISOString() : ""); -const KEEP = 3000; - -function merge(prev: PanelData, next: PanelData): PanelData { - // A poll reads in pages, and an entry logged between two pages shows up on - // both, so the fresh entries are checked against each other as well. - const join = (fresh: T[], held: T[]) => { - const seen = new Set(); - return [...fresh, ...held] - .filter((e) => { - const key = JSON.stringify(e); - if (seen.has(key)) return false; - seen.add(key); - return true; - }) - .sort((a, b) => b.t.localeCompare(a.t)) - .slice(0, KEEP); - }; - return { ...next, visits: join(next.visits, prev.visits), asks: join(next.asks, prev.asks) }; -} - -/** The inspector docks at lg and is a sheet below it, so only one is mounted. */ -const subscribeWide = (cb: () => void) => { - const mq = window.matchMedia("(min-width: 1024px)"); - mq.addEventListener("change", cb); - return () => mq.removeEventListener("change", cb); -}; -const useWide = () => - useSyncExternalStore(subscribeWide, () => window.matchMedia("(min-width: 1024px)").matches, () => false); - -/** - * Ali's control panel: the visit and Ask logs, laid out as the editing suite - * lays out the portfolio. A bin of comps on the left, the comp in the viewer, - * the inspector on the right, and the sessions on a timeline underneath. - */ -export default function ControlPanel() { - const [phase, setPhase] = useState<"checking" | "locked" | "open">("checking"); - const [enrolled, setEnrolled] = useState(true); - const [data, setData] = useState(null); - const [now, setNow] = useState(0); - const [error, setError] = useState(null); - const [syncing, setSyncing] = useState(false); - const [comp, setComp] = useState("overview"); - const [rangeId, setRangeId] = useState("7d"); - const [selected, setSelected] = useState(null); - const [query, setQuery] = useState(""); - // Folded or open, kept per browser like a panel in the suite. - const [timeline, setTimeline] = useState(() => { - try { - return localStorage.getItem("cp-timeline") !== "closed"; - } catch { - return true; - } - }); - const wide = useWide(); - const sheetRef = useRef(null); - // What the last poll left, for the next one to build on. - const held = useRef(null); - // Bumped on Lock and on a sign-out, so a poll that was already in flight - // cannot reopen the panel when its answer lands afterwards. - const generation = useRef(0); - // Refreshes can overlap, as a poll still loading when a save refreshes. An - // answer is dropped only once a later-started one has landed, so a poll - // from before the save cannot undo it, and slow polls still all land. - const started = useRef(0); - const landed = useRef(0); - - const refresh = useCallback(async () => { - const mine = generation.current; - const order = ++started.current; - setSyncing(true); - try { - const prev = held.current; - const fresh = await load(prev ? { visits: back(prev.visits[0]?.t), asks: back(prev.asks[0]?.t) } : undefined); - if (mine !== generation.current || order < landed.current) return; - landed.current = order; - // Merged into what is held now, not what was held when this started, - // so entries another refresh brought in meanwhile stay. - const d = held.current ? merge(held.current, fresh) : fresh; - held.current = d; - setData(d); - setNow(Date.now()); - setError(null); - // Data came back, so a passkey exists, and locking leads to Unlock - // rather than to first-time setup. - setEnrolled(true); - setPhase("open"); - } catch (err) { - if (mine !== generation.current || order < landed.current) return; - if (err instanceof SignedOut) { - generation.current++; - token.clear(); - held.current = null; - setData(null); - setPhase("locked"); - } else { - setError(err instanceof Error ? err.message : "Could not sync."); - } - } finally { - setSyncing(false); - } - }, []); - - // Asks first whether this browser is signed in and whether any passkey - // exists, which decides between Unlock and first-time setup. - useEffect(() => { - let gone = false; - status() - .then((s) => { - if (gone) return; - setEnrolled(s.enrolled); - if (s.signedIn) refresh(); - else { - token.clear(); - setPhase("locked"); - } - }) - .catch((err) => { - if (gone) return; - setError(err instanceof Error ? err.message : "The panel's API did not answer."); - setPhase("locked"); - }); - return () => { - gone = true; - }; - }, [refresh]); - - useEffect(() => { - if (phase !== "open") return; - const tick = () => { - if (document.visibilityState === "visible") refresh(); - }; - const timer = setInterval(tick, POLL_MS); - document.addEventListener("visibilitychange", tick); - return () => { - clearInterval(timer); - document.removeEventListener("visibilitychange", tick); - }; - }, [phase, refresh]); - - // The log leaves the screen first, then the server ends the session. - const signOut = useCallback(async () => { - generation.current++; - held.current = null; - setData(null); - setSelected(null); - setPhase("locked"); - await lock().catch(() => {}); - }, []); - - // 1 to 5 open the comps, R syncs, Escape closes the inspector. - useEffect(() => { - if (phase !== "open") return; - const onKey = (e: KeyboardEvent) => { - if (e.metaKey || e.ctrlKey || e.altKey) return; - if (e.target instanceof HTMLElement && e.target.closest("input, textarea, select, [contenteditable]")) return; - const n = Number(e.key); - if (n >= 1 && n <= COMPS.length) setComp(COMPS[n - 1].id); - else if (e.key === "r") refresh(); - else if (e.key === "Escape") setSelected(null); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [phase, refresh]); - - const everyone = useMemo(() => (data ? visitors(data) : new Map()), [data]); - const range = useMemo(() => rangeOf(rangeId, now), [rangeId, now]); - const people = useMemo(() => active(everyone, range), [everyone, range]); - const hidden = useMemo( - () => [...everyone.values()].filter((v) => v.hidden).sort((a, b) => b.last - a.last), - [everyone], - ); - const live = useMemo(() => liveNow(everyone, now).length, [everyone, now]); - const sel = selected ? everyone.get(selected) : undefined; - - useFocusTrap(sheetRef, Boolean(sel) && !wide); - - if (phase === "checking") { - return ( -
- {error ? ( - <> - - {error} - - - - ) : ( - Connecting… - )} -
- ); - } - - if (phase === "locked" || !data) { - return ( - <> - - {error && ( -

- {error} -

- )} - - ); - } - - const select = (key: string) => setSelected((cur) => (cur === key && wide ? null : key)); - const counts: Record = { - overview: plural(people.reduce((n, v) => n + v.views.filter((x) => ms(x.t) >= range.start).length, 0), "view"), - visitors: plural(people.length, "visitor"), - asks: plural( - data.asks.filter((a) => ms(a.t) >= range.start && !everyone.get(keyOf(a))?.hidden).length, - "question", - ), - links: plural(Object.keys(data.links).length, "link"), - devices: plural(data.devices.length, "passkey"), - }; - const current = COMPS.find((c) => c.id === comp)!; - - const inspector = sel && ( - setSelected(null)} - onChanged={refresh} - /> - ); - - return ( -
- {/* The top bar: who is on the site, the file, the range and the session. */} -
- - - {live ? `${live} on the site` : "Nobody on the site"} - controlpanel.aep - -
-
- {RANGES.map((r) => ( - - ))} -
- - - -
-
- - {error && ( -

- Could not sync: {error} -

- )} - - {/* The bin collapses to tabs on a phone. */} - - -
- - -
-
- -
-
-
- {comp === "overview" && ( - { - setQuery(q); - setComp("visitors"); - }} - /> - )} - {comp === "visitors" && ( -
-
-
- - {wide && sel && ( - - )} -
- -
- - - - one visit - - {people.length > TRACKS && ( - - Latest {TRACKS} of {people.length} visitors - - )} - - - - {timeline && ( - // Capped for a short window, where four rows would be most of it. -
- -
- )} -
- - {!wide && sel && ( -
-
- )} -
- ); -} diff --git a/src/app/controlpanel/Inspector.tsx b/src/app/controlpanel/Inspector.tsx deleted file mode 100644 index c6cf868..0000000 --- a/src/app/controlpanel/Inspector.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { LuEye, LuEyeOff, LuX } from "react-icons/lu"; -import { edit } from "./api"; -import { clock, day, ms, plural, stamp, timecode, type Link, type Visitor } from "./model"; -import { Chip, Copy, Fact, PanelHead } from "./ui"; - -/** - * Everything known about one visitor, the way an inspector shows one clip. - * - * The name field is how Ali puts a name to someone the log could only call by - * their network. It sticks to the visitor id, so every view and question that - * browser sends afterwards carries it too. - */ -export default function Inspector({ - visitor: v, - links, - now, - onClose, - onChanged, -}: { - visitor: Visitor; - links: Record; - now: number; - onClose: () => void; - onChanged: () => void; -}) { - const [name, setName] = useState(v.label?.name ?? ""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const save = async (next: { name?: string; hidden?: boolean }) => { - if (!v.id) return; - setBusy(true); - setError(null); - try { - await edit({ - action: "label", - id: v.id, - name: next.name ?? v.label?.name ?? "", - hidden: next.hidden ?? v.hidden, - }); - onChanged(); - } catch (err) { - setError(err instanceof Error ? err.message : "Could not save."); - } finally { - setBusy(false); - } - }; - - const sessions = [...v.sessions].reverse(); - - return ( -
- - - - -
-
-

{v.name}

-
- {v.you && This is you} - {v.link && Link · {links[v.link]?.name}} - {v.tags - .filter((t) => !links[t]) - .map((t) => ( - ?ref={t} - ))} - {v.hidden && Hidden from stats} -
- - {v.id ? ( -
{ - e.preventDefault(); - save({ name }); - }} - > - setName(e.target.value)} - /> - -
- ) : ( -

Logged before visitor ids, so it cannot be named.

- )} - {v.id && ( - - )} - {error &&

{error}

} -
- -
- - {v.ip ? ( - - {v.ip} - - - ) : ( - "Not logged" - )} - - {v.ips.length > 1 && {v.ips.filter((ip) => ip !== v.ip).join(", ")}} - {v.org ?? "Unknown"} - {v.place ?? "Unknown"} - {v.device ?? "Unknown"} - {stamp(v.first)} - {stamp(v.last)} - - {plural(v.views.length, "view")} · {plural(v.sessions.length, "visit")} - {v.asks.length ? ` · ${plural(v.asks.length, "question")}` : ""} - - {v.id && ( - - {v.id} - - )} -
- - {v.asks.length > 0 && ( -
-

Asked

-
    - {[...v.asks].reverse().map((a) => ( -
  • -
    - - {clock(ms(a.t))} - {a.q} - -

    - {a.a ?? a.error} -

    -
    -
  • - ))} -
-
- )} - - {/* The path through the site, as an edit decision list: one event - per page, numbered, with its time. */} -
-

Edit decision list

- {sessions.map((s, i) => ( -
-
- {day(s.start)} - {timecode(s.end - s.start)} - {s.source ?? ""} -
-
    - {s.views.map((view, j) => ( -
  1. - - {String(sessions.length - i).padStart(2, "0")}.{String(j + 1).padStart(2, "0")} - - {view.path} - {clock(ms(view.t))} -
  2. - ))} -
-
- ))} - {!sessions.length &&

No page views, only questions.

} - {sessions.length > 0 && ( -

- Last seen {Math.round((now - v.last) / 60000) < 1 ? "just now" : `${day(v.last)} ${clock(v.last)}`} -

- )} -
-
-
- ); -} diff --git a/src/app/controlpanel/Lock.tsx b/src/app/controlpanel/Lock.tsx deleted file mode 100644 index 8e054d8..0000000 --- a/src/app/controlpanel/Lock.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { LuKeyRound, LuScanFace } from "react-icons/lu"; -import { enroll, passkeysWork, unlock, why } from "./api"; - -/** - * What anyone but Ali sees: the viewer an editor shows for footage it cannot - * find, with the file's title bar across the top and no frame around it. - * Nothing from the log is on this page or in its bundle. The data only - * leaves the API for a session a passkey started. - * - * Three ways through. Unlock is the everyday one. Pairing is how a new device - * gets its own passkey. The password form shows only before any passkey - * exists, for the very first device. - */ -export default function Lock({ - enrolled, - onOpen, -}: { - enrolled: boolean; - onOpen: () => void; -}) { - const [mode, setMode] = useState<"unlock" | "pair" | "setup">(enrolled ? "unlock" : "setup"); - const [secret, setSecret] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const works = passkeysWork(); - - const run = async (job: () => Promise) => { - setBusy(true); - setError(null); - try { - await job(); - onOpen(); - } catch (err) { - setError(why(err)); - } finally { - setBusy(false); - } - }; - - const submit = (e: React.FormEvent) => { - e.preventDefault(); - if (!secret.trim()) return; - run(() => enroll(mode === "pair" ? { code: secret } : { password: secret })); - }; - - return ( -
-
- - controlpanel.aep - 00:00:00:00 -
- -
-
-

Media offline

-

- {mode === "setup" ? "Set up the first device" : mode === "pair" ? "Pair this device" : "Locked"} -

- {mode !== "unlock" && ( -

- {mode === "setup" - ? "Enter the admin password once. This device then makes a passkey, and the password stops working for good." - : "On a signed-in device, open Devices and tap Pair a device. Type the code it shows here."} -

- )} - - {!works ? ( -

- Passkeys need an https page in a current browser, and this one has neither. -

- ) : mode === "unlock" ? ( - - ) : ( -
- - setSecret(e.target.value)} - /> - -
- )} - - {error && ( -

- {error} -

- )} - - {enrolled && works && ( - - )} -
-
-
- ); -} diff --git a/src/app/controlpanel/Timeline.tsx b/src/app/controlpanel/Timeline.tsx deleted file mode 100644 index 5be8edd..0000000 --- a/src/app/controlpanel/Timeline.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { clock, day, plural, stamp, type Range, type Visitor } from "./model"; - -/** - * The range as a comp's layers. Each recent visitor is one layer with a - * keyframe at every visit. The playhead sits at now, on the right edge, the - * way a live recording grows. - */ - -const ROW_H = 40; - -/** Ruler steps, chosen per range so the labels never crowd. */ -const HOUR = 60 * 60 * 1000; -const DAY = 24 * HOUR; -function ticks(r: Range) { - const step = r.ms <= DAY ? 3 * HOUR : r.ms <= 7 * DAY ? DAY : r.ms <= 30 * DAY ? 5 * DAY : 15 * DAY; - const first = new Date(r.start); - if (step < DAY) first.setMinutes(0, 0, 0); - else first.setHours(0, 0, 0, 0); - const out: number[] = []; - // Day ticks step by the calendar. A fixed 24 hours lands on 11 PM after the - // clocks fall back, and snapping that to midnight repeated a day and left - // today off the ruler. - for (const d = first; d.getTime() <= r.end; ) { - if (d.getTime() >= r.start) out.push(d.getTime()); - if (step < DAY) d.setTime(d.getTime() + step); - else d.setDate(d.getDate() + step / DAY); - } - return { list: out, label: (t: number) => (step < DAY ? clock(t).replace(":00", "") : day(t)) }; -} - -export default function Timeline({ - people, - range, - selected, - onSelect, - tracks, -}: { - /** Visitors active in the range, most recent first. */ - people: Visitor[]; - range: Range; - selected: string | null; - onSelect: (key: string) => void; - tracks: number; -}) { - const laneRef = useRef(null); - const shown = people.slice(0, tracks); - const at = (t: number) => Math.min(100, Math.max(0, ((t - range.start) / range.ms) * 100)); - // A keyframe's center, held 7px inside each edge so the whole diamond - // stays in the lane, at the range's start and at now. - const key = (t: number) => `clamp(7px, ${at(t)}%, calc(100% - 7px))`; - const rule = useMemo(() => ticks(range), [range]); - - // Labels need about 60px each, so a phone shows every other day or fewer. - const [laneW, setLaneW] = useState(0); - useEffect(() => { - const el = laneRef.current; - if (!el) return; - const ro = new ResizeObserver(([e]) => setLaneW(e.contentRect.width)); - ro.observe(el); - return () => ro.disconnect(); - }, []); - const every = laneW ? Math.max(1, Math.ceil((rule.list.length * 60) / laneW)) : rule.list.length; - // Clear of the NOW flag in the corner. - const labelled = (t: number, i: number) => i % every === 0 && laneW > 0 && (at(t) / 100) * laneW < laneW - 48; - - return ( -
- {/* Layer names, the column every comp keeps at the left. */} -
-
- {shown.map((v) => { - const visits = v.sessions.filter((s) => s.end >= range.start); - return ( - - ); - })} -
- -
- {/* Ruler. */} -
- {rule.list.map((t, i) => ( - - - {labelled(t, i) && {rule.label(t)}} - - ))} -
- - {/* Faint gridlines down through the layers, from each tick. */} -
- {rule.list.map((t) => ( - - ))} -
- - {shown.map((v) => { - const visits = v.sessions.filter((s) => s.end >= range.start); - return ( -
- {visits.map((s) => ( -
- ); - })} - - {/* Now. */} - - - NOW - - - {!people.length && ( -

Nobody visited in this range.

- )} -
-
- ); -} diff --git a/src/app/controlpanel/api.ts b/src/app/controlpanel/api.ts deleted file mode 100644 index bec6ab4..0000000 --- a/src/app/controlpanel/api.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * The panel's side of ask/api/auth.js and ask/api/panel.js. - * - * The functions live on another origin, so the session goes in an - * Authorization header rather than a cookie, which Safari would refuse as a - * third-party one. It is kept in localStorage and dies on the server after - * twelve hours, or at once on Lock. - */ -import { askEndpoint } from "../lib/askApi"; -import type { PanelData } from "./model"; - -const TOKEN = "cp-session"; - -/** - * This tab's session, ahead of storage. When storage reads but will not save, - * a stale token left in it must not win over the one just issued. - */ -let memory: string | null = null; - -export const token = { - get: () => { - if (memory) return memory; - try { - return localStorage.getItem(TOKEN); - } catch { - return null; - } - }, - set: (value: string) => { - memory = value; - try { - localStorage.setItem(TOKEN, value); - // Ali's own browsing stops counting once he has signed in here. - localStorage.setItem("notrack", "1"); - } catch { - // Storage is blocked, so the copy in memory is the session. - } - }, - clear: () => { - memory = null; - try { - localStorage.removeItem(TOKEN); - } catch {} - }, -}; - -export class SignedOut extends Error {} - -async function call(name: string, init: RequestInit = {}, t = token.get()): Promise { - const res = await fetch(askEndpoint(name), { - ...init, - headers: { - ...(init.body ? { "Content-Type": "application/json" } : {}), - ...(t ? { Authorization: `Bearer ${t}` } : {}), - }, - cache: "no-store", - }); - const body = await res.json().catch(() => ({})); - if (res.status === 401 && name.startsWith("panel")) throw new SignedOut(); - if (!res.ok) throw new Error(body.error || `The panel's API answered ${res.status}.`); - return body as T; -} - -const auth = (action: string, extra: object = {}) => - call("auth", { method: "POST", body: JSON.stringify({ action, ...extra }) }); - -export const status = () => auth<{ enrolled: boolean; signedIn: boolean }>("status"); -/** Drops the token here first, so nothing still running can use it, then ends it on the server. */ -export function lock() { - const t = token.get(); - token.clear(); - return call("auth", { method: "POST", body: JSON.stringify({ action: "lock" }) }, t); -} -export const pair = () => auth<{ code: string; expires: number }>("pair"); - -/** The whole log, or with `since` only what came after those times. */ -export const load = (since?: { visits: string; asks: string }) => - call(since ? `panel?${new URLSearchParams(since)}` : "panel"); -export const edit = (body: object) => - call("panel", { method: "POST", body: JSON.stringify(body) }); - -// ── Passkeys ──────────────────────────────────────────────────────────────── - -const b64 = (buf: ArrayBuffer) => - btoa(String.fromCharCode(...new Uint8Array(buf))) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - -const bytes = (s: string) => - Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); - -type Descriptor = { type: "public-key"; id: string; transports?: AuthenticatorTransport[] }; -const descriptors = (list: Descriptor[] = []) => list.map((d) => ({ ...d, id: bytes(d.id) })); - -/** Passkeys need a secure page and a browser from the last few years. */ -export const passkeysWork = () => - typeof window !== "undefined" && window.isSecureContext && "PublicKeyCredential" in window; - -/** What the browser's refusal means, in words. */ -export function why(err: unknown) { - if (err instanceof DOMException) { - if (err.name === "NotAllowedError") return "Cancelled, or the passkey prompt timed out."; - if (err.name === "InvalidStateError") return "This device already has a passkey for the panel. Use Unlock."; - if (err.name === "SecurityError") return "Passkeys are not allowed on this address."; - } - return err instanceof Error ? err.message : "Something went wrong."; -} - -/** A name for the passkey list, like "iPhone · Safari". */ -export function deviceName() { - const ua = navigator.userAgent; - const os = /iPhone/.test(ua) ? "iPhone" - : /iPad/.test(ua) ? "iPad" - : /Android/.test(ua) ? "Android" - : /Mac OS X/.test(ua) ? "Mac" - : /Windows/.test(ua) ? "Windows" - : "Device"; - const browser = /Edg\//.test(ua) ? "Edge" - : /Firefox\/|FxiOS/.test(ua) ? "Firefox" - : /Chrome\/|CriOS/.test(ua) ? "Chrome" - : /Safari\//.test(ua) ? "Safari" - : ""; - return browser ? `${os} · ${browser}` : os; -} - -/** Unlocks with a passkey this panel already knows. */ -export async function unlock() { - const o = await auth<{ - challenge: string; - rpId: string; - allowCredentials: Descriptor[]; - userVerification: UserVerificationRequirement; - timeout: number; - }>("sign-in-options"); - const cred = (await navigator.credentials.get({ - publicKey: { ...o, challenge: bytes(o.challenge), allowCredentials: descriptors(o.allowCredentials) }, - })) as PublicKeyCredential | null; - if (!cred) throw new Error("No passkey was chosen."); - const r = cred.response as AuthenticatorAssertionResponse; - const { token: t } = await auth<{ token: string }>("sign-in", { - credential: { - id: b64(cred.rawId), - clientDataJSON: b64(r.clientDataJSON), - authenticatorData: b64(r.authenticatorData), - signature: b64(r.signature), - }, - }); - token.set(t); -} - -/** - * Makes a passkey on this device and stores it. `grant` is what lets it in: - * the password for the very first device, a pairing code for a new one, or - * nothing when a signed-in device adds its own. - */ -export async function enroll(grant: { password?: string; code?: string } = {}) { - const o = await auth< - Omit & { - challenge: string; - user: { id: string; name: string; displayName: string }; - excludeCredentials: Descriptor[]; - } - >("enroll-options", grant); - const cred = (await navigator.credentials.create({ - publicKey: { - ...o, - challenge: bytes(o.challenge), - user: { ...o.user, id: bytes(o.user.id) }, - excludeCredentials: descriptors(o.excludeCredentials), - }, - })) as PublicKeyCredential | null; - if (!cred) throw new Error("No passkey was made."); - const r = cred.response as AuthenticatorAttestationResponse; - const publicKey = r.getPublicKey?.(); - if (!publicKey) throw new Error("This browser cannot hand over the passkey's public key. Try Safari or Chrome."); - const { token: t } = await auth<{ token: string }>("enroll", { - name: deviceName(), - credential: { - id: b64(cred.rawId), - clientDataJSON: b64(r.clientDataJSON), - authenticatorData: b64(r.getAuthenticatorData()), - publicKey: b64(publicKey), - alg: r.getPublicKeyAlgorithm(), - transports: r.getTransports?.() ?? [], - }, - }); - token.set(t); -} diff --git a/src/app/controlpanel/comps/Asks.tsx b/src/app/controlpanel/comps/Asks.tsx deleted file mode 100644 index f71abf5..0000000 --- a/src/app/controlpanel/comps/Asks.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { ago, keyOf, ms, plural, type Ask, type Range, type Visitor } from "../model"; -import { Card, Chip, Empty } from "../ui"; - -/** - * What people asked the Ask panel, and how Gemini held up answering. A spent - * free quota shows up as HTTP 429 in a miss, which is the number to watch. - */ -export default function Asks({ - asks, - everyone, - range, - now, - onSelect, -}: { - asks: Ask[]; - everyone: Map; - range: Range; - now: number; - onSelect: (key: string) => void; -}) { - const [failedOnly, setFailedOnly] = useState(false); - const inRange = useMemo( - () => asks.filter((a) => ms(a.t) >= range.start && !everyone.get(keyOf(a))?.hidden), - [asks, everyone, range], - ); - const rows = failedOnly ? inRange.filter((a) => !a.a) : inRange; - - const answered = inRange.filter((a) => a.a).length; - const times = inRange - .filter((a) => a.a && a.ms != null) - .map((a) => a.ms!) - .sort((x, y) => x - y); - const mid = times.length >> 1; - const median = !times.length ? null : times.length % 2 ? times[mid] : (times[mid - 1] + times[mid]) / 2; - const quota = inRange.filter((a) => a.misses?.some((m) => m.includes("429"))).length; - - const tile = (label: string, value: string, note?: string, warn = false) => ( -
- {label} - {value} - {note && {note}} -
- ); - - return ( -
-
- {tile("Questions", inRange.length.toLocaleString(), `in the ${range.label}`)} - {tile( - "Answered", - inRange.length ? `${Math.round((answered / inRange.length) * 100)}%` : "None", - `${answered} by Gemini`, - inRange.length > 0 && answered < inRange.length, - )} - {tile("Median time", median == null ? "None" : `${(median / 1000).toFixed(1)}s`, "to answer")} - {tile("Quota hits", String(quota), "HTTP 429 from a model", quota > 0)} -
- - setFailedOnly(!failedOnly)} - className={`cp-btn h-6! px-2! text-[11px]! ${failedOnly ? "border-cp-red/40! text-cp-red-text!" : ""}`} - > - Failed only - - } - > - {rows.length ? ( -
    - {rows.map((a) => { - const who = everyone.get(keyOf(a)); - return ( -
  • -
    - - - {a.q} - {ago(ms(a.t), now)} - - - {a.a ? {a.model ?? "answered"} : {a.error ?? "failed"}} - {a.ms != null && {(a.ms / 1000).toFixed(1)}s} - {(a.turn ?? 1) > 1 && follow-up {a.turn}} - {a.misses?.map((m) => ( - - missed {m} - - ))} - - -

    - {a.a ?? a.error} -

    -
    - {who && ( - - )} -
  • - ); - })} -
- ) : ( - {failedOnly ? "No failed questions in this range." : `No questions in the ${range.label}.`} - )} -
-

{plural(asks.length, "question")} in the log

-
- ); -} diff --git a/src/app/controlpanel/comps/Devices.tsx b/src/app/controlpanel/comps/Devices.tsx deleted file mode 100644 index b50182e..0000000 --- a/src/app/controlpanel/comps/Devices.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { LuFingerprint, LuLock, LuSmartphone, LuTrash2 } from "react-icons/lu"; -import { edit, enroll, pair, why } from "../api"; -import { ago, stamp, type Device } from "../model"; -import { Card, Chip } from "../ui"; - -/** - * The passkeys that open this panel, and the controls for this browser. - * - * A Mac on the same Apple ID as the iPhone already has the iPhone's passkey - * through iCloud Keychain and needs nothing here. Anything else pairs with a - * code from a device that is signed in. - */ -export default function Devices({ - devices, - current, - now, - onLock, - onChanged, -}: { - devices: Device[]; - current: string; - now: number; - onLock: () => void; - onChanged: () => void; -}) { - const [code, setCode] = useState<{ code: string; expires: number } | null>(null); - const [error, setError] = useState(null); - const [counted, setCounted] = useState(null); - - // Read after mount, since the server render has no storage to read. - useEffect(() => { - try { - // eslint-disable-next-line react-hooks/set-state-in-effect - setCounted(!localStorage.getItem("notrack")); - } catch { - setCounted(null); - } - }, []); - - // Its own second hand, so the countdown runs without re-counting the log. - const [tick, setTick] = useState(0); - useEffect(() => { - if (!code) return; - const update = () => setTick(Date.now()); - const timer = setInterval(update, 1000); - const first = setTimeout(update, 0); - return () => { - clearInterval(timer); - clearTimeout(first); - }; - }, [code]); - const left = code && tick ? Math.max(0, Math.round((code.expires - tick) / 1000)) : code ? 600 : 0; - - const act = async (job: () => Promise) => { - setError(null); - try { - await job(); - onChanged(); - } catch (err) { - setError(why(err)); - } - }; - - return ( -
- -
-
-

- {devices.find((d) => d.id === current)?.name ?? "Signed in"} -

-

- Signed in for up to 12 hours.{" "} - {counted === false - ? "Your visits from this browser are not counted." - : counted - ? "Your visits from this browser are counted." - : ""} -

-
-
- {counted !== null && ( - - )} - - -
-
-
- - -
- {code && left > 0 ? ( -
- Pairing code - {code.code} - - Works once, for {Math.floor(left / 60)}:{String(left % 60).padStart(2, "0")} more - -

- On the new device, open alitleis.dev/controlpanel, tap New device, and type this code. It then makes its - own passkey. -

-
- ) : ( -
-

- For a computer that cannot use this phone's passkey. You get a code that works once, for ten - minutes. -

- -
- )} -
-
- - {devices.length}}> -
    - {devices.map((d) => ( -
  • - -
    -

    - {d.name} - {d.id === current && Signed in} -

    -

    - Added {stamp(Date.parse(d.t))} - {d.used ? ` · used ${ago(Date.parse(d.used), now)}` : ""} -

    -
    - -
  • - ))} -
-
- - {error && ( -

- {error} -

- )} -
- ); -} diff --git a/src/app/controlpanel/comps/Links.tsx b/src/app/controlpanel/comps/Links.tsx deleted file mode 100644 index b7c8e66..0000000 --- a/src/app/controlpanel/comps/Links.tsx +++ /dev/null @@ -1,194 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { LuPlus, LuShare, LuTrash2 } from "react-icons/lu"; -import { edit } from "../api"; -import { ago, day, ms, plural, type Link, type Visitor } from "../model"; -import { Card, Copy, Empty } from "../ui"; - -const PAGES = [ - { path: "/", label: "Chooser" }, - { path: "/galaxy/", label: "Reading view" }, - { path: "/studio/", label: "Editing suite" }, -]; - -/** - * Named links, which is how the log learns a name. Make one per person the - * site goes to, and whoever opens it is called that from then on, on every - * later visit from that browser too, link or not. - */ -export default function Links({ - links, - everyone, - now, - onSelect, - onChanged, -}: { - links: Record; - everyone: Map; - now: number; - onSelect: (key: string) => void; - onChanged: () => void; -}) { - const [name, setName] = useState(""); - const [note, setNote] = useState(""); - const [page, setPage] = useState("/"); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [fresh, setFresh] = useState(null); - - const url = (code: string, path = "/") => `${window.location.origin}${path}?ref=${code}`; - - /** Who opened each link, from every view that carried its code. */ - const opened = useMemo(() => { - const out = new Map(); - for (const code of Object.keys(links)) out.set(code, { people: [], arrivals: 0, last: 0 }); - for (const v of everyone.values()) { - for (const code of v.tags) { - const o = out.get(code); - if (!o) continue; - o.people.push(v); - for (const x of v.views) { - if (x.src === code) o.arrivals++; - if (x.tag === code || x.src === code) o.last = Math.max(o.last, ms(x.t)); - } - } - } - return out; - }, [links, everyone]); - - const create = async (e: React.FormEvent) => { - e.preventDefault(); - if (!name.trim()) return; - setBusy(true); - setError(null); - try { - const { code } = await edit<{ code: string }>({ action: "link", name, note, path: page }); - setFresh(url(code, page)); - setName(""); - setNote(""); - onChanged(); - } catch (err) { - setError(err instanceof Error ? err.message : "Could not make the link."); - } finally { - setBusy(false); - } - }; - - const remove = async (code: string, label: string) => { - if (!confirm(`Delete the link for ${label}? Visits already logged keep their code, but lose the name.`)) return; - try { - await edit({ action: "unlink", code }); - onChanged(); - } catch (err) { - setError(err instanceof Error ? err.message : "Could not delete the link."); - } - }; - - const list = Object.entries(links).sort((a, b) => ms(b[1].t) - ms(a[1].t)); - - return ( -
- -
-
- setName(e.target.value)} - /> - setNote(e.target.value)} - /> - -
-
- -

- The link carries a random code, so the person it goes to sees nothing with their name in it. -

-
- {fresh && ( -
- {fresh} -
- - {typeof navigator !== "undefined" && "share" in navigator && ( - - )} -
-
- )} - {error &&

{error}

} -
-
- - {list.length}}> - {list.length ? ( -
    - {list.map(([code, link]) => { - const o = opened.get(code)!; - return ( -
  • -
    -
    -

    {link.name}

    - {link.note &&

    {link.note}

    } -

    - {url(code, link.path)} -

    -
    -
    -

    - {o.people.length ? plural(o.arrivals || 1, "open") : "Not opened"} -

    -

    - {o.last ? ago(o.last, now) : `made ${day(ms(link.t))}`} -

    -
    -
    -
    - - - {o.people.map((v) => ( - - ))} -
    -
  • - ); - })} -
- ) : ( - No links yet. Make one for the next person you send the site to. - )} -
-
- ); -} diff --git a/src/app/controlpanel/comps/Overview.tsx b/src/app/controlpanel/comps/Overview.tsx deleted file mode 100644 index c630297..0000000 --- a/src/app/controlpanel/comps/Overview.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import Timeline from "../Timeline"; -import { - ago, - buckets, - day, - liveNow, - ms, - plural, - rank, - sourceOf, - stamp, - totals, - type Link, - type Range, - type Totals, - type Visitor, -} from "../model"; -import { Card, Empty, Levels } from "../ui"; - -/** - * The first comp: who is on the site now, the range's numbers against the - * range before it, the views over the range, and where they came from. - */ -export default function Overview({ - everyone, - people, - links, - range, - now, - selected, - onSelect, - onFind, -}: { - everyone: Map; - links: Record; - people: Visitor[]; - range: Range; - now: number; - selected: string | null; - onSelect: (key: string) => void; - /** Opens Visitors searching for this, as a network row does. */ - onFind: (query: string) => void; -}) { - const live = useMemo(() => liveNow(everyone, now), [everyone, now]); - const cur = useMemo(() => totals(everyone, range), [everyone, range]); - const prev = useMemo( - () => totals(everyone, { start: range.start - range.ms, end: range.start }), - [everyone, range], - ); - const bars = useMemo(() => buckets(everyone, range, range.bars), [everyone, range]); - - const views = useMemo( - () => people.flatMap((v) => v.views.filter((x) => ms(x.t) >= range.start).map((x) => ({ x, v }))), - [people, range], - ); - const networks = rank(views, ({ v }) => v.org ?? "Unknown network"); - const sources = rank(views, ({ x }) => sourceOf(x, links)); - // /galaxy and /galaxy/ are one page, logged both ways over time. - const pages = rank(views, ({ x }) => x.path.replace(/\/?$/, "/")); - const devices = rank(people, (v) => v.device); - - const meter = (key: keyof Totals, label: string) => ( -
- {label} - {cur[key].toLocaleString()} - - {prev[key].toLocaleString()} the {range.before} - -
- ); - - const peak = Math.max(1, ...bars); - // A day's range is told by the hour, the longer ones by the day. - const when = (t: number) => (range.id === "24h" ? stamp(t) : day(t)); - - return ( -
- last 5 minutes} - > - {live.length ? ( -
    - {live.map(({ visitor: v, path, t }) => ( -
  • - -
  • - ))} -
- ) : ( - Nobody is on the site right now. - )} -
- -
- {meter("visitors", "Visitors")} - {meter("views", "Page views")} - {meter("questions", "Questions")} -
- - {/* The range's views, one bar per hour, day or five days. */} - {range.per}} - > -
- {bars.map((n, i) => ( - - ))} -
-
- {when(range.start)} - now -
-
- - {/* The docked timeline is md and up. On a phone it lives here. */} -
- - {people.length > 5 && ( -

- Latest 5 of {people.length} visitors -

- )} -
- -
- - onFind(org === "Unknown network" ? "" : org)} - /> - - - - - - - - - - -
-
- ); -} diff --git a/src/app/controlpanel/comps/Visitors.tsx b/src/app/controlpanel/comps/Visitors.tsx deleted file mode 100644 index cb790a1..0000000 --- a/src/app/controlpanel/comps/Visitors.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { LuSearch } from "react-icons/lu"; -import { ago, plural, type Visitor } from "../model"; -import { Card, Chip, Empty } from "../ui"; - -const FILTERS = [ - { id: "all", label: "All" }, - { id: "known", label: "Named" }, - { id: "asked", label: "Asked" }, - { id: "hidden", label: "Hidden" }, -] as const; - -/** - * Every visitor in the range, newest first, searchable by anything the log - * knows about them: name, network, place, address, device or link tag. - */ -export default function Visitors({ - people, - hidden, - now, - selected, - query, - onQuery, - onSelect, -}: { - people: Visitor[]; - /** Visitors Ali hid, listed only under their own filter. */ - hidden: Visitor[]; - now: number; - selected: string | null; - query: string; - onQuery: (q: string) => void; - onSelect: (key: string) => void; -}) { - const [filter, setFilter] = useState<(typeof FILTERS)[number]["id"]>("all"); - - const rows = useMemo(() => { - const base = - filter === "hidden" ? hidden - : filter === "known" ? people.filter((v) => v.known) - : filter === "asked" ? people.filter((v) => v.asks.length) - : people; - const q = query.trim().toLowerCase(); - if (!q) return base; - return base.filter((v) => - [v.name, v.org, v.place, v.device, v.id, ...v.ips, ...v.tags].some((f) => f?.toLowerCase().includes(q)), - ); - }, [people, hidden, filter, query]); - - return ( - {plural(rows.length, "visitor")}} - > -
- -
- {FILTERS.map((f) => ( - - ))} -
-
- - {rows.length ? ( -
    - {rows.map((v) => { - const on = selected === v.key; - return ( -
  • - -
  • - ); - })} -
- ) : ( - {query ? "Nobody matches that." : filter === "hidden" ? "Nobody is hidden." : "No visitors in this range."} - )} -
- ); -} diff --git a/src/app/controlpanel/model.ts b/src/app/controlpanel/model.ts deleted file mode 100644 index c168275..0000000 --- a/src/app/controlpanel/model.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * The log as the panel reads it. - * - * The API sends raw entries, newest first. Everything here is worked out in - * the browser: which views belong to one visitor, where their sessions start - * and stop, what to call them, and the counts for a range. - */ - -export type Visit = { - t: string; - path: string; - first?: boolean; - ref?: string | null; - src?: string | null; - tag?: string | null; - id?: string | null; - ip?: string | null; - org?: string | null; - place?: string | null; - device?: string | null; -}; - -export type Ask = { - t: string; - q: string; - turn?: number; - a?: string; - error?: string; - model?: string; - misses?: string[]; - ms?: number; - id?: string | null; - ip?: string | null; - org?: string | null; - place?: string | null; - device?: string | null; -}; - -export type Label = { name: string | null; hidden: boolean }; -export type Link = { name: string; note: string | null; path?: string; t: string }; -export type Device = { id: string; name: string; t: string; used: string | null }; - -export type PanelData = { - visits: Visit[]; - asks: Ask[]; - labels: Record; - links: Record; - devices: Device[]; - device: string; - you: string | null; -}; - -export type Session = { - start: number; - end: number; - views: Visit[]; - /** Where it arrived from, or null when it did not start with a page load. */ - source: string | null; -}; - -export type Visitor = { - key: string; - id: string | null; - /** What to call them: Ali's name for them, a link's name, or the network. */ - name: string; - /** Named by Ali or by a link, rather than by their network. */ - known: boolean; - /** The named link that identifies them, if one does. */ - link: string | null; - tags: string[]; - label?: Label; - hidden: boolean; - you: boolean; - ip: string | null; - ips: string[]; - org: string | null; - place: string | null; - device: string | null; - first: number; - last: number; - /** Oldest first. */ - views: Visit[]; - sessions: Session[]; - asks: Ask[]; -}; - -/** A gap this long starts a new session, the usual analytics cut. */ -const SESSION_GAP = 30 * 60 * 1000; - -export const ms = (t: string) => Date.parse(t); - -/** One browser on one network, or the address when an entry has no id. */ -export const keyOf = (e: { id?: string | null; ip?: string | null }) => - e.id ?? (e.ip ? `ip:${e.ip}` : "unknown"); - -/** Where a session's first view says it came from, by link name when it was one. */ -export function sourceOf(v: Visit, links: Record) { - if (!v.first) return null; - const src = v.src && (links[v.src] ? `Link · ${links[v.src].name}` : `?ref=${v.src}`); - return [src, v.ref].filter(Boolean).join(" via ") || "direct"; -} - -export function visitors(data: PanelData): Map { - const out = new Map(); - - const get = (e: Visit | Ask) => { - const key = keyOf(e); - let v = out.get(key); - if (!v) { - const label = e.id ? data.labels[e.id] : undefined; - v = { - key, - id: e.id ?? null, - name: "", - known: false, - link: null, - tags: [], - label, - hidden: Boolean(label?.hidden), - you: Boolean(e.id && e.id === data.you), - ip: null, - ips: [], - org: null, - place: null, - device: null, - first: Infinity, - last: -Infinity, - views: [], - sessions: [], - asks: [], - }; - out.set(key, v); - } - const t = ms(e.t); - v.first = Math.min(v.first, t); - // Newest wins for the details, since an address or a network can change. - if (t >= v.last) { - v.last = t; - v.ip = e.ip ?? v.ip; - v.org = e.org ?? v.org; - v.place = e.place ?? v.place; - v.device = e.device ?? v.device; - } - if (e.ip && !v.ips.includes(e.ip)) v.ips.push(e.ip); - return v; - }; - - // Oldest first, by time rather than list order. A view is stamped when it - // arrives but written after its network lookup, so a slow lookup can land - // in the list behind a later view. - const byTime = (list: T[]) => [...list].sort((a, b) => a.t.localeCompare(b.t)); - for (const visit of byTime(data.visits)) { - const v = get(visit); - v.views.push(visit); - for (const tag of [visit.tag, visit.src]) { - if (tag && !v.tags.includes(tag)) v.tags.push(tag); - } - } - for (const ask of byTime(data.asks)) get(ask).asks.push(ask); - - // The named link a view carried: its own ?ref= first, then the one the - // browser kept from an earlier arrival. - const linkOf = (x: Visit) => [x.src, x.tag].find((code) => code && data.links[code]) ?? null; - - for (const v of out.values()) { - for (const view of v.views) { - const t = ms(view.t); - const s = v.sessions.at(-1); - if (s && t - s.end < SESSION_GAP) { - s.end = t; - s.views.push(view); - } else { - v.sessions.push({ start: t, end: t, views: [view], source: sourceOf(view, data.links) }); - } - } - // The newest view decides, so reopening an older link names them by it. - v.link = v.views.map(linkOf).findLast(Boolean) ?? null; - const named = v.label?.name || (v.link && data.links[v.link].name); - v.known = Boolean(named); - v.name = named || (v.you ? "You" : v.org) || (v.id ? `Visitor ${v.id.slice(0, 4)}` : "Unknown visitor"); - } - return out; -} - -// ── Ranges ────────────────────────────────────────────────────────────────── - -/** Each range with what the range before it is called, and how the views chart splits it. */ -export const RANGES = [ - { id: "24h", label: "24H", ms: 24 * 60 * 60 * 1000, before: "day before", bars: 24, per: "per hour" }, - { id: "7d", label: "7D", ms: 7 * 24 * 60 * 60 * 1000, before: "week before", bars: 7, per: "per day" }, - { id: "30d", label: "30D", ms: 30 * 24 * 60 * 60 * 1000, before: "30 days before", bars: 30, per: "per day" }, - { id: "90d", label: "90D", ms: 90 * 24 * 60 * 60 * 1000, before: "90 days before", bars: 18, per: "per 5 days" }, -] as const; - -export type RangeId = (typeof RANGES)[number]["id"]; -export type Range = (typeof RANGES)[number] & { start: number; end: number }; - -export function rangeOf(id: RangeId, now: number): Range { - const r = RANGES.find((x) => x.id === id) ?? RANGES[1]; - return { ...r, start: now - r.ms, end: now }; -} - -const inRange = (t: number, r: { start: number; end: number }) => t >= r.start && t <= r.end; - -/** Visitors with anything in the range, most recent first, hidden ones left out. */ -export function active(all: Map, r: Range) { - return [...all.values()] - .filter((v) => !v.hidden && (v.views.some((x) => inRange(ms(x.t), r)) || v.asks.some((a) => inRange(ms(a.t), r)))) - .sort((a, b) => b.last - a.last); -} - -export type Totals = { views: number; visitors: number; questions: number }; - -export function totals(all: Map, r: { start: number; end: number }): Totals { - const out = { views: 0, visitors: 0, questions: 0 }; - for (const v of all.values()) { - if (v.hidden) continue; - const views = v.views.filter((x) => inRange(ms(x.t), r)).length; - const asks = v.asks.filter((a) => inRange(ms(a.t), r)).length; - out.views += views; - out.questions += asks; - if (views || asks) out.visitors++; - } - return out; -} - -/** Views per bucket across the range, for the views chart. */ -export function buckets(all: Map, r: Range, count: number) { - const out = new Array(count).fill(0); - const width = (r.end - r.start) / count; - for (const v of all.values()) { - if (v.hidden) continue; - for (const x of v.views) { - const t = ms(x.t); - if (!inRange(t, r)) continue; - out[Math.min(count - 1, Math.floor((t - r.start) / width))]++; - } - } - return out; -} - -/** A ranked count, for the networks, sources, pages and devices lists. */ -export function rank(items: T[], key: (item: T) => string | null | undefined, limit = 6) { - const counts = new Map(); - for (const item of items) { - const k = key(item); - if (k) counts.set(k, (counts.get(k) ?? 0) + 1); - } - return [...counts].sort((a, b) => b[1] - a[1]).slice(0, limit); -} - -/** - * Who has viewed a page or asked a question in the last five minutes, and the - * page they are on. The time is that latest move, so someone who opened a - * page half an hour ago and just asked something reads "now", not "30m ago". - */ -export function liveNow(all: Map, now: number) { - return [...all.values()] - .filter((v) => !v.hidden && !v.you && now - v.last < 5 * 60 * 1000 && v.views.length) - .map((v) => ({ visitor: v, path: v.views.at(-1)!.path, t: v.last })) - .sort((a, b) => b.t - a.t); -} - -// ── Formatting ────────────────────────────────────────────────────────────── - -export function ago(t: number, now: number) { - const s = Math.max(0, Math.round((now - t) / 1000)); - if (s < 45) return "now"; - // Each limit is checked after rounding, or 59.5 minutes read "60m ago". - const m = Math.round(s / 60); - if (m < 60) return `${m}m ago`; - const h = Math.round(s / 3600); - if (h < 24) return `${h}h ago`; - const d = Math.round(s / 86400); - if (d < 7) return `${d}d ago`; - return day(t); -} - -export const day = (t: number) => - new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric" }); - -export const clock = (t: number) => - new Date(t).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); - -export const stamp = (t: number) => `${day(t)}, ${clock(t)}`; - -/** A length as an editor shows it, HH:MM:SS. */ -export function timecode(length: number) { - const s = Math.max(0, Math.round(length / 1000)); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(Math.floor(s / 3600))}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`; -} - -export const plural = (n: number, word: string) => `${n.toLocaleString()} ${word}${n === 1 ? "" : "s"}`; diff --git a/src/app/controlpanel/page.tsx b/src/app/controlpanel/page.tsx deleted file mode 100644 index 073fe1f..0000000 --- a/src/app/controlpanel/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from "next"; -import ControlPanel from "./ControlPanel"; - -// Kept out of search results. The page itself holds no data, only the lock. -export const metadata: Metadata = { - title: "Control panel · Ali Tleis", - robots: { index: false, follow: false }, -}; - -export default function ControlPanelPage() { - return ; -} diff --git a/src/app/controlpanel/ui.tsx b/src/app/controlpanel/ui.tsx deleted file mode 100644 index afcd444..0000000 --- a/src/app/controlpanel/ui.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { LuCheck, LuCopy } from "react-icons/lu"; - -/** A panel's title strip, the same as every panel in the suite. */ -export function PanelHead({ label, children }: { label: string; children?: React.ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - -/** A boxed section inside a comp. */ -export function Card({ - label, - right, - children, - className = "", -}: { - label: string; - right?: React.ReactNode; - children: React.ReactNode; - className?: string; -}) { - return ( -
- {right &&
{right}
}
- {children} -
- ); -} - -export function Meter({ value, max }: { value: number; max: number }) { - const pct = max > 0 ? Math.max(4, Math.round((value / max) * 100)) : 0; - return ( -
- -
- ); -} - -/** A ranked list with a level meter per row. */ -export function Levels({ - rows, - empty, - unit, - onPick, -}: { - rows: [string, number][]; - empty: string; - /** Singular, like "view". */ - unit: string; - onPick?: (row: string) => void; -}) { - if (!rows.length) return {empty}; - const max = rows[0][1]; - return ( -
    - {rows.map(([name, n]) => { - const body = ( - <> - - {name} - - {n.toLocaleString()} {n === 1 ? unit : `${unit}s`} - - - - - ); - return ( -
  • - {onPick ? ( - - ) : ( -
    {body}
    - )} -
  • - ); - })} -
- ); -} - -export function Empty({ children }: { children: React.ReactNode }) { - return

{children}

; -} - -/** A key and value row, like the project info at the foot of the suite's bin. */ -export function Fact({ k, children }: { k: string; children: React.ReactNode }) { - return ( -
- {k} - {children} -
- ); -} - -export function Copy({ text, label = "Copy" }: { text: string; label?: string }) { - const [done, setDone] = useState(false); - return ( - - ); -} - -/** A small tag beside a name. */ -export function Chip({ children, tone = "plain" }: { children: React.ReactNode; tone?: "plain" | "accent" | "red" }) { - const colors = - tone === "accent" - ? "border-[var(--st-sel-line)] bg-[var(--st-sel)] text-[var(--st-accent)]" - : tone === "red" - ? "border-cp-red/40 bg-cp-red/10 text-cp-red-text" - : "border-[var(--st-line-strong)] text-[var(--st-dim)]"; - return ( - - {children} - - ); -} diff --git a/src/app/globals.css b/src/app/globals.css index c153ad0..b796440 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -96,10 +96,9 @@ --font-mono: var(--font-geist-mono); } -/* The control panel's red, for errors, failed asks and the live dot. The - lighter shade is for text, which needs the extra contrast on dark grey. */ +/* The Media Pool door's red, for its one error. A light shade, for the + contrast text needs on dark grey. */ @theme { - --color-cp-red: #ff5a4f; --color-cp-red-text: #ff8a80; } @@ -1234,39 +1233,12 @@ section { } } -/* ── Control panel ───────────────────────────────────────────── - /controlpanel takes the suite's palette through .st-root and adds - only what the suite has no equivalent for: level meters, the record - light, and form controls sized for reading rather than for chrome. +/* ── Media Pool door ─────────────────────────────────────────── + /mediapool takes the suite's palette through .st-root and adds only + the record light and the button the suite has no equivalent for. ─────────────────────────────────────────────────────────────── */ -/* The panel is read rather than edited, so its labels run a size up and a - step brighter than the suite's 10px chrome. */ -.cp-root { - --st-faint: rgba(244, 244, 241, 0.62); -} - -.cp-root .st-label { - font-size: 11px; -} - -/* A level meter, segmented like the ones on an audio track. The fill uses - the same stripe as the trough, so its segments sit on the trough's. */ -.cp-meter { - height: 6px; - overflow: hidden; - border-radius: 1px; - background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.07) 0 3px, transparent 3px 5px); -} - -.cp-meter > span { - display: block; - height: 100%; - background: repeating-linear-gradient(90deg, var(--st-accent) 0 3px, transparent 3px 5px); -} - -/* Red because recording is red in every editor, and it means the same here: - someone is on the site right now. Grey when nobody is. */ +/* Grey: nothing is recording. */ .cp-rec { width: 7px; height: 7px; @@ -1275,39 +1247,6 @@ section { background: var(--st-faint); } -.cp-rec-on { - background: var(--color-cp-red); - box-shadow: 0 0 10px color-mix(in oklab, var(--color-cp-red) 55%, transparent); -} - -@media (prefers-reduced-motion: no-preference) { - .cp-rec-on { - animation: cp-rec 1.6s ease-in-out infinite; - } -} - -@keyframes cp-rec { - 50% { - opacity: 0.35; - } -} - -.cp-input { - height: 32px; - width: 100%; - min-width: 0; - border: 1px solid var(--st-line-strong); - border-radius: 3px; - background: var(--st-stage); - padding: 0 10px; - font-size: 13px; - color: var(--st-text); -} - -.cp-input::placeholder { - color: var(--st-faint); -} - .cp-btn { display: inline-flex; height: 28px; @@ -1346,18 +1285,3 @@ section { color: #2a1a02; filter: brightness(1.12); } - -/* iOS zooms the page into any field under 16px, so touch gets 16px and the - taller target a thumb needs. */ -@media (pointer: coarse) { - .cp-input { - height: 40px; - font-size: 16px; - } - - .cp-btn { - height: 38px; - padding: 0 14px; - font-size: 13px; - } -} diff --git a/src/app/lib/askApi.ts b/src/app/lib/askApi.ts index bd7955f..60eb7be 100644 --- a/src/app/lib/askApi.ts +++ b/src/app/lib/askApi.ts @@ -1,11 +1,11 @@ /** - * The Vercel functions in ask/: the Ask panel's writer, the visit log and the - * control panel's API. The override exists for local previews, and may be a - * path, so callers resolve it against the page. + * The Vercel functions in ask/: the Ask panel's writer and the visit log. The + * override exists for local previews, and may be a path, so callers resolve + * it against the page. */ export const ASK_API = process.env.NEXT_PUBLIC_ASK_API || "https://alitleis-ask.vercel.app/api/ask"; -/** Another function beside the writer, like "visit" or "panel". */ +/** Another function beside the writer, like "visit". */ export const askEndpoint = (name: string) => new URL(name, new URL(ASK_API, window.location.href)).href; diff --git a/src/app/mediapool/Door.tsx b/src/app/mediapool/Door.tsx new file mode 100644 index 0000000..d09fce9 --- /dev/null +++ b/src/app/mediapool/Door.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { LuScanFace } from "react-icons/lu"; + +/** Media Pool, Ali's fleet app, which answers only on his own tailnet. */ +const MEDIA_POOL = "https://light-vm.tailde9e5d.ts.net:8088/"; + +/** Whether this browser reaches Media Pool, which answers /api/door only to Ali's own devices. */ +async function reachable() { + try { + const res = await fetch(`${MEDIA_POOL}api/door`, { cache: "no-store", signal: AbortSignal.timeout(4000) }); + return res.ok; + } catch { + return false; + } +} + +/** + * The way into Media Pool from this site: the viewer an editor shows for + * footage it cannot find, with the file's title bar across the top. + * + * Media Pool runs on Ali's own network and answers nothing outside it, so this + * page holds none of it. It asks whether this browser can reach Media Pool + * and, if it can, goes there, to a passkey page that looks the same as this + * one. Anyone else stays on the lock. A browser that reaches it is one of + * Ali's, so its visits stop counting on the site too. + */ +export default function Door() { + const [phase, setPhase] = useState<"checking" | "opening" | "locked">("checking"); + const [tried, setTried] = useState(false); + + const knock = useCallback( + () => + reachable().then((ok) => { + if (!ok) return setPhase("locked"); + setPhase("opening"); + try { + localStorage.setItem("notrack", "1"); + } catch { + // Storage is blocked, so this browser's visits keep counting. + } + window.location.replace(MEDIA_POOL); + }), + [], + ); + + useEffect(() => { + void knock(); + }, [knock]); + + return ( +
+
+ + mediapool.aep + 00:00:00:00 +
+ +
+
+

Media offline

+

Locked

+ + {phase === "locked" && tried && ( +

+ This device can't reach Media Pool. +

+ )} +
+
+
+ ); +} diff --git a/src/app/mediapool/page.tsx b/src/app/mediapool/page.tsx new file mode 100644 index 0000000..468f7ba --- /dev/null +++ b/src/app/mediapool/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import Door from "./Door"; + +// Kept out of search results. The page holds nothing of Media Pool, only the way in. +export const metadata: Metadata = { + title: "Media Pool · Ali Tleis", + robots: { index: false, follow: false }, +}; + +export default function MediaPoolPage() { + return ; +} From e628da227d8c212bcef83735d3dd69df1d1e8361 Mon Sep 17 00:00:00 2001 From: Ali Tleis Date: Tue, 10 Nov 2026 17:01:12 -0500 Subject: [PATCH 2/2] Door: drop a pending check when the page is left, and offer a direct link when the check fails --- src/app/mediapool/Door.tsx | 57 +++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/src/app/mediapool/Door.tsx b/src/app/mediapool/Door.tsx index d09fce9..2dbebce 100644 --- a/src/app/mediapool/Door.tsx +++ b/src/app/mediapool/Door.tsx @@ -1,15 +1,18 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { LuScanFace } from "react-icons/lu"; /** Media Pool, Ali's fleet app, which answers only on his own tailnet. */ const MEDIA_POOL = "https://light-vm.tailde9e5d.ts.net:8088/"; /** Whether this browser reaches Media Pool, which answers /api/door only to Ali's own devices. */ -async function reachable() { +async function reachable(signal: AbortSignal) { try { - const res = await fetch(`${MEDIA_POOL}api/door`, { cache: "no-store", signal: AbortSignal.timeout(4000) }); + const res = await fetch(`${MEDIA_POOL}api/door`, { + cache: "no-store", + signal: AbortSignal.any([signal, AbortSignal.timeout(4000)]), + }); return res.ok; } catch { return false; @@ -28,26 +31,24 @@ async function reachable() { */ export default function Door() { const [phase, setPhase] = useState<"checking" | "opening" | "locked">("checking"); - const [tried, setTried] = useState(false); - - const knock = useCallback( - () => - reachable().then((ok) => { - if (!ok) return setPhase("locked"); - setPhase("opening"); - try { - localStorage.setItem("notrack", "1"); - } catch { - // Storage is blocked, so this browser's visits keep counting. - } - window.location.replace(MEDIA_POOL); - }), - [], - ); + const [attempt, setAttempt] = useState(0); useEffect(() => { - void knock(); - }, [knock]); + // Leaving the page, or trying again, drops this check, so a late answer can't redirect. + const check = new AbortController(); + void reachable(check.signal).then((ok) => { + if (check.signal.aborted) return; + if (!ok) return setPhase("locked"); + setPhase("opening"); + try { + localStorage.setItem("notrack", "1"); + } catch { + // Storage is blocked, so this browser's visits keep counting. + } + window.location.replace(MEDIA_POOL); + }); + return () => check.abort(); + }, [attempt]); return (
@@ -66,17 +67,23 @@ export default function Door() { disabled={phase !== "locked"} onClick={() => { setPhase("checking"); - setTried(true); - void knock(); + setAttempt((n) => n + 1); }} className="cp-btn cp-btn-primary mt-6 h-11! w-full text-[14px]!" > {phase === "locked" ? "Unlock with passkey" : phase === "opening" ? "Opening…" : "Connecting…"} - {phase === "locked" && tried && ( + + {phase === "locked" && attempt > 0 && (

- This device can't reach Media Pool. + Couldn't reach Media Pool from this page.{" "} + {/* A browser can block this page's check of a private address, but not a visit. */} + + Open it directly +

)}