Compare commits

..
26 Commits
Author SHA1 Message Date
Codex 6a08638849 Support new ChatGPT share paths 2026-07-21 12:53:46 +00:00
Codex 2020f8e356 Support Unicode capture slugs 2026-07-13 03:25:11 +00:00
Codex 3ed933fb35 Add anonymous captures and retention controls 2026-07-09 03:25:44 +00:00
Codex 90cf3d415c Hide interactive controls in screenshots 2026-07-09 02:34:19 +00:00
Codex 88e4dc3dff Fix external link icons in captured pages 2026-07-08 10:29:01 +00:00
Codex 14306b1854 Preserve KaTeX line rendering details 2026-07-08 10:07:10 +00:00
Codex a25dfd4d1e Use official KaTeX assets for math rendering 2026-07-08 10:01:17 +00:00
Codex 9ddc369536 Compact MathML cleanup 2026-07-08 09:48:46 +00:00
Codex 65759088bd Preserve KaTeX display math rendering 2026-07-08 09:36:44 +00:00
Codex 1f4d1dde1b Normalize MathML in captured shares 2026-07-08 09:28:30 +00:00
Codex 8734bc1f07 Improve captured page citations and navigation 2026-07-08 08:43:39 +00:00
Codex 37a5450107 Handle unavailable ChatGPT shares 2026-07-08 08:00:06 +00:00
Codex 83c82206c4 Add admin theme toggle 2026-07-08 07:44:53 +00:00
Codex 132e2648ac Improve admin table actions and pagination 2026-07-08 07:37:19 +00:00
Codex 5ae4ebb55e Bound ChatGPT DOM extraction time 2026-07-08 07:24:07 +00:00
Codex c2198383f1 Add reader theme toggle 2026-07-08 07:11:25 +00:00
Codex 49e9f5e640 Support flexible base paths and thin proxies 2026-07-08 06:38:41 +00:00
Codex bbe7b6f562 Use proxy-safe relative URLs 2026-07-08 06:24:02 +00:00
Codex 65c1e8bddf Use toast feedback for copy actions 2026-07-08 06:18:14 +00:00
Codex 72bd06fc9d Fix screenshot dropdown layering 2026-07-08 06:15:54 +00:00
Codex 746fe2a65d Compact export toolbar and inline HTML assets 2026-07-08 06:10:27 +00:00
Codex 0c57525807 Stack ChatGPT marker above assistant content 2026-07-08 05:55:41 +00:00
Codex 02419f7161 Fix screenshot assets and code blocks 2026-07-08 05:46:12 +00:00
Codex 743c1332fd Match ChatGPT share message layout 2026-07-08 05:28:36 +00:00
Codex a86050f005 Normalize captured ChatGPT rendering 2026-07-08 04:29:09 +00:00
Codex a84ceb8065 Remove standalone capture debug helpers 2026-07-08 03:37:04 +00:00
29 changed files with 1687 additions and 640 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
AISHARE_BASE_URL=https://xcel.me/aishare AISHARE_BASE_PATH=/aishare
AISHARE_ADMIN_TOKEN=change-me AISHARE_ADMIN_TOKEN=change-me
AISHARE_DATA_DIR=/data AISHARE_DATA_DIR=/data
AISHARE_PORT=8080 AISHARE_PORT=8080
-1
View File
@@ -33,7 +33,6 @@ RUN ./node_modules/.bin/playwright install --with-deps --only-shell chromium \
&& rm -rf /var/lib/apt/lists/* /tmp/* && rm -rf /var/lib/apt/lists/* /tmp/*
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/dist-client ./dist-client COPY --from=build /app/dist-client ./dist-client
COPY scripts ./scripts
EXPOSE 8080 EXPOSE 8080
CMD ["node", "dist/server.js"] CMD ["node", "dist/server.js"]
+8 -3
View File
@@ -1,15 +1,16 @@
# AI Share # AI Share
`aishare` captures public ChatGPT shared conversations and publishes immutable read-only snapshots at `https://xcel.me/aishare/<slug>`. `aishare` captures public ChatGPT shared conversations and publishes immutable read-only snapshots under a configurable base path.
V1 scope: V1 scope:
- accepts only public `https://chatgpt.com/share/...` and `https://chat.openai.com/share/...` URLs - accepts public ChatGPT links under the legacy `/share/...` path and the current `/s/t_...` path
- admin-only capture/delete UI at `/aishare/admin` - admin-only capture/delete UI at `${AISHARE_BASE_PATH}/admin`
- public reader pages generated as static artifacts under `/data/html/<slug>` - public reader pages generated as static artifacts under `/data/html/<slug>`
- screenshots under `/data/screenshots/<slug>` - screenshots under `/data/screenshots/<slug>`
- SQLite state in `/data/aishare.db` - SQLite state in `/data/aishare.db`
- Docker Compose deployment with Playwright Chromium and multilingual Noto fonts - Docker Compose deployment with Playwright Chromium and multilingual Noto fonts
- relative reader/admin links so the same origin can be proxied from different hostnames and path prefixes
## Local Development ## Local Development
@@ -25,6 +26,8 @@ Open:
- admin: `http://127.0.0.1:8088/aishare/admin` - admin: `http://127.0.0.1:8088/aishare/admin`
- public snapshots: `http://127.0.0.1:8088/aishare/<slug>` - public snapshots: `http://127.0.0.1:8088/aishare/<slug>`
Set `AISHARE_BASE_PATH=/` to serve at the domain root, or a path such as `/services/aishare` to serve directly under a different prefix.
## Git Remote ## Git Remote
```bash ```bash
@@ -34,3 +37,5 @@ git remote add origin gitea:cabbage/aishare.git
## Deploy ## Deploy
See [docs/deploy-titan.md](docs/deploy-titan.md). See [docs/deploy-titan.md](docs/deploy-titan.md).
For small servers that should only proxy to an existing AI Share origin, see [proxy/README.md](proxy/README.md).
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>AI Share Admin</title> <title>AI Share</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+268 -146
View File
@@ -10,240 +10,362 @@ type Capture = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
capturedAt: string | null; capturedAt: string | null;
retentionDays: number;
expiresAt: string;
publicUrl: string | null; publicUrl: string | null;
}; };
const tokenKey = "aishare.adminToken"; const tokenKey = "aishare.adminToken";
const themeKey = "aishare.theme";
const legacyThemeKey = "aishare.adminTheme";
const app = document.querySelector<HTMLDivElement>("#app")!; const app = document.querySelector<HTMLDivElement>("#app")!;
let token = localStorage.getItem(tokenKey) || ""; let adminToken = "";
let isAdmin = false;
let page = 1; let page = 1;
const pageSize = 20; let pageSize = 20;
let totalPages = 1;
let polling: number | undefined; let polling: number | undefined;
let toastTimer: number | undefined;
let keyValidationTimer: number | undefined;
let manualTheme = false;
function api(path: string, options: RequestInit = {}) { const icons = {
return fetch(`/aishare/api${path}`, { sun: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"/></svg>`,
moon: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.6A7.6 7.6 0 0 1 9.4 4 8 8 0 1 0 20 14.6Z"/></svg>`,
refresh: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 6"/><path d="M20 5v6h-6"/></svg>`
};
function preferredTheme(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
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): void {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
manualTheme = manual;
if (manual) localStorage.setItem(themeKey, theme);
updateThemeToggle();
}
function updateThemeToggle(): void {
const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
const toggle = document.querySelector<HTMLButtonElement>("[data-theme-toggle]");
if (!toggle) return;
const label = theme === "dark" ? "Switch to light theme" : "Switch to dark theme";
toggle.setAttribute("aria-label", label);
toggle.setAttribute("title", label);
}
function initTheme(): void {
const theme = savedTheme();
applyTheme(theme || preferredTheme(), Boolean(theme));
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (!manualTheme) applyTheme(preferredTheme(), false);
});
}
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 = {}): Promise<Response> {
return fetch(new URL(`api${path}`, serviceBaseUrl()), {
...options, ...options,
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}), ...(isAdmin && adminToken ? { authorization: `Bearer ${adminToken}` } : {}),
...(options.headers || {}) ...(options.headers || {})
} }
}); });
} }
function fmt(value: string | null) { function captureUrl(capture: Capture): string {
if (!value) return ""; if (capture.slug) return new URL(`${capture.slug}/`, serviceBaseUrl()).href;
return capture.publicUrl || "";
}
function fmt(value: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString(); return new Date(value).toLocaleString();
} }
function statusClass(status: Capture["status"]) { function escapeHtml(value: string): string {
return `pill ${status}`; return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
} }
async function copy(text: string) { async function copy(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
textarea.remove();
if (!ok) throw new Error("copy failed");
}
} }
function renderShell() { function showToast(message: string): void {
let toast = document.querySelector<HTMLDivElement>("[data-toast]");
if (!toast) {
toast = document.createElement("div");
toast.className = "toast";
toast.dataset.toast = "true";
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
document.body.appendChild(toast);
}
toast.textContent = message;
toast.dataset.visible = "true";
if (toastTimer) window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => delete toast?.dataset.visible, 1800);
}
function renderShell(): void {
app.innerHTML = ` app.innerHTML = `
<nav class="navbar">
<div class="nav-inner">
<a class="brand" href="${escapeHtml(serviceBaseUrl().pathname)}" aria-label="AI Share home"><span class="brand-mark">A</span><span>AI Share</span></a>
<div class="nav-actions">
<label class="admin-key-field">
<span class="sr-only">Admin key</span>
<input id="admin-key" type="password" autocomplete="current-password" placeholder="Admin key" value="${escapeHtml(adminToken)}" aria-label="Admin key" />
<span class="key-state" data-key-state aria-hidden="true"></span>
</label>
<button class="icon-button theme-toggle" type="button" data-theme-toggle aria-label="Switch theme"><span class="sun-icon">${icons.sun}</span><span class="moon-icon">${icons.moon}</span></button>
</div>
</div>
</nav>
<main class="shell"> <main class="shell">
<header class="header"> <section class="intro">
<div> <div>
<h1>AI Share Admin</h1> <p class="eyebrow">Public ChatGPT snapshots</p>
<p>Capture public ChatGPT shares as read-only snapshots.</p> <h1>${isAdmin ? "Capture library" : "Your captures"}</h1>
</div> <p>${isAdmin ? "Manage every submitted snapshot and its retention window." : "Save a public ChatGPT conversation as an immutable page and screenshot."}</p>
<button class="icon-button" id="reset-token" title="Reset admin token" aria-label="Reset admin token">Reset</button>
</header>
<section class="panel token-panel" ${token ? "hidden" : ""}>
<label>Admin token</label>
<div class="row">
<input id="token-input" type="password" autocomplete="current-password" placeholder="AISHARE_ADMIN_TOKEN" />
<button id="save-token">Save</button>
</div> </div>
${isAdmin ? '<span class="admin-badge">Admin view</span>' : ""}
</section> </section>
<section class="panel" ${token ? "" : "hidden"}> <section class="panel capture-panel">
<form id="capture-form" class="capture-form"> <form id="capture-form" class="capture-form">
<label> <label class="url-field">ChatGPT share URL<input name="sourceUrl" required inputmode="url" autocomplete="url" placeholder="https://chatgpt.com/s/t_…" /></label>
ChatGPT share URL ${isAdmin ? '<label class="slug-field">Custom slug<input name="slug" placeholder="Optional" /></label>' : ""}
<input name="sourceUrl" required inputmode="url" placeholder="https://chatgpt.com/share/..." /> <label class="retention-field">Keep for<select name="retentionDays" aria-label="Retention period"><option value="3" selected>3 days</option><option value="7">7 days</option><option value="10">10 days</option><option value="30">30 days</option></select></label>
</label> <button class="primary" type="submit">Capture</button>
<label>
Slug
<input name="slug" placeholder="Optional" />
</label>
<button type="submit">Capture</button>
</form> </form>
<p id="form-status" class="status-line"></p> <p id="form-status" class="status-line" aria-live="polite"></p>
</section> </section>
<section class="panel table-panel" ${token ? "" : "hidden"}> <section class="panel table-panel">
<div class="table-head"> <div class="table-head">
<h2>Captured Shares</h2> <div><h2>${isAdmin ? "All captures" : "Recent captures"}</h2><p class="table-summary" id="table-summary">Loading…</p></div>
<div class="actions"> <div class="actions">
<button id="refresh">Refresh</button> <button class="icon-button" id="refresh" title="Refresh" aria-label="Refresh">${icons.refresh}</button>
<button id="delete-all" class="danger">Delete all</button> ${isAdmin ? '<button id="delete-all" class="danger">Delete all</button>' : ""}
</div> </div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead><tr><th>Capture</th><th>Status</th><th>Created</th><th>Expires</th><th><span class="sr-only">Actions</span></th></tr></thead>
<tr> <tbody id="captures-body"><tr><td colspan="5" class="empty-state">Loading captures…</td></tr></tbody>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Link</th>
<th></th>
</tr>
</thead>
<tbody id="captures-body"></tbody>
</table> </table>
</div> </div>
<div class="pager"> <div class="pager" aria-label="Pagination">
<button id="prev">Previous</button> <span id="page-label" class="page-label"></span>
<span id="page-label"></span> <select id="page-size" aria-label="Rows per page"><option value="10">10 rows</option><option value="20">20 rows</option><option value="50">50 rows</option><option value="100">100 rows</option></select>
<button id="next">Next</button> <button class="pager-button" id="prev" aria-label="Previous page">←</button>
<button class="pager-button" id="next" aria-label="Next page">→</button>
</div> </div>
</section> </section>
</main> </main>`;
`;
document.querySelector("#save-token")?.addEventListener("click", () => { bindShellEvents();
const input = document.querySelector<HTMLInputElement>("#token-input"); updateThemeToggle();
token = input?.value.trim() || ""; }
if (token) {
localStorage.setItem(tokenKey, token); function bindShellEvents(): void {
renderShell(); const keyInput = document.querySelector<HTMLInputElement>("#admin-key");
void loadCaptures(); keyInput?.addEventListener("input", () => {
if (keyValidationTimer) window.clearTimeout(keyValidationTimer);
keyValidationTimer = window.setTimeout(() => void validateAdminKey(keyInput.value.trim()), 350);
});
keyInput?.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
if (keyValidationTimer) window.clearTimeout(keyValidationTimer);
void validateAdminKey(keyInput.value.trim());
} }
}); });
document.querySelector("#reset-token")?.addEventListener("click", () => { document.querySelector("[data-theme-toggle]")?.addEventListener("click", () => {
localStorage.removeItem(tokenKey); applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
token = "";
if (polling) window.clearInterval(polling);
renderShell();
}); });
document.querySelector("#capture-form")?.addEventListener("submit", async (event) => { document.querySelector("#capture-form")?.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const form = event.currentTarget as HTMLFormElement; const form = event.currentTarget as HTMLFormElement;
const data = new FormData(form); const data = new FormData(form);
const status = document.querySelector("#form-status")!; const status = document.querySelector<HTMLElement>("#form-status")!;
status.textContent = "Queued"; status.textContent = "Queueing capture…";
const response = await api("/captures", { const response = await api("/captures", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({ sourceUrl: data.get("sourceUrl"), slug: isAdmin ? data.get("slug") : null, retentionDays: Number(data.get("retentionDays") || 3) })
sourceUrl: data.get("sourceUrl"),
slug: data.get("slug")
})
}); });
const body = await response.json(); const body = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
status.textContent = body.error || "Capture failed to queue"; status.textContent = body.error || "Capture could not be queued.";
showToast(status.textContent);
return; return;
} }
form.reset(); form.reset();
status.textContent = "Capture queued"; status.textContent = "Capture queued.";
showToast("Capture queued");
page = 1;
await loadCaptures(); await loadCaptures();
startPolling(); startPolling();
}); });
document.querySelector("#refresh")?.addEventListener("click", () => loadCaptures()); document.querySelector("#refresh")?.addEventListener("click", async () => {
await loadCaptures();
showToast("Refreshed");
});
document.querySelector("#delete-all")?.addEventListener("click", async () => { 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;
await api("/captures", { method: "DELETE" }); const response = await api("/captures", { method: "DELETE" });
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(); }
});
document.querySelector("#next")?.addEventListener("click", async () => {
if (page < totalPages) { page += 1; await loadCaptures(); }
});
const pageSizeSelect = document.querySelector<HTMLSelectElement>("#page-size");
if (pageSizeSelect) {
pageSizeSelect.value = String(pageSize);
pageSizeSelect.addEventListener("change", async () => {
pageSize = Number(pageSizeSelect.value);
page = 1; page = 1;
await loadCaptures(); await loadCaptures();
}); });
document.querySelector("#prev")?.addEventListener("click", async () => {
if (page > 1) {
page -= 1;
await loadCaptures();
} }
});
document.querySelector("#next")?.addEventListener("click", async () => {
page += 1;
await loadCaptures();
});
} }
async function loadCaptures() { async function validateAdminKey(candidate: string, initial = false): Promise<void> {
if (!token) return; const response = await fetch(new URL("api/session", serviceBaseUrl()), {
const response = await api(`/captures?page=${page}&pageSize=${pageSize}`); headers: candidate ? { authorization: `Bearer ${candidate}` } : {}
if (response.status === 401) { });
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); localStorage.removeItem(tokenKey);
token = ""; }
if (changed || initial) {
page = 1;
if (polling) { window.clearInterval(polling); polling = undefined; }
renderShell(); 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<void> {
const response = await api(`/captures?page=${page}&pageSize=${pageSize}`);
if (!response.ok) {
showToast("Could not load captures");
return; return;
} }
const body = await response.json(); 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 captures = body.captures as Capture[];
const tbody = document.querySelector<HTMLTableSectionElement>("#captures-body")!;
tbody.innerHTML = captures
.map((capture) => {
const title = capture.title || capture.slug || capture.sourceUrl;
const link = capture.publicUrl || "";
return `
<tr>
<td>
<div class="title-cell">${escapeHtml(title)}</div>
<div class="sub">${escapeHtml(capture.slug || capture.id)}</div>
${capture.errorPublic ? `<div class="error">${escapeHtml(capture.errorPublic)}</div>` : ""}
</td>
<td><span class="${statusClass(capture.status)}">${capture.status}</span></td>
<td>${escapeHtml(fmt(capture.createdAt))}</td>
<td>
${link ? `<button class="small" data-copy="${escapeHtml(link)}">Copy</button> <a class="small link" href="${escapeHtml(link)}" target="_blank" rel="noreferrer">Open</a>` : ""}
</td>
<td><button class="small danger" data-delete="${capture.id}">Delete</button></td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>("[data-copy]").forEach((button) => {
button.addEventListener("click", async () => {
await copy(button.dataset.copy || "");
button.textContent = "Copied";
setTimeout(() => (button.textContent = "Copy"), 1200);
});
});
tbody.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach((button) => {
button.addEventListener("click", async () => {
if (!confirm("Delete this capture and related files?")) return;
await api(`/captures/${button.dataset.delete}`, { method: "DELETE" });
await loadCaptures();
});
});
const total = Number(body.total || 0); const total = Number(body.total || 0);
document.querySelector("#page-label")!.textContent = `Page ${page} of ${Math.max(1, Math.ceil(total / pageSize))}`; totalPages = Math.max(1, Math.ceil(total / pageSize));
(document.querySelector("#prev") as HTMLButtonElement).disabled = page <= 1; if (page > totalPages) { page = totalPages; return loadCaptures(); }
(document.querySelector("#next") as HTMLButtonElement).disabled = page * pageSize >= total;
const tbody = document.querySelector<HTMLTableSectionElement>("#captures-body")!;
tbody.innerHTML = captures.length ? captures.map(captureRow).join("") : `<tr><td colspan="5" class="empty-state"><strong>No captures yet</strong><span>Submit a public ChatGPT share above to get started.</span></td></tr>`;
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<HTMLSelectElement>("#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(); 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 `<tr>
<td><div class="title-cell">${escapeHtml(title)}</div><div class="sub">${escapeHtml(capture.slug || capture.id)}</div>${capture.errorPublic ? `<div class="error">${escapeHtml(capture.errorPublic)}</div>` : ""}</td>
<td><span class="pill ${capture.status}">${capture.status}</span></td>
<td class="date-cell">${escapeHtml(fmt(capture.createdAt))}</td>
<td class="date-cell"><strong>${capture.retentionDays} days</strong><span>${escapeHtml(fmt(capture.expiresAt))}</span></td>
<td><div class="row-actions">${link ? `<button class="small" data-copy="${escapeHtml(link)}">Copy</button><a class="small link" href="${escapeHtml(link)}" target="_blank" rel="noreferrer">Open</a>` : ""}${capture.status === "failed" ? `<button class="small" data-retry="${capture.id}">Retry</button>` : ""}<button class="small danger" data-delete="${capture.id}">Delete</button></div></td>
</tr>`;
}
function bindRowEvents(tbody: HTMLTableSectionElement): void {
tbody.querySelectorAll<HTMLButtonElement>("[data-copy]").forEach((button) => button.addEventListener("click", async () => {
try { await copy(button.dataset.copy || ""); showToast("Link copied"); } catch { showToast("Copy failed"); }
}));
tbody.querySelectorAll<HTMLButtonElement>("[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<HTMLButtonElement>("[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; if (polling) return;
polling = window.setInterval(async () => { polling = window.setInterval(async () => {
await loadCaptures(); await loadCaptures();
const active = [...document.querySelectorAll(".pill")].some((pill) => ["queued", "capturing", "rendering"].includes(pill.textContent || "")); const active = [...document.querySelectorAll(".pill")].some((pill) => ["queued", "capturing", "rendering"].includes(pill.textContent || ""));
if (!active && polling) { if (!active && polling) { window.clearInterval(polling); polling = undefined; }
window.clearInterval(polling);
polling = undefined;
}
}, 3000); }, 3000);
} }
function escapeHtml(value: string) { initTheme();
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
renderShell(); renderShell();
void loadCaptures(); void validateAdminKey(localStorage.getItem(tokenKey) || "", true);
+113 -49
View File
@@ -1,63 +1,127 @@
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--bg: #f6f7f9; --bg: #f4f5f7;
--panel: #ffffff; --panel: #ffffff;
--text: #1f2328; --panel-soft: #f8f9fa;
--muted: #6b7280; --text: #17191c;
--border: #d9dee7; --muted: #69717d;
--accent: #0f766e; --border: #dfe3e8;
--accent: #0b7a65;
--accent-hover: #096b59;
--accent-soft: #e5f5f0;
--danger: #b42318; --danger: #b42318;
--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; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Arial, sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #18181b; --bg: #171819; --panel: #222426; --panel-soft: #292c2f; --text: #f0f2f3; --muted: #a5abb3; --border: #3b3f44;
--panel: #242428; --accent: #28b995; --accent-hover: #34caa4; --accent-soft: #173c34; --danger: #ff8177; --danger-soft: #442724; --warning: #e9b949; --shadow: rgb(0 0 0 / 28%);
--text: #f4f4f5;
--muted: #a1a1aa;
--border: #3f3f46;
--accent: #14b8a6;
--danger: #f87171;
} }
} }
* { box-sizing: border-box; } 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%); }
body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; } 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%); }
button, input { font: inherit; }
.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; }
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 { width: 100%; min-height: 40px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; background: var(--panel); color: var(--text); }
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[type="submit"] { background: var(--accent); border-color: var(--accent); color: white; }
.danger { color: var(--danger); }
.icon-button { min-height: 34px; }
.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; }
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; }
.pager { justify-content: flex-end; margin-top: 12px; color: var(--muted); }
@media (max-width: 760px) { * { box-sizing: border-box; }
.shell { padding: 20px 14px 36px; } html { min-height: 100%; background: var(--bg); }
.header, .table-head, .actions, .pager { align-items: stretch; flex-direction: column; } body { min-width: 320px; margin: 0; background: var(--bg); color: var(--text); font-size: 15px; }
.capture-form { grid-template-columns: 1fr; } button, input, select { font: inherit; }
button { width: 100%; } 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: 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); }
.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-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 { 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; }
.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: 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%; }
} }
+1 -1
View File
@@ -5,7 +5,7 @@ services:
restart: unless-stopped restart: unless-stopped
user: "1001:1001" user: "1001:1001"
environment: environment:
AISHARE_BASE_URL: ${AISHARE_BASE_URL:-https://xcel.me/aishare} AISHARE_BASE_PATH: ${AISHARE_BASE_PATH:-/aishare}
AISHARE_ADMIN_TOKEN: ${AISHARE_ADMIN_TOKEN:?set AISHARE_ADMIN_TOKEN} AISHARE_ADMIN_TOKEN: ${AISHARE_ADMIN_TOKEN:?set AISHARE_ADMIN_TOKEN}
AISHARE_DATA_DIR: /data AISHARE_DATA_DIR: /data
AISHARE_PORT: 8080 AISHARE_PORT: 8080
+25
View File
@@ -17,6 +17,7 @@ cp .env.example .env
``` ```
Edit `/root/compose/aishare/.env` and set `AISHARE_ADMIN_TOKEN`. Edit `/root/compose/aishare/.env` and set `AISHARE_ADMIN_TOKEN`.
Keep `AISHARE_BASE_PATH=/aishare` for the legacy `xcel.me/aishare` endpoint.
Start: Start:
@@ -55,3 +56,27 @@ Reload nginx:
nginx -t nginx -t
docker exec nginx nginx -s reload docker exec nginx nginx -s reload
``` ```
## Proxying From Other Hosts Or Paths
The app does not require a fixed hostname. Reader pages use relative links, and the admin UI derives API and share URLs from the browser's current URL.
For direct serving, set the app mount explicitly:
```env
AISHARE_BASE_PATH=/
```
or:
```env
AISHARE_BASE_PATH=/services/aishare
```
For thin proxy servers that should forward to this origin, use the compose stack in [`../proxy`](../proxy). It can expose URLs such as:
- `https://aishare.domain.com/`
- `https://domain.com/services/aishare/`
- `https://xcel.me/aishare/`
while forwarding to an origin such as `https://x1.xcel.me/aishare/`.
+26
View File
@@ -13,6 +13,7 @@
"cheerio": "1.0.0", "cheerio": "1.0.0",
"express": "^4.19.2", "express": "^4.19.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"katex": "^0.17.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"playwright": "1.61.1", "playwright": "1.61.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
@@ -1403,6 +1404,15 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
}, },
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/compress-commons": { "node_modules/compress-commons": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
@@ -2182,6 +2192,22 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/katex": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/lazystream": { "node_modules/lazystream": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "tsx watch src/server.ts", "dev": "tsx watch src/server.ts",
"build": "tsc && vite build", "build": "tsc && vite build",
"start": "node dist/server.js", "start": "node dist/server.js",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "npm run build && node --test tests/*.test.mjs"
}, },
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
@@ -15,6 +16,7 @@
"cheerio": "1.0.0", "cheerio": "1.0.0",
"express": "^4.19.2", "express": "^4.19.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"katex": "^0.17.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"playwright": "1.61.1", "playwright": "1.61.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
+6
View File
@@ -0,0 +1,6 @@
AISHARE_PROXY_BIND=127.0.0.1:8090
AISHARE_PROXY_SERVER_NAME=_
AISHARE_PROXY_EXTERNAL_PATH=/aishare
AISHARE_UPSTREAM_ORIGIN=https://origin.example.com
AISHARE_UPSTREAM_PATH=/aishare
AISHARE_PROXY_CLIENT_MAX_BODY_SIZE=20m
+62
View File
@@ -0,0 +1,62 @@
# AI Share Thin Proxy
This compose stack runs only nginx and forwards an external hostname/path to an existing AI Share origin. It is intended for small servers where the browser-facing URL may be different from the origin URL.
The origin service should keep a stable internal mount, usually:
```env
AISHARE_BASE_PATH=/aishare
```
The proxy can expose that same origin as any hostname/path because reader pages use relative links and the admin UI derives API/share URLs from the browser's current URL.
## Prefix Proxy
Example: expose `https://domain.com/services/aishare/` and forward to an origin mounted at `/aishare`.
```bash
cd proxy
cp .env.example .env
```
```env
AISHARE_PROXY_BIND=127.0.0.1:8090
AISHARE_PROXY_SERVER_NAME=domain.com
AISHARE_PROXY_EXTERNAL_PATH=/services/aishare
AISHARE_UPSTREAM_ORIGIN=https://origin.example.com
AISHARE_UPSTREAM_PATH=/aishare
```
```bash
docker compose up -d
```
Point the server's edge nginx at `http://127.0.0.1:8090`.
## Root Host Proxy
Example: expose `https://aishare.domain.com/` and forward to an origin mounted at `/aishare`.
```env
AISHARE_PROXY_BIND=127.0.0.1:8090
AISHARE_PROXY_SERVER_NAME=aishare.domain.com
AISHARE_PROXY_EXTERNAL_PATH=/
AISHARE_UPSTREAM_ORIGIN=https://origin.example.com
AISHARE_UPSTREAM_PATH=/aishare
```
## Direct Serving
If this service is not behind a rewriting proxy, set the app mount directly:
```env
AISHARE_BASE_PATH=/
```
or:
```env
AISHARE_BASE_PATH=/services/aishare
```
Then route traffic to the AI Share container without path rewriting.
+16
View File
@@ -0,0 +1,16 @@
services:
aishare-proxy:
image: nginx:1.27-alpine
container_name: aishare-proxy
restart: unless-stopped
environment:
AISHARE_PROXY_SERVER_NAME: ${AISHARE_PROXY_SERVER_NAME:-_}
AISHARE_PROXY_LISTEN: ${AISHARE_PROXY_LISTEN:-8080}
AISHARE_PROXY_EXTERNAL_PATH: ${AISHARE_PROXY_EXTERNAL_PATH:-/aishare}
AISHARE_UPSTREAM_ORIGIN: ${AISHARE_UPSTREAM_ORIGIN:?set AISHARE_UPSTREAM_ORIGIN, for example https://origin.example.com}
AISHARE_UPSTREAM_PATH: ${AISHARE_UPSTREAM_PATH:-/aishare}
AISHARE_PROXY_CLIENT_MAX_BODY_SIZE: ${AISHARE_PROXY_CLIENT_MAX_BODY_SIZE:-20m}
ports:
- "${AISHARE_PROXY_BIND:-127.0.0.1:8090}:${AISHARE_PROXY_LISTEN:-8080}"
volumes:
- ./nginx/render-aishare-proxy.sh:/docker-entrypoint.d/40-render-aishare-proxy.sh:ro
+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
set -eu
normalize_path() {
value="${1:-}"
value="/$(printf '%s' "$value" | sed -e 's#^/*##' -e 's#/*$##')"
if [ "$value" = "/" ]; then
printf ''
else
printf '%s' "$value"
fi
}
external_path="$(normalize_path "${AISHARE_PROXY_EXTERNAL_PATH:-/aishare}")"
upstream_path="$(normalize_path "${AISHARE_UPSTREAM_PATH:-/aishare}")"
upstream_origin="$(printf '%s' "${AISHARE_UPSTREAM_ORIGIN:?set AISHARE_UPSTREAM_ORIGIN}" | sed -e 's#/*$##')"
listen_port="${AISHARE_PROXY_LISTEN:-8080}"
server_name="${AISHARE_PROXY_SERVER_NAME:-_}"
client_max_body_size="${AISHARE_PROXY_CLIENT_MAX_BODY_SIZE:-20m}"
external_slash="${external_path}/"
upstream_slash="${upstream_path}/"
if [ -z "$external_path" ]; then external_slash="/"; fi
if [ -z "$upstream_path" ]; then upstream_slash="/"; fi
cat > /etc/nginx/conf.d/default.conf <<EOF
server {
listen ${listen_port};
server_name ${server_name};
client_max_body_size ${client_max_body_size};
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host \$proxy_host;
proxy_set_header X-Forwarded-Host \$http_host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Prefix ${external_path:-/};
proxy_set_header X-Real-Ip \$remote_addr;
EOF
write_proxy_block() {
location_line="$1"
rewrite_line="$2"
cat >> /etc/nginx/conf.d/default.conf <<EOF
${location_line} {
${rewrite_line}
proxy_redirect ${upstream_slash} ${external_slash};
proxy_redirect ${upstream_origin}${upstream_slash} \$scheme://\$http_host${external_slash};
proxy_pass ${upstream_origin};
}
EOF
}
if [ -z "$external_path" ]; then
write_proxy_block "location /" "rewrite ^/(.*)\$ ${upstream_path}/\$1 break;"
else
cat >> /etc/nginx/conf.d/default.conf <<EOF
location = ${external_path} {
return 301 ${external_path}/;
}
EOF
write_proxy_block "location ^~ ${external_path}/" "rewrite ^${external_path}/?(.*)\$ ${upstream_path}/\$1 break;"
fi
cat >> /etc/nginx/conf.d/default.conf <<'EOF'
}
EOF
-147
View File
@@ -1,147 +0,0 @@
#!/usr/bin/env python3
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import urllib.parse
import urllib.request
CDP_ENDPOINT = os.environ.get("CDP_ENDPOINT", "http://127.0.0.1:9222")
def get_page_ws_url():
with urllib.request.urlopen(f"{CDP_ENDPOINT}/json/list", timeout=30) as response:
targets = json.loads(response.read().decode("utf-8"))
pages = [target for target in targets if target.get("type") == "page"]
if not pages:
raise SystemExit("No CDP page target found.")
selected = next((page for page in pages if "chatgpt.com/share/" in page.get("url", "")), pages[0])
return selected["webSocketDebuggerUrl"], selected
def websocket_connect(ws_url):
parsed = urllib.parse.urlparse(ws_url)
if parsed.scheme != "ws":
raise SystemExit(f"Only ws:// endpoints are supported, got {ws_url}")
host = parsed.hostname or "127.0.0.1"
port = parsed.port or 80
path = parsed.path
if parsed.query:
path += "?" + parsed.query
sock = socket.create_connection((host, port), timeout=30)
key = base64.b64encode(os.urandom(16)).decode("ascii")
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
).encode("ascii")
sock.sendall(request)
response = b""
while b"\r\n\r\n" not in response:
response += sock.recv(4096)
header = response.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
if " 101 " not in header.split("\r\n", 1)[0]:
raise SystemExit(f"WebSocket upgrade failed:\n{header}")
accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest()).decode("ascii")
if f"Sec-WebSocket-Accept: {accept}".lower() not in header.lower():
raise SystemExit("WebSocket accept header did not match.")
return sock
def send_frame(sock, payload):
data = payload.encode("utf-8")
header = bytearray([0x81])
if len(data) < 126:
header.append(0x80 | len(data))
elif len(data) < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", len(data)))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", len(data)))
mask = os.urandom(4)
header.extend(mask)
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(data))
sock.sendall(bytes(header) + masked)
def recv_exact(sock, size):
chunks = []
remaining = size
while remaining:
chunk = sock.recv(remaining)
if not chunk:
raise EOFError("WebSocket closed")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def recv_frame(sock):
first, second = recv_exact(sock, 2)
opcode = first & 0x0F
masked = second & 0x80
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", recv_exact(sock, 2))[0]
elif length == 127:
length = struct.unpack("!Q", recv_exact(sock, 8))[0]
mask = recv_exact(sock, 4) if masked else b""
payload = recv_exact(sock, length) if length else b""
if masked:
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
if opcode == 0x8:
raise EOFError("WebSocket closed")
if opcode == 0x9:
return None
if opcode != 0x1:
return None
return payload.decode("utf-8")
def call(sock, method, params=None, message_id=1):
send_frame(sock, json.dumps({"id": message_id, "method": method, "params": params or {}}))
while True:
message = recv_frame(sock)
if not message:
continue
parsed = json.loads(message)
if parsed.get("id") == message_id:
return parsed
def main():
expression = sys.stdin.read().strip()
if not expression:
expression = "({ title: document.title, url: location.href, text: document.body.innerText.slice(0, 1000) })"
ws_url, target = get_page_ws_url()
sock = websocket_connect(ws_url)
try:
result = call(
sock,
"Runtime.evaluate",
{
"expression": expression,
"returnByValue": True,
"awaitPromise": True,
"timeout": 30000,
},
)
finally:
sock.close()
print(json.dumps({"target": target, "result": result}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
-170
View File
@@ -1,170 +0,0 @@
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
const sourceUrl = process.argv[2];
const holdMs = Number(process.env.DEBUG_HOLD_MS || "900000");
const port = Number(process.env.DEBUG_PORT || "9222");
const proxyPort = process.env.DEBUG_PROXY_PORT ? Number(process.env.DEBUG_PROXY_PORT) : null;
const cdpOnly = process.env.DEBUG_CDP_ONLY === "1";
if (!sourceUrl) {
console.error("usage: node scripts/debug-chatgpt-share.mjs <chatgpt-share-url>");
process.exit(2);
}
let browser;
let context;
let browserProcess;
let proxyServer;
let userDataDir;
function now() {
return new Date().toISOString();
}
function findChromiumExecutable() {
const preferred = process.env.DEBUG_CHROMIUM_EXECUTABLE || chromium.executablePath();
if (existsSync(preferred)) return preferred;
const browsersRoot = process.env.PLAYWRIGHT_BROWSERS_PATH || path.dirname(path.dirname(preferred));
const headlessShell = readdirSync(browsersRoot)
.filter((entry) => entry.startsWith("chromium_headless_shell-"))
.sort()
.reverse()
.map((entry) => path.join(browsersRoot, entry, "chrome-headless-shell-linux64", "chrome-headless-shell"))
.find((candidate) => existsSync(candidate));
if (headlessShell) return headlessShell;
throw new Error(`No Chromium executable found. Tried ${preferred} and ${browsersRoot}/chromium_headless_shell-*.`);
}
async function close() {
if (context) await context.close().catch(() => undefined);
if (browser) await browser.close().catch(() => undefined);
if (proxyServer) await new Promise((resolve) => proxyServer.close(resolve)).catch(() => undefined);
if (browserProcess && !browserProcess.killed) browserProcess.kill("SIGTERM");
if (userDataDir) await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
}
process.on("SIGINT", () => close().finally(() => process.exit(130)));
process.on("SIGTERM", () => close().finally(() => process.exit(143)));
userDataDir = await mkdtemp(path.join(os.tmpdir(), "aishare-debug-"));
const executablePath = findChromiumExecutable();
console.log(`[${now()}] Chromium executable: ${executablePath}`);
browserProcess = spawn(executablePath, [
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--hide-scrollbars",
"--mute-audio",
"--window-size=1360,900",
"--remote-debugging-address=0.0.0.0",
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
cdpOnly ? sourceUrl : "about:blank"
], {
stdio: ["ignore", "pipe", "pipe"]
});
browserProcess.stdout.on("data", (data) => process.stdout.write(data));
browserProcess.stderr.on("data", (data) => process.stderr.write(data));
browserProcess.on("exit", (code, signal) => {
console.log(`[${now()}] Chromium exited code=${code} signal=${signal}`);
});
let devtoolsReady = false;
for (let attempt = 0; attempt < 100; attempt += 1) {
const response = await fetch(`http://127.0.0.1:${port}/json/version`).catch(() => null);
if (response?.ok) {
devtoolsReady = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
if (!devtoolsReady) throw new Error(`Chromium DevTools endpoint did not start on 127.0.0.1:${port}.`);
if (proxyPort) {
proxyServer = net.createServer((client) => {
const upstream = net.connect(port, "127.0.0.1");
client.pipe(upstream);
upstream.pipe(client);
client.on("error", () => upstream.destroy());
upstream.on("error", () => client.destroy());
});
await new Promise((resolve, reject) => {
proxyServer.once("error", reject);
proxyServer.listen(proxyPort, "0.0.0.0", resolve);
});
console.log(`[${now()}] DevTools proxy: 0.0.0.0:${proxyPort} -> 127.0.0.1:${port}`);
}
if (cdpOnly) {
console.log(`[${now()}] CDP-only mode; holding browser for ${Math.round(holdMs / 1000)}s.`);
await new Promise((resolve) => setTimeout(resolve, holdMs));
await close();
process.exit(0);
}
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
context = await browser.newContext({
viewport: { width: 1360, height: 900 },
deviceScaleFactor: 1,
locale: "en-US"
});
const page = await context.newPage();
page.setDefaultTimeout(30_000);
console.log(`[${now()}] Chromium DevTools port: ${port}`);
console.log(`[${now()}] Opening ${sourceUrl}`);
await page.goto(sourceUrl, { waitUntil: "commit", timeout: 45_000 }).catch((error) => {
console.log(`[${now()}] goto did not complete: ${error.message}`);
});
await page.waitForLoadState("domcontentloaded", { timeout: 20_000 }).catch(() => undefined);
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(3000);
const summary = await page.evaluate(() => {
const selectors = [
"[data-message-author-role]",
"[data-message-id]",
"[data-testid]",
"main article",
"main [role=listitem]",
"main .markdown",
"main [class*=markdown]",
"script[type='application/json']",
"script#__NEXT_DATA__"
];
const counts = Object.fromEntries(selectors.map((selector) => [selector, document.querySelectorAll(selector).length]));
const title = document.querySelector("h1")?.textContent?.trim() || document.title;
const bodySample = (document.body.textContent || "").replace(/\s+/g, " ").trim().slice(0, 600);
const testIds = Array.from(document.querySelectorAll("[data-testid]"))
.map((element) => element.getAttribute("data-testid"))
.filter(Boolean)
.slice(0, 80);
return {
href: location.href,
title,
counts,
testIds,
bodySample
};
});
console.log(JSON.stringify(summary, null, 2));
console.log(`[${now()}] Holding browser for ${Math.round(holdMs / 1000)}s.`);
await new Promise((resolve) => setTimeout(resolve, holdMs));
await close();
+59
View File
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import path from "node:path"; import path from "node:path";
import fs from "fs-extra"; import fs from "fs-extra";
import archiver from "archiver"; import archiver from "archiver";
import * as cheerio from "cheerio";
import { config } from "./config.js"; import { config } from "./config.js";
import type { CaptureRow } from "./types.js"; import type { CaptureRow } from "./types.js";
@@ -61,3 +62,61 @@ export async function zipArtifact(slug: string, destination: string): Promise<vo
archive.finalize().catch(reject); archive.finalize().catch(reject);
}); });
} }
function contentTypeForAsset(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (ext === ".svg") return "image/svg+xml";
if (ext === ".png") return "image/png";
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
if (ext === ".woff2") return "font/woff2";
if (ext === ".woff") return "font/woff";
return "application/octet-stream";
}
async function assetDataUri(slug: string, assetReference: string): Promise<string | null> {
if (!assetReference || assetReference.startsWith("data:") || /^https?:\/\//i.test(assetReference)) return null;
const normalized = path.normalize(assetReference).replace(/^(\.\.(\/|\\|$))+/, "");
if (!normalized.startsWith("assets/")) return null;
const assetPath = path.join(artifactDir(slug), normalized);
const bytes = await fs.readFile(assetPath);
return `data:${contentTypeForAsset(assetPath)};base64,${bytes.toString("base64")}`;
}
async function inlineCssAssetUrls(slug: string, css: string): Promise<string> {
const urls = [...css.matchAll(/url\((["']?)([^)"']+)\1\)/g)]
.map((match) => match[2])
.filter((url) => url.startsWith("assets/"));
const replacements = new Map<string, string>();
await Promise.all(
[...new Set(urls)].map(async (url) => {
const dataUri = await assetDataUri(slug, url).catch(() => null);
if (dataUri) replacements.set(url, dataUri);
})
);
return css.replace(/url\((["']?)([^)"']+)\1\)/g, (match, quote: string, url: string) => {
const replacement = replacements.get(url);
return replacement ? `url("${replacement}")` : match;
});
}
export async function selfContainedHtml(slug: string): Promise<string> {
const html = await fs.readFile(artifactIndexPath(slug), "utf8");
const $ = cheerio.load(html);
for (const image of $("img").toArray()) {
const src = $(image).attr("src");
if (!src) continue;
const data = await assetDataUri(slug, src).catch(() => null);
if (data) $(image).attr("src", data);
}
for (const style of $("style").toArray()) {
const css = $(style).html();
if (!css) continue;
$(style).html(await inlineCssAssetUrls(slug, css));
}
return $.html();
}
+437 -26
View File
@@ -12,6 +12,8 @@ import { config } from "./config.js";
import { artifactAssetsDir, artifactIndexPath, safeAssetName, screenshotPath, screenshotsDir } from "./artifacts.js"; import { artifactAssetsDir, artifactIndexPath, safeAssetName, screenshotPath, screenshotsDir } from "./artifacts.js";
import { slugify } from "./slug.js"; import { slugify } from "./slug.js";
import { writeConversationArtifact, writeFailureArtifact } from "./render.js"; import { writeConversationArtifact, writeFailureArtifact } from "./render.js";
import { finalizeKatexAssets, prepareKatexAssets } from "./katex-assets.js";
import { isChatGptSharePath } from "./url.js";
import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js"; import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js";
interface ExtractedConversation { interface ExtractedConversation {
@@ -19,11 +21,38 @@ interface ExtractedConversation {
messages: NormalizedMessage[]; messages: NormalizedMessage[];
} }
const chatGptIconUrl = "https://chatgpt.com/cdn/assets/favicon-l4nq08hd.svg";
const screenshotOnlyCss = `
button,
input,
select,
textarea,
summary,
.icon-button,
[contenteditable="true"],
[role="button"],
[role="checkbox"],
[role="combobox"],
[role="listbox"],
[role="menu"],
[role="menuitem"],
[role="radio"],
[role="slider"],
[role="spinbutton"],
[role="switch"],
[role="tab"] {
display: none !important;
}`;
function publicErrorFor(error: unknown): string { function publicErrorFor(error: unknown): string {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (/captcha|verify|robot/i.test(message)) { if (/captcha|verify|robot/i.test(message)) {
return "ChatGPT asked for verification, so this public share could not be captured automatically."; return "ChatGPT asked for verification, so this public share could not be captured automatically.";
} }
if (/unavailable|no longer public|not public|redirected away/i.test(message)) {
return "This ChatGPT share is not available publicly anymore, so it cannot be captured.";
}
if (/timeout/i.test(message)) { if (/timeout/i.test(message)) {
return "The public ChatGPT share took too long to load."; return "The public ChatGPT share took too long to load.";
} }
@@ -40,6 +69,15 @@ function timeoutSignal(ms: number): AbortSignal {
} }
async function downloadAsset(assetUrl: string, slug: string): Promise<string | null> { async function downloadAsset(assetUrl: string, slug: string): Promise<string | null> {
const downloaded = await downloadImage(assetUrl);
if (!downloaded) return null;
const filename = safeAssetName(assetUrl, downloaded.contentType, downloaded.bytes);
await fs.ensureDir(artifactAssetsDir(slug));
await fs.writeFile(path.join(artifactAssetsDir(slug), filename), downloaded.bytes);
return `assets/${filename}`;
}
async function downloadImage(assetUrl: string): Promise<{ contentType: string; bytes: Buffer } | null> {
const response = await fetch(assetUrl, { const response = await fetch(assetUrl, {
signal: timeoutSignal(20_000), signal: timeoutSignal(20_000),
headers: { headers: {
@@ -51,10 +89,14 @@ async function downloadAsset(assetUrl: string, slug: string): Promise<string | n
if (!contentType?.startsWith("image/")) return null; if (!contentType?.startsWith("image/")) return null;
const bytes = Buffer.from(await response.arrayBuffer()); const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.byteLength > 20 * 1024 * 1024) return null; if (bytes.byteLength > 20 * 1024 * 1024) return null;
const filename = safeAssetName(assetUrl, contentType, bytes); return { contentType, bytes };
await fs.ensureDir(artifactAssetsDir(slug)); }
await fs.writeFile(path.join(artifactAssetsDir(slug), filename), bytes);
return `/aishare/${slug}/assets/${filename}`; async function downloadAssetDataUri(assetUrl: string): Promise<string | null> {
const downloaded = await downloadImage(assetUrl);
if (!downloaded) return null;
if (downloaded.bytes.byteLength > 256 * 1024) return null;
return `data:${downloaded.contentType};base64,${downloaded.bytes.toString("base64")}`;
} }
async function localizeAssets(conversation: NormalizedConversation, slug: string): Promise<{ failures: string[] }> { async function localizeAssets(conversation: NormalizedConversation, slug: string): Promise<{ failures: string[] }> {
@@ -79,9 +121,10 @@ async function localizeAssets(conversation: NormalizedConversation, slug: string
continue; continue;
} }
try { try {
const local = await downloadAsset(absolute, slug); const isCitationIcon = $(image).closest("a[data-citation]").length > 0;
if (local) { const src = isCitationIcon ? await downloadAssetDataUri(absolute) : await downloadAsset(absolute, slug);
$(image).attr("src", local); if (src) {
$(image).attr("src", src);
} else { } else {
$(image).removeAttr("src"); $(image).removeAttr("src");
failures.push(absolute); failures.push(absolute);
@@ -96,6 +139,182 @@ async function localizeAssets(conversation: NormalizedConversation, slug: string
return { failures }; return { failures };
} }
function normalizeCopiedHtml(html: string): string {
const $ = cheerio.load(html, {}, false);
$("script, style, iframe, object, embed, form, button, textarea, input, nav").remove();
$("svg")
.filter((_, element) => $(element).parents(".katex, .katex-display").length === 0)
.remove();
function escapeCode(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function comparableMathText(value: string): string {
return value
.replace(/\\div/g, "÷")
.replace(/\s+/g, "")
.trim();
}
function trimDuplicateMathFallback(fallback: string, expression: string): string | null {
const target = comparableMathText(expression);
if (!target) return null;
let consumed = "";
let index = 0;
while (index < fallback.length && consumed.length < target.length) {
const next = fallback.startsWith("\\div", index) ? "÷" : fallback[index];
const width = fallback.startsWith("\\div", index) ? 4 : 1;
index += width;
if (/\s/.test(next)) continue;
consumed += next;
}
if (consumed === target) return fallback.slice(index);
const leading = fallback.match(/^\s*/)?.[0] || "";
const rest = fallback.slice(leading.length);
const fallbackRun = rest.match(/^[^\s<]+/)?.[0] || "";
const mathOperators = /[=∑∏√±×⋅*/^_≥≤<>+÷]/;
if (fallbackRun.length >= 3 && mathOperators.test(fallbackRun) && mathOperators.test(target)) {
return `${leading}${rest.slice(fallbackRun.length)}`;
}
const firstLine = rest.split(/\n/, 1)[0] || "";
const compactLine = comparableMathText(firstLine);
if (compactLine.length >= 8 && mathOperators.test(compactLine) && mathOperators.test(target)) {
const available = new Map<string, number>();
for (const char of target) available.set(char, (available.get(char) || 0) + 1);
let shared = 0;
for (const char of compactLine) {
const count = available.get(char) || 0;
if (count <= 0) continue;
available.set(char, count - 1);
shared += 1;
}
if (shared / Math.min(target.length, compactLine.length) >= 0.6) {
return `${leading}${rest.slice(firstLine.length)}`;
}
}
return null;
}
function normalizeMathMl(document: cheerio.CheerioAPI): void {
type DomNode = {
type?: string;
name?: string;
data?: string;
children?: DomNode[];
parent?: DomNode;
};
function visibleMathText(math: DomNode): string {
const visibleMathNode = document(math as never).clone();
visibleMathNode.find("annotation, annotation-xml").remove();
return visibleMathNode.text();
}
function trimTextNode(node: DomNode | undefined, expression: string): boolean {
if (!node || node.type !== "text" || typeof node.data !== "string") return false;
const trimmed = trimDuplicateMathFallback(node.data, expression);
if (trimmed === null) return false;
node.data = trimmed;
return true;
}
function trimSimpleFallbackElement(node: DomNode | undefined, expression: string): boolean {
if (!node || node.type !== "tag" || node.name !== "span") return false;
const children = node.children || [];
if (children.length !== 1 || children[0]?.type !== "text") return false;
return trimTextNode(children[0], expression);
}
function normalizeChildList(children: DomNode[]): void {
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
if (child?.children) normalizeChildList(child.children);
if (child?.type !== "tag" || child.name !== "math") continue;
const visibleMath = visibleMathText(child);
const next = children[index + 1];
trimTextNode(next, visibleMath) || trimSimpleFallbackElement(next, visibleMath);
}
}
document("math").find("annotation, annotation-xml").remove();
normalizeChildList(document.root()[0].children as DomNode[]);
document("math").each((_, math) => {
document(math).find("annotation, annotation-xml").remove();
});
}
$("pre")
.toArray()
.filter((pre) => $(pre).parents("pre").length === 0)
.forEach((pre) => {
const block = $(pre);
const nestedCode = block.find("pre code").last();
const directCode = block.children("code").first();
const codeNode = nestedCode.length ? nestedCode : directCode.length ? directCode : block.find("code").last();
const code = codeNode.length ? codeNode.text() : block.text();
if (!code.trim()) return;
const labelProbe = block.clone();
labelProbe.find("pre, code").remove();
const label = labelProbe.text().replace(/\s+/g, " ").trim();
const language = label.length > 0 && label.length <= 24 ? label : "";
block.replaceWith(
`<div class="code-block">` +
`<div class="code-header"><span>${escapeCode(language || "code")}</span><button type="button" data-copy-code>Copy</button></div>` +
`<pre><code>${escapeCode(code.trimEnd())}</code></pre>` +
`</div>`
);
});
$("a").each((_, anchor) => {
const link = $(anchor);
const decorativeImages = link.find("img").filter((_, image) => {
const alt = ($(image).attr("alt") || "").trim();
return alt.length === 0;
});
if (decorativeImages.length > 0) {
link.attr("data-citation", "true");
link.removeAttr("alt");
const firstImageHtml = $.html(decorativeImages.first());
const label = link.text().replace(/\s+/g, " ").trim();
link.empty();
if (firstImageHtml) link.append(firstImageHtml);
if (label) link.append(`<span>${escapeCode(label)}</span>`);
}
});
$("span").each((_, span) => {
const element = $(span);
if (element.closest("a[data-citation]").length > 0) return;
if (element.closest(".katex, .katex-display").length > 0) return;
if (Object.keys(span.attribs || {}).length === 0) element.replaceWith(element.contents());
});
$("p, li, blockquote").each((_, node) => {
const element = $(node);
element.html(
(element.html() || "").replace(/\*\*([^*<>\n][^*<>]*?)\*\*/g, (_match, text: string) => `<strong>${text}</strong>`)
);
});
$(":empty")
.not("br,img,hr")
.filter((_, element) => $(element).parents("math, .katex, .katex-display").length === 0)
.remove();
const finalDocument = cheerio.load($.html(), {}, false);
normalizeMathMl(finalDocument);
return finalDocument.html().trim();
}
function findChromiumExecutable(): string { function findChromiumExecutable(): string {
const preferred = chromium.executablePath(); const preferred = chromium.executablePath();
if (nodeFs.existsSync(preferred)) return preferred; if (nodeFs.existsSync(preferred)) return preferred;
@@ -106,7 +325,10 @@ function findChromiumExecutable(): string {
.filter((entry) => entry.startsWith("chromium_headless_shell-")) .filter((entry) => entry.startsWith("chromium_headless_shell-"))
.sort() .sort()
.reverse() .reverse()
.map((entry) => path.join(browsersRoot, entry, "chrome-headless-shell-linux64", "chrome-headless-shell")) .flatMap((entry) => [
path.join(browsersRoot, entry, "chrome-linux", "headless_shell"),
path.join(browsersRoot, entry, "chrome-headless-shell-linux64", "chrome-headless-shell")
])
.find((candidate) => nodeFs.existsSync(candidate)); .find((candidate) => nodeFs.existsSync(candidate));
if (headlessShell) return headlessShell; if (headlessShell) return headlessShell;
@@ -143,6 +365,19 @@ async function waitForCdp(port: number, process: ChildProcess): Promise<void> {
throw new Error("Chromium CDP endpoint did not become ready."); throw new Error("Chromium CDP endpoint did not become ready.");
} }
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timeout) clearTimeout(timeout);
promise.catch(() => undefined);
}
}
async function stopProcess(process: ChildProcess): Promise<void> { async function stopProcess(process: ChildProcess): Promise<void> {
if (process.exitCode !== null || process.signalCode !== null) return; if (process.exitCode !== null || process.signalCode !== null) return;
process.kill("SIGTERM"); process.kill("SIGTERM");
@@ -186,17 +421,26 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
await waitForCdp(port, chromiumProcess); await waitForCdp(port, chromiumProcess);
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: config.captureTimeoutMs }); browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: config.captureTimeoutMs });
const context = browser.contexts()[0]; const context = browser.contexts()[0];
let page = context.pages().find((candidate) => candidate.url().includes("/share/")) || context.pages()[0]; const isSharePage = (candidate: { url(): string }): boolean => {
try {
return isChatGptSharePath(new URL(candidate.url()).pathname);
} catch {
return false;
}
};
let page = context.pages().find(isSharePage) || context.pages()[0];
for (let attempt = 0; !page && attempt < 100; attempt += 1) { for (let attempt = 0; !page && attempt < 100; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
page = context.pages().find((candidate) => candidate.url().includes("/share/")) || context.pages()[0]; page = context.pages().find(isSharePage) || context.pages()[0];
} }
if (!page) throw new Error("Chromium CDP page target was not created."); if (!page) throw new Error("Chromium CDP page target was not created.");
await page.setViewportSize({ width: 1360, height: 900 }).catch(() => undefined); await page.setViewportSize({ width: 1360, height: 900 }).catch(() => undefined);
page.setDefaultTimeout(config.captureTimeoutMs); page.setDefaultTimeout(config.captureTimeoutMs);
const expectedPath = new URL(sourceUrl).pathname;
await page await page
.waitForFunction( .waitForFunction(
() => { (sharePath) => {
if (!location.pathname.startsWith(sharePath)) return true;
const bodyText = document.body?.innerText || document.body?.textContent || ""; const bodyText = document.body?.innerText || document.body?.textContent || "";
if (/verify you are human|captcha|unusual activity/i.test(bodyText)) return true; if (/verify you are human|captcha|unusual activity/i.test(bodyText)) return true;
if (document.querySelectorAll("[data-message-author-role]").length > 0) return true; if (document.querySelectorAll("[data-message-author-role]").length > 0) return true;
@@ -204,24 +448,77 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
if ((markdown?.textContent || "").trim().length > 0) return true; if ((markdown?.textContent || "").trim().length > 0) return true;
return /(?:^|\s)(You said:|ChatGPT said:)/.test(bodyText); return /(?:^|\s)(You said:|ChatGPT said:)/.test(bodyText);
}, },
expectedPath,
{ timeout: config.captureTimeoutMs } { timeout: config.captureTimeoutMs }
) )
.catch(() => undefined); .catch(() => undefined);
await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await page.evaluate(() => window.scrollTo(0, 0)).catch(() => undefined);
await page
.waitForFunction(
() => {
const firstTurn = document.querySelector("[data-testid='conversation-turn-1'], [data-turn]");
if (!firstTurn) return false;
return Boolean(firstTurn.querySelector("[data-message-author-role]")) || (firstTurn.textContent || "").trim().length > 0;
},
{ timeout: 3_000 }
)
.catch(() => undefined);
await page.waitForTimeout(750);
const extracted = await page.evaluate(() => { const actualUrl = page.url();
if (!new URL(actualUrl).pathname.startsWith(expectedPath)) {
throw new Error(`ChatGPT share is unavailable or no longer public: page redirected away to ${actualUrl}.`);
}
const extracted = await withTimeout(page.evaluate(() => {
function cleanHtml(root: Element): string { function cleanHtml(root: Element): string {
const clone = root.cloneNode(true) as Element; const clone = root.cloneNode(true) as Element;
clone.querySelectorAll("script, style, iframe, object, embed, form, button, textarea, input, nav").forEach((node) => node.remove()); clone.querySelectorAll("script, style, iframe, object, embed, form, button, textarea, input, nav").forEach((node) => node.remove());
clone.querySelectorAll("*").forEach((element) => { clone.querySelectorAll("*").forEach((element) => {
Array.from(element.attributes).forEach((attr) => { Array.from(element.attributes).forEach((attr) => {
const name = attr.name.toLowerCase(); const name = attr.name.toLowerCase();
const keep = ["href", "src", "alt", "title", "colspan", "rowspan"].includes(name); const elementClass = element.getAttribute("class") || "";
const inMathOrKatex =
element.closest("math, .katex, .katex-display") !== null ||
/\bkatex(?:-|$)/.test(elementClass);
const inKatexSvg = inMathOrKatex && ["svg", "path", "line"].includes(element.tagName.toLowerCase());
const keep =
["href", "src", "alt", "title", "colspan", "rowspan"].includes(name) ||
(inKatexSvg &&
[
"aria-hidden",
"class",
"d",
"fill",
"height",
"style",
"viewbox",
"width",
"xmlns"
].includes(name)) ||
(inMathOrKatex &&
[
"aria-hidden",
"class",
"display",
"encoding",
"fence",
"mathvariant",
"maxsize",
"minsize",
"separator",
"stretchy",
"style",
"xmlns"
].includes(name));
if (name.startsWith("on") || !keep) element.removeAttribute(attr.name); if (name.startsWith("on") || !keep) element.removeAttribute(attr.name);
if ((name === "href" || name === "src") && /^javascript:/i.test(attr.value)) element.removeAttribute(attr.name); if ((name === "href" || name === "src") && /^javascript:/i.test(attr.value)) element.removeAttribute(attr.name);
if (name === "style" && /(?:expression|@import|url\s*\()/i.test(attr.value)) element.removeAttribute(attr.name);
}); });
}); });
return clone.innerHTML.trim(); return clone.innerHTML.trim();
} }
@@ -238,7 +535,10 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
const title = const title =
document.querySelector("h1")?.textContent?.trim() || document.querySelector("h1")?.textContent?.trim() ||
document.title.replace(/\s*[|-]\s*ChatGPT\s*$/i, "").trim() || document.title
.replace(/^ChatGPT\s*[|-]\s*/i, "")
.replace(/\s*[|-]\s*ChatGPT\s*$/i, "")
.trim() ||
"ChatGPT Share"; "ChatGPT Share";
const roleNodes = Array.from(document.querySelectorAll("[data-message-author-role]")); const roleNodes = Array.from(document.querySelectorAll("[data-message-author-role]"));
@@ -253,23 +553,108 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
: index % 2 === 0 : index % 2 === 0
? "user" ? "user"
: "assistant"; : "assistant";
const contentRoot = const markdownRoot = node.querySelector(".markdown") || node.querySelector("[class*=markdown]");
node.querySelector(".markdown") || const contentRoot = markdownRoot || node.querySelector("[data-message-content]") || node;
node.querySelector("[data-message-content]") ||
node;
const text = (contentRoot.textContent || "").replace(/\s+\n/g, "\n").trim(); const text = (contentRoot.textContent || "").replace(/\s+\n/g, "\n").trim();
const html = cleanHtml(contentRoot) || textToHtml(text); const html = markdownRoot ? cleanHtml(markdownRoot) || textToHtml(text) : textToHtml(text);
return { id: `m-${index + 1}`, role, text, html }; return { id: `m-${index + 1}`, role, text, html };
}) })
.filter((message) => message.text.length > 0); .filter((message) => message.text.length > 0);
function normalizeText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function parsePayloadMessages(): Array<{ role: "user" | "assistant"; text: string }> {
const script = Array.from(document.scripts).find((candidate) => {
const text = candidate.textContent || "";
return text.includes("serverResponse") && text.includes("linear_conversation");
})?.textContent;
const match = script?.match(/enqueue\((.*)\);?$/s);
if (!match) return [];
try {
const flattened = JSON.parse(JSON.parse(match[1]));
const keyCache = new Map<string, number>();
function keyRef(name: string): number {
if (!keyCache.has(name)) keyCache.set(name, flattened.indexOf(name));
return keyCache.get(name) ?? -1;
}
function propRef(objectRef: unknown, key: string): unknown {
if (typeof objectRef !== "number" || objectRef < 0) return undefined;
const object = flattened[objectRef];
if (!object || typeof object !== "object" || Array.isArray(object)) return undefined;
return (object as Record<string, unknown>)[`_${keyRef(key)}`];
}
function primitive(ref: unknown): unknown {
if (typeof ref !== "number" || ref < 0) return undefined;
return flattened[ref];
}
const linearKey = keyRef("linear_conversation");
const dataObject = flattened.find((value: unknown) => {
return Boolean(value && typeof value === "object" && !Array.isArray(value) && `_${linearKey}` in (value as Record<string, unknown>));
}) as Record<string, unknown> | undefined;
const linearRef = dataObject?.[`_${linearKey}`];
const linearRefs = primitive(linearRef);
if (!Array.isArray(linearRefs)) return [];
function messageText(messageRef: unknown): string {
const contentRef = propRef(messageRef, "content");
const partsRef = propRef(contentRef, "parts");
const parts = primitive(partsRef);
if (!Array.isArray(parts)) return "";
return normalizeText(
parts
.map((partRef) => primitive(partRef))
.filter((part): part is string => typeof part === "string")
.join("\n")
);
}
return linearRefs
.map((itemRef) => {
const messageRef = propRef(itemRef, "message");
const authorRef = propRef(messageRef, "author");
const role = primitive(propRef(authorRef, "role"));
const text = messageText(messageRef);
return { role, text };
})
.filter((message): message is { role: "user" | "assistant"; text: string } => {
if (message.role !== "user" && message.role !== "assistant") return false;
if (!message.text) return false;
if (message.text === "Original custom instructions no longer available") return false;
if (message.text === "The output of this plugin was redacted.") return false;
if (/^\{"only_tools":true\}$/.test(message.text)) return false;
return true;
});
} catch {
return [];
}
}
const payloadMessages = parsePayloadMessages();
if (messages.length > 0 && payloadMessages.length > 0) {
const firstDomText = normalizeText(messages[0].text);
const firstDomIndex = payloadMessages.findIndex((message) => {
const payloadText = normalizeText(message.text);
return payloadText === firstDomText || payloadText.startsWith(firstDomText.slice(0, 120)) || firstDomText.startsWith(payloadText.slice(0, 120));
});
if (firstDomIndex > 0) {
messages.unshift(
...payloadMessages.slice(0, firstDomIndex).map((message, index) => ({
id: `payload-${index + 1}`,
role: message.role,
text: message.text,
html: textToHtml(message.text)
}))
);
}
}
const bodyText = document.body.textContent || ""; const bodyText = document.body.textContent || "";
if (/verify you are human|captcha|unusual activity/i.test(bodyText)) { if (/verify you are human|captcha|unusual activity/i.test(bodyText)) {
throw new Error("ChatGPT verification or CAPTCHA page was shown."); throw new Error("ChatGPT verification or CAPTCHA page was shown.");
} }
return { title, messages }; return { title, messages };
}); }), config.captureTimeoutMs, "Extracting ChatGPT share DOM");
if (!extracted.messages.length) { if (!extracted.messages.length) {
throw new Error("Unsupported ChatGPT share page: no conversation messages were found."); throw new Error("Unsupported ChatGPT share page: no conversation messages were found.");
@@ -283,9 +668,10 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
} }
} }
async function generateScreenshotVariant(slug: string, variant: ScreenshotVariant): Promise<void> { async function generateScreenshotVariant(slug: string, variant: ScreenshotVariant): Promise<string[]> {
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
try { try {
const katexFonts = new Set<string>();
const isMobile = variant.device === "mobile"; const isMobile = variant.device === "mobile";
const context = await browser.newContext({ const context = await browser.newContext({
viewport: isMobile ? { width: 430, height: 932 } : { width: 1360, height: 900 }, viewport: isMobile ? { width: 430, height: 932 } : { width: 1360, height: 900 },
@@ -294,8 +680,19 @@ async function generateScreenshotVariant(slug: string, variant: ScreenshotVarian
isMobile isMobile
}); });
const page = await context.newPage(); const page = await context.newPage();
page.on("request", (request) => {
try {
const url = new URL(request.url());
if (!url.pathname.includes("/assets/katex/")) return;
const filename = path.basename(url.pathname);
if (/\.woff2$/i.test(filename)) katexFonts.add(filename);
} catch {
return;
}
});
const fileUrl = `file://${artifactIndexPath(slug)}?theme=${variant.theme}`; const fileUrl = `file://${artifactIndexPath(slug)}?theme=${variant.theme}`;
await page.goto(fileUrl, { waitUntil: "load", timeout: 60_000 }); await page.goto(fileUrl, { waitUntil: "load", timeout: 60_000 });
await page.addStyleTag({ content: screenshotOnlyCss });
const height = await page.evaluate(() => Math.ceil(document.documentElement.scrollHeight)); const height = await page.evaluate(() => Math.ceil(document.documentElement.scrollHeight));
if (height > config.maxScreenshotHeight) { if (height > config.maxScreenshotHeight) {
throw new Error(`Screenshot height ${height}px exceeds limit ${config.maxScreenshotHeight}px.`); throw new Error(`Screenshot height ${height}px exceeds limit ${config.maxScreenshotHeight}px.`);
@@ -307,12 +704,13 @@ async function generateScreenshotVariant(slug: string, variant: ScreenshotVarian
type: "png" type: "png"
}); });
await context.close(); await context.close();
return [...katexFonts].sort();
} finally { } finally {
await browser.close(); await browser.close();
} }
} }
async function generateScreenshots(slug: string): Promise<{ generated: string[]; failed: string[] }> { async function generateScreenshots(slug: string): Promise<{ generated: string[]; failed: string[]; katexFonts: string[] }> {
const variants: ScreenshotVariant[] = [ const variants: ScreenshotVariant[] = [
{ device: "desktop", theme: "light" }, { device: "desktop", theme: "light" },
{ device: "desktop", theme: "dark" }, { device: "desktop", theme: "dark" },
@@ -321,16 +719,18 @@ async function generateScreenshots(slug: string): Promise<{ generated: string[];
]; ];
const generated: string[] = []; const generated: string[] = [];
const failed: string[] = []; const failed: string[] = [];
const katexFonts = new Set<string>();
for (const variant of variants) { for (const variant of variants) {
const name = `${variant.device}-${variant.theme}`; const name = `${variant.device}-${variant.theme}`;
try { try {
await generateScreenshotVariant(slug, variant); const usedFonts = await generateScreenshotVariant(slug, variant);
usedFonts.forEach((fontFile) => katexFonts.add(fontFile));
generated.push(name); generated.push(name);
} catch (error) { } catch (error) {
failed.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); failed.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
} }
} }
return { generated, failed }; return { generated, failed, katexFonts: [...katexFonts].sort() };
} }
export async function runCapture(row: CaptureRow): Promise<void> { export async function runCapture(row: CaptureRow): Promise<void> {
@@ -345,13 +745,20 @@ export async function runCapture(row: CaptureRow): Promise<void> {
title: extracted.title || "ChatGPT Share", title: extracted.title || "ChatGPT Share",
sourceUrl: row.source_url, sourceUrl: row.source_url,
capturedAt: new Date().toISOString(), capturedAt: new Date().toISOString(),
messages: extracted.messages messages: extracted.messages.map((message) => ({
...message,
html: normalizeCopiedHtml(message.html)
}))
}; };
store.updateStatus(row.id, "rendering"); store.updateStatus(row.id, "rendering");
conversation.chatGptIconSrc = (await downloadAsset(chatGptIconUrl, slug).catch(() => null)) || chatGptIconUrl;
const assetResult = await localizeAssets(conversation, slug); const assetResult = await localizeAssets(conversation, slug);
const preparedKatexFonts = await prepareKatexAssets(slug, conversation);
await writeConversationArtifact(slug, conversation); await writeConversationArtifact(slug, conversation);
const screenshots = await generateScreenshots(slug); const screenshots = await generateScreenshots(slug);
const keptKatexFonts = await finalizeKatexAssets(slug, conversation, screenshots.katexFonts);
if (keptKatexFonts.length > 0) await writeConversationArtifact(slug, conversation);
store.completeCapture(row.id, { store.completeCapture(row.id, {
slug, slug,
@@ -359,7 +766,11 @@ export async function runCapture(row: CaptureRow): Promise<void> {
dataJson: JSON.stringify(conversation), dataJson: JSON.stringify(conversation),
metadataJson: JSON.stringify({ metadataJson: JSON.stringify({
assetFailures: assetResult.failures, assetFailures: assetResult.failures,
screenshots screenshots,
katexFonts: {
prepared: preparedKatexFonts.length,
kept: keptKatexFonts
}
}) })
}); });
} catch (error) { } catch (error) {
+7 -3
View File
@@ -2,7 +2,6 @@ import path from "node:path";
export interface AppConfig { export interface AppConfig {
basePath: string; basePath: string;
baseUrl: string;
port: number; port: number;
dataDir: string; dataDir: string;
htmlDir: string; htmlDir: string;
@@ -21,11 +20,16 @@ function intEnv(name: string, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
function basePathEnv(): string {
const raw = (process.env.AISHARE_BASE_PATH || "/aishare").trim();
if (!raw || raw === "/") return "";
return `/${raw.replace(/^\/+|\/+$/g, "")}`;
}
const dataDir = process.env.AISHARE_DATA_DIR || path.resolve("data"); const dataDir = process.env.AISHARE_DATA_DIR || path.resolve("data");
export const config: AppConfig = { export const config: AppConfig = {
basePath: "/aishare", basePath: basePathEnv(),
baseUrl: (process.env.AISHARE_BASE_URL || "http://127.0.0.1:8088/aishare").replace(/\/$/, ""),
port: intEnv("AISHARE_PORT", 8080), port: intEnv("AISHARE_PORT", 8080),
dataDir, dataDir,
htmlDir: path.join(dataDir, "html"), htmlDir: path.join(dataDir, "html"),
+59 -7
View File
@@ -7,6 +7,8 @@ export interface CaptureInsert {
id: string; id: string;
sourceUrl: string; sourceUrl: string;
requestedSlug: string | null; requestedSlug: string | null;
ownerToken: string | null;
retentionDays: number;
} }
export class Store { 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_status ON captures(status);
CREATE INDEX IF NOT EXISTS idx_captures_created_at ON captures(created_at); 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 { createCapture(input: CaptureInsert): CaptureRow {
const now = new Date().toISOString(); const now = new Date().toISOString();
const expiresAt = new Date(Date.now() + input.retentionDays * 86_400_000).toISOString();
this.db this.db
.prepare( .prepare(
`INSERT INTO captures `INSERT INTO captures
(id, source_url, provider, requested_slug, status, created_at, updated_at) (id, source_url, provider, requested_slug, status, created_at, updated_at, owner_token, retention_days, expires_at)
VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?)` 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)!; return this.getCapture(input.id)!;
} }
@@ -65,15 +93,28 @@ export class Store {
} }
listCaptures(page: number, pageSize: number): { rows: CaptureRow[]; total: number } { 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 limit = Math.min(Math.max(pageSize, 1), 100);
const offset = Math.max(page - 1, 0) * limit; const offset = Math.max(page - 1, 0) * limit;
const rows = this.db const where = ownerToken === undefined ? "" : " WHERE owner_token = ?";
.prepare("SELECT * FROM captures ORDER BY created_at DESC LIMIT ? OFFSET ?") const params = ownerToken === undefined ? [limit, offset] : [ownerToken, limit, offset];
.all(limit, offset) as CaptureRow[]; const rows = this.db.prepare(`SELECT * FROM captures${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params) as CaptureRow[];
const total = (this.db.prepare("SELECT count(*) as count FROM captures").get() as { count: number }).count; 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 }; 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[] { queuedCaptures(): CaptureRow[] {
return this.db return this.db
.prepare("SELECT * FROM captures WHERE status IN ('queued', 'capturing', 'rendering') ORDER BY created_at ASC") .prepare("SELECT * FROM captures WHERE status IN ('queued', 'capturing', 'rendering') ORDER BY created_at ASC")
@@ -90,6 +131,17 @@ export class Store {
.run(status, detail?.errorPublic ?? null, detail?.errorDetail ?? null, new Date().toISOString(), id); .run(status, detail?.errorPublic ?? null, detail?.errorDetail ?? null, new Date().toISOString(), id);
} }
retryCapture(id: string): void {
this.db
.prepare(
`UPDATE captures
SET status = 'queued', title = NULL, error_public = NULL, error_detail = NULL,
data_json = NULL, metadata_json = NULL, captured_at = NULL, updated_at = ?
WHERE id = ?`
)
.run(new Date().toISOString(), id);
}
completeCapture(id: string, input: { slug: string; title: string; dataJson: string; metadataJson: string }): void { completeCapture(id: string, input: { slug: string; title: string; dataJson: string; metadataJson: string }): void {
const now = new Date().toISOString(); const now = new Date().toISOString();
this.db this.db
+85
View File
@@ -0,0 +1,85 @@
import { createRequire } from "node:module";
import path from "node:path";
import fs from "fs-extra";
import { artifactAssetsDir } from "./artifacts.js";
import type { NormalizedConversation } from "./types.js";
const require = createRequire(import.meta.url);
const katexCssPath = require.resolve("katex/dist/katex.min.css");
const katexDistDir = path.dirname(katexCssPath);
const katexFontsDir = path.join(katexDistDir, "fonts");
const fontFacePattern = /@font-face\{[^}]*\}/g;
const woff2Pattern = /url\((?:["']?)fonts\/([^)"']+\.woff2)(?:["']?)\)/;
export function conversationUsesKatex(conversation: NormalizedConversation): boolean {
return conversation.messages.some((message) => /\bkatex(?:-|["'\s>])/.test(message.html));
}
function katexAssetDir(slug: string): string {
return path.join(artifactAssetsDir(slug), "katex");
}
async function readOfficialKatexCss(): Promise<string> {
return await fs.readFile(katexCssPath, "utf8");
}
function rewriteFontFace(block: string): { css: string; fontFile: string | null } {
const fontFile = block.match(woff2Pattern)?.[1] || null;
if (!fontFile) return { css: block, fontFile: null };
return {
css: block.replace(/src:[^;}]+(?=[;}])/, `src:url("assets/katex/${fontFile}") format("woff2")`),
fontFile
};
}
async function buildKatexCss(allowedFonts?: Set<string>): Promise<{ css: string; fonts: string[] }> {
const css = await readOfficialKatexCss();
const fonts = new Set<string>();
const rewritten = css.replace(fontFacePattern, (block) => {
const result = rewriteFontFace(block);
if (!result.fontFile) return block;
if (allowedFonts && !allowedFonts.has(result.fontFile)) return "";
fonts.add(result.fontFile);
return result.css;
});
return { css: rewritten, fonts: [...fonts].sort() };
}
async function copyFonts(slug: string, fontFiles: string[]): Promise<void> {
const destination = katexAssetDir(slug);
await fs.emptyDir(destination);
await Promise.all(
fontFiles.map(async (fontFile) => {
await fs.copyFile(path.join(katexFontsDir, fontFile), path.join(destination, fontFile));
})
);
}
export async function prepareKatexAssets(slug: string, conversation: NormalizedConversation): Promise<string[]> {
if (!conversationUsesKatex(conversation)) {
delete conversation.katexCss;
await fs.remove(katexAssetDir(slug));
return [];
}
const { css, fonts } = await buildKatexCss();
conversation.katexCss = css;
await copyFonts(slug, fonts);
return fonts;
}
export async function finalizeKatexAssets(
slug: string,
conversation: NormalizedConversation,
requestedFonts: Iterable<string>
): Promise<string[]> {
if (!conversation.katexCss) return [];
const available = new Set(await fs.readdir(katexFontsDir));
const used = new Set([...requestedFonts].filter((fontFile) => available.has(fontFile)));
if (used.size === 0) return [];
const { css, fonts } = await buildKatexCss(used);
conversation.katexCss = css;
await copyFonts(slug, fonts);
return fonts;
}
+221 -52
View File
@@ -1,14 +1,11 @@
import path from "node:path"; import path from "node:path";
import fs from "fs-extra"; import fs from "fs-extra";
import * as cheerio from "cheerio";
import type { CaptureRow, NormalizedConversation } from "./types.js"; import type { CaptureRow, NormalizedConversation } from "./types.js";
import { artifactAssetsDir, artifactDir, artifactIndexPath } from "./artifacts.js"; import { artifactAssetsDir, artifactDir, artifactIndexPath } from "./artifacts.js";
import { config } from "./config.js"; import { config } from "./config.js";
const chatGptAvatar = ` const fallbackChatGptIconUrl = "https://chatgpt.com/cdn/assets/favicon-l4nq08hd.svg";
<svg viewBox="0 0 40 40" role="img" aria-label="ChatGPT" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="20" fill="#10a37f"/>
<path fill="#fff" d="M29.6 17.9a7 7 0 0 0-9.4-8.2 7 7 0 0 0-10.4 6.1 7 7 0 0 0 1.4 12.7 7 7 0 0 0 9.4 1.8 7 7 0 0 0 10.3-6.1 7 7 0 0 0-1.3-6.3Zm-9.4-6.2a5 5 0 0 1 6.9 5.4l-5.6-3.2a1 1 0 0 0-1 0l-7.6 4.4v-2.7l6.8-3.9h.5Zm-8.4 4.1a5 5 0 0 1 6.2-4.9l-5.5 3.2a1 1 0 0 0-.5.9v8.8l-2.4-1.4v-7.9c.6-.7 1.3-1.3 2.2-1.7Zm-1.2 10.5a5 5 0 0 1-2.8-8l.1 6.4c0 .4.2.7.5.9l7.6 4.4-2.4 1.4-6.8-3.9c-.2-.4-.2-.8-.2-1.2Zm9.2 3.9a5 5 0 0 1-6.9-5.4l5.6 3.2c.3.2.7.2 1 0l7.6-4.4v2.8l-6.8 3.9-.5-.1Zm8.4-4.1a5 5 0 0 1-6.2 4.9l5.5-3.2c.3-.2.5-.5.5-.9v-8.8l2.4 1.4v7.9c-.6.7-1.3 1.3-2.2 1.7Zm-8.2-.2-3-1.7v-3.5l3-1.7 3 1.7v3.5l-3 1.7Zm4-7-3.5-2a1 1 0 0 0-1 0l-3.5 2v-2.8l4-2.3 6.8 3.9c.2.4.2.8.2 1.2l-3-1.7Z"/>
</svg>`;
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value return value
@@ -18,8 +15,34 @@ function escapeHtml(value: string): string {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function css(): string { function prepareMessageHtml(html: string): string {
return ` const $ = cheerio.load(html, {}, false);
$("a").each((_, anchor) => {
const link = $(anchor);
if (link.is("[data-citation]")) return;
link.find("svg").remove();
const href = (link.attr("href") || "").trim();
if (/^https?:\/\//i.test(href) && link.find(".external-link-icon").length === 0) {
link.append('<span class="external-link-icon" aria-hidden="true"></span>');
}
});
return $.html();
}
const icons = {
download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v10m0 0 4-4m-4 4-4-4"/><path d="M5 18.5h14"/></svg>`,
image: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 4-4 3 3 2-2 3 3"/><circle cx="8.5" cy="8.5" r="1.2"/></svg>`,
share: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 12.5 15.5 8.5M8.5 11.5 15.5 15.5"/><circle cx="6.5" cy="12" r="2.5"/><circle cx="17.5" cy="7.5" r="2.5"/><circle cx="17.5" cy="16.5" r="2.5"/></svg>`,
chevron: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8 10 4 4 4-4"/></svg>`,
sun: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"/></svg>`,
moon: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.6A7.6 7.6 0 0 1 9.4 4 8 8 0 1 0 20 14.6Z"/></svg>`,
arrowUp: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 14 6-6 6 6"/><path d="M12 8v12"/></svg>`,
arrowDown: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 10 6 6 6-6"/><path d="M12 4v12"/></svg>`
};
function css(extraCss = ""): string {
return `${extraCss}
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--bg: #ffffff; --bg: #ffffff;
@@ -31,6 +54,7 @@ function css(): string {
--code: #0f172a; --code: #0f172a;
--code-bg: #f4f4f5; --code-bg: #f4f4f5;
--accent: #10a37f; --accent: #10a37f;
--user-bubble: #f4f4f4;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans Thai", "Helvetica Neue", Arial, sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans Thai", "Helvetica Neue", Arial, sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -43,43 +67,95 @@ function css(): string {
--soft: #2f2f2f; --soft: #2f2f2f;
--code: #e5e7eb; --code: #e5e7eb;
--code-bg: #171717; --code-bg: #171717;
--user-bubble: #303030;
} }
} }
html[data-theme="light"] { html[data-theme="light"] {
--bg: #ffffff; --panel: #ffffff; --text: #1f2328; --muted: #6b7280; --border: #e5e7eb; --soft: #f7f7f8; --code: #0f172a; --code-bg: #f4f4f5; --bg: #ffffff; --panel: #ffffff; --text: #1f2328; --muted: #6b7280; --border: #e5e7eb; --soft: #f7f7f8; --code: #0f172a; --code-bg: #f4f4f5; --user-bubble: #f4f4f4;
} }
html[data-theme="dark"] { html[data-theme="dark"] {
--bg: #212121; --panel: #212121; --text: #ececec; --muted: #a8a8a8; --border: #3f3f46; --soft: #2f2f2f; --code: #e5e7eb; --code-bg: #171717; --bg: #212121; --panel: #212121; --text: #ececec; --muted: #a8a8a8; --border: #3f3f46; --soft: #2f2f2f; --code: #e5e7eb; --code-bg: #171717; --user-bubble: #303030;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body, .topbar, .message.user .content, .content pre, .content :not(pre) > code, .code-block, .code-block .code-header, .content th, .content td, .content blockquote, .content a, .downloads, .icon-button, .shot-menu, .shot-menu button, .toast {
transition: background-color .22s ease, border-color .22s ease, color .22s ease, box-shadow .22s ease;
}
body { margin: 0; background: var(--bg); color: var(--text); font-size: 16px; line-height: 1.65; } body { margin: 0; background: var(--bg); color: var(--text); font-size: 16px; line-height: 1.65; }
a { color: inherit; } a { color: inherit; }
.page { min-height: 100vh; } .page { min-height: 100vh; }
.topbar { position: sticky; top: 0; z-index: 2; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(12px); } .topbar { position: sticky; top: 0; z-index: 2; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(12px); }
.topbar-inner { max-width: 880px; margin: 0 auto; padding: 14px 20px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .topbar-inner { max-width: 880px; margin: 0 auto; padding: 14px 20px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.topbar-actions { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.snapshot-label { font-size: 13px; color: var(--muted); white-space: nowrap; } .snapshot-label { font-size: 13px; color: var(--muted); white-space: nowrap; }
.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; }
.title { max-width: 880px; margin: 0 auto; padding: 34px 20px 18px; } .title { max-width: 880px; margin: 0 auto; padding: 34px 20px 18px; }
h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spacing: 0; } h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spacing: 0; }
.conversation { max-width: 880px; margin: 0 auto; padding: 0 20px 36px; } .conversation { max-width: 880px; margin: 0 auto; padding: 4px 20px 36px; }
.message { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 16px; padding: 22px 0; border-top: 1px solid var(--border); } .message { padding: 18px 0; }
.avatar { width: 32px; height: 32px; display: grid; place-items: center; margin-top: 2px; } .message.assistant { display: block; }
.avatar.user { border-radius: 50%; background: var(--soft); color: var(--text); font-weight: 650; font-size: 13px; } .message.user { display: flex; justify-content: flex-end; }
.avatar svg { width: 32px; height: 32px; display: block; } .message.user .content-wrap { max-width: min(72%, 620px); }
.role { color: var(--muted); font-size: 13px; margin-bottom: 4px; } .message.user .content { background: var(--user-bubble); border-radius: 18px; padding: 10px 16px; line-height: 1.55; }
.message.user .content p { margin: 0; }
.avatar { width: 28px; height: 28px; display: grid; place-items: center; margin: 0 0 10px; }
.assistant-icon { width: 28px; height: 28px; display: block; border-radius: 50%; }
.role { display: none; }
.content { min-width: 0; overflow-wrap: anywhere; } .content { min-width: 0; overflow-wrap: anywhere; }
.content p { margin: 0 0 1em; } .content h2, .content h3, .content h4 { margin: 1.35em 0 0.55em; line-height: 1.35; font-weight: 700; }
.content h2 { font-size: 1.28em; }
.content h3 { font-size: 1.12em; }
.content h4 { font-size: 1em; }
.content p { margin: 0 0 0.9em; }
.content p:last-child { margin-bottom: 0; } .content p:last-child { margin-bottom: 0; }
.content pre { margin: 1em 0; overflow: auto; padding: 14px 16px; border-radius: 8px; background: var(--code-bg); color: var(--code); font-size: 14px; line-height: 1.55; } .content ul, .content ol { margin: 0.6em 0 1em; padding-left: 1.45em; }
.content li { margin: 0.3em 0; padding-left: 0.1em; }
.content li > p { margin: 0.2em 0; }
.content li > ul, .content li > ol { margin: 0.35em 0 0.45em; padding-left: 1.25em; }
.content strong { font-weight: 700; }
.content pre { margin: 1em 0; overflow: auto; padding: 14px 16px; border-radius: 8px; background: var(--code-bg); color: var(--code); font-size: 14px; line-height: 1.55; white-space: pre; overflow-wrap: normal; word-break: normal; }
.content code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Noto Sans Mono", monospace; } .content code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Noto Sans Mono", monospace; }
.content :not(pre) > code { background: var(--code-bg); border-radius: 5px; padding: 0.1em 0.32em; } .content :not(pre) > code { background: var(--code-bg); border-radius: 5px; padding: 0.1em 0.32em; }
.code-block { margin: 1em 0; border-radius: 8px; background: var(--code-bg); overflow: hidden; }
.code-block .code-header { min-height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 10px 6px 14px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--border); }
.code-block .code-header span:empty::before { content: "code"; }
.code-block [data-copy-code] { border: 0; background: transparent; color: var(--muted); font: inherit; font-size: 12px; padding: 4px 6px; cursor: pointer; }
.code-block pre { margin: 0; border-radius: 0; background: transparent; }
.content table { width: 100%; border-collapse: collapse; margin: 1em 0; display: block; overflow-x: auto; } .content table { width: 100%; border-collapse: collapse; margin: 1em 0; display: block; overflow-x: auto; }
.content th, .content td { border: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; } .content th, .content td { border: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; }
.content blockquote { border-left: 3px solid var(--border); margin: 1em 0; padding-left: 14px; color: var(--muted); } .content blockquote { border-left: 3px solid var(--border); margin: 1em 0; padding-left: 14px; color: var(--muted); }
.content img { max-width: 100%; height: auto; border-radius: 8px; } .content img { max-width: 100%; height: auto; border-radius: 8px; }
.downloads { border-top: 1px solid var(--border); background: var(--soft); } .content a:not([data-citation]) { color: inherit; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
.downloads-inner { max-width: 880px; margin: 0 auto; padding: 22px 20px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .content a:not([data-citation]):hover { color: color-mix(in srgb, var(--text) 78%, var(--accent) 22%); }
.button, select { min-height: 38px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 8px 12px; font: inherit; font-size: 14px; text-decoration: none; } .external-link-icon { display: inline-block; width: 0.82em; height: 0.82em; margin-left: 0.18em; vertical-align: -0.05em; background: currentColor; opacity: 0.72; -webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox%3D%270%200%2024%2024%27%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%3E%3Cpath%20fill%3D%27black%27%20d%3D%27M14%203h7v7h-2V6.41l-9.29%209.3-1.42-1.42%209.3-9.29H14V3ZM5%205h6v2H7v10h10v-4h2v6H5V5Z%27/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg%20viewBox%3D%270%200%2024%2024%27%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%3E%3Cpath%20fill%3D%27black%27%20d%3D%27M14%203h7v7h-2V6.41l-9.29%209.3-1.42-1.42%209.3-9.29H14V3ZM5%205h6v2H7v10h10v-4h2v6H5V5Z%27/%3E%3C/svg%3E") center / contain no-repeat; }
.button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } .content math { color: inherit; font-family: math, "STIX Two Math", "Cambria Math", "Noto Sans Math", serif; font-size: 1.02em; }
.content > math { display: block; max-width: 100%; margin: 0.95em 0; overflow-x: auto; overflow-y: hidden; }
.content p math, .content li math { vertical-align: -0.08em; }
.content .katex-display { max-width: 100%; padding: 14px 16px; overflow-x: auto; overflow-y: visible; border-radius: 8px; background: var(--soft); }
.content a[data-citation] { display: inline-flex; align-items: center; gap: 5px; max-width: min(100%, 260px); min-height: 24px; margin: 0 2px; padding: 2px 8px 2px 5px; border: 1px solid var(--border); border-radius: 999px; background: var(--soft); color: var(--text); font-size: 0.88em; line-height: 1.25; white-space: nowrap; text-decoration: none; vertical-align: -5px; }
.content a[data-citation]:hover { background: color-mix(in srgb, var(--soft) 82%, var(--text) 18%); }
.content a[data-citation] img { width: 16px; height: 16px; max-width: 16px; max-height: 16px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
.content a[data-citation] span { overflow: hidden; text-overflow: ellipsis; }
.downloads { position: relative; z-index: 3; border-top: 1px solid var(--border); background: var(--soft); }
.downloads-inner { max-width: 880px; margin: 0 auto; padding: 14px 20px; display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; overflow: visible; }
.icon-button { width: 36px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 0; display: inline-grid; place-items: center; flex: 0 0 auto; cursor: pointer; text-decoration: none; }
.icon-button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.icon-button:hover { background: color-mix(in srgb, var(--panel) 88%, var(--text) 12%); }
.shot-control { display: inline-flex; align-items: stretch; flex: 0 0 auto; position: relative; }
.shot-control .shot-main { border-radius: 8px 0 0 8px; border-right: 0; }
.shot-control details { position: static; }
.shot-control summary { list-style: none; border-radius: 0 8px 8px 0; width: 28px; }
.shot-control summary::-webkit-details-marker { display: none; }
.shot-control summary svg { width: 15px; height: 15px; }
.shot-menu { position: absolute; left: 0; bottom: calc(100% + 8px); z-index: 20; min-width: 150px; padding: 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 30px rgba(0,0,0,.12); }
.shot-menu button { width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--text); padding: 8px 10px; font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
.shot-menu button:hover, .shot-menu button[data-current="true"] { background: var(--soft); }
.toast-region { position: fixed; left: 50%; bottom: 22px; z-index: 40; display: grid; justify-items: center; pointer-events: none; transform: translateX(-50%); }
.toast { max-width: min(calc(100vw - 32px), 320px); border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 9px 12px; font-size: 13px; line-height: 1.35; box-shadow: 0 12px 30px rgba(0,0,0,.14); opacity: 0; transform: translateY(8px); transition: opacity .16s ease, transform .16s ease; }
.toast[data-visible="true"] { opacity: 1; transform: translateY(0); }
.jump-controls { position: fixed; right: 18px; bottom: 18px; z-index: 30; display: grid; gap: 8px; }
.jump-controls .icon-button { box-shadow: 0 8px 24px rgba(0,0,0,.12); }
.source { max-width: 880px; margin: 0 auto; padding: 18px 20px 34px; color: var(--muted); font-size: 12px; } .source { max-width: 880px; margin: 0 auto; padding: 18px 20px 34px; color: var(--muted); font-size: 12px; }
.source a { color: var(--muted); } .source a { color: var(--muted); }
.failure { max-width: 720px; margin: 0 auto; padding: 80px 20px; } .failure { max-width: 720px; margin: 0 auto; padding: 80px 20px; }
@@ -87,42 +163,123 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa
@media (max-width: 640px) { @media (max-width: 640px) {
body { font-size: 15px; } body { font-size: 15px; }
.topbar-inner { padding: 12px 16px; } .topbar-inner { padding: 12px 16px; }
.topbar-actions { gap: 6px; }
.title { padding: 26px 16px 12px; } .title { padding: 26px 16px 12px; }
.conversation { padding: 0 16px 28px; } .conversation { padding: 0 16px 28px; }
.message { grid-template-columns: 32px minmax(0, 1fr); gap: 12px; padding: 18px 0; } .message { padding: 16px 0; }
.avatar, .avatar svg { width: 28px; height: 28px; } .message.assistant { display: block; }
.downloads-inner { padding: 18px 16px; align-items: stretch; } .message.user .content-wrap { max-width: 84%; }
.button, select { width: 100%; } .message.user .content { border-radius: 18px; padding: 9px 14px; }
.avatar, .assistant-icon { width: 26px; height: 26px; }
.content ul, .content ol { padding-left: 1.25em; }
.downloads-inner { padding: 12px 16px; }
.jump-controls { right: 12px; bottom: 12px; }
}`; }`;
} }
function initialThemeScript(): string {
return `!function(){var p=new URLSearchParams(location.search),t=p.get("theme");if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=t}();`;
}
function pageScript(slug: string): string { function pageScript(slug: string): string {
return ` return `
(function () { (function () {
function currentVariant() {
var device = matchMedia("(max-width: 640px)").matches ? "mobile" : "desktop";
var theme = document.documentElement.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
return device + "-" + theme;
}
function downloadShot(variant) {
location.href = "screenshots/" + variant + ".png";
}
var systemTheme = matchMedia("(prefers-color-scheme: dark)");
var themeToggle = document.querySelector("[data-theme-toggle]");
var manualTheme = false;
function preferredTheme() {
return systemTheme.matches ? "dark" : "light";
}
function applyTheme(theme, manual) {
document.documentElement.dataset.theme = theme;
if (manual) manualTheme = true;
if (themeToggle) {
themeToggle.setAttribute("aria-label", theme === "dark" ? "Switch to light theme" : "Switch to dark theme");
themeToggle.setAttribute("title", theme === "dark" ? "Switch to light theme" : "Switch to dark theme");
}
}
var toastTimer;
function showToast(message) {
var region = document.querySelector("[data-toast-region]");
if (!region) return;
var toast = region.querySelector("[data-toast]");
if (!toast) {
toast = document.createElement("div");
toast.className = "toast";
toast.dataset.toast = "true";
region.appendChild(toast);
}
toast.textContent = message;
toast.dataset.visible = "true";
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
delete toast.dataset.visible;
}, 1600);
}
async function copyText(text, successMessage) {
try {
await navigator.clipboard.writeText(text);
showToast(successMessage);
} catch (error) {
showToast("Copy failed");
}
}
var params = new URLSearchParams(location.search); var params = new URLSearchParams(location.search);
var theme = params.get("theme"); var theme = params.get("theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme; manualTheme = theme === "light" || theme === "dark";
applyTheme(theme === "light" || theme === "dark" ? theme : preferredTheme(), theme === "light" || theme === "dark");
systemTheme.addEventListener("change", function () {
if (!manualTheme) applyTheme(preferredTheme(), false);
});
if (themeToggle) themeToggle.addEventListener("click", function () {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
});
var copy = document.querySelector("[data-copy]"); var copy = document.querySelector("[data-copy]");
if (copy) copy.addEventListener("click", async function () { if (copy) copy.addEventListener("click", async function () {
await navigator.clipboard.writeText(location.href.replace(/[?#].*$/, "")); await copyText(location.href.replace(/[?#].*$/, ""), "Link copied");
copy.textContent = "Copied"; });
setTimeout(function () { copy.textContent = "Copy link"; }, 1400); var jumpTop = document.querySelector("[data-jump-top]");
if (jumpTop) jumpTop.addEventListener("click", function () {
scrollTo({ top: 0, behavior: "smooth" });
});
var jumpBottom = document.querySelector("[data-jump-bottom]");
if (jumpBottom) jumpBottom.addEventListener("click", function () {
scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth" });
});
document.querySelectorAll("[data-copy-code]").forEach(function (button) {
button.addEventListener("click", async function () {
var block = button.closest(".code-block");
var code = block && block.querySelector("code");
if (!code) return;
await copyText(code.textContent || "", "Code copied");
});
});
var defaultShot = document.querySelector("[data-shot-current]");
if (defaultShot) defaultShot.addEventListener("click", function () {
downloadShot(currentVariant());
});
document.querySelectorAll("[data-shot-variant]").forEach(function (button) {
if (button.dataset.shotVariant === currentVariant()) button.dataset.current = "true";
button.addEventListener("click", function () {
downloadShot(button.dataset.shotVariant);
}); });
var form = document.querySelector("[data-shot-form]");
if (form) form.addEventListener("submit", function (event) {
event.preventDefault();
var device = form.querySelector("[name=device]").value;
var selectedTheme = form.querySelector("[name=theme]").value;
if (selectedTheme === "system") selectedTheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
location.href = "/aishare/${slug}/screenshots/" + device + "-" + selectedTheme + ".png";
}); });
})();`; })();`;
} }
function messageAvatar(role: string): string { function messageAvatar(role: string, chatGptIconSrc?: string): string {
if (role === "assistant") return `<div class="avatar">${chatGptAvatar}</div>`; if (role === "assistant") {
if (role === "user") return `<div class="avatar user">You</div>`; return `<div class="avatar"><img class="assistant-icon" src="${escapeHtml(chatGptIconSrc || fallbackChatGptIconUrl)}" alt="ChatGPT"></div>`;
return `<div class="avatar user">i</div>`; }
return "";
} }
export function renderConversationPage(slug: string, conversation: NormalizedConversation): string { export function renderConversationPage(slug: string, conversation: NormalizedConversation): string {
@@ -130,10 +287,10 @@ export function renderConversationPage(slug: string, conversation: NormalizedCon
const messages = conversation.messages const messages = conversation.messages
.map((message) => ` .map((message) => `
<article class="message ${message.role}"> <article class="message ${message.role}">
${messageAvatar(message.role)} ${messageAvatar(message.role, conversation.chatGptIconSrc)}
<div class="content-wrap"> <div class="content-wrap">
<div class="role">${message.role === "assistant" ? "Assistant" : message.role === "user" ? "You" : "Message"}</div> <div class="role">${message.role === "assistant" ? "Assistant" : message.role === "user" ? "You" : "Message"}</div>
<div class="content">${message.html}</div> <div class="content">${prepareMessageHtml(message.html)}</div>
</div> </div>
</article>`) </article>`)
.join("\n"); .join("\n");
@@ -145,31 +302,43 @@ export function renderConversationPage(slug: string, conversation: NormalizedCon
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<title>${title}</title> <title>${title}</title>
<style>${css()}</style> <script>${initialThemeScript()}</script>
<style>${css(conversation.katexCss || "")}</style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<header class="topbar"><div class="topbar-inner"><strong>AI Share</strong><span class="snapshot-label">ChatGPT snapshot</span></div></header> <header class="topbar"><div class="topbar-inner"><strong>AI Share</strong><div class="topbar-actions"><span class="snapshot-label">ChatGPT snapshot</span><button class="icon-button theme-toggle" type="button" data-theme-toggle aria-label="Switch theme" title="Switch theme"><span class="sun-icon">${icons.sun}</span><span class="moon-icon">${icons.moon}</span></button></div></div></header>
<main> <main>
<section class="title"><h1>${title}</h1></section> <section class="title"><h1>${title}</h1></section>
<section class="conversation">${messages}</section> <section class="conversation">${messages}</section>
</main> </main>
<section class="downloads"> <section class="downloads">
<div class="downloads-inner"> <div class="downloads-inner">
<a class="button primary" href="/aishare/${slug}/download.zip">Download HTML ZIP</a> <a class="icon-button" href="download" aria-label="Download export" title="Download export">${icons.download}</a>
<a class="button" href="/aishare/${slug}/download.html">Download HTML</a> <div class="shot-control">
<form data-shot-form style="display: contents"> <button class="icon-button shot-main" type="button" data-shot-current aria-label="Download screenshot" title="Download screenshot">${icons.image}</button>
<select name="device" aria-label="Screenshot size"><option value="desktop">Desktop</option><option value="mobile">Mobile</option></select> <details>
<select name="theme" aria-label="Screenshot theme"><option value="system">Current theme</option><option value="light">Light</option><option value="dark">Dark</option></select> <summary class="icon-button" aria-label="Choose screenshot variant" title="Choose screenshot variant">${icons.chevron}</summary>
<button class="button" type="submit">Download screenshot</button> <div class="shot-menu">
</form> <button type="button" data-shot-variant="desktop-light">Desktop light</button>
<button class="button" type="button" data-copy>Copy link</button> <button type="button" data-shot-variant="desktop-dark">Desktop dark</button>
<button type="button" data-shot-variant="mobile-light">Mobile light</button>
<button type="button" data-shot-variant="mobile-dark">Mobile dark</button>
</div>
</details>
</div>
<button class="icon-button" type="button" data-copy aria-label="Copy link" title="Copy link">${icons.share}</button>
</div> </div>
</section> </section>
<footer class="source"> <footer class="source">
Captured from ChatGPT on ${escapeHtml(new Date(conversation.capturedAt).toLocaleString())}. Captured from ChatGPT on ${escapeHtml(new Date(conversation.capturedAt).toLocaleString())}.
Source: <a href="${escapeHtml(conversation.sourceUrl)}" rel="nofollow noreferrer">${escapeHtml(conversation.sourceUrl)}</a> Source: <a href="${escapeHtml(conversation.sourceUrl)}" rel="nofollow noreferrer">${escapeHtml(conversation.sourceUrl)}</a>
</footer> </footer>
<div class="jump-controls" aria-label="Page navigation">
<button class="icon-button" type="button" data-jump-top aria-label="Jump to top" title="Jump to top">${icons.arrowUp}</button>
<button class="icon-button" type="button" data-jump-bottom aria-label="Jump to bottom" title="Jump to bottom">${icons.arrowDown}</button>
</div>
<div class="toast-region" data-toast-region aria-live="polite" aria-atomic="true"></div>
</div> </div>
<script>${pageScript(slug)}</script> <script>${pageScript(slug)}</script>
</body> </body>
+146 -27
View File
@@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
import express, { type NextFunction, type Request, type Response } from "express"; import express, { type NextFunction, type Request, type Response } from "express";
import fs from "fs-extra"; import fs from "fs-extra";
import { config } from "./config.js"; import { config } from "./config.js";
@@ -14,14 +15,28 @@ import {
deleteAllArtifactRoots, deleteAllArtifactRoots,
deleteArtifactsFor, deleteArtifactsFor,
screenshotPath, screenshotPath,
selfContainedHtml,
zipArtifact zipArtifact
} from "./artifacts.js"; } from "./artifacts.js";
const app = express(); const app = express();
const retentionOptions = new Set([3, 7, 10, 30]);
const ownerCookieName = "aishare_owner";
app.disable("x-powered-by"); app.disable("x-powered-by");
app.enable("strict routing");
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
function route(pathname = ""): string {
if (!pathname) return config.basePath || "/";
const suffix = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${config.basePath}${suffix}` || "/";
}
function publicPath(slug: string): string {
return `${config.basePath}/${slug}/` || `/${slug}/`;
}
function requireAdmin(req: Request, res: Response, next: NextFunction): void { function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!config.adminToken) { if (!config.adminToken) {
res.status(500).json({ error: "AISHARE_ADMIN_TOKEN is not configured." }); res.status(500).json({ error: "AISHARE_ADMIN_TOKEN is not configured." });
@@ -36,6 +51,45 @@ function requireAdmin(req: Request, res: Response, next: NextFunction): void {
res.status(401).json({ error: "Admin token required." }); 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<ReturnType<typeof store.getCapture>>): boolean {
const ownerToken = (req as Request & { ownerToken?: string }).ownerToken;
return isAdminRequest(req) || Boolean(row.owner_token && row.owner_token === ownerToken);
}
function publicCapture(row: ReturnType<typeof store.getCapture>) { function publicCapture(row: ReturnType<typeof store.getCapture>) {
if (!row) return null; if (!row) return null;
return { return {
@@ -49,7 +103,9 @@ function publicCapture(row: ReturnType<typeof store.getCapture>) {
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
capturedAt: row.captured_at, capturedAt: row.captured_at,
publicUrl: row.slug ? `${config.baseUrl}/${row.slug}` : null retentionDays: row.retention_days,
expiresAt: row.expires_at,
publicUrl: row.slug ? publicPath(row.slug) : null
}; };
} }
@@ -64,21 +120,39 @@ function safeSendFrom(root: string, requested: string, res: Response): void {
res.sendFile(target); res.sendFile(target);
} }
app.use(`${config.basePath}/admin-assets`, express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" })); app.use(route("/assets"), express.static(path.resolve("dist-client/assets"), { immutable: true, maxAge: "1y" }));
app.use(route("/admin-assets"), express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" }));
app.get(`${config.basePath}/admin`, (_req, res) => { app.get(route("/admin"), (_req, res) => {
res.sendFile(path.resolve("dist-client/index.html")); res.redirect(302, route() || "/");
}); });
app.get(`${config.basePath}/api/health`, (_req, res) => { 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 }); res.json({ ok: true });
}); });
app.post(`${config.basePath}/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 { try {
const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || "")); const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || ""));
const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null; const admin = isAdminRequest(req);
const row = store.createCapture({ id: newCaptureId(), sourceUrl, requestedSlug }); 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); captureQueue.enqueue(row.id);
res.status(202).json({ capture: publicCapture(row) }); res.status(202).json({ capture: publicCapture(row) });
} catch (error) { } catch (error) {
@@ -86,30 +160,32 @@ app.post(`${config.basePath}/api/captures`, requireAdmin, (req, res) => {
} }
}); });
app.get(`${config.basePath}/api/captures`, requireAdmin, (req, res) => { app.get(route("/api/captures"), (req, res) => {
const page = Number.parseInt(String(req.query.page || "1"), 10); const page = Number.parseInt(String(req.query.page || "1"), 10);
const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 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({ res.json({
page, page,
pageSize, pageSize,
total: result.total, total: result.total,
isAdmin: admin,
captures: result.rows.map(publicCapture) captures: result.rows.map(publicCapture)
}); });
}); });
app.get(`${config.basePath}/api/captures/:id`, requireAdmin, (req, res) => { app.get(route("/api/captures/:id"), (req, res) => {
const row = store.getCapture(req.params.id); const row = store.getCapture(req.params.id);
if (!row) { if (!row || !canManageCapture(req, row)) {
res.status(404).json({ error: "Capture not found." }); res.status(404).json({ error: "Capture not found." });
return; return;
} }
res.json({ capture: publicCapture(row), errorDetail: row.error_detail, metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null }); res.json({ capture: publicCapture(row), errorDetail: row.error_detail, metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null });
}); });
app.post(`${config.basePath}/api/captures/:id/retry`, requireAdmin, (req, res) => { app.post(route("/api/captures/:id/retry"), (req, res) => {
const row = store.getCapture(req.params.id); const row = store.getCapture(req.params.id);
if (!row) { if (!row || !canManageCapture(req, row)) {
res.status(404).json({ error: "Capture not found." }); res.status(404).json({ error: "Capture not found." });
return; return;
} }
@@ -117,43 +193,44 @@ app.post(`${config.basePath}/api/captures/:id/retry`, requireAdmin, (req, res) =
res.status(409).json({ error: "Successful captures are immutable. Create a new capture instead." }); res.status(409).json({ error: "Successful captures are immutable. Create a new capture instead." });
return; return;
} }
store.updateStatus(row.id, "queued"); store.retryCapture(row.id);
captureQueue.enqueue(row.id); captureQueue.enqueue(row.id);
res.json({ capture: publicCapture(store.getCapture(row.id)) }); res.json({ capture: publicCapture(store.getCapture(row.id)) });
}); });
app.delete(`${config.basePath}/api/captures/:id`, requireAdmin, async (req, res) => { app.delete(route("/api/captures/:id"), async (req, res) => {
const row = store.deleteCapture(req.params.id); const existing = store.getCapture(req.params.id);
if (!row) { if (!existing || !canManageCapture(req, existing)) {
res.status(404).json({ error: "Capture not found." }); res.status(404).json({ error: "Capture not found." });
return; return;
} }
const row = store.deleteCapture(req.params.id)!;
await deleteArtifactsFor(row); await deleteArtifactsFor(row);
res.json({ ok: true }); res.json({ ok: true });
}); });
app.delete(`${config.basePath}/api/captures`, requireAdmin, async (_req, res) => { app.delete(route("/api/captures"), requireAdmin, async (_req, res) => {
store.deleteAllCaptures(); store.deleteAllCaptures();
await deleteAllArtifactRoots(); await deleteAllArtifactRoots();
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get(`${config.basePath}/:slug/assets/*`, (req, res) => { app.get(route("/:slug/assets/*"), (req, res) => {
const wildcard = (req.params as Record<string, string>)[0] || ""; const wildcard = (req.params as Record<string, string>)[0] || "";
safeSendFrom(artifactAssetsDir(req.params.slug), wildcard, res); safeSendFrom(artifactAssetsDir(req.params.slug), wildcard, res);
}); });
app.get(`${config.basePath}/:slug/screenshots/:variant.png`, (req, res) => { app.get(route("/:slug/screenshots/:variant.png"), (req, res) => {
res.sendFile(screenshotPath(req.params.slug, req.params.variant)); res.sendFile(screenshotPath(req.params.slug, req.params.variant));
}); });
app.get(`${config.basePath}/:slug/screenshot.png`, (req, res) => { app.get(route("/:slug/screenshot.png"), (req, res) => {
const device = req.query.device === "mobile" ? "mobile" : "desktop"; const device = req.query.device === "mobile" ? "mobile" : "desktop";
const theme = req.query.theme === "dark" ? "dark" : "light"; const theme = req.query.theme === "dark" ? "dark" : "light";
res.sendFile(screenshotPath(req.params.slug, `${device}-${theme}`)); res.sendFile(screenshotPath(req.params.slug, `${device}-${theme}`));
}); });
app.get(`${config.basePath}/:slug/download.html`, (req, res) => { app.get(route("/:slug/download.html"), (req, res) => {
const row = store.getCaptureBySlug(req.params.slug); const row = store.getCaptureBySlug(req.params.slug);
if (!row) { if (!row) {
res.status(404).send("Not found"); res.status(404).send("Not found");
@@ -162,7 +239,29 @@ app.get(`${config.basePath}/:slug/download.html`, (req, res) => {
res.download(artifactIndexPath(req.params.slug), `${req.params.slug}.html`); res.download(artifactIndexPath(req.params.slug), `${req.params.slug}.html`);
}); });
app.get(`${config.basePath}/:slug/download.zip`, async (req, res, next) => { app.get(route("/:slug/download"), async (req, res, next) => {
try {
const row = store.getCaptureBySlug(req.params.slug);
if (!row) {
res.status(404).send("Not found");
return;
}
try {
const html = await selfContainedHtml(req.params.slug);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.attachment(`${req.params.slug}.html`);
res.send(html);
} catch {
const zipPath = path.join(config.cacheDir, `${req.params.slug}.zip`);
await zipArtifact(req.params.slug, zipPath);
res.download(zipPath, `${req.params.slug}.zip`);
}
} catch (error) {
next(error);
}
});
app.get(route("/:slug/download.zip"), async (req, res, next) => {
try { try {
const row = store.getCaptureBySlug(req.params.slug); const row = store.getCaptureBySlug(req.params.slug);
if (!row) { if (!row) {
@@ -177,7 +276,7 @@ app.get(`${config.basePath}/:slug/download.zip`, async (req, res, next) => {
} }
}); });
app.get(`${config.basePath}/:slug`, async (req, res) => { app.get(route("/:slug/"), async (req, res) => {
const row = store.getCaptureBySlug(req.params.slug); const row = store.getCaptureBySlug(req.params.slug);
if (!row) { if (!row) {
res.status(404).send("Not found"); res.status(404).send("Not found");
@@ -193,10 +292,19 @@ app.get(`${config.basePath}/:slug`, async (req, res) => {
.send(`<!doctype html><meta name="robots" content="noindex"><title>${row.title || "AI Share"}</title><p>${row.error_public || `Capture is ${row.status}.`}</p>`); .send(`<!doctype html><meta name="robots" content="noindex"><title>${row.title || "AI Share"}</title><p>${row.error_public || `Capture is ${row.status}.`}</p>`);
}); });
app.get(config.basePath, (_req, res) => { app.get(route("/:slug"), (req, res) => {
res.redirect(301, `${config.basePath}/admin`); res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`);
}); });
if (config.basePath) {
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) => { app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error); console.error(error);
res.status(500).json({ error: "Internal server error." }); res.status(500).json({ error: "Internal server error." });
@@ -205,7 +313,18 @@ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
await fs.ensureDir(config.htmlDir); await fs.ensureDir(config.htmlDir);
await fs.ensureDir(config.screenshotDir); await fs.ensureDir(config.screenshotDir);
await fs.ensureDir(config.cacheDir); await fs.ensureDir(config.cacheDir);
async function deleteExpiredCaptures(): Promise<void> {
for (const row of store.expiredCaptures()) {
const deleted = store.deleteCapture(row.id);
if (deleted) await deleteArtifactsFor(deleted);
}
}
await deleteExpiredCaptures();
captureQueue.restorePending(); 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", () => { app.listen(config.port, "0.0.0.0", () => {
console.log(`aishare listening on 0.0.0.0:${config.port}`); console.log(`aishare listening on 0.0.0.0:${config.port}`);
+5 -4
View File
@@ -5,12 +5,13 @@ export function slugify(input: string, fallback = "chatgpt-share"): string {
.normalize("NFKD") .normalize("NFKD")
.replace(STOP_CHARS, "") .replace(STOP_CHARS, "")
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "-") .replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-") .replace(/-{2,}/g, "-");
.slice(0, 80);
return base || fallback; const truncated = Array.from(base).slice(0, 80).join("").replace(/-+$/g, "");
return truncated || fallback;
} }
export function displayTitleFromSlug(slug: string): string { export function displayTitleFromSlug(slug: string): string {
+5
View File
@@ -15,6 +15,9 @@ export interface CaptureRow {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
captured_at: string | null; captured_at: string | null;
owner_token: string | null;
retention_days: number;
expires_at: string;
} }
export interface NormalizedConversation { export interface NormalizedConversation {
@@ -22,6 +25,8 @@ export interface NormalizedConversation {
title: string; title: string;
sourceUrl: string; sourceUrl: string;
capturedAt: string; capturedAt: string;
chatGptIconSrc?: string;
katexCss?: string;
messages: NormalizedMessage[]; messages: NormalizedMessage[];
} }
+9 -2
View File
@@ -1,3 +1,10 @@
const legacySharePath = /^\/share\/[^/]+\/?$/;
const postSharePath = /^\/s\/t_[A-Za-z0-9_-]+\/?$/;
export function isChatGptSharePath(pathname: string): boolean {
return legacySharePath.test(pathname) || postSharePath.test(pathname);
}
export function validateChatGptShareUrl(raw: string): string { export function validateChatGptShareUrl(raw: string): string {
let url: URL; let url: URL;
try { try {
@@ -12,8 +19,8 @@ export function validateChatGptShareUrl(raw: string): string {
const host = url.hostname.toLowerCase(); const host = url.hostname.toLowerCase();
const allowedHost = host === "chatgpt.com" || host === "chat.openai.com"; const allowedHost = host === "chatgpt.com" || host === "chat.openai.com";
if (!allowedHost || !url.pathname.startsWith("/share/")) { if (!allowedHost || !isChatGptSharePath(url.pathname)) {
throw new Error("Only public ChatGPT share URLs under /share/ are accepted."); throw new Error("Only public ChatGPT share URLs under /share/ or /s/t_ are accepted.");
} }
url.hash = ""; url.hash = "";
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import test from "node:test";
import { slugify } from "../dist/slug.js";
test("preserves Unicode letters and numbers", () => {
assert.equal(slugify("多喝水减肥效果"), "多喝水减肥效果");
assert.equal(slugify("减肥 Plan 2026"), "减肥-plan-2026");
});
test("normalizes punctuation and Latin accents", () => {
assert.equal(slugify(" Café — healthy choices! "), "cafe-healthy-choices");
});
test("uses the fallback when no letters or numbers remain", () => {
assert.equal(slugify("🎉 !!!"), "chatgpt-share");
assert.equal(slugify("🎉", "capture"), "capture");
});
test("limits slugs to 80 Unicode code points without a trailing separator", () => {
assert.equal(slugify(`${"界".repeat(79)} hello`), "界".repeat(79));
assert.equal(Array.from(slugify("界".repeat(100))).length, 80);
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isChatGptSharePath, validateChatGptShareUrl } from "../dist/url.js";
test("accepts current ChatGPT post share URLs", () => {
const source = "https://chatgpt.com/s/t_6a5f58aa36d08191b4d794e9aac88146";
assert.equal(validateChatGptShareUrl(source), source);
assert.equal(isChatGptSharePath("/s/t_6a5f58aa36d08191b4d794e9aac88146"), true);
});
test("keeps accepting legacy ChatGPT share URLs", () => {
assert.equal(
validateChatGptShareUrl("https://chatgpt.com/share/12345678-abcd-4321-abcd-1234567890ab"),
"https://chatgpt.com/share/12345678-abcd-4321-abcd-1234567890ab"
);
assert.equal(
validateChatGptShareUrl("https://chat.openai.com/share/12345678-abcd-4321-abcd-1234567890ab"),
"https://chat.openai.com/share/12345678-abcd-4321-abcd-1234567890ab"
);
});
test("rejects non-share and deceptive ChatGPT paths", () => {
for (const source of [
"https://chatgpt.com/s/example",
"https://chatgpt.com/sharex/example",
"https://chatgpt.com/share/example/extra",
"https://example.com/s/t_6a5f58aa36d08191b4d794e9aac88146"
]) {
assert.throws(() => validateChatGptShareUrl(source), /Only public ChatGPT share URLs/);
}
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
root: "client", root: "client",
base: "/aishare/admin-assets/", base: "./",
build: { build: {
outDir: "../dist-client", outDir: "../dist-client",
emptyOutDir: true emptyOutDir: true