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
+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}`);