Session 45: support email, audit fixes (template validation, content-type, admin auth, waitUntil)
All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 2m20s

- Added support@docfast.dev to footer, impressum, terms, landing page, openapi.json
- Fixed audit #6: Template render validates required fields (400 on missing)
- Fixed audit #7: Content-Type check on markdown/URL routes (415)
- Fixed audit #11: /v1/usage and /v1/concurrency now require ADMIN_API_KEY
- Fixed audit Critical #3: URL convert uses domcontentloaded instead of networkidle0
This commit is contained in:
OpenClaw 2026-02-16 19:30:21 +00:00
parent 8a86e34f91
commit 59cc8f3d0e
22 changed files with 166 additions and 61 deletions

View file

@ -103,13 +103,19 @@ app.use("/v1/email-change", emailChangeRouter);
app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
// Admin: usage stats
app.get("/v1/usage", authMiddleware, (req: any, res) => {
// Admin: usage stats (admin key required)
const adminAuth = (req: any, res: any, next: any) => {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; }
if (req.apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; }
next();
};
app.get("/v1/usage", authMiddleware, adminAuth, (req: any, res: any) => {
res.json(getUsageStats(req.apiKeyInfo?.key));
});
// Admin: concurrency stats
app.get("/v1/concurrency", authMiddleware, (_req, res) => {
// Admin: concurrency stats (admin key required)
app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: any, res: any) => {
res.json(getConcurrencyStats());
});

View file

@ -107,6 +107,12 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => {
let slotAcquired = false;
try {
// Reject non-JSON content types
const ct = req.headers["content-type"] || "";
if (!ct.includes("application/json")) {
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
return;
}
const body: ConvertBody =
typeof req.body === "string" ? { markdown: req.body } : req.body;
@ -151,6 +157,12 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => {
let slotAcquired = false;
try {
// Reject non-JSON content types
const ct = req.headers["content-type"] || "";
if (!ct.includes("application/json")) {
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
return;
}
const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string };
if (!body.url) {

View file

@ -3,6 +3,10 @@ import { renderPdf } from "../services/browser.js";
import logger from "../services/logger.js";
import { templates, renderTemplate } from "../services/templates.js";
function sanitizeFilename(name: string): string {
return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200);
}
export const templatesRouter = Router();
// GET /v1/templates — list available templates
@ -27,13 +31,28 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => {
}
const data = req.body.data || req.body;
// Validate required fields
const missingFields = template.fields
.filter((f) => f.required && (data[f.name] === undefined || data[f.name] === null || data[f.name] === ""))
.map((f) => f.name);
if (missingFields.length > 0) {
res.status(400).json({
error: "Missing required fields",
missing: missingFields,
hint: `Required fields for '${id}': ${template.fields.filter((f) => f.required).map((f) => f.name).join(", ")}`,
});
return;
}
const html = renderTemplate(id, data);
const pdf = await renderPdf(html, {
format: data._format || "A4",
margin: data._margin,
});
const filename = data._filename || `${id}.pdf`;
const filename = sanitizeFilename(data._filename || `${id}.pdf`);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);

View file

@ -308,7 +308,7 @@ export async function renderUrlPdf(
const result = await Promise.race([
(async () => {
await page.goto(url, {
waitUntil: (options.waitUntil as any) || "networkidle0",
waitUntil: (options.waitUntil as any) || "domcontentloaded",
timeout: 30_000,
});
const pdf = await page.pdf({