db397a14bc
Build & Push Docker Image / build (push) Successful in 4m32s
Backend security: - Admin-gate /admin/redundancy (GET+POST) with zod validation and tool existence checks - Restrict CORS to same-origin (plus CORS_ORIGIN allowlist) and SameSite=Lax cookie - Validate returnTo to prevent open redirect in the OIDC flow - Validate/coerce relations body, reject self-relations and non-admin 'recommended' - Add central JSON error middleware (no more Express HTML 500s) - Fail fast at startup when SESSION_SECRET/VOTER_SECRET missing in production Backend correctness: - Stop leaking voterToken in the create-rating response - Allow clearing websiteUrl/iconUrl (nullable in UpdateToolBody, frontend sends null) - Regenerate session after login/callback (session fixation) and add OIDC state check - Block self-demotion and last-admin demotion in user PATCH - Set created_by to NULL on user delete (FK-safe) - Validate cost create/update bodies with zod - Unique index (tool_id, voter_token) + 409 on race duplicate ratings - Clamp audit limit, escape ilike wildcards in search, O(N) analytics queries Frontend: - tools-browse reads and syncs URL query params (fixes home 'View all' links) - Invalidate analytics/top-tools/categories/features caches after mutations - Sync category combobox input when the value changes externally - Hide Write a Review for anonymous users, drop unreachable rating guard
128 lines
3.6 KiB
TypeScript
128 lines
3.6 KiB
TypeScript
import express, { type Express } from "express";
|
|
import { existsSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import pinoHttp from "pino-http";
|
|
import session from "express-session";
|
|
import ConnectPgSimple from "connect-pg-simple";
|
|
import router from "./routes";
|
|
import { logger } from "./lib/logger";
|
|
import "./types/session.d.ts";
|
|
|
|
const PgStore = ConnectPgSimple(session);
|
|
|
|
const isProd = process.env.NODE_ENV === "production";
|
|
|
|
if (isProd && !process.env.SESSION_SECRET) {
|
|
throw new Error("SESSION_SECRET must be set in production");
|
|
}
|
|
if (isProd && !process.env.VOTER_SECRET) {
|
|
throw new Error("VOTER_SECRET must be set in production");
|
|
}
|
|
|
|
const app: Express = express();
|
|
|
|
app.set("trust proxy", 1);
|
|
|
|
app.use(
|
|
pinoHttp({
|
|
logger,
|
|
serializers: {
|
|
req(req) {
|
|
return {
|
|
id: req.id,
|
|
method: req.method,
|
|
url: req.url?.split("?")[0],
|
|
};
|
|
},
|
|
res(res) {
|
|
return {
|
|
statusCode: res.statusCode,
|
|
};
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const allowedOrigins = (process.env.CORS_ORIGIN ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
|
|
app.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
const host = req.headers.host;
|
|
let sameOrigin = false;
|
|
if (origin && host) {
|
|
try {
|
|
sameOrigin = new URL(origin).host === host;
|
|
} catch {
|
|
sameOrigin = false;
|
|
}
|
|
}
|
|
const allow = !origin || sameOrigin || allowedOrigins.includes(origin);
|
|
if (allow) {
|
|
if (origin) res.setHeader("Access-Control-Allow-Origin", origin);
|
|
res.setHeader("Vary", "Origin");
|
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
if (req.method === "OPTIONS") {
|
|
res.sendStatus(204);
|
|
return;
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
app.use(
|
|
session({
|
|
store: new PgStore({
|
|
conString: process.env.DATABASE_URL,
|
|
tableName: "sessions",
|
|
createTableIfMissing: true,
|
|
}),
|
|
secret: process.env.SESSION_SECRET || "dev-secret-change-in-production",
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: isProd,
|
|
httpOnly: true,
|
|
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
|
sameSite: "lax",
|
|
},
|
|
}),
|
|
);
|
|
|
|
app.use("/api", router);
|
|
|
|
// Any unmatched /api route should return a JSON 404 instead of falling
|
|
// through to the SPA catch-all below.
|
|
app.use("/api", (_req, res) => {
|
|
res.status(404).json({ error: "Not found" });
|
|
});
|
|
|
|
// Central JSON error handler (Express 5 forwards rejected async handlers here).
|
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
const statusCode = (err as { status?: unknown })?.status ?? (err as { statusCode?: unknown })?.statusCode;
|
|
const status = typeof statusCode === "number" && statusCode >= 400 && statusCode < 600 ? statusCode : 500;
|
|
if (isProd) {
|
|
logger.error({ err: (err as Error)?.message }, "Unhandled error");
|
|
} else {
|
|
logger.error({ err }, "Unhandled error");
|
|
}
|
|
res.status(status).json({ error: isProd ? "Internal server error" : (err as Error)?.message ?? "Internal server error" });
|
|
});
|
|
|
|
const staticDir = process.env.STATIC_DIR;
|
|
if (staticDir && existsSync(staticDir)) {
|
|
app.use(express.static(staticDir));
|
|
app.get("/*any", (_req, res) => {
|
|
res.sendFile(resolve(staticDir, "index.html"));
|
|
});
|
|
logger.info({ staticDir }, "Serving static files");
|
|
}
|
|
|
|
export default app;
|