Initial aishare service
This commit is contained in:
+212
@@ -0,0 +1,212 @@
|
||||
import path from "node:path";
|
||||
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,
|
||||
zipArtifact
|
||||
} from "./artifacts.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
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 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,
|
||||
publicUrl: row.slug ? `${config.baseUrl}/${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(`${config.basePath}/admin-assets`, express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" }));
|
||||
|
||||
app.get(`${config.basePath}/admin`, (_req, res) => {
|
||||
res.sendFile(path.resolve("dist-client/index.html"));
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/api/health`, (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post(`${config.basePath}/api/captures`, requireAdmin, (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 });
|
||||
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(`${config.basePath}/api/captures`, requireAdmin, (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);
|
||||
res.json({
|
||||
page,
|
||||
pageSize,
|
||||
total: result.total,
|
||||
captures: result.rows.map(publicCapture)
|
||||
});
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/api/captures/:id`, requireAdmin, (req, res) => {
|
||||
const row = store.getCapture(req.params.id);
|
||||
if (!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(`${config.basePath}/api/captures/:id/retry`, requireAdmin, (req, res) => {
|
||||
const row = store.getCapture(req.params.id);
|
||||
if (!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.updateStatus(row.id, "queued");
|
||||
captureQueue.enqueue(row.id);
|
||||
res.json({ capture: publicCapture(store.getCapture(row.id)) });
|
||||
});
|
||||
|
||||
app.delete(`${config.basePath}/api/captures/:id`, requireAdmin, async (req, res) => {
|
||||
const row = store.deleteCapture(req.params.id);
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Capture not found." });
|
||||
return;
|
||||
}
|
||||
await deleteArtifactsFor(row);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete(`${config.basePath}/api/captures`, requireAdmin, async (_req, res) => {
|
||||
store.deleteAllCaptures();
|
||||
await deleteAllArtifactRoots();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/:slug/assets/*`, (req, res) => {
|
||||
const wildcard = (req.params as Record<string, string>)[0] || "";
|
||||
safeSendFrom(artifactAssetsDir(req.params.slug), wildcard, res);
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/:slug/screenshots/:variant.png`, (req, res) => {
|
||||
res.sendFile(screenshotPath(req.params.slug, req.params.variant));
|
||||
});
|
||||
|
||||
app.get(`${config.basePath}/: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(`${config.basePath}/: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(`${config.basePath}/: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(`${config.basePath}/: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(config.basePath, (_req, res) => {
|
||||
res.redirect(301, `${config.basePath}/admin`);
|
||||
});
|
||||
|
||||
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);
|
||||
captureQueue.restorePending();
|
||||
|
||||
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