Add anonymous captures and retention controls

This commit is contained in:
Codex
2026-07-09 03:25:44 +00:00
parent 90cf3d415c
commit 3ed933fb35
6 changed files with 418 additions and 328 deletions
+48 -7
View File
@@ -7,6 +7,8 @@ export interface CaptureInsert {
id: string;
sourceUrl: string;
requestedSlug: string | null;
ownerToken: string | null;
retentionDays: number;
}
export class Store {
@@ -42,17 +44,43 @@ export class Store {
CREATE INDEX IF NOT EXISTS idx_captures_status ON captures(status);
CREATE INDEX IF NOT EXISTS idx_captures_created_at ON captures(created_at);
`);
const columns = new Set(
(this.db.prepare("PRAGMA table_info(captures)").all() as Array<{ name: string }>).map((column) => column.name)
);
if (!columns.has("owner_token")) this.db.exec("ALTER TABLE captures ADD COLUMN owner_token TEXT");
if (!columns.has("retention_days")) this.db.exec("ALTER TABLE captures ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 30");
if (!columns.has("expires_at")) this.db.exec("ALTER TABLE captures ADD COLUMN expires_at TEXT");
const missingExpiry = this.db.prepare("SELECT id, created_at, retention_days FROM captures WHERE expires_at IS NULL").all() as Array<{
id: string;
created_at: string;
retention_days: number;
}>;
const setExpiry = this.db.prepare("UPDATE captures SET expires_at = ? WHERE id = ?");
const backfillExpiry = this.db.transaction(() => {
for (const row of missingExpiry) {
const expiresAt = new Date(new Date(row.created_at).getTime() + row.retention_days * 86_400_000).toISOString();
setExpiry.run(expiresAt, row.id);
}
});
backfillExpiry();
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_captures_owner_created ON captures(owner_token, created_at);
CREATE INDEX IF NOT EXISTS idx_captures_expires_at ON captures(expires_at);
`);
}
createCapture(input: CaptureInsert): CaptureRow {
const now = new Date().toISOString();
const expiresAt = new Date(Date.now() + input.retentionDays * 86_400_000).toISOString();
this.db
.prepare(
`INSERT INTO captures
(id, source_url, provider, requested_slug, status, created_at, updated_at)
VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?)`
(id, source_url, provider, requested_slug, status, created_at, updated_at, owner_token, retention_days, expires_at)
VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?, ?, ?, ?)`
)
.run(input.id, input.sourceUrl, input.requestedSlug, now, now);
.run(input.id, input.sourceUrl, input.requestedSlug, now, now, input.ownerToken, input.retentionDays, expiresAt);
return this.getCapture(input.id)!;
}
@@ -65,15 +93,28 @@ export class Store {
}
listCaptures(page: number, pageSize: number): { rows: CaptureRow[]; total: number } {
return this.listCapturesWhere(page, pageSize);
}
listCapturesForOwner(ownerToken: string, page: number, pageSize: number): { rows: CaptureRow[]; total: number } {
return this.listCapturesWhere(page, pageSize, ownerToken);
}
private listCapturesWhere(page: number, pageSize: number, ownerToken?: string): { rows: CaptureRow[]; total: number } {
const limit = Math.min(Math.max(pageSize, 1), 100);
const offset = Math.max(page - 1, 0) * limit;
const rows = this.db
.prepare("SELECT * FROM captures ORDER BY created_at DESC LIMIT ? OFFSET ?")
.all(limit, offset) as CaptureRow[];
const total = (this.db.prepare("SELECT count(*) as count FROM captures").get() as { count: number }).count;
const where = ownerToken === undefined ? "" : " WHERE owner_token = ?";
const params = ownerToken === undefined ? [limit, offset] : [ownerToken, limit, offset];
const rows = this.db.prepare(`SELECT * FROM captures${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params) as CaptureRow[];
const countParams = ownerToken === undefined ? [] : [ownerToken];
const total = (this.db.prepare(`SELECT count(*) as count FROM captures${where}`).get(...countParams) as { count: number }).count;
return { rows, total };
}
expiredCaptures(now = new Date().toISOString()): CaptureRow[] {
return this.db.prepare("SELECT * FROM captures WHERE expires_at <= ?").all(now) as CaptureRow[];
}
queuedCaptures(): CaptureRow[] {
return this.db
.prepare("SELECT * FROM captures WHERE status IN ('queued', 'capturing', 'rendering') ORDER BY created_at ASC")
+93 -19
View File
@@ -1,4 +1,5 @@
import path from "node:path";
import crypto from "node:crypto";
import express, { type NextFunction, type Request, type Response } from "express";
import fs from "fs-extra";
import { config } from "./config.js";
@@ -19,6 +20,8 @@ import {
} from "./artifacts.js";
const app = express();
const retentionOptions = new Set([3, 7, 10, 30]);
const ownerCookieName = "aishare_owner";
app.disable("x-powered-by");
app.enable("strict routing");
@@ -48,6 +51,45 @@ function requireAdmin(req: Request, res: Response, next: NextFunction): void {
res.status(401).json({ error: "Admin token required." });
}
function isAdminRequest(req: Request): boolean {
const header = req.header("authorization") || "";
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : req.header("x-admin-token");
return Boolean(config.adminToken && token && token === config.adminToken);
}
function cookieValue(req: Request, name: string): string | undefined {
const cookie = req.header("cookie") || "";
for (const part of cookie.split(";")) {
const [key, ...value] = part.trim().split("=");
if (key === name) return decodeURIComponent(value.join("="));
}
return undefined;
}
app.use((req, res, next) => {
let ownerToken = cookieValue(req, ownerCookieName);
if (!ownerToken || !/^[A-Za-z0-9_-]{32,128}$/.test(ownerToken)) {
ownerToken = crypto.randomBytes(32).toString("base64url");
const cookiePath = config.basePath || "/";
const secure = req.secure || req.header("x-forwarded-proto") === "https";
res.cookie(ownerCookieName, ownerToken, {
httpOnly: true,
sameSite: "lax",
secure,
path: cookiePath,
maxAge: 365 * 24 * 60 * 60 * 1000
});
}
(req as Request & { ownerToken?: string }).ownerToken = ownerToken;
res.locals.ownerToken = ownerToken;
next();
});
function canManageCapture(req: Request, row: NonNullable<ReturnType<typeof store.getCapture>>): boolean {
const ownerToken = (req as Request & { ownerToken?: string }).ownerToken;
return isAdminRequest(req) || Boolean(row.owner_token && row.owner_token === ownerToken);
}
function publicCapture(row: ReturnType<typeof store.getCapture>) {
if (!row) return null;
return {
@@ -61,6 +103,8 @@ function publicCapture(row: ReturnType<typeof store.getCapture>) {
createdAt: row.created_at,
updatedAt: row.updated_at,
capturedAt: row.captured_at,
retentionDays: row.retention_days,
expiresAt: row.expires_at,
publicUrl: row.slug ? publicPath(row.slug) : null
};
}
@@ -80,18 +124,35 @@ app.use(route("/assets"), express.static(path.resolve("dist-client/assets"), { i
app.use(route("/admin-assets"), express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" }));
app.get(route("/admin"), (_req, res) => {
res.sendFile(path.resolve("dist-client/index.html"));
res.redirect(302, route() || "/");
});
function sendApp(_req: Request, res: Response): void {
res.sendFile(path.resolve("dist-client/index.html"));
}
app.get(route("/api/health"), (_req, res) => {
res.json({ ok: true });
});
app.post(route("/api/captures"), requireAdmin, (req, res) => {
app.get(route("/api/session"), (req, res) => {
res.json({ isAdmin: isAdminRequest(req) });
});
app.post(route("/api/captures"), (req, res) => {
try {
const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || ""));
const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null;
const row = store.createCapture({ id: newCaptureId(), sourceUrl, requestedSlug });
const admin = isAdminRequest(req);
const requestedSlug = admin && req.body?.slug ? slugify(String(req.body.slug)) : null;
const retentionDays = Number(req.body?.retentionDays ?? 3);
if (!retentionOptions.has(retentionDays)) throw new Error("Retention must be 3, 7, 10, or 30 days.");
const row = store.createCapture({
id: newCaptureId(),
sourceUrl,
requestedSlug,
ownerToken: admin ? null : String(res.locals.ownerToken),
retentionDays
});
captureQueue.enqueue(row.id);
res.status(202).json({ capture: publicCapture(row) });
} catch (error) {
@@ -99,30 +160,32 @@ app.post(route("/api/captures"), requireAdmin, (req, res) => {
}
});
app.get(route("/api/captures"), requireAdmin, (req, res) => {
app.get(route("/api/captures"), (req, res) => {
const page = Number.parseInt(String(req.query.page || "1"), 10);
const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 10);
const result = store.listCaptures(page, pageSize);
const admin = isAdminRequest(req);
const result = admin ? store.listCaptures(page, pageSize) : store.listCapturesForOwner(String(res.locals.ownerToken), page, pageSize);
res.json({
page,
pageSize,
total: result.total,
isAdmin: admin,
captures: result.rows.map(publicCapture)
});
});
app.get(route("/api/captures/:id"), requireAdmin, (req, res) => {
app.get(route("/api/captures/:id"), (req, res) => {
const row = store.getCapture(req.params.id);
if (!row) {
if (!row || !canManageCapture(req, row)) {
res.status(404).json({ error: "Capture not found." });
return;
}
res.json({ capture: publicCapture(row), errorDetail: row.error_detail, metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null });
});
app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => {
app.post(route("/api/captures/:id/retry"), (req, res) => {
const row = store.getCapture(req.params.id);
if (!row) {
if (!row || !canManageCapture(req, row)) {
res.status(404).json({ error: "Capture not found." });
return;
}
@@ -135,12 +198,13 @@ app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => {
res.json({ capture: publicCapture(store.getCapture(row.id)) });
});
app.delete(route("/api/captures/:id"), requireAdmin, async (req, res) => {
const row = store.deleteCapture(req.params.id);
if (!row) {
app.delete(route("/api/captures/:id"), async (req, res) => {
const existing = store.getCapture(req.params.id);
if (!existing || !canManageCapture(req, existing)) {
res.status(404).json({ error: "Capture not found." });
return;
}
const row = store.deleteCapture(req.params.id)!;
await deleteArtifactsFor(row);
res.json({ ok: true });
});
@@ -232,14 +296,13 @@ app.get(route("/:slug"), (req, res) => {
res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`);
});
app.get(route(), (_req, res) => {
res.redirect(301, route("/admin"));
});
if (config.basePath) {
app.get(route("/"), (_req, res) => {
res.redirect(301, route("/admin"));
app.get(route(), (_req, res) => {
res.redirect(308, route("/"));
});
app.get(route("/"), sendApp);
} else {
app.get("/", sendApp);
}
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
@@ -250,7 +313,18 @@ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
await fs.ensureDir(config.htmlDir);
await fs.ensureDir(config.screenshotDir);
await fs.ensureDir(config.cacheDir);
async function deleteExpiredCaptures(): Promise<void> {
for (const row of store.expiredCaptures()) {
const deleted = store.deleteCapture(row.id);
if (deleted) await deleteArtifactsFor(deleted);
}
}
await deleteExpiredCaptures();
captureQueue.restorePending();
setInterval(() => {
void deleteExpiredCaptures().catch((error) => console.error("Failed to delete expired captures", error));
}, 60 * 60 * 1000).unref();
app.listen(config.port, "0.0.0.0", () => {
console.log(`aishare listening on 0.0.0.0:${config.port}`);
+3
View File
@@ -15,6 +15,9 @@ export interface CaptureRow {
created_at: string;
updated_at: string;
captured_at: string | null;
owner_token: string | null;
retention_days: number;
expires_at: string;
}
export interface NormalizedConversation {