From 3ed933fb35a78a95a199765cc5def3f739fecbab Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 03:25:44 +0000 Subject: [PATCH] Add anonymous captures and retention controls --- client/index.html | 2 +- client/src/main.ts | 377 +++++++++++++++++++----------------------- client/src/styles.css | 197 +++++++++++----------- src/db.ts | 55 +++++- src/server.ts | 112 ++++++++++--- src/types.ts | 3 + 6 files changed, 418 insertions(+), 328 deletions(-) diff --git a/client/index.html b/client/index.html index e272ed0..2e7e3d2 100644 --- a/client/index.html +++ b/client/index.html @@ -4,7 +4,7 @@ - AI Share Admin + AI Share
diff --git a/client/src/main.ts b/client/src/main.ts index e223199..b214e16 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -10,35 +10,41 @@ type Capture = { createdAt: string; updatedAt: string; capturedAt: string | null; + retentionDays: number; + expiresAt: string; publicUrl: string | null; }; const tokenKey = "aishare.adminToken"; -const themeKey = "aishare.adminTheme"; +const themeKey = "aishare.theme"; +const legacyThemeKey = "aishare.adminTheme"; const app = document.querySelector("#app")!; -let token = localStorage.getItem(tokenKey) || ""; +let adminToken = ""; +let isAdmin = false; let page = 1; let pageSize = 20; let totalPages = 1; let polling: number | undefined; let toastTimer: number | undefined; +let keyValidationTimer: number | undefined; let manualTheme = false; const icons = { sun: ``, - moon: `` + moon: ``, + refresh: `` }; -function preferredTheme() { +function preferredTheme(): "light" | "dark" { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } -function savedTheme() { - const theme = localStorage.getItem(themeKey); +function savedTheme(): "light" | "dark" | null { + const theme = localStorage.getItem(themeKey) || localStorage.getItem(legacyThemeKey); return theme === "light" || theme === "dark" ? theme : null; } -function applyTheme(theme: "light" | "dark", manual: boolean) { +function applyTheme(theme: "light" | "dark", manual: boolean): void { document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; manualTheme = manual; @@ -46,60 +52,60 @@ function applyTheme(theme: "light" | "dark", manual: boolean) { updateThemeToggle(); } -function updateThemeToggle() { +function updateThemeToggle(): void { const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; const toggle = document.querySelector("[data-theme-toggle]"); if (!toggle) return; - toggle.setAttribute("aria-label", theme === "dark" ? "Switch to light theme" : "Switch to dark theme"); - toggle.setAttribute("title", theme === "dark" ? "Switch to light theme" : "Switch to dark theme"); + const label = theme === "dark" ? "Switch to light theme" : "Switch to dark theme"; + toggle.setAttribute("aria-label", label); + toggle.setAttribute("title", label); } -function initTheme() { +function initTheme(): void { const theme = savedTheme(); applyTheme(theme || preferredTheme(), Boolean(theme)); - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); - systemTheme.addEventListener("change", () => { + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if (!manualTheme) applyTheme(preferredTheme(), false); }); } -function serviceBaseUrl() { +function serviceBaseUrl(): URL { const url = new URL(location.href); url.pathname = url.pathname.replace(/\/admin\/?$/, "/"); + if (!url.pathname.endsWith("/")) url.pathname += "/"; url.search = ""; url.hash = ""; return url; } -function api(path: string, options: RequestInit = {}) { +function api(path: string, options: RequestInit = {}): Promise { return fetch(new URL(`api${path}`, serviceBaseUrl()), { ...options, headers: { "content-type": "application/json", - ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(isAdmin && adminToken ? { authorization: `Bearer ${adminToken}` } : {}), ...(options.headers || {}) } }); } -function captureUrl(capture: Capture) { +function captureUrl(capture: Capture): string { if (capture.slug) return new URL(`${capture.slug}/`, serviceBaseUrl()).href; return capture.publicUrl || ""; } -function fmt(value: string | null) { - if (!value) return ""; +function fmt(value: string | null): string { + if (!value) return "—"; return new Date(value).toLocaleString(); } -function statusClass(status: Capture["status"]) { - return `pill ${status}`; +function escapeHtml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } -async function copy(text: string) { +async function copy(text: string): Promise { try { await navigator.clipboard.writeText(text); - return; } catch { const textarea = document.createElement("textarea"); textarea.value = text; @@ -114,7 +120,7 @@ async function copy(text: string) { } } -function showToast(message: string) { +function showToast(message: string): void { let toast = document.querySelector("[data-toast]"); if (!toast) { toast = document.createElement("div"); @@ -127,130 +133,110 @@ function showToast(message: string) { toast.textContent = message; toast.dataset.visible = "true"; if (toastTimer) window.clearTimeout(toastTimer); - toastTimer = window.setTimeout(() => { - delete toast?.dataset.visible; - }, 1800); + toastTimer = window.setTimeout(() => delete toast?.dataset.visible, 1800); } -function renderShell() { +function renderShell(): void { app.innerHTML = ` + +
-
+
-

AI Share Admin

-

Capture public ChatGPT shares as read-only snapshots.

-
-
- - -
-
- -
- -
- - +

Public ChatGPT snapshots

+

${isAdmin ? "Capture library" : "Your captures"}

+

${isAdmin ? "Manage every submitted snapshot and its retention window." : "Save a public ChatGPT conversation as an immutable page and screenshot."}

+ ${isAdmin ? 'Admin view' : ""}
-
+
- - - + + ${isAdmin ? '' : ""} + +
-

+

-
+
-

Captured Shares

+

${isAdmin ? "All captures" : "Recent captures"}

Loading…

- - + + ${isAdmin ? '' : ""}
- - - - - - - - - + +
TitleStatusCreatedActions
CaptureStatusCreatedExpiresActions
Loading captures…
- - - + + +
-
- `; + `; - document.querySelector("#save-token")?.addEventListener("click", () => { - const input = document.querySelector("#token-input"); - token = input?.value.trim() || ""; - if (token) { - localStorage.setItem(tokenKey, token); - showToast("Admin token saved"); - renderShell(); - void loadCaptures(); - } + bindShellEvents(); + updateThemeToggle(); +} + +function bindShellEvents(): void { + const keyInput = document.querySelector("#admin-key"); + keyInput?.addEventListener("input", () => { + if (keyValidationTimer) window.clearTimeout(keyValidationTimer); + keyValidationTimer = window.setTimeout(() => void validateAdminKey(keyInput.value.trim()), 350); }); - - document.querySelector("#reset-token")?.addEventListener("click", () => { - localStorage.removeItem(tokenKey); - token = ""; - if (polling) window.clearInterval(polling); - showToast("Admin token reset"); - renderShell(); + keyInput?.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (keyValidationTimer) window.clearTimeout(keyValidationTimer); + void validateAdminKey(keyInput.value.trim()); + } }); document.querySelector("[data-theme-toggle]")?.addEventListener("click", () => { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true); }); - updateThemeToggle(); document.querySelector("#capture-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const data = new FormData(form); - const status = document.querySelector("#form-status")!; - status.textContent = "Queued"; + const status = document.querySelector("#form-status")!; + status.textContent = "Queueing capture…"; const response = await api("/captures", { method: "POST", - body: JSON.stringify({ - sourceUrl: data.get("sourceUrl"), - slug: data.get("slug") - }) + body: JSON.stringify({ sourceUrl: data.get("sourceUrl"), slug: isAdmin ? data.get("slug") : null, retentionDays: Number(data.get("retentionDays") || 3) }) }); - const body = await response.json(); + const body = await response.json().catch(() => ({})); if (!response.ok) { - status.textContent = body.error || "Capture failed to queue"; + status.textContent = body.error || "Capture could not be queued."; showToast(status.textContent); return; } form.reset(); - status.textContent = "Capture queued"; + status.textContent = "Capture queued."; showToast("Capture queued"); + page = 1; await loadCaptures(); startPolling(); }); @@ -260,26 +246,18 @@ function renderShell() { showToast("Refreshed"); }); document.querySelector("#delete-all")?.addEventListener("click", async () => { - if (!confirm("Delete all captures and related files?")) return; + if (!confirm("Delete every capture and all related files?")) return; const response = await api("/captures", { method: "DELETE" }); - if (!response.ok) { - showToast("Delete all failed"); - return; - } + if (!response.ok) return showToast("Delete all failed"); page = 1; await loadCaptures(); showToast("All captures deleted"); }); document.querySelector("#prev")?.addEventListener("click", async () => { - if (page > 1) { - page -= 1; - await loadCaptures(); - } + if (page > 1) { page -= 1; await loadCaptures(); } }); document.querySelector("#next")?.addEventListener("click", async () => { - if (page >= totalPages) return; - page += 1; - await loadCaptures(); + if (page < totalPages) { page += 1; await loadCaptures(); } }); const pageSizeSelect = document.querySelector("#page-size"); if (pageSizeSelect) { @@ -288,119 +266,106 @@ function renderShell() { pageSize = Number(pageSizeSelect.value); page = 1; await loadCaptures(); - showToast(`${pageSize} rows per page`); }); } } -async function loadCaptures() { - if (!token) return; - const response = await api(`/captures?page=${page}&pageSize=${pageSize}`); - if (response.status === 401) { +async function validateAdminKey(candidate: string, initial = false): Promise { + const response = await fetch(new URL("api/session", serviceBaseUrl()), { + headers: candidate ? { authorization: `Bearer ${candidate}` } : {} + }); + const body = await response.json().catch(() => ({ isAdmin: false })); + const valid = Boolean(body.isAdmin); + const changed = valid !== isAdmin || (valid && candidate !== adminToken); + if (valid) { + adminToken = candidate; + isAdmin = true; + localStorage.setItem(tokenKey, candidate); + } else { + adminToken = ""; + isAdmin = false; localStorage.removeItem(tokenKey); - token = ""; + } + if (changed || initial) { + page = 1; + if (polling) { window.clearInterval(polling); polling = undefined; } renderShell(); + await loadCaptures(); + if (!initial) showToast(valid ? "Admin view unlocked" : "Showing your captures"); + } else if (!valid) { + document.querySelector("[data-key-state]")?.setAttribute("data-invalid", "true"); + } +} + +async function loadCaptures(): Promise { + const response = await api(`/captures?page=${page}&pageSize=${pageSize}`); + if (!response.ok) { + showToast("Could not load captures"); return; } const body = await response.json(); + if (Boolean(body.isAdmin) !== isAdmin) { + isAdmin = Boolean(body.isAdmin); + if (!isAdmin) { adminToken = ""; localStorage.removeItem(tokenKey); } + renderShell(); + return loadCaptures(); + } const captures = body.captures as Capture[]; - const tbody = document.querySelector("#captures-body")!; - tbody.innerHTML = captures - .map((capture) => { - const title = capture.title || capture.slug || capture.sourceUrl; - const link = captureUrl(capture); - return ` - - -
${escapeHtml(title)}
-
${escapeHtml(capture.slug || capture.id)}
- ${capture.errorPublic ? `
${escapeHtml(capture.errorPublic)}
` : ""} - - ${capture.status} - ${escapeHtml(fmt(capture.createdAt))} - -
- ${link ? `Open` : ""} - ${capture.status === "failed" ? `` : ""} - -
- - `; - }) - .join(""); - - tbody.querySelectorAll("[data-copy]").forEach((button) => { - button.addEventListener("click", async () => { - try { - await copy(button.dataset.copy || ""); - showToast("Copied"); - } catch { - showToast("Copy failed"); - } - }); - }); - tbody.querySelectorAll("[data-retry]").forEach((button) => { - button.addEventListener("click", async () => { - const response = await api(`/captures/${button.dataset.retry}/retry`, { method: "POST" }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - showToast(body.error || "Retry failed"); - return; - } - await loadCaptures(); - startPolling(); - showToast("Retry queued"); - }); - }); - tbody.querySelectorAll("[data-delete]").forEach((button) => { - button.addEventListener("click", async () => { - if (!confirm("Delete this capture and related files?")) return; - const response = await api(`/captures/${button.dataset.delete}`, { method: "DELETE" }); - if (!response.ok) { - showToast("Delete failed"); - return; - } - await loadCaptures(); - showToast("Capture deleted"); - }); - }); - const total = Number(body.total || 0); totalPages = Math.max(1, Math.ceil(total / pageSize)); - if (page > totalPages) { - page = totalPages; - await loadCaptures(); - return; - } - document.querySelector("#page-label")!.textContent = `${page} / ${totalPages}`; - const pageSizeSelect = document.querySelector("#page-size"); - if (pageSizeSelect) pageSizeSelect.value = String(pageSize); - (document.querySelector("#prev") as HTMLButtonElement).disabled = page <= 1; - (document.querySelector("#next") as HTMLButtonElement).disabled = page * pageSize >= total; + if (page > totalPages) { page = totalPages; return loadCaptures(); } + const tbody = document.querySelector("#captures-body")!; + tbody.innerHTML = captures.length ? captures.map(captureRow).join("") : `No captures yetSubmit a public ChatGPT share above to get started.`; + bindRowEvents(tbody); + + document.querySelector("#table-summary")!.textContent = `${total} ${total === 1 ? "capture" : "captures"}${isAdmin ? " across all users" : " in this browser"}`; + document.querySelector("#page-label")!.textContent = `Page ${page} of ${totalPages}`; + const size = document.querySelector("#page-size"); + if (size) size.value = String(pageSize); + (document.querySelector("#prev") as HTMLButtonElement).disabled = page <= 1; + (document.querySelector("#next") as HTMLButtonElement).disabled = page >= totalPages; if (captures.some((capture) => ["queued", "capturing", "rendering"].includes(capture.status))) startPolling(); } -function startPolling() { +function captureRow(capture: Capture): string { + const title = capture.title || capture.slug || capture.sourceUrl; + const link = captureUrl(capture); + return ` +
${escapeHtml(title)}
${escapeHtml(capture.slug || capture.id)}
${capture.errorPublic ? `
${escapeHtml(capture.errorPublic)}
` : ""} + ${capture.status} + ${escapeHtml(fmt(capture.createdAt))} + ${capture.retentionDays} days${escapeHtml(fmt(capture.expiresAt))} +
${link ? `Open` : ""}${capture.status === "failed" ? `` : ""}
+ `; +} + +function bindRowEvents(tbody: HTMLTableSectionElement): void { + tbody.querySelectorAll("[data-copy]").forEach((button) => button.addEventListener("click", async () => { + try { await copy(button.dataset.copy || ""); showToast("Link copied"); } catch { showToast("Copy failed"); } + })); + tbody.querySelectorAll("[data-retry]").forEach((button) => button.addEventListener("click", async () => { + const response = await api(`/captures/${button.dataset.retry}/retry`, { method: "POST" }); + if (!response.ok) return showToast("Retry failed"); + await loadCaptures(); startPolling(); showToast("Retry queued"); + })); + tbody.querySelectorAll("[data-delete]").forEach((button) => button.addEventListener("click", async () => { + if (!confirm("Delete this capture and its files?")) return; + const response = await api(`/captures/${button.dataset.delete}`, { method: "DELETE" }); + if (!response.ok) return showToast("Delete failed"); + await loadCaptures(); showToast("Capture deleted"); + })); +} + +function startPolling(): void { if (polling) return; polling = window.setInterval(async () => { await loadCaptures(); const active = [...document.querySelectorAll(".pill")].some((pill) => ["queued", "capturing", "rendering"].includes(pill.textContent || "")); - if (!active && polling) { - window.clearInterval(polling); - polling = undefined; - } + if (!active && polling) { window.clearInterval(polling); polling = undefined; } }, 3000); } -function escapeHtml(value: string) { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - initTheme(); renderShell(); -void loadCaptures(); +void validateAdminKey(localStorage.getItem(tokenKey) || "", true); diff --git a/client/src/styles.css b/client/src/styles.css index 30e7f37..92d6fd4 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -1,120 +1,127 @@ :root { color-scheme: light dark; - --bg: #f6f7f9; + --bg: #f4f5f7; --panel: #ffffff; - --text: #1f2328; - --muted: #6b7280; - --border: #d9dee7; - --accent: #0f766e; + --panel-soft: #f8f9fa; + --text: #17191c; + --muted: #69717d; + --border: #dfe3e8; + --accent: #0b7a65; + --accent-hover: #096b59; + --accent-soft: #e5f5f0; --danger: #b42318; - --toast-bg: #ffffff; - --toast-text: #1f2328; - --shadow: rgb(15 23 42 / 14%); + --danger-soft: #fff0ee; + --warning: #9a6700; + --shadow: rgb(24 29 36 / 8%); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Arial, sans-serif; } @media (prefers-color-scheme: dark) { :root { - --bg: #18181b; - --panel: #242428; - --text: #f4f4f5; - --muted: #a1a1aa; - --border: #3f3f46; - --accent: #14b8a6; - --danger: #f87171; - --toast-bg: #2f2f33; - --toast-text: #f4f4f5; - --shadow: rgb(0 0 0 / 34%); + --bg: #171819; --panel: #222426; --panel-soft: #292c2f; --text: #f0f2f3; --muted: #a5abb3; --border: #3b3f44; + --accent: #28b995; --accent-hover: #34caa4; --accent-soft: #173c34; --danger: #ff8177; --danger-soft: #442724; --warning: #e9b949; --shadow: rgb(0 0 0 / 28%); } } -html[data-theme="light"] { - color-scheme: light; - --bg: #f6f7f9; - --panel: #ffffff; - --text: #1f2328; - --muted: #6b7280; - --border: #d9dee7; - --accent: #0f766e; - --danger: #b42318; - --toast-bg: #ffffff; - --toast-text: #1f2328; - --shadow: rgb(15 23 42 / 14%); -} - -html[data-theme="dark"] { - color-scheme: dark; - --bg: #18181b; - --panel: #242428; - --text: #f4f4f5; - --muted: #a1a1aa; - --border: #3f3f46; - --accent: #14b8a6; - --danger: #f87171; - --toast-bg: #2f2f33; - --toast-text: #f4f4f5; - --shadow: rgb(0 0 0 / 34%); -} +html[data-theme="light"] { color-scheme: light; --bg: #f4f5f7; --panel: #fff; --panel-soft: #f8f9fa; --text: #17191c; --muted: #69717d; --border: #dfe3e8; --accent: #0b7a65; --accent-hover: #096b59; --accent-soft: #e5f5f0; --danger: #b42318; --danger-soft: #fff0ee; --warning: #9a6700; --shadow: rgb(24 29 36 / 8%); } +html[data-theme="dark"] { color-scheme: dark; --bg: #171819; --panel: #222426; --panel-soft: #292c2f; --text: #f0f2f3; --muted: #a5abb3; --border: #3b3f44; --accent: #28b995; --accent-hover: #34caa4; --accent-soft: #173c34; --danger: #ff8177; --danger-soft: #442724; --warning: #e9b949; --shadow: rgb(0 0 0 / 28%); } * { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; } +html { min-height: 100%; background: var(--bg); } +body { min-width: 320px; margin: 0; background: var(--bg); color: var(--text); font-size: 15px; } button, input, select { font: inherit; } -body, .panel, input, select, button, .small.link, .table-wrap, th, td, .pill { transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease; } -.shell { max-width: 1120px; margin: 0 auto; padding: 28px 20px 48px; } -.header { display: flex; justify-content: space-between; gap: 20px; align-items: start; margin-bottom: 22px; } -.header-actions { display: flex; align-items: center; gap: 8px; } +body, .navbar, .panel, input, select, button, .small.link, .table-wrap, th, td, .pill, .admin-badge { transition: background-color 220ms ease, border-color 220ms ease, color 220ms ease, box-shadow 220ms ease; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +.navbar { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--panel) 92%, transparent); backdrop-filter: blur(14px); } +.nav-inner { width: min(1180px, 100%); min-height: 64px; margin: 0 auto; padding: 10px 24px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--text); font-size: 17px; font-weight: 720; text-decoration: none; letter-spacing: -.01em; } +.brand-mark { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; background: var(--text); color: var(--panel); font-size: 15px; } +.nav-actions { display: flex; align-items: center; gap: 9px; } +.admin-key-field { position: relative; display: block; } +.admin-key-field input { width: 184px; height: 38px; padding: 8px 32px 8px 11px; } +.key-state { position: absolute; right: 11px; top: 50%; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); transform: translateY(-50%); opacity: 0; } +.key-state[data-invalid="true"] { background: var(--danger); opacity: 1; } + +.shell { width: min(1180px, 100%); margin: 0 auto; padding: 48px 24px 64px; } +.intro { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 24px; } +.eyebrow { margin: 0 0 7px; color: var(--accent); font-size: 12px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } h1, h2, p { margin: 0; } -h1 { font-size: 30px; line-height: 1.15; letter-spacing: 0; } -h2 { font-size: 18px; } -.header p, .sub, .status-line { color: var(--muted); margin-top: 6px; } -.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 18px; margin-bottom: 18px; } -.row, .capture-form, .table-head, .actions, .pager { display: flex; gap: 10px; align-items: end; } -.capture-form { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(160px, 260px) auto; } -label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } -input, select { min-height: 40px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; background: var(--panel); color: var(--text); } -input { width: 100%; } -button, .small.link { min-height: 38px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 12px; background: var(--panel); color: var(--text); text-decoration: none; cursor: pointer; white-space: nowrap; } -button:disabled { cursor: not-allowed; opacity: 0.42; } -button[type="submit"] { background: var(--accent); border-color: var(--accent); color: white; } +h1 { font-size: clamp(32px, 5vw, 48px); line-height: 1.06; letter-spacing: -.035em; } +h2 { font-size: 18px; letter-spacing: -.01em; } +.intro div > p:last-child { max-width: 660px; margin-top: 10px; color: var(--muted); font-size: 16px; line-height: 1.55; } +.admin-badge { align-self: center; border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); border-radius: 999px; padding: 6px 11px; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 700; } + +.panel { margin-bottom: 18px; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px var(--shadow); } +.capture-panel { padding: 18px; } +.capture-form { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(150px, 220px) 130px auto; gap: 12px; align-items: end; } +.capture-form:not(:has(.slug-field)) { grid-template-columns: minmax(300px, 1fr) 130px auto; } +label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 650; } +input, select { width: 100%; min-height: 42px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 11px; outline: 0; background: var(--panel); color: var(--text); } +input::placeholder { color: color-mix(in srgb, var(--muted) 72%, transparent); } +input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); } +button, .small.link { min-height: 38px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; background: var(--panel); color: var(--text); text-decoration: none; cursor: pointer; white-space: nowrap; } +button:hover, .small.link:hover { background: var(--panel-soft); } +button:disabled { cursor: not-allowed; opacity: .42; } +.primary { min-height: 42px; border-color: var(--accent); background: var(--accent); color: #fff; font-weight: 700; } +.primary:hover { border-color: var(--accent-hover); background: var(--accent-hover); } .danger { color: var(--danger); } -.icon-button { min-height: 34px; } -.theme-toggle { width: 38px; min-height: 38px; padding: 8px; display: inline-flex; align-items: center; justify-content: center; } -.theme-toggle svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.status-line { min-height: 18px; margin-top: 10px; color: var(--muted); font-size: 13px; } +.icon-button { width: 38px; min-width: 38px; min-height: 38px; padding: 8px; display: inline-flex; align-items: center; justify-content: center; } +.icon-button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .theme-toggle .moon-icon { display: none; } html[data-theme="dark"] .theme-toggle .sun-icon { display: none; } html[data-theme="dark"] .theme-toggle .moon-icon { display: block; } -.table-head { justify-content: space-between; align-items: center; margin-bottom: 12px; } -.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; } -table { width: 100%; border-collapse: collapse; min-width: 760px; } -th, td { text-align: left; padding: 12px; border-bottom: 1px solid var(--border); vertical-align: top; } -th { font-size: 12px; color: var(--muted); font-weight: 650; } + +.table-panel { padding: 18px; } +.table-head { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 14px; } +.table-summary { margin-top: 4px; color: var(--muted); font-size: 13px; } +.actions { display: flex; align-items: center; gap: 8px; } +.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 9px; } +table { width: 100%; min-width: 900px; border-collapse: collapse; } +th, td { padding: 13px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } +th { background: var(--panel-soft); color: var(--muted); font-size: 11px; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; } tr:last-child td { border-bottom: 0; } -.title-cell { font-weight: 650; max-width: 420px; overflow-wrap: anywhere; } -.error { color: var(--danger); margin-top: 6px; max-width: 520px; } -.pill { display: inline-flex; align-items: center; min-height: 26px; border-radius: 999px; padding: 3px 9px; font-size: 12px; border: 1px solid var(--border); } -.pill.ready { color: var(--accent); } -.pill.failed { color: var(--danger); } -.pill.queued, .pill.capturing, .pill.rendering { color: #b7791f; } -.small { min-height: 32px; padding: 6px 9px; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; } -.row-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; width: 100%; white-space: nowrap; } -.row-actions .small { min-width: 58px; min-height: 32px; padding: 6px 9px; } -.row-actions .danger { min-width: 66px; } -.pager { justify-content: flex-end; align-items: center; margin-top: 12px; color: var(--muted); } -.pager-button { width: 38px; min-height: 36px; padding: 6px 10px; } -.page-label { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; font-variant-numeric: tabular-nums; color: var(--text); } +.title-cell { max-width: 390px; font-weight: 680; overflow-wrap: anywhere; } +.sub { margin-top: 4px; color: var(--muted); font-size: 12px; } +.error { max-width: 480px; margin-top: 6px; color: var(--danger); font-size: 13px; } +.date-cell { color: var(--muted); font-size: 13px; white-space: nowrap; } +.date-cell strong { display: block; color: var(--text); font-weight: 650; } +.date-cell span { display: block; margin-top: 3px; font-size: 11px; } +.pill { display: inline-flex; min-height: 25px; align-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 3px 9px; font-size: 11px; font-weight: 700; text-transform: capitalize; } +.pill.ready { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); background: var(--accent-soft); color: var(--accent); } +.pill.failed { background: var(--danger-soft); color: var(--danger); } +.pill.queued, .pill.capturing, .pill.rendering { color: var(--warning); } +.small { min-height: 30px; padding: 5px 8px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; } +.row-actions { display: flex; justify-content: flex-end; align-items: center; gap: 6px; white-space: nowrap; } +.empty-state { padding: 42px 20px; color: var(--muted); text-align: center; } +.empty-state strong, .empty-state span { display: block; } +.empty-state strong { margin-bottom: 5px; color: var(--text); font-size: 15px; } +.pager { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 14px; } +.page-label { margin-right: 4px; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; } .pager select { width: auto; min-height: 36px; padding: 6px 30px 6px 10px; } -.toast { position: fixed; left: 50%; bottom: 22px; z-index: 50; transform: translate(-50%, 12px); padding: 9px 13px; border: 1px solid var(--border); border-radius: 8px; background: var(--toast-bg); color: var(--toast-text); box-shadow: 0 10px 28px var(--shadow); opacity: 0; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease; } +.pager-button { width: 38px; min-height: 36px; padding: 6px; } +.toast { position: fixed; left: 50%; bottom: 22px; z-index: 50; max-width: calc(100vw - 32px); transform: translate(-50%, 12px); border: 1px solid var(--border); border-radius: 9px; padding: 9px 13px; background: var(--panel); color: var(--text); box-shadow: 0 12px 32px var(--shadow); opacity: 0; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease; } .toast[data-visible="true"] { opacity: 1; transform: translate(-50%, 0); } -@media (max-width: 760px) { - .shell { padding: 20px 14px 36px; } - .header, .table-head, .actions { align-items: stretch; flex-direction: column; } - .header-actions { align-self: flex-end; } - .capture-form { grid-template-columns: 1fr; } - button { width: 100%; } - .theme-toggle { width: 38px; } - .row-actions { justify-content: flex-start; flex-wrap: wrap; } - .row-actions .small, .pager button, .pager select { width: auto; } - .pager { flex-direction: row; justify-content: flex-end; align-items: center; } - .toast { width: max-content; max-width: calc(100vw - 28px); text-align: center; } +@media (max-width: 820px) { + .nav-inner { padding: 10px 16px; } + .brand span:last-child { display: none; } + .admin-key-field input { width: min(42vw, 184px); } + .shell { padding: 34px 16px 48px; } + .intro { align-items: flex-start; } + .capture-form, .capture-form:not(:has(.slug-field)) { grid-template-columns: 1fr 130px; } + .url-field, .slug-field { grid-column: 1 / -1; } + .primary { width: 100%; } +} + +@media (max-width: 520px) { + .intro { display: block; } + .admin-badge { display: inline-flex; margin-top: 14px; } + .capture-form, .capture-form:not(:has(.slug-field)) { grid-template-columns: 1fr; } + .url-field, .slug-field { grid-column: auto; } + .table-head { align-items: flex-start; } + .table-panel, .capture-panel { padding: 14px; } + .pager { justify-content: space-between; flex-wrap: wrap; } + .page-label { width: 100%; } } diff --git a/src/db.ts b/src/db.ts index ad44398..a84eefa 100644 --- a/src/db.ts +++ b/src/db.ts @@ -7,6 +7,8 @@ export interface CaptureInsert { id: string; sourceUrl: string; requestedSlug: string | null; + ownerToken: string | null; + retentionDays: number; } export class Store { @@ -42,17 +44,43 @@ export class Store { CREATE INDEX IF NOT EXISTS idx_captures_status ON captures(status); CREATE INDEX IF NOT EXISTS idx_captures_created_at ON captures(created_at); `); + + const columns = new Set( + (this.db.prepare("PRAGMA table_info(captures)").all() as Array<{ name: string }>).map((column) => column.name) + ); + if (!columns.has("owner_token")) this.db.exec("ALTER TABLE captures ADD COLUMN owner_token TEXT"); + if (!columns.has("retention_days")) this.db.exec("ALTER TABLE captures ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 30"); + if (!columns.has("expires_at")) this.db.exec("ALTER TABLE captures ADD COLUMN expires_at TEXT"); + + const missingExpiry = this.db.prepare("SELECT id, created_at, retention_days FROM captures WHERE expires_at IS NULL").all() as Array<{ + id: string; + created_at: string; + retention_days: number; + }>; + const setExpiry = this.db.prepare("UPDATE captures SET expires_at = ? WHERE id = ?"); + const backfillExpiry = this.db.transaction(() => { + for (const row of missingExpiry) { + const expiresAt = new Date(new Date(row.created_at).getTime() + row.retention_days * 86_400_000).toISOString(); + setExpiry.run(expiresAt, row.id); + } + }); + backfillExpiry(); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_captures_owner_created ON captures(owner_token, created_at); + CREATE INDEX IF NOT EXISTS idx_captures_expires_at ON captures(expires_at); + `); } createCapture(input: CaptureInsert): CaptureRow { const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + input.retentionDays * 86_400_000).toISOString(); this.db .prepare( `INSERT INTO captures - (id, source_url, provider, requested_slug, status, created_at, updated_at) - VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?)` + (id, source_url, provider, requested_slug, status, created_at, updated_at, owner_token, retention_days, expires_at) + VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?, ?, ?, ?)` ) - .run(input.id, input.sourceUrl, input.requestedSlug, now, now); + .run(input.id, input.sourceUrl, input.requestedSlug, now, now, input.ownerToken, input.retentionDays, expiresAt); return this.getCapture(input.id)!; } @@ -65,15 +93,28 @@ export class Store { } listCaptures(page: number, pageSize: number): { rows: CaptureRow[]; total: number } { + return this.listCapturesWhere(page, pageSize); + } + + listCapturesForOwner(ownerToken: string, page: number, pageSize: number): { rows: CaptureRow[]; total: number } { + return this.listCapturesWhere(page, pageSize, ownerToken); + } + + private listCapturesWhere(page: number, pageSize: number, ownerToken?: string): { rows: CaptureRow[]; total: number } { const limit = Math.min(Math.max(pageSize, 1), 100); const offset = Math.max(page - 1, 0) * limit; - const rows = this.db - .prepare("SELECT * FROM captures ORDER BY created_at DESC LIMIT ? OFFSET ?") - .all(limit, offset) as CaptureRow[]; - const total = (this.db.prepare("SELECT count(*) as count FROM captures").get() as { count: number }).count; + const where = ownerToken === undefined ? "" : " WHERE owner_token = ?"; + const params = ownerToken === undefined ? [limit, offset] : [ownerToken, limit, offset]; + const rows = this.db.prepare(`SELECT * FROM captures${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params) as CaptureRow[]; + const countParams = ownerToken === undefined ? [] : [ownerToken]; + const total = (this.db.prepare(`SELECT count(*) as count FROM captures${where}`).get(...countParams) as { count: number }).count; return { rows, total }; } + expiredCaptures(now = new Date().toISOString()): CaptureRow[] { + return this.db.prepare("SELECT * FROM captures WHERE expires_at <= ?").all(now) as CaptureRow[]; + } + queuedCaptures(): CaptureRow[] { return this.db .prepare("SELECT * FROM captures WHERE status IN ('queued', 'capturing', 'rendering') ORDER BY created_at ASC") diff --git a/src/server.ts b/src/server.ts index 4919185..d226d90 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import crypto from "node:crypto"; import express, { type NextFunction, type Request, type Response } from "express"; import fs from "fs-extra"; import { config } from "./config.js"; @@ -19,6 +20,8 @@ import { } from "./artifacts.js"; const app = express(); +const retentionOptions = new Set([3, 7, 10, 30]); +const ownerCookieName = "aishare_owner"; app.disable("x-powered-by"); app.enable("strict routing"); @@ -48,6 +51,45 @@ function requireAdmin(req: Request, res: Response, next: NextFunction): void { res.status(401).json({ error: "Admin token required." }); } +function isAdminRequest(req: Request): boolean { + const header = req.header("authorization") || ""; + const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : req.header("x-admin-token"); + return Boolean(config.adminToken && token && token === config.adminToken); +} + +function cookieValue(req: Request, name: string): string | undefined { + const cookie = req.header("cookie") || ""; + for (const part of cookie.split(";")) { + const [key, ...value] = part.trim().split("="); + if (key === name) return decodeURIComponent(value.join("=")); + } + return undefined; +} + +app.use((req, res, next) => { + let ownerToken = cookieValue(req, ownerCookieName); + if (!ownerToken || !/^[A-Za-z0-9_-]{32,128}$/.test(ownerToken)) { + ownerToken = crypto.randomBytes(32).toString("base64url"); + const cookiePath = config.basePath || "/"; + const secure = req.secure || req.header("x-forwarded-proto") === "https"; + res.cookie(ownerCookieName, ownerToken, { + httpOnly: true, + sameSite: "lax", + secure, + path: cookiePath, + maxAge: 365 * 24 * 60 * 60 * 1000 + }); + } + (req as Request & { ownerToken?: string }).ownerToken = ownerToken; + res.locals.ownerToken = ownerToken; + next(); +}); + +function canManageCapture(req: Request, row: NonNullable>): boolean { + const ownerToken = (req as Request & { ownerToken?: string }).ownerToken; + return isAdminRequest(req) || Boolean(row.owner_token && row.owner_token === ownerToken); +} + function publicCapture(row: ReturnType) { if (!row) return null; return { @@ -61,6 +103,8 @@ function publicCapture(row: ReturnType) { createdAt: row.created_at, updatedAt: row.updated_at, capturedAt: row.captured_at, + retentionDays: row.retention_days, + expiresAt: row.expires_at, publicUrl: row.slug ? publicPath(row.slug) : null }; } @@ -80,18 +124,35 @@ app.use(route("/assets"), express.static(path.resolve("dist-client/assets"), { i app.use(route("/admin-assets"), express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" })); app.get(route("/admin"), (_req, res) => { - res.sendFile(path.resolve("dist-client/index.html")); + res.redirect(302, route() || "/"); }); +function sendApp(_req: Request, res: Response): void { + res.sendFile(path.resolve("dist-client/index.html")); +} + app.get(route("/api/health"), (_req, res) => { res.json({ ok: true }); }); -app.post(route("/api/captures"), requireAdmin, (req, res) => { +app.get(route("/api/session"), (req, res) => { + res.json({ isAdmin: isAdminRequest(req) }); +}); + +app.post(route("/api/captures"), (req, res) => { try { const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || "")); - const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null; - const row = store.createCapture({ id: newCaptureId(), sourceUrl, requestedSlug }); + const admin = isAdminRequest(req); + const requestedSlug = admin && req.body?.slug ? slugify(String(req.body.slug)) : null; + const retentionDays = Number(req.body?.retentionDays ?? 3); + if (!retentionOptions.has(retentionDays)) throw new Error("Retention must be 3, 7, 10, or 30 days."); + const row = store.createCapture({ + id: newCaptureId(), + sourceUrl, + requestedSlug, + ownerToken: admin ? null : String(res.locals.ownerToken), + retentionDays + }); captureQueue.enqueue(row.id); res.status(202).json({ capture: publicCapture(row) }); } catch (error) { @@ -99,30 +160,32 @@ app.post(route("/api/captures"), requireAdmin, (req, res) => { } }); -app.get(route("/api/captures"), requireAdmin, (req, res) => { +app.get(route("/api/captures"), (req, res) => { const page = Number.parseInt(String(req.query.page || "1"), 10); const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 10); - const result = store.listCaptures(page, pageSize); + const admin = isAdminRequest(req); + const result = admin ? store.listCaptures(page, pageSize) : store.listCapturesForOwner(String(res.locals.ownerToken), page, pageSize); res.json({ page, pageSize, total: result.total, + isAdmin: admin, captures: result.rows.map(publicCapture) }); }); -app.get(route("/api/captures/:id"), requireAdmin, (req, res) => { +app.get(route("/api/captures/:id"), (req, res) => { const row = store.getCapture(req.params.id); - if (!row) { + if (!row || !canManageCapture(req, row)) { res.status(404).json({ error: "Capture not found." }); return; } res.json({ capture: publicCapture(row), errorDetail: row.error_detail, metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null }); }); -app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => { +app.post(route("/api/captures/:id/retry"), (req, res) => { const row = store.getCapture(req.params.id); - if (!row) { + if (!row || !canManageCapture(req, row)) { res.status(404).json({ error: "Capture not found." }); return; } @@ -135,12 +198,13 @@ app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => { res.json({ capture: publicCapture(store.getCapture(row.id)) }); }); -app.delete(route("/api/captures/:id"), requireAdmin, async (req, res) => { - const row = store.deleteCapture(req.params.id); - if (!row) { +app.delete(route("/api/captures/:id"), async (req, res) => { + const existing = store.getCapture(req.params.id); + if (!existing || !canManageCapture(req, existing)) { res.status(404).json({ error: "Capture not found." }); return; } + const row = store.deleteCapture(req.params.id)!; await deleteArtifactsFor(row); res.json({ ok: true }); }); @@ -232,14 +296,13 @@ app.get(route("/:slug"), (req, res) => { res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`); }); -app.get(route(), (_req, res) => { - res.redirect(301, route("/admin")); -}); - if (config.basePath) { - app.get(route("/"), (_req, res) => { - res.redirect(301, route("/admin")); + app.get(route(), (_req, res) => { + res.redirect(308, route("/")); }); + app.get(route("/"), sendApp); +} else { + app.get("/", sendApp); } app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { @@ -250,7 +313,18 @@ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { await fs.ensureDir(config.htmlDir); await fs.ensureDir(config.screenshotDir); await fs.ensureDir(config.cacheDir); +async function deleteExpiredCaptures(): Promise { + for (const row of store.expiredCaptures()) { + const deleted = store.deleteCapture(row.id); + if (deleted) await deleteArtifactsFor(deleted); + } +} + +await deleteExpiredCaptures(); captureQueue.restorePending(); +setInterval(() => { + void deleteExpiredCaptures().catch((error) => console.error("Failed to delete expired captures", error)); +}, 60 * 60 * 1000).unref(); app.listen(config.port, "0.0.0.0", () => { console.log(`aishare listening on 0.0.0.0:${config.port}`); diff --git a/src/types.ts b/src/types.ts index 346d8cd..9b7b767 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,9 @@ export interface CaptureRow { created_at: string; updated_at: string; captured_at: string | null; + owner_token: string | null; + retention_days: number; + expires_at: string; } export interface NormalizedConversation {