Add anonymous captures and retention controls
This commit is contained in:
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user