Initial aishare service
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
AISHARE_BASE_URL=https://xcel.me/aishare
|
||||
AISHARE_ADMIN_TOKEN=change-me
|
||||
AISHARE_DATA_DIR=/data
|
||||
AISHARE_PORT=8080
|
||||
AISHARE_CAPTURE_TIMEOUT_MS=180000
|
||||
AISHARE_MAX_SCREENSHOT_HEIGHT=60000
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-client/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
playwright-report/
|
||||
test-results/
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.45.0-jammy AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json vite.config.ts ./
|
||||
COPY src ./src
|
||||
COPY client ./client
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM mcr.microsoft.com/playwright:v1.45.0-jammy
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV AISHARE_PORT=8080
|
||||
ENV AISHARE_DATA_DIR=/data
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fonts-noto fonts-noto-cjk fonts-noto-color-emoji fonts-thai-tlwg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/dist-client ./dist-client
|
||||
COPY package.json ./
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# AI Share
|
||||
|
||||
`aishare` captures public ChatGPT shared conversations and publishes immutable read-only snapshots at `https://xcel.me/aishare/<slug>`.
|
||||
|
||||
V1 scope:
|
||||
|
||||
- accepts only public `https://chatgpt.com/share/...` and `https://chat.openai.com/share/...` URLs
|
||||
- admin-only capture/delete UI at `/aishare/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
|
||||
|
||||
## Local Development
|
||||
|
||||
Do not install Node dependencies on the host. Use Docker:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- admin: `http://127.0.0.1:8088/aishare/admin`
|
||||
- public snapshots: `http://127.0.0.1:8088/aishare/<slug>`
|
||||
|
||||
## Git Remote
|
||||
|
||||
```bash
|
||||
git remote add origin gitea:cabbage/aishare.git
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
See [docs/deploy-titan.md](docs/deploy-titan.md).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>AI Share Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,249 @@
|
||||
import "./styles.css";
|
||||
|
||||
type Capture = {
|
||||
id: string;
|
||||
sourceUrl: string;
|
||||
slug: string | null;
|
||||
title: string | null;
|
||||
status: "queued" | "capturing" | "rendering" | "ready" | "failed";
|
||||
errorPublic: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
capturedAt: string | null;
|
||||
publicUrl: string | null;
|
||||
};
|
||||
|
||||
const tokenKey = "aishare.adminToken";
|
||||
const app = document.querySelector<HTMLDivElement>("#app")!;
|
||||
let token = localStorage.getItem(tokenKey) || "";
|
||||
let page = 1;
|
||||
const pageSize = 20;
|
||||
let polling: number | undefined;
|
||||
|
||||
function api(path: string, options: RequestInit = {}) {
|
||||
return fetch(`/aishare/api${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(value: string | null) {
|
||||
if (!value) return "";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function statusClass(status: Capture["status"]) {
|
||||
return `pill ${status}`;
|
||||
}
|
||||
|
||||
async function copy(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
app.innerHTML = `
|
||||
<main class="shell">
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>AI Share Admin</h1>
|
||||
<p>Capture public ChatGPT shares as read-only snapshots.</p>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="panel" ${token ? "" : "hidden"}>
|
||||
<form id="capture-form" class="capture-form">
|
||||
<label>
|
||||
ChatGPT share URL
|
||||
<input name="sourceUrl" required inputmode="url" placeholder="https://chatgpt.com/share/..." />
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input name="slug" placeholder="Optional" />
|
||||
</label>
|
||||
<button type="submit">Capture</button>
|
||||
</form>
|
||||
<p id="form-status" class="status-line"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel" ${token ? "" : "hidden"}>
|
||||
<div class="table-head">
|
||||
<h2>Captured Shares</h2>
|
||||
<div class="actions">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="delete-all" class="danger">Delete all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Link</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="captures-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<button id="prev">Previous</button>
|
||||
<span id="page-label"></span>
|
||||
<button id="next">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
document.querySelector("#save-token")?.addEventListener("click", () => {
|
||||
const input = document.querySelector<HTMLInputElement>("#token-input");
|
||||
token = input?.value.trim() || "";
|
||||
if (token) {
|
||||
localStorage.setItem(tokenKey, token);
|
||||
renderShell();
|
||||
void loadCaptures();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#reset-token")?.addEventListener("click", () => {
|
||||
localStorage.removeItem(tokenKey);
|
||||
token = "";
|
||||
if (polling) window.clearInterval(polling);
|
||||
renderShell();
|
||||
});
|
||||
|
||||
document.querySelector("#capture-form")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const data = new FormData(form);
|
||||
const status = document.querySelector("#form-status")!;
|
||||
status.textContent = "Queued";
|
||||
const response = await api("/captures", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sourceUrl: data.get("sourceUrl"),
|
||||
slug: data.get("slug")
|
||||
})
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
status.textContent = body.error || "Capture failed to queue";
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
status.textContent = "Capture queued";
|
||||
await loadCaptures();
|
||||
startPolling();
|
||||
});
|
||||
|
||||
document.querySelector("#refresh")?.addEventListener("click", () => loadCaptures());
|
||||
document.querySelector("#delete-all")?.addEventListener("click", async () => {
|
||||
if (!confirm("Delete all captures and related files?")) return;
|
||||
await api("/captures", { method: "DELETE" });
|
||||
page = 1;
|
||||
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() {
|
||||
if (!token) return;
|
||||
const response = await api(`/captures?page=${page}&pageSize=${pageSize}`);
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem(tokenKey);
|
||||
token = "";
|
||||
renderShell();
|
||||
return;
|
||||
}
|
||||
const body = await response.json();
|
||||
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);
|
||||
document.querySelector("#page-label")!.textContent = `Page ${page} of ${Math.max(1, Math.ceil(total / pageSize))}`;
|
||||
(document.querySelector("#prev") as HTMLButtonElement).disabled = page <= 1;
|
||||
(document.querySelector("#next") as HTMLButtonElement).disabled = page * pageSize >= total;
|
||||
|
||||
if (captures.some((capture) => ["queued", "capturing", "rendering"].includes(capture.status))) startPolling();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (polling) return;
|
||||
polling = window.setInterval(async () => {
|
||||
await loadCaptures();
|
||||
const active = [...document.querySelectorAll(".pill")].some((pill) => ["queued", "capturing", "rendering"].includes(pill.textContent || ""));
|
||||
if (!active && polling) {
|
||||
window.clearInterval(polling);
|
||||
polling = undefined;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
renderShell();
|
||||
void loadCaptures();
|
||||
@@ -0,0 +1,63 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2328;
|
||||
--muted: #6b7280;
|
||||
--border: #d9dee7;
|
||||
--accent: #0f766e;
|
||||
--danger: #b42318;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Arial, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #18181b;
|
||||
--panel: #242428;
|
||||
--text: #f4f4f5;
|
||||
--muted: #a1a1aa;
|
||||
--border: #3f3f46;
|
||||
--accent: #14b8a6;
|
||||
--danger: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; }
|
||||
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) {
|
||||
.shell { padding: 20px 14px 36px; }
|
||||
.header, .table-head, .actions, .pager { align-items: stretch; flex-direction: column; }
|
||||
.capture-form { grid-template-columns: 1fr; }
|
||||
button { width: 100%; }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
aishare:
|
||||
build: .
|
||||
container_name: aishare
|
||||
restart: unless-stopped
|
||||
user: "1001:1001"
|
||||
environment:
|
||||
AISHARE_BASE_URL: ${AISHARE_BASE_URL:-https://xcel.me/aishare}
|
||||
AISHARE_ADMIN_TOKEN: ${AISHARE_ADMIN_TOKEN:?set AISHARE_ADMIN_TOKEN}
|
||||
AISHARE_DATA_DIR: /data
|
||||
AISHARE_PORT: 8080
|
||||
AISHARE_CAPTURE_TIMEOUT_MS: ${AISHARE_CAPTURE_TIMEOUT_MS:-180000}
|
||||
AISHARE_MAX_SCREENSHOT_HEIGHT: ${AISHARE_MAX_SCREENSHOT_HEIGHT:-60000}
|
||||
ports:
|
||||
- "127.0.0.1:8088:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
shm_size: "1gb"
|
||||
@@ -0,0 +1,56 @@
|
||||
# Titan Deployment
|
||||
|
||||
Recommended paths on Titan:
|
||||
|
||||
```text
|
||||
/root/repo/aishare
|
||||
/root/compose/aishare
|
||||
```
|
||||
|
||||
Clone/update the repo:
|
||||
|
||||
```bash
|
||||
ssh titan-reverse
|
||||
mkdir -p /root/repo /root/compose
|
||||
git clone gitea:cabbage/aishare.git /root/repo/aishare
|
||||
cp /root/repo/aishare/compose.yml /root/compose/aishare/compose.yml
|
||||
cp /root/repo/aishare/.env.example /root/compose/aishare/.env
|
||||
```
|
||||
|
||||
Edit `/root/compose/aishare/.env` and set `AISHARE_ADMIN_TOKEN`.
|
||||
|
||||
Start:
|
||||
|
||||
```bash
|
||||
cd /root/compose/aishare
|
||||
docker compose up --build -d
|
||||
docker compose logs --tail=80 aishare
|
||||
```
|
||||
|
||||
Add this location block to the existing `server_name xcel.me` server in `/root/compose/nginx/etc/nginx/sites-available/xcel.me.conf`, before `location /`:
|
||||
|
||||
```nginx
|
||||
location = /aishare {
|
||||
return 301 /aishare/;
|
||||
}
|
||||
|
||||
location ^~ /aishare/ {
|
||||
if ($https != on) {
|
||||
return 307 https://$host$request_uri;
|
||||
}
|
||||
client_max_body_size 20m;
|
||||
proxy_set_header Host $http_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-Real-Ip $remote_addr;
|
||||
proxy_pass http://127.0.0.1:8088;
|
||||
}
|
||||
```
|
||||
|
||||
Reload nginx:
|
||||
|
||||
```bash
|
||||
docker compose -f /root/compose/nginx/compose.yml exec nginx nginx -t
|
||||
docker compose -f /root/compose/nginx/compose.yml exec nginx nginx -s reload
|
||||
```
|
||||
Generated
+4008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "aishare",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc && vite build",
|
||||
"start": "node dist/server.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.45.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.1.2",
|
||||
"cheerio": "1.0.0",
|
||||
"express": "^4.19.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import fs from "fs-extra";
|
||||
import archiver from "archiver";
|
||||
import { config } from "./config.js";
|
||||
import type { CaptureRow } from "./types.js";
|
||||
|
||||
export function artifactDir(slug: string): string {
|
||||
return path.join(config.htmlDir, slug);
|
||||
}
|
||||
|
||||
export function artifactIndexPath(slug: string): string {
|
||||
return path.join(artifactDir(slug), "index.html");
|
||||
}
|
||||
|
||||
export function artifactAssetsDir(slug: string): string {
|
||||
return path.join(artifactDir(slug), "assets");
|
||||
}
|
||||
|
||||
export function screenshotsDir(slug: string): string {
|
||||
return path.join(config.screenshotDir, slug);
|
||||
}
|
||||
|
||||
export function screenshotPath(slug: string, variant: string): string {
|
||||
return path.join(screenshotsDir(slug), `${variant}.png`);
|
||||
}
|
||||
|
||||
export function safeAssetName(url: string, contentType: string | null, bytes: Buffer): string {
|
||||
const hash = crypto.createHash("sha256").update(url).update(bytes).digest("hex").slice(0, 20);
|
||||
const extFromType =
|
||||
contentType?.includes("jpeg") ? ".jpg" :
|
||||
contentType?.includes("png") ? ".png" :
|
||||
contentType?.includes("webp") ? ".webp" :
|
||||
contentType?.includes("gif") ? ".gif" :
|
||||
contentType?.includes("svg") ? ".svg" :
|
||||
path.extname(new URL(url).pathname).slice(0, 8) || ".bin";
|
||||
return `${hash}${extFromType}`;
|
||||
}
|
||||
|
||||
export async function deleteArtifactsFor(row: CaptureRow): Promise<void> {
|
||||
if (!row.slug) return;
|
||||
await fs.remove(artifactDir(row.slug));
|
||||
await fs.remove(screenshotsDir(row.slug));
|
||||
}
|
||||
|
||||
export async function deleteAllArtifactRoots(): Promise<void> {
|
||||
await fs.emptyDir(config.htmlDir);
|
||||
await fs.emptyDir(config.screenshotDir);
|
||||
await fs.emptyDir(config.cacheDir);
|
||||
}
|
||||
|
||||
export async function zipArtifact(slug: string, destination: string): Promise<void> {
|
||||
await fs.ensureDir(path.dirname(destination));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const output = fs.createWriteStream(destination);
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
output.on("close", resolve);
|
||||
archive.on("error", reject);
|
||||
archive.pipe(output);
|
||||
archive.directory(artifactDir(slug), false);
|
||||
archive.finalize().catch(reject);
|
||||
});
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
import path from "node:path";
|
||||
import fs from "fs-extra";
|
||||
import * as cheerio from "cheerio";
|
||||
import { chromium, type Browser } from "@playwright/test";
|
||||
import { nanoid } from "nanoid";
|
||||
import { store } from "./db.js";
|
||||
import { config } from "./config.js";
|
||||
import { artifactAssetsDir, artifactIndexPath, safeAssetName, screenshotPath, screenshotsDir } from "./artifacts.js";
|
||||
import { slugify } from "./slug.js";
|
||||
import { writeConversationArtifact, writeFailureArtifact } from "./render.js";
|
||||
import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js";
|
||||
|
||||
interface ExtractedConversation {
|
||||
title: string;
|
||||
messages: NormalizedMessage[];
|
||||
}
|
||||
|
||||
function publicErrorFor(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/captcha|verify|robot/i.test(message)) {
|
||||
return "ChatGPT asked for verification, so this public share could not be captured automatically.";
|
||||
}
|
||||
if (/timeout/i.test(message)) {
|
||||
return "The public ChatGPT share took too long to load.";
|
||||
}
|
||||
if (/unsupported|message/i.test(message)) {
|
||||
return "This public ChatGPT share could not be converted because its page format is not supported yet.";
|
||||
}
|
||||
return "This public ChatGPT share could not be captured.";
|
||||
}
|
||||
|
||||
function timeoutSignal(ms: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms).unref();
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
async function downloadAsset(assetUrl: string, slug: string): Promise<string | null> {
|
||||
const response = await fetch(assetUrl, {
|
||||
signal: timeoutSignal(20_000),
|
||||
headers: {
|
||||
"user-agent": "aishare/0.1"
|
||||
}
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType?.startsWith("image/")) return null;
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.byteLength > 20 * 1024 * 1024) return null;
|
||||
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}`;
|
||||
}
|
||||
|
||||
async function localizeAssets(conversation: NormalizedConversation, slug: string): Promise<{ failures: string[] }> {
|
||||
const failures: string[] = [];
|
||||
for (const message of conversation.messages) {
|
||||
const $ = cheerio.load(message.html, {}, false);
|
||||
const images = $("img").toArray();
|
||||
for (const image of images) {
|
||||
const current = $(image).attr("src");
|
||||
if (!current) continue;
|
||||
let absolute: string;
|
||||
try {
|
||||
absolute = new URL(current, conversation.sourceUrl).toString();
|
||||
} catch {
|
||||
$(image).removeAttr("src");
|
||||
failures.push(current);
|
||||
continue;
|
||||
}
|
||||
if (!absolute.startsWith("https://")) {
|
||||
$(image).removeAttr("src");
|
||||
failures.push(absolute);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const local = await downloadAsset(absolute, slug);
|
||||
if (local) {
|
||||
$(image).attr("src", local);
|
||||
} else {
|
||||
$(image).removeAttr("src");
|
||||
failures.push(absolute);
|
||||
}
|
||||
} catch {
|
||||
$(image).removeAttr("src");
|
||||
failures.push(absolute);
|
||||
}
|
||||
}
|
||||
message.html = $.html();
|
||||
}
|
||||
return { failures };
|
||||
}
|
||||
|
||||
async function extractConversation(browser: Browser, sourceUrl: string): Promise<ExtractedConversation> {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1360, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: "en-US"
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(config.captureTimeoutMs);
|
||||
await page.goto(sourceUrl, { waitUntil: "domcontentloaded", timeout: config.captureTimeoutMs });
|
||||
await page.waitForLoadState("networkidle", { timeout: Math.min(config.captureTimeoutMs, 60_000) }).catch(() => undefined);
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const extracted = await page.evaluate(() => {
|
||||
function cleanHtml(root: Element): string {
|
||||
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("*").forEach((element) => {
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const keep = ["href", "src", "alt", "title", "colspan", "rowspan"].includes(name);
|
||||
if (name.startsWith("on") || !keep) element.removeAttribute(attr.name);
|
||||
if ((name === "href" || name === "src") && /^javascript:/i.test(attr.value)) element.removeAttribute(attr.name);
|
||||
});
|
||||
});
|
||||
return clone.innerHTML.trim();
|
||||
}
|
||||
|
||||
function textToHtml(text: string): string {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return escaped
|
||||
.split(/\n{2,}/)
|
||||
.map((part) => `<p>${part.replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const title =
|
||||
document.querySelector("h1")?.textContent?.trim() ||
|
||||
document.title.replace(/\s*[|-]\s*ChatGPT\s*$/i, "").trim() ||
|
||||
"ChatGPT Share";
|
||||
|
||||
const roleNodes = Array.from(document.querySelectorAll("[data-message-author-role]"));
|
||||
const articleNodes = roleNodes.length ? [] : Array.from(document.querySelectorAll("main article"));
|
||||
const nodes = roleNodes.length ? roleNodes : articleNodes;
|
||||
const messages = nodes
|
||||
.map((node, index) => {
|
||||
const roleAttr = node.getAttribute("data-message-author-role") || "";
|
||||
const role =
|
||||
roleAttr === "user" || roleAttr === "assistant" || roleAttr === "system"
|
||||
? roleAttr
|
||||
: index % 2 === 0
|
||||
? "user"
|
||||
: "assistant";
|
||||
const contentRoot =
|
||||
node.querySelector(".markdown") ||
|
||||
node.querySelector("[data-message-content]") ||
|
||||
node;
|
||||
const text = (contentRoot.textContent || "").replace(/\s+\n/g, "\n").trim();
|
||||
const html = cleanHtml(contentRoot) || textToHtml(text);
|
||||
return { id: `m-${index + 1}`, role, text, html };
|
||||
})
|
||||
.filter((message) => message.text.length > 0);
|
||||
|
||||
const bodyText = document.body.textContent || "";
|
||||
if (/verify you are human|captcha|unusual activity/i.test(bodyText)) {
|
||||
throw new Error("ChatGPT verification or CAPTCHA page was shown.");
|
||||
}
|
||||
|
||||
return { title, messages };
|
||||
});
|
||||
|
||||
await context.close();
|
||||
|
||||
if (!extracted.messages.length) {
|
||||
throw new Error("Unsupported ChatGPT share page: no conversation messages were found.");
|
||||
}
|
||||
|
||||
return extracted as ExtractedConversation;
|
||||
}
|
||||
|
||||
async function generateScreenshotVariant(slug: string, variant: ScreenshotVariant): Promise<void> {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const isMobile = variant.device === "mobile";
|
||||
const context = await browser.newContext({
|
||||
viewport: isMobile ? { width: 430, height: 932 } : { width: 1360, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: variant.theme,
|
||||
isMobile
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const fileUrl = `file://${artifactIndexPath(slug)}?theme=${variant.theme}`;
|
||||
await page.goto(fileUrl, { waitUntil: "load", timeout: 60_000 });
|
||||
const height = await page.evaluate(() => Math.ceil(document.documentElement.scrollHeight));
|
||||
if (height > config.maxScreenshotHeight) {
|
||||
throw new Error(`Screenshot height ${height}px exceeds limit ${config.maxScreenshotHeight}px.`);
|
||||
}
|
||||
await fs.ensureDir(screenshotsDir(slug));
|
||||
await page.screenshot({
|
||||
path: screenshotPath(slug, `${variant.device}-${variant.theme}`),
|
||||
fullPage: true,
|
||||
type: "png"
|
||||
});
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function generateScreenshots(slug: string): Promise<{ generated: string[]; failed: string[] }> {
|
||||
const variants: ScreenshotVariant[] = [
|
||||
{ device: "desktop", theme: "light" },
|
||||
{ device: "desktop", theme: "dark" },
|
||||
{ device: "mobile", theme: "light" },
|
||||
{ device: "mobile", theme: "dark" }
|
||||
];
|
||||
const generated: string[] = [];
|
||||
const failed: string[] = [];
|
||||
for (const variant of variants) {
|
||||
const name = `${variant.device}-${variant.theme}`;
|
||||
try {
|
||||
await generateScreenshotVariant(slug, variant);
|
||||
generated.push(name);
|
||||
} catch (error) {
|
||||
failed.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return { generated, failed };
|
||||
}
|
||||
|
||||
export async function runCapture(row: CaptureRow): Promise<void> {
|
||||
let browser: Browser | null = null;
|
||||
const fallbackSlug = store.uniqueSlug(slugify(row.requested_slug || `chatgpt-share-${row.id.slice(0, 8)}`));
|
||||
try {
|
||||
store.updateStatus(row.id, "capturing");
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const extracted = await extractConversation(browser, row.source_url);
|
||||
await browser.close();
|
||||
browser = null;
|
||||
|
||||
const slug = store.uniqueSlug(slugify(row.requested_slug || extracted.title || fallbackSlug));
|
||||
const conversation: NormalizedConversation = {
|
||||
provider: "chatgpt",
|
||||
title: extracted.title || "ChatGPT Share",
|
||||
sourceUrl: row.source_url,
|
||||
capturedAt: new Date().toISOString(),
|
||||
messages: extracted.messages
|
||||
};
|
||||
|
||||
store.updateStatus(row.id, "rendering");
|
||||
const assetResult = await localizeAssets(conversation, slug);
|
||||
await writeConversationArtifact(slug, conversation);
|
||||
const screenshots = await generateScreenshots(slug);
|
||||
|
||||
store.completeCapture(row.id, {
|
||||
slug,
|
||||
title: conversation.title,
|
||||
dataJson: JSON.stringify(conversation),
|
||||
metadataJson: JSON.stringify({
|
||||
assetFailures: assetResult.failures,
|
||||
screenshots
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
const current = store.getCapture(row.id) || row;
|
||||
const slug = current.slug || fallbackSlug;
|
||||
store.failCapture(row.id, {
|
||||
slug,
|
||||
title: current.title || "Capture failed",
|
||||
errorPublic: publicErrorFor(error),
|
||||
errorDetail: error instanceof Error ? `${error.message}\n${error.stack || ""}` : String(error),
|
||||
metadataJson: JSON.stringify({ failedAt: new Date().toISOString() })
|
||||
});
|
||||
const failedRow = store.getCapture(row.id);
|
||||
if (failedRow) await writeFailureArtifact(failedRow);
|
||||
}
|
||||
}
|
||||
|
||||
export function newCaptureId(): string {
|
||||
return nanoid(12);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import path from "node:path";
|
||||
|
||||
export interface AppConfig {
|
||||
basePath: string;
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
dataDir: string;
|
||||
htmlDir: string;
|
||||
screenshotDir: string;
|
||||
cacheDir: string;
|
||||
dbPath: string;
|
||||
adminToken: string;
|
||||
captureTimeoutMs: number;
|
||||
maxScreenshotHeight: number;
|
||||
}
|
||||
|
||||
function intEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
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(/\/$/, ""),
|
||||
port: intEnv("AISHARE_PORT", 8080),
|
||||
dataDir,
|
||||
htmlDir: path.join(dataDir, "html"),
|
||||
screenshotDir: path.join(dataDir, "screenshots"),
|
||||
cacheDir: path.join(dataDir, "cache"),
|
||||
dbPath: path.join(dataDir, "aishare.db"),
|
||||
adminToken: process.env.AISHARE_ADMIN_TOKEN || "",
|
||||
captureTimeoutMs: intEnv("AISHARE_CAPTURE_TIMEOUT_MS", 180_000),
|
||||
maxScreenshotHeight: intEnv("AISHARE_MAX_SCREENSHOT_HEIGHT", 60_000)
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs-extra";
|
||||
import { config } from "./config.js";
|
||||
import type { CaptureRow, CaptureStatus } from "./types.js";
|
||||
|
||||
export interface CaptureInsert {
|
||||
id: string;
|
||||
sourceUrl: string;
|
||||
requestedSlug: string | null;
|
||||
}
|
||||
|
||||
export class Store {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor() {
|
||||
fs.ensureDirSync(config.dataDir);
|
||||
this.db = new Database(config.dbPath);
|
||||
this.db.pragma("journal_mode = WAL");
|
||||
this.db.pragma("foreign_keys = ON");
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
migrate(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS captures (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'chatgpt',
|
||||
slug TEXT UNIQUE,
|
||||
requested_slug TEXT,
|
||||
title TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error_public TEXT,
|
||||
error_detail TEXT,
|
||||
data_json TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
captured_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_status ON captures(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_created_at ON captures(created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
createCapture(input: CaptureInsert): CaptureRow {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO captures
|
||||
(id, source_url, provider, requested_slug, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?)`
|
||||
)
|
||||
.run(input.id, input.sourceUrl, input.requestedSlug, now, now);
|
||||
return this.getCapture(input.id)!;
|
||||
}
|
||||
|
||||
getCapture(id: string): CaptureRow | undefined {
|
||||
return this.db.prepare("SELECT * FROM captures WHERE id = ?").get(id) as CaptureRow | undefined;
|
||||
}
|
||||
|
||||
getCaptureBySlug(slug: string): CaptureRow | undefined {
|
||||
return this.db.prepare("SELECT * FROM captures WHERE slug = ?").get(slug) as CaptureRow | undefined;
|
||||
}
|
||||
|
||||
listCaptures(page: number, pageSize: number): { rows: CaptureRow[]; total: number } {
|
||||
const limit = Math.min(Math.max(pageSize, 1), 100);
|
||||
const offset = Math.max(page - 1, 0) * limit;
|
||||
const rows = this.db
|
||||
.prepare("SELECT * FROM captures ORDER BY created_at DESC LIMIT ? OFFSET ?")
|
||||
.all(limit, offset) as CaptureRow[];
|
||||
const total = (this.db.prepare("SELECT count(*) as count FROM captures").get() as { count: number }).count;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
queuedCaptures(): CaptureRow[] {
|
||||
return this.db
|
||||
.prepare("SELECT * FROM captures WHERE status IN ('queued', 'capturing', 'rendering') ORDER BY created_at ASC")
|
||||
.all() as CaptureRow[];
|
||||
}
|
||||
|
||||
updateStatus(id: string, status: CaptureStatus, detail?: { errorPublic?: string; errorDetail?: string }): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE captures
|
||||
SET status = ?, error_public = COALESCE(?, error_public), error_detail = COALESCE(?, error_detail), updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(status, detail?.errorPublic ?? null, detail?.errorDetail ?? null, new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
completeCapture(id: string, input: { slug: string; title: string; dataJson: string; metadataJson: string }): void {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE captures
|
||||
SET slug = ?, title = ?, data_json = ?, metadata_json = ?, status = 'ready',
|
||||
error_public = NULL, error_detail = NULL, updated_at = ?, captured_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(input.slug, input.title, input.dataJson, input.metadataJson, now, now, id);
|
||||
}
|
||||
|
||||
failCapture(id: string, input: { slug: string; title?: string; errorPublic: string; errorDetail: string; metadataJson?: string }): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE captures
|
||||
SET slug = ?, title = COALESCE(?, title), status = 'failed', error_public = ?, error_detail = ?,
|
||||
metadata_json = COALESCE(?, metadata_json), updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(input.slug, input.title ?? null, input.errorPublic, input.errorDetail, input.metadataJson ?? null, new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
deleteCapture(id: string): CaptureRow | undefined {
|
||||
const row = this.getCapture(id);
|
||||
if (!row) return undefined;
|
||||
this.db.prepare("DELETE FROM captures WHERE id = ?").run(id);
|
||||
return row;
|
||||
}
|
||||
|
||||
deleteAllCaptures(): CaptureRow[] {
|
||||
const rows = this.db.prepare("SELECT * FROM captures").all() as CaptureRow[];
|
||||
this.db.prepare("DELETE FROM captures").run();
|
||||
return rows;
|
||||
}
|
||||
|
||||
slugExists(slug: string): boolean {
|
||||
const row = this.db.prepare("SELECT 1 FROM captures WHERE slug = ?").get(slug);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
uniqueSlug(base: string): string {
|
||||
let slug = base;
|
||||
let counter = 2;
|
||||
while (this.slugExists(slug)) {
|
||||
slug = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new Store();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { store } from "./db.js";
|
||||
import { runCapture } from "./capture.js";
|
||||
|
||||
class CaptureQueue {
|
||||
private queue: string[] = [];
|
||||
private running = false;
|
||||
|
||||
enqueue(id: string): void {
|
||||
if (!this.queue.includes(id)) this.queue.push(id);
|
||||
void this.drain();
|
||||
}
|
||||
|
||||
restorePending(): void {
|
||||
for (const row of store.queuedCaptures()) {
|
||||
store.updateStatus(row.id, "queued");
|
||||
this.enqueue(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
while (this.queue.length) {
|
||||
const id = this.queue.shift()!;
|
||||
const row = store.getCapture(id);
|
||||
if (!row || row.status === "ready") continue;
|
||||
await runCapture(row);
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const captureQueue = new CaptureQueue();
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import path from "node:path";
|
||||
import fs from "fs-extra";
|
||||
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>`;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function css(): string {
|
||||
return `
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2328;
|
||||
--muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--soft: #f7f7f8;
|
||||
--code: #0f172a;
|
||||
--code-bg: #f4f4f5;
|
||||
--accent: #10a37f;
|
||||
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) {
|
||||
:root {
|
||||
--bg: #212121;
|
||||
--panel: #212121;
|
||||
--text: #ececec;
|
||||
--muted: #a8a8a8;
|
||||
--border: #3f3f46;
|
||||
--soft: #2f2f2f;
|
||||
--code: #e5e7eb;
|
||||
--code-bg: #171717;
|
||||
}
|
||||
}
|
||||
html[data-theme="light"] {
|
||||
--bg: #ffffff; --panel: #ffffff; --text: #1f2328; --muted: #6b7280; --border: #e5e7eb; --soft: #f7f7f8; --code: #0f172a; --code-bg: #f4f4f5;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--bg: #212121; --panel: #212121; --text: #ececec; --muted: #a8a8a8; --border: #3f3f46; --soft: #2f2f2f; --code: #e5e7eb; --code-bg: #171717;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-size: 16px; line-height: 1.65; }
|
||||
a { color: inherit; }
|
||||
.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-inner { max-width: 880px; margin: 0 auto; padding: 14px 20px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.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; }
|
||||
.content { min-width: 0; overflow-wrap: anywhere; }
|
||||
.content p { margin: 0 0 1em; }
|
||||
.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 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 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; }
|
||||
.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; }
|
||||
.failure h1 { font-size: 32px; }
|
||||
@media (max-width: 640px) {
|
||||
body { font-size: 15px; }
|
||||
.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%; }
|
||||
}`;
|
||||
}
|
||||
|
||||
function pageScript(slug: string): string {
|
||||
return `
|
||||
(function () {
|
||||
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);
|
||||
});
|
||||
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 {
|
||||
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>`;
|
||||
}
|
||||
|
||||
export function renderConversationPage(slug: string, conversation: NormalizedConversation): string {
|
||||
const title = escapeHtml(conversation.title);
|
||||
const messages = conversation.messages
|
||||
.map((message) => `
|
||||
<article class="message ${message.role}">
|
||||
${messageAvatar(message.role)}
|
||||
<div class="content-wrap">
|
||||
<div class="role">${message.role === "assistant" ? "Assistant" : message.role === "user" ? "You" : "Message"}</div>
|
||||
<div class="content">${message.html}</div>
|
||||
</div>
|
||||
</article>`)
|
||||
.join("\n");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>${title}</title>
|
||||
<style>${css()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="topbar"><div class="topbar-inner"><strong>AI Share</strong><span class="snapshot-label">ChatGPT snapshot</span></div></header>
|
||||
<main>
|
||||
<section class="title"><h1>${title}</h1></section>
|
||||
<section class="conversation">${messages}</section>
|
||||
</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>
|
||||
</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>
|
||||
<script>${pageScript(slug)}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function renderFailurePage(row: CaptureRow): string {
|
||||
const title = escapeHtml(row.title || "Capture failed");
|
||||
const reason = escapeHtml(row.error_public || "This ChatGPT share could not be captured.");
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>${title}</title>
|
||||
<style>${css()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="failure">
|
||||
<p class="snapshot-label">AI Share capture</p>
|
||||
<h1>${title}</h1>
|
||||
<p>${reason}</p>
|
||||
</main>
|
||||
<footer class="source">
|
||||
Source: <a href="${escapeHtml(row.source_url)}" rel="nofollow noreferrer">${escapeHtml(row.source_url)}</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function writeConversationArtifact(slug: string, conversation: NormalizedConversation): Promise<void> {
|
||||
await fs.ensureDir(artifactAssetsDir(slug));
|
||||
await fs.writeFile(artifactIndexPath(slug), renderConversationPage(slug, conversation), "utf8");
|
||||
}
|
||||
|
||||
export async function writeFailureArtifact(row: CaptureRow): Promise<void> {
|
||||
if (!row.slug) return;
|
||||
await fs.ensureDir(artifactDir(row.slug));
|
||||
await fs.writeFile(path.join(artifactDir(row.slug), "index.html"), renderFailurePage(row), "utf8");
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import path from "node:path";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import { config } from "./config.js";
|
||||
import { store } from "./db.js";
|
||||
import { captureQueue } from "./queue.js";
|
||||
import { newCaptureId } from "./capture.js";
|
||||
import { validateChatGptShareUrl } from "./url.js";
|
||||
import { slugify } from "./slug.js";
|
||||
import {
|
||||
artifactAssetsDir,
|
||||
artifactDir,
|
||||
artifactIndexPath,
|
||||
deleteAllArtifactRoots,
|
||||
deleteArtifactsFor,
|
||||
screenshotPath,
|
||||
zipArtifact
|
||||
} from "./artifacts.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!config.adminToken) {
|
||||
res.status(500).json({ error: "AISHARE_ADMIN_TOKEN is not configured." });
|
||||
return;
|
||||
}
|
||||
const header = req.header("authorization") || "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : req.header("x-admin-token");
|
||||
if (token && token === config.adminToken) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: "Admin token required." });
|
||||
}
|
||||
|
||||
function publicCapture(row: ReturnType<typeof store.getCapture>) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
sourceUrl: row.source_url,
|
||||
provider: row.provider,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
errorPublic: row.error_public,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
capturedAt: row.captured_at,
|
||||
publicUrl: row.slug ? `${config.baseUrl}/${row.slug}` : null
|
||||
};
|
||||
}
|
||||
|
||||
function safeSendFrom(root: string, requested: string, res: Response): void {
|
||||
const clean = path.normalize(requested).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
const target = path.resolve(root, clean);
|
||||
const resolvedRoot = path.resolve(root);
|
||||
if (!target.startsWith(resolvedRoot)) {
|
||||
res.status(400).send("Invalid path");
|
||||
return;
|
||||
}
|
||||
res.sendFile(target);
|
||||
}
|
||||
|
||||
app.use(`${config.basePath}/admin-assets`, express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" }));
|
||||
|
||||
app.get(`${config.basePath}/admin`, (_req, res) => {
|
||||
res.sendFile(path.resolve("dist-client/index.html"));
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/api/health`, (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post(`${config.basePath}/api/captures`, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || ""));
|
||||
const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null;
|
||||
const row = store.createCapture({ id: newCaptureId(), sourceUrl, requestedSlug });
|
||||
captureQueue.enqueue(row.id);
|
||||
res.status(202).json({ capture: publicCapture(row) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/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);
|
||||
res.json({
|
||||
page,
|
||||
pageSize,
|
||||
total: result.total,
|
||||
captures: result.rows.map(publicCapture)
|
||||
});
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/api/captures/:id`, requireAdmin, (req, res) => {
|
||||
const row = store.getCapture(req.params.id);
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Capture not found." });
|
||||
return;
|
||||
}
|
||||
res.json({ capture: publicCapture(row), errorDetail: row.error_detail, metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null });
|
||||
});
|
||||
|
||||
app.post(`${config.basePath}/api/captures/:id/retry`, requireAdmin, (req, res) => {
|
||||
const row = store.getCapture(req.params.id);
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Capture not found." });
|
||||
return;
|
||||
}
|
||||
if (row.status === "ready") {
|
||||
res.status(409).json({ error: "Successful captures are immutable. Create a new capture instead." });
|
||||
return;
|
||||
}
|
||||
store.updateStatus(row.id, "queued");
|
||||
captureQueue.enqueue(row.id);
|
||||
res.json({ capture: publicCapture(store.getCapture(row.id)) });
|
||||
});
|
||||
|
||||
app.delete(`${config.basePath}/api/captures/:id`, requireAdmin, async (req, res) => {
|
||||
const row = store.deleteCapture(req.params.id);
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Capture not found." });
|
||||
return;
|
||||
}
|
||||
await deleteArtifactsFor(row);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete(`${config.basePath}/api/captures`, requireAdmin, async (_req, res) => {
|
||||
store.deleteAllCaptures();
|
||||
await deleteAllArtifactRoots();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/: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) => {
|
||||
res.sendFile(screenshotPath(req.params.slug, req.params.variant));
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/: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) => {
|
||||
const row = store.getCaptureBySlug(req.params.slug);
|
||||
if (!row) {
|
||||
res.status(404).send("Not found");
|
||||
return;
|
||||
}
|
||||
res.download(artifactIndexPath(req.params.slug), `${req.params.slug}.html`);
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/:slug/download.zip`, async (req, res, next) => {
|
||||
try {
|
||||
const row = store.getCaptureBySlug(req.params.slug);
|
||||
if (!row) {
|
||||
res.status(404).send("Not found");
|
||||
return;
|
||||
}
|
||||
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(`${config.basePath}/:slug`, async (req, res) => {
|
||||
const row = store.getCaptureBySlug(req.params.slug);
|
||||
if (!row) {
|
||||
res.status(404).send("Not found");
|
||||
return;
|
||||
}
|
||||
const index = artifactIndexPath(req.params.slug);
|
||||
if (await fs.pathExists(index)) {
|
||||
res.sendFile(index);
|
||||
return;
|
||||
}
|
||||
res
|
||||
.status(row.status === "failed" ? 500 : 202)
|
||||
.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.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: "Internal server error." });
|
||||
});
|
||||
|
||||
await fs.ensureDir(config.htmlDir);
|
||||
await fs.ensureDir(config.screenshotDir);
|
||||
await fs.ensureDir(config.cacheDir);
|
||||
captureQueue.restorePending();
|
||||
|
||||
app.listen(config.port, "0.0.0.0", () => {
|
||||
console.log(`aishare listening on 0.0.0.0:${config.port}`);
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
const STOP_CHARS = /['"`]/g;
|
||||
|
||||
export function slugify(input: string, fallback = "chatgpt-share"): string {
|
||||
const base = input
|
||||
.normalize("NFKD")
|
||||
.replace(STOP_CHARS, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.slice(0, 80);
|
||||
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
export function displayTitleFromSlug(slug: string): string {
|
||||
return slug
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type CaptureStatus = "queued" | "capturing" | "rendering" | "ready" | "failed";
|
||||
|
||||
export interface CaptureRow {
|
||||
id: string;
|
||||
source_url: string;
|
||||
provider: "chatgpt";
|
||||
slug: string | null;
|
||||
requested_slug: string | null;
|
||||
title: string | null;
|
||||
status: CaptureStatus;
|
||||
error_public: string | null;
|
||||
error_detail: string | null;
|
||||
data_json: string | null;
|
||||
metadata_json: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
captured_at: string | null;
|
||||
}
|
||||
|
||||
export interface NormalizedConversation {
|
||||
provider: "chatgpt";
|
||||
title: string;
|
||||
sourceUrl: string;
|
||||
capturedAt: string;
|
||||
messages: NormalizedMessage[];
|
||||
}
|
||||
|
||||
export interface NormalizedMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system" | "unknown";
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotVariant {
|
||||
device: "desktop" | "mobile";
|
||||
theme: "light" | "dark";
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
export function validateChatGptShareUrl(raw: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error("Enter a valid public ChatGPT share URL.");
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error("Only HTTPS ChatGPT share URLs are accepted.");
|
||||
}
|
||||
|
||||
const host = url.hostname.toLowerCase();
|
||||
const allowedHost = host === "chatgpt.com" || host === "chat.openai.com";
|
||||
if (!allowedHost || !url.pathname.startsWith("/share/")) {
|
||||
throw new Error("Only public ChatGPT share URLs under /share/ are accepted.");
|
||||
}
|
||||
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
root: "client",
|
||||
base: "/aishare/admin-assets/",
|
||||
build: {
|
||||
outDir: "../dist-client",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user