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
This commit is contained in:
parent
5f10977705
commit
6aa1fa4d84
5 changed files with 127 additions and 3 deletions
|
|
@ -19,6 +19,7 @@ RUN npm install --omit=dev
|
||||||
|
|
||||||
COPY dist/ dist/
|
COPY dist/ dist/
|
||||||
COPY public/ public/
|
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
|
# Create data directory and set ownership to docfast user
|
||||||
RUN mkdir -p /app/data && chown -R docfast:docfast /app
|
RUN mkdir -p /app/data && chown -R docfast:docfast /app
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
/opt/docfast/node_modules/swagger-ui-dist
|
../node_modules/swagger-ui-dist
|
||||||
18
src/index.ts
18
src/index.ts
|
|
@ -9,6 +9,7 @@ import { healthRouter } from "./routes/health.js";
|
||||||
import { signupRouter } from "./routes/signup.js";
|
import { signupRouter } from "./routes/signup.js";
|
||||||
import { recoverRouter } from "./routes/recover.js";
|
import { recoverRouter } from "./routes/recover.js";
|
||||||
import { billingRouter } from "./routes/billing.js";
|
import { billingRouter } from "./routes/billing.js";
|
||||||
|
import { emailChangeRouter } from "./routes/email-change.js";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { usageMiddleware } from "./middleware/usage.js";
|
import { usageMiddleware } from "./middleware/usage.js";
|
||||||
import { getUsageStats } 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
|
// Load API keys from persistent store
|
||||||
loadKeys();
|
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
|
// Differentiated CORS middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const isAuthBillingRoute = req.path.startsWith('/v1/signup') ||
|
const isAuthBillingRoute = req.path.startsWith('/v1/signup') ||
|
||||||
req.path.startsWith('/v1/recover') ||
|
req.path.startsWith('/v1/recover') ||
|
||||||
req.path.startsWith('/v1/billing');
|
req.path.startsWith('/v1/billing') ||
|
||||||
|
req.path.startsWith('/v1/email-change');
|
||||||
|
|
||||||
if (isAuthBillingRoute) {
|
if (isAuthBillingRoute) {
|
||||||
// Auth/billing routes: restrict to docfast.dev
|
// Auth/billing routes: restrict to docfast.dev
|
||||||
|
|
@ -71,6 +84,7 @@ app.use("/health", healthRouter);
|
||||||
app.use("/v1/signup", signupRouter);
|
app.use("/v1/signup", signupRouter);
|
||||||
app.use("/v1/recover", recoverRouter);
|
app.use("/v1/recover", recoverRouter);
|
||||||
app.use("/v1/billing", billingRouter);
|
app.use("/v1/billing", billingRouter);
|
||||||
|
app.use("/v1/email-change", emailChangeRouter);
|
||||||
|
|
||||||
// Authenticated routes
|
// Authenticated routes
|
||||||
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
|
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
|
||||||
|
|
|
||||||
101
src/routes/email-change.ts
Normal file
101
src/routes/email-change.ts
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -118,3 +118,11 @@ export function revokeByCustomer(stripeCustomerId: string): boolean {
|
||||||
export function getAllKeys(): ApiKey[] {
|
export function getAllKeys(): ApiKey[] {
|
||||||
return [...store.keys];
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue