Compare commits

..
10 Commits
19 changed files with 515 additions and 400 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_DATA_DIR=/data
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/*
COPY --from=build /app/dist ./dist
COPY --from=build /app/dist-client ./dist-client
COPY scripts ./scripts
EXPOSE 8080
CMD ["node", "dist/server.js"]
+7 -2
View File
@@ -1,15 +1,16 @@
# 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:
- accepts only public `https://chatgpt.com/share/...` and `https://chat.openai.com/share/...` URLs
- 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>`
- screenshots under `/data/screenshots/<slug>`
- SQLite state in `/data/aishare.db`
- 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
@@ -25,6 +26,8 @@ Open:
- admin: `http://127.0.0.1:8088/aishare/admin`
- 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
```bash
@@ -34,3 +37,5 @@ git remote add origin gitea:cabbage/aishare.git
## Deploy
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).
+15 -2
View File
@@ -20,8 +20,16 @@ let page = 1;
const pageSize = 20;
let polling: number | undefined;
function serviceBaseUrl() {
const url = new URL(location.href);
url.pathname = url.pathname.replace(/\/admin\/?$/, "/");
url.search = "";
url.hash = "";
return url;
}
function api(path: string, options: RequestInit = {}) {
return fetch(`/aishare/api${path}`, {
return fetch(new URL(`api${path}`, serviceBaseUrl()), {
...options,
headers: {
"content-type": "application/json",
@@ -31,6 +39,11 @@ function api(path: string, options: RequestInit = {}) {
});
}
function captureUrl(capture: Capture) {
if (capture.slug) return new URL(`${capture.slug}/`, serviceBaseUrl()).href;
return capture.publicUrl || "";
}
function fmt(value: string | null) {
if (!value) return "";
return new Date(value).toLocaleString();
@@ -184,7 +197,7 @@ async function loadCaptures() {
tbody.innerHTML = captures
.map((capture) => {
const title = capture.title || capture.slug || capture.sourceUrl;
const link = capture.publicUrl || "";
const link = captureUrl(capture);
return `
<tr>
<td>
+1 -1
View File
@@ -5,7 +5,7 @@ services:
restart: unless-stopped
user: "1001:1001"
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_DATA_DIR: /data
AISHARE_PORT: 8080
+25
View File
@@ -17,6 +17,7 @@ cp .env.example .env
```
Edit `/root/compose/aishare/.env` and set `AISHARE_ADMIN_TOKEN`.
Keep `AISHARE_BASE_PATH=/aishare` for the legacy `xcel.me/aishare` endpoint.
Start:
@@ -55,3 +56,27 @@ Reload nginx:
nginx -t
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/`.
+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();
+29
View File
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import path from "node:path";
import fs from "fs-extra";
import archiver from "archiver";
import * as cheerio from "cheerio";
import { config } from "./config.js";
import type { CaptureRow } from "./types.js";
@@ -61,3 +62,31 @@ export async function zipArtifact(slug: string, destination: string): Promise<vo
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";
return "application/octet-stream";
}
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 || src.startsWith("data:") || /^https?:\/\//i.test(src)) continue;
const normalized = path.normalize(src).replace(/^(\.\.(\/|\\|$))+/, "");
if (!normalized.startsWith("assets/")) continue;
const assetPath = path.join(artifactDir(slug), normalized);
const bytes = await fs.readFile(assetPath);
const data = `data:${contentTypeForAsset(assetPath)};base64,${bytes.toString("base64")}`;
$(image).attr("src", data);
}
return $.html();
}
+74 -8
View File
@@ -19,6 +19,8 @@ interface ExtractedConversation {
messages: NormalizedMessage[];
}
const chatGptIconUrl = "https://chatgpt.com/cdn/assets/favicon-l4nq08hd.svg";
function publicErrorFor(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (/captcha|verify|robot/i.test(message)) {
@@ -54,7 +56,7 @@ async function downloadAsset(assetUrl: string, slug: string): Promise<string | n
const filename = safeAssetName(assetUrl, contentType, bytes);
await fs.ensureDir(artifactAssetsDir(slug));
await fs.writeFile(path.join(artifactAssetsDir(slug), filename), bytes);
return `/aishare/${slug}/assets/${filename}`;
return `assets/${filename}`;
}
async function localizeAssets(conversation: NormalizedConversation, slug: string): Promise<{ failures: string[] }> {
@@ -96,6 +98,65 @@ async function localizeAssets(conversation: NormalizedConversation, slug: string
return { failures };
}
function normalizeCopiedHtml(html: string): string {
const $ = cheerio.load(html, {}, false);
$("script, style, iframe, object, embed, form, button, textarea, input, nav").remove();
function escapeCode(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
$("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");
}
});
$("span").each((_, span) => {
const element = $(span);
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").remove();
return $.html().trim();
}
function findChromiumExecutable(): string {
const preferred = chromium.executablePath();
if (nodeFs.existsSync(preferred)) return preferred;
@@ -106,7 +167,10 @@ function findChromiumExecutable(): string {
.filter((entry) => entry.startsWith("chromium_headless_shell-"))
.sort()
.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));
if (headlessShell) return headlessShell;
@@ -253,12 +317,10 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
: index % 2 === 0
? "user"
: "assistant";
const contentRoot =
node.querySelector(".markdown") ||
node.querySelector("[data-message-content]") ||
node;
const markdownRoot = node.querySelector(".markdown") || node.querySelector("[class*=markdown]");
const contentRoot = markdownRoot || node.querySelector("[data-message-content]") || node;
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 };
})
.filter((message) => message.text.length > 0);
@@ -345,10 +407,14 @@ export async function runCapture(row: CaptureRow): Promise<void> {
title: extracted.title || "ChatGPT Share",
sourceUrl: row.source_url,
capturedAt: new Date().toISOString(),
messages: extracted.messages
messages: extracted.messages.map((message) => ({
...message,
html: normalizeCopiedHtml(message.html)
}))
};
store.updateStatus(row.id, "rendering");
conversation.chatGptIconSrc = (await downloadAsset(chatGptIconUrl, slug).catch(() => null)) || chatGptIconUrl;
const assetResult = await localizeAssets(conversation, slug);
await writeConversationArtifact(slug, conversation);
const screenshots = await generateScreenshots(slug);
+7 -3
View File
@@ -2,7 +2,6 @@ import path from "node:path";
export interface AppConfig {
basePath: string;
baseUrl: string;
port: number;
dataDir: string;
htmlDir: string;
@@ -21,11 +20,16 @@ function intEnv(name: string, fallback: number): number {
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");
export const config: AppConfig = {
basePath: "/aishare",
baseUrl: (process.env.AISHARE_BASE_URL || "http://127.0.0.1:8088/aishare").replace(/\/$/, ""),
basePath: basePathEnv(),
port: intEnv("AISHARE_PORT", 8080),
dataDir,
htmlDir: path.join(dataDir, "html"),
+137 -46
View File
@@ -4,11 +4,7 @@ import type { CaptureRow, NormalizedConversation } from "./types.js";
import { artifactAssetsDir, artifactDir, artifactIndexPath } from "./artifacts.js";
import { config } from "./config.js";
const chatGptAvatar = `
<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>`;
const fallbackChatGptIconUrl = "https://chatgpt.com/cdn/assets/favicon-l4nq08hd.svg";
function escapeHtml(value: string): string {
return value
@@ -18,6 +14,13 @@ function escapeHtml(value: string): string {
.replace(/"/g, "&quot;");
}
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>`
};
function css(): string {
return `
:root {
@@ -31,6 +34,7 @@ function css(): string {
--code: #0f172a;
--code-bg: #f4f4f5;
--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;
}
@media (prefers-color-scheme: dark) {
@@ -43,13 +47,14 @@ function css(): string {
--soft: #2f2f2f;
--code: #e5e7eb;
--code-bg: #171717;
--user-bubble: #303030;
}
}
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"] {
--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; }
body { margin: 0; background: var(--bg); color: var(--text); font-size: 16px; line-height: 1.65; }
@@ -60,26 +65,59 @@ a { color: inherit; }
.snapshot-label { font-size: 13px; color: var(--muted); white-space: nowrap; }
.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; }
.conversation { max-width: 880px; margin: 0 auto; padding: 0 20px 36px; }
.message { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 16px; padding: 22px 0; border-top: 1px solid var(--border); }
.avatar { width: 32px; height: 32px; display: grid; place-items: center; margin-top: 2px; }
.avatar.user { border-radius: 50%; background: var(--soft); color: var(--text); font-weight: 650; font-size: 13px; }
.avatar svg { width: 32px; height: 32px; display: block; }
.role { color: var(--muted); font-size: 13px; margin-bottom: 4px; }
.conversation { max-width: 880px; margin: 0 auto; padding: 4px 20px 36px; }
.message { padding: 18px 0; }
.message.assistant { display: block; }
.message.user { display: flex; justify-content: flex-end; }
.message.user .content-wrap { max-width: min(72%, 620px); }
.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 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 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 :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 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 img { max-width: 100%; height: auto; border-radius: 8px; }
.downloads { border-top: 1px solid var(--border); background: var(--soft); }
.downloads-inner { max-width: 880px; margin: 0 auto; padding: 22px 20px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.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; }
.button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.content a[data-citation] { color: var(--muted); font-size: 0.92em; white-space: nowrap; text-decoration-thickness: 1px; text-underline-offset: 2px; }
.content a[data-citation] img { width: 14px; height: 14px; max-width: 14px; max-height: 14px; border-radius: 3px; object-fit: cover; vertical-align: -2px; margin: 0 3px 0 4px; }
.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); }
.source { max-width: 880px; margin: 0 auto; padding: 18px 20px 34px; color: var(--muted); font-size: 12px; }
.source a { color: var(--muted); }
.failure { max-width: 720px; margin: 0 auto; padding: 80px 20px; }
@@ -89,40 +127,86 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa
.topbar-inner { padding: 12px 16px; }
.title { padding: 26px 16px 12px; }
.conversation { padding: 0 16px 28px; }
.message { grid-template-columns: 32px minmax(0, 1fr); gap: 12px; padding: 18px 0; }
.avatar, .avatar svg { width: 28px; height: 28px; }
.downloads-inner { padding: 18px 16px; align-items: stretch; }
.button, select { width: 100%; }
.message { padding: 16px 0; }
.message.assistant { display: block; }
.message.user .content-wrap { max-width: 84%; }
.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; }
}`;
}
function pageScript(slug: string): string {
return `
(function () {
function currentVariant() {
var device = matchMedia("(max-width: 640px)").matches ? "mobile" : "desktop";
var theme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return device + "-" + theme;
}
function downloadShot(variant) {
location.href = "screenshots/" + variant + ".png";
}
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 theme = params.get("theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
var copy = document.querySelector("[data-copy]");
if (copy) copy.addEventListener("click", async function () {
await navigator.clipboard.writeText(location.href.replace(/[?#].*$/, ""));
copy.textContent = "Copied";
setTimeout(function () { copy.textContent = "Copy link"; }, 1400);
await copyText(location.href.replace(/[?#].*$/, ""), "Link copied");
});
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";
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);
});
});
})();`;
}
function messageAvatar(role: string): string {
if (role === "assistant") return `<div class="avatar">${chatGptAvatar}</div>`;
if (role === "user") return `<div class="avatar user">You</div>`;
return `<div class="avatar user">i</div>`;
function messageAvatar(role: string, chatGptIconSrc?: string): string {
if (role === "assistant") {
return `<div class="avatar"><img class="assistant-icon" src="${escapeHtml(chatGptIconSrc || fallbackChatGptIconUrl)}" alt="ChatGPT"></div>`;
}
return "";
}
export function renderConversationPage(slug: string, conversation: NormalizedConversation): string {
@@ -130,7 +214,7 @@ export function renderConversationPage(slug: string, conversation: NormalizedCon
const messages = conversation.messages
.map((message) => `
<article class="message ${message.role}">
${messageAvatar(message.role)}
${messageAvatar(message.role, conversation.chatGptIconSrc)}
<div class="content-wrap">
<div class="role">${message.role === "assistant" ? "Assistant" : message.role === "user" ? "You" : "Message"}</div>
<div class="content">${message.html}</div>
@@ -156,20 +240,27 @@ export function renderConversationPage(slug: string, conversation: NormalizedCon
</main>
<section class="downloads">
<div class="downloads-inner">
<a class="button primary" href="/aishare/${slug}/download.zip">Download HTML ZIP</a>
<a class="button" href="/aishare/${slug}/download.html">Download HTML</a>
<form data-shot-form style="display: contents">
<select name="device" aria-label="Screenshot size"><option value="desktop">Desktop</option><option value="mobile">Mobile</option></select>
<select name="theme" aria-label="Screenshot theme"><option value="system">Current theme</option><option value="light">Light</option><option value="dark">Dark</option></select>
<button class="button" type="submit">Download screenshot</button>
</form>
<button class="button" type="button" data-copy>Copy link</button>
<a class="icon-button" href="download" aria-label="Download export" title="Download export">${icons.download}</a>
<div class="shot-control">
<button class="icon-button shot-main" type="button" data-shot-current aria-label="Download screenshot" title="Download screenshot">${icons.image}</button>
<details>
<summary class="icon-button" aria-label="Choose screenshot variant" title="Choose screenshot variant">${icons.chevron}</summary>
<div class="shot-menu">
<button type="button" data-shot-variant="desktop-light">Desktop light</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>
</section>
<footer class="source">
Captured from ChatGPT on ${escapeHtml(new Date(conversation.capturedAt).toLocaleString())}.
Source: <a href="${escapeHtml(conversation.sourceUrl)}" rel="nofollow noreferrer">${escapeHtml(conversation.sourceUrl)}</a>
</footer>
<div class="toast-region" data-toast-region aria-live="polite" aria-atomic="true"></div>
</div>
<script>${pageScript(slug)}</script>
</body>
+63 -18
View File
@@ -14,14 +14,26 @@ import {
deleteAllArtifactRoots,
deleteArtifactsFor,
screenshotPath,
selfContainedHtml,
zipArtifact
} from "./artifacts.js";
const app = express();
app.disable("x-powered-by");
app.enable("strict routing");
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 {
if (!config.adminToken) {
res.status(500).json({ error: "AISHARE_ADMIN_TOKEN is not configured." });
@@ -49,7 +61,7 @@ function publicCapture(row: ReturnType<typeof store.getCapture>) {
createdAt: row.created_at,
updatedAt: row.updated_at,
capturedAt: row.captured_at,
publicUrl: row.slug ? `${config.baseUrl}/${row.slug}` : null
publicUrl: row.slug ? publicPath(row.slug) : null
};
}
@@ -64,17 +76,18 @@ function safeSendFrom(root: string, requested: string, res: Response): void {
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"));
});
app.get(`${config.basePath}/api/health`, (_req, res) => {
app.get(route("/api/health"), (_req, res) => {
res.json({ ok: true });
});
app.post(`${config.basePath}/api/captures`, requireAdmin, (req, res) => {
app.post(route("/api/captures"), requireAdmin, (req, res) => {
try {
const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || ""));
const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null;
@@ -86,7 +99,7 @@ app.post(`${config.basePath}/api/captures`, requireAdmin, (req, res) => {
}
});
app.get(`${config.basePath}/api/captures`, requireAdmin, (req, res) => {
app.get(route("/api/captures"), requireAdmin, (req, res) => {
const page = Number.parseInt(String(req.query.page || "1"), 10);
const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 10);
const result = store.listCaptures(page, pageSize);
@@ -98,7 +111,7 @@ app.get(`${config.basePath}/api/captures`, requireAdmin, (req, res) => {
});
});
app.get(`${config.basePath}/api/captures/:id`, requireAdmin, (req, res) => {
app.get(route("/api/captures/:id"), requireAdmin, (req, res) => {
const row = store.getCapture(req.params.id);
if (!row) {
res.status(404).json({ error: "Capture not found." });
@@ -107,7 +120,7 @@ app.get(`${config.basePath}/api/captures/:id`, requireAdmin, (req, res) => {
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"), requireAdmin, (req, res) => {
const row = store.getCapture(req.params.id);
if (!row) {
res.status(404).json({ error: "Capture not found." });
@@ -122,7 +135,7 @@ app.post(`${config.basePath}/api/captures/:id/retry`, requireAdmin, (req, res) =
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"), requireAdmin, async (req, res) => {
const row = store.deleteCapture(req.params.id);
if (!row) {
res.status(404).json({ error: "Capture not found." });
@@ -132,28 +145,28 @@ app.delete(`${config.basePath}/api/captures/:id`, requireAdmin, async (req, res)
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();
await deleteAllArtifactRoots();
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] || "";
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));
});
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 theme = req.query.theme === "dark" ? "dark" : "light";
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);
if (!row) {
res.status(404).send("Not found");
@@ -162,7 +175,29 @@ app.get(`${config.basePath}/:slug/download.html`, (req, res) => {
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 {
const row = store.getCaptureBySlug(req.params.slug);
if (!row) {
@@ -177,7 +212,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);
if (!row) {
res.status(404).send("Not found");
@@ -193,10 +228,20 @@ 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>`);
});
app.get(config.basePath, (_req, res) => {
res.redirect(301, `${config.basePath}/admin`);
app.get(route("/:slug"), (req, res) => {
res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`);
});
app.get(route(), (_req, res) => {
res.redirect(301, route("/admin"));
});
if (config.basePath) {
app.get(route("/"), (_req, res) => {
res.redirect(301, route("/admin"));
});
}
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
res.status(500).json({ error: "Internal server error." });
+1
View File
@@ -22,6 +22,7 @@ export interface NormalizedConversation {
title: string;
sourceUrl: string;
capturedAt: string;
chatGptIconSrc?: string;
messages: NormalizedMessage[];
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
export default defineConfig({
root: "client",
base: "/aishare/admin-assets/",
base: "./",
build: {
outDir: "../dist-client",
emptyOutDir: true