From 6aa1fa4d845823476035e212dae9d0150837b383 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 14 Feb 2026 22:29:56 +0000 Subject: [PATCH] fix: swagger UI symlink, CSP headers, email-change route, updateKeyEmail - Fix swagger-ui symlink in Dockerfile (was pointing to /opt/docfast instead of /app) - Add CSP directives to allow inline scripts/styles and Google Fonts - Add email-change.ts route with rate limiting (3/hr) and verification - Add updateKeyEmail to keys service - Add email-change route to index.ts with CORS support --- Dockerfile | 1 + public/swagger-ui | 2 +- src/index.ts | 18 ++++++- src/routes/email-change.ts | 101 +++++++++++++++++++++++++++++++++++++ src/services/keys.ts | 8 +++ 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/routes/email-change.ts diff --git a/Dockerfile b/Dockerfile index 92909c1..367f83d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN npm install --omit=dev COPY dist/ dist/ COPY public/ public/ +RUN rm -f public/swagger-ui && ln -s /app/node_modules/swagger-ui-dist public/swagger-ui # Create data directory and set ownership to docfast user RUN mkdir -p /app/data && chown -R docfast:docfast /app diff --git a/public/swagger-ui b/public/swagger-ui index 51eba1f..985193e 120000 --- a/public/swagger-ui +++ b/public/swagger-ui @@ -1 +1 @@ -/opt/docfast/node_modules/swagger-ui-dist \ No newline at end of file +../node_modules/swagger-ui-dist \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 005d618..cbdc6e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { healthRouter } from "./routes/health.js"; import { signupRouter } from "./routes/signup.js"; import { recoverRouter } from "./routes/recover.js"; import { billingRouter } from "./routes/billing.js"; +import { emailChangeRouter } from "./routes/email-change.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware } from "./middleware/usage.js"; import { getUsageStats } from "./middleware/usage.js"; @@ -22,13 +23,25 @@ const PORT = parseInt(process.env.PORT || "3100", 10); // Load API keys from persistent store loadKeys(); -app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); +app.use(helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], + imgSrc: ["'self'", "data:"], + fontSrc: ["'self'", "https://fonts.gstatic.com"], + } + } +})); // Differentiated CORS middleware app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || req.path.startsWith('/v1/recover') || - req.path.startsWith('/v1/billing'); + req.path.startsWith('/v1/billing') || + req.path.startsWith('/v1/email-change'); if (isAuthBillingRoute) { // Auth/billing routes: restrict to docfast.dev @@ -71,6 +84,7 @@ app.use("/health", healthRouter); app.use("/v1/signup", signupRouter); app.use("/v1/recover", recoverRouter); app.use("/v1/billing", billingRouter); +app.use("/v1/email-change", emailChangeRouter); // Authenticated routes app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter); diff --git a/src/routes/email-change.ts b/src/routes/email-change.ts new file mode 100644 index 0000000..fd820df --- /dev/null +++ b/src/routes/email-change.ts @@ -0,0 +1,101 @@ +import { Router } from "express"; +import type { Request, Response } from "express"; +import rateLimit from "express-rate-limit"; +import { createPendingVerification, verifyCode } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import { getAllKeys, updateKeyEmail } from "../services/keys.js"; + +const router = Router(); + +const changeLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: "Too many attempts. Please try again in 1 hour." }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Step 1: Request email change — sends verification code to NEW email +router.post("/", changeLimiter, async (req: Request, res: Response) => { + const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; + const newEmail = req.body?.newEmail; + + if (!apiKey || typeof apiKey !== "string") { + res.status(400).json({ error: "API key is required (Authorization header or body)." }); + return; + } + if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + res.status(400).json({ error: "A valid new email address is required." }); + return; + } + + const cleanEmail = newEmail.trim().toLowerCase(); + const keys = getAllKeys(); + const userKey = keys.find((k: any) => k.key === apiKey); + + if (!userKey) { + res.status(401).json({ error: "Invalid API key." }); + return; + } + + const existing = keys.find((k: any) => k.email === cleanEmail); + if (existing) { + res.status(409).json({ error: "This email is already associated with another account." }); + return; + } + + const pending = createPendingVerification(cleanEmail); + (pending as any)._changeContext = { apiKey, newEmail: cleanEmail, oldEmail: userKey.email }; + + sendVerificationEmail(cleanEmail, (pending as any).code).catch((err: Error) => { + console.error(`Failed to send email change verification to ${cleanEmail}:`, err); + }); + + res.json({ status: "verification_sent", message: "Verification code sent to your new email address." }); +}); + +// Step 2: Verify code — updates email +router.post("/verify", changeLimiter, async (req: Request, res: Response) => { + const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; + const { newEmail, code } = req.body || {}; + + if (!apiKey || !newEmail || !code) { + res.status(400).json({ error: "API key, new email, and code are required." }); + return; + } + + const cleanEmail = newEmail.trim().toLowerCase(); + const cleanCode = String(code).trim(); + + const keys = getAllKeys(); + const userKey = keys.find((k: any) => k.key === apiKey); + if (!userKey) { + res.status(401).json({ error: "Invalid API key." }); + return; + } + + const result = verifyCode(cleanEmail, cleanCode); + + switch (result.status) { + case "ok": { + const updated = updateKeyEmail(apiKey, cleanEmail); + if (updated) { + res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail }); + } else { + res.status(500).json({ error: "Failed to update email." }); + } + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please request a new one." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } +}); + +export { router as emailChangeRouter }; diff --git a/src/services/keys.ts b/src/services/keys.ts index 228efd5..d5db146 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -118,3 +118,11 @@ export function revokeByCustomer(stripeCustomerId: string): boolean { export function getAllKeys(): ApiKey[] { return [...store.keys]; } + +export function updateKeyEmail(apiKey: string, newEmail: string): boolean { + const entry = store.keys.find(k => k.key === apiKey); + if (!entry) return false; + entry.email = newEmail; + save(); + return true; +}