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
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:
parent
8a86e34f91
commit
59cc8f3d0e
22 changed files with 166 additions and 61 deletions
14
src/index.ts
14
src/index.ts
|
|
@ -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());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue