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