332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
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";
|
|
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,
|
|
selfContainedHtml,
|
|
zipArtifact
|
|
} 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");
|
|
app.use(express.json({ limit: "1mb" }));
|
|
|
|
function route(pathname = ""): string {
|
|
if (!pathname) return config.basePath || "/";
|
|
const suffix = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
return `${config.basePath}${suffix}` || "/";
|
|
}
|
|
|
|
function publicPath(slug: string): string {
|
|
return `${config.basePath}/${slug}/` || `/${slug}/`;
|
|
}
|
|
|
|
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 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 {
|
|
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,
|
|
retentionDays: row.retention_days,
|
|
expiresAt: row.expires_at,
|
|
publicUrl: row.slug ? publicPath(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(route("/assets"), express.static(path.resolve("dist-client/assets"), { immutable: true, maxAge: "1y" }));
|
|
app.use(route("/admin-assets"), express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" }));
|
|
|
|
app.get(route("/admin"), (_req, res) => {
|
|
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.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 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) {
|
|
res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
});
|
|
|
|
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 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"), (req, res) => {
|
|
const row = store.getCapture(req.params.id);
|
|
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"), (req, res) => {
|
|
const row = store.getCapture(req.params.id);
|
|
if (!row || !canManageCapture(req, 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.retryCapture(row.id);
|
|
captureQueue.enqueue(row.id);
|
|
res.json({ capture: publicCapture(store.getCapture(row.id)) });
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
app.delete(route("/api/captures"), requireAdmin, async (_req, res) => {
|
|
store.deleteAllCaptures();
|
|
await deleteAllArtifactRoots();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get(route("/:slug/assets/*"), (req, res) => {
|
|
const wildcard = (req.params as Record<string, string>)[0] || "";
|
|
safeSendFrom(artifactAssetsDir(req.params.slug), wildcard, res);
|
|
});
|
|
|
|
app.get(route("/:slug/screenshots/:variant.png"), (req, res) => {
|
|
res.sendFile(screenshotPath(req.params.slug, req.params.variant));
|
|
});
|
|
|
|
app.get(route("/: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(route("/: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(route("/:slug/download"), async (req, res, next) => {
|
|
try {
|
|
const row = store.getCaptureBySlug(req.params.slug);
|
|
if (!row) {
|
|
res.status(404).send("Not found");
|
|
return;
|
|
}
|
|
try {
|
|
const html = await selfContainedHtml(req.params.slug);
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.attachment(`${req.params.slug}.html`);
|
|
res.send(html);
|
|
} catch {
|
|
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(route("/: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(route("/: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(route("/:slug"), (req, res) => {
|
|
res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`);
|
|
});
|
|
|
|
if (config.basePath) {
|
|
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) => {
|
|
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);
|
|
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}`);
|
|
});
|