ilia b72612fd06 Add Basic Auth gate, job owner context, and multi-tenant job isolation.
- Server: auth routes, owner profile on requests/jobs/pipeline, migrations
- Client: BasicAuthAppGate, SSE/API session handling, profile quick switch
- Tests: tracer-links, ghostwriter request-context mock, pipeline coverage
- Env examples for cron and optional basic auth credentials

Made-with: Cursor
2026-04-20 21:34:42 -04:00

445 lines
13 KiB
TypeScript

/**
* Express app factory (useful for tests).
*/
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, extname, join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { fileURLToPath } from "node:url";
import { unauthorized } from "@infra/errors";
import {
apiErrorHandler,
fail,
legacyApiResponseShim,
notFoundApiHandler,
requestContextMiddleware,
} from "@infra/http";
import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
import {
basicAuthMatchesDecodedUserPass,
isBasicAuthEnabled,
parseBasicAuthCredentials,
} from "@server/infra/basic-auth-credentials";
import { jobOwnerContextMiddleware } from "@server/infra/job-owner-context";
import cors from "cors";
import express from "express";
import { apiRouter } from "./api/index";
import { getDataDir } from "./config/dataDir";
import { isDemoMode } from "./config/demo";
import { resolveTracerRedirect } from "./services/tracer-links";
const __dirname = dirname(fileURLToPath(import.meta.url));
const UMAMI_UPSTREAM_ORIGIN = "https://umami.dakheera47.com";
const UMAMI_PROXY_TIMEOUT_MS = 5_000;
const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
"connection",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
const REQUEST_HEADERS_TO_SKIP = new Set([
"authorization",
"connection",
"content-length",
"cookie",
"host",
"transfer-encoding",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
"x-forwarded-server",
]);
const ALLOWED_UMAMI_PROXY_PATHS = new Set(["/script.js", "/api/send"]);
const ALLOWED_UMAMI_PROXY_METHODS = new Map<string, string[]>([
["/script.js", ["GET", "HEAD"]],
["/api/send", ["POST"]],
]);
function isStatsRoute(path: string): boolean {
return path === "/stats" || path.startsWith("/stats/");
}
function getUmamiUpstreamUrl(originalUrl: string): URL {
const incomingUrl = new URL(originalUrl, "http://localhost");
const upstreamUrl = new URL(UMAMI_UPSTREAM_ORIGIN);
upstreamUrl.pathname = incomingUrl.pathname.replace(/^\/stats/, "") || "/";
upstreamUrl.search = incomingUrl.search;
return upstreamUrl;
}
function isAllowedUmamiProxyPath(pathname: string): boolean {
return ALLOWED_UMAMI_PROXY_PATHS.has(pathname);
}
function getAllowedUmamiMethods(pathname: string): string[] {
return ALLOWED_UMAMI_PROXY_METHODS.get(pathname) ?? [];
}
function isAllowedUmamiMethod(method: string, pathname: string): boolean {
return getAllowedUmamiMethods(pathname).includes(method.toUpperCase());
}
function isUmamiProxyTimeoutError(error: unknown): boolean {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
(error.name === "AbortError" || error.name === "TimeoutError")
) {
return true;
}
return (
error instanceof Error &&
(error.name === "AbortError" || error.name === "TimeoutError")
);
}
function buildUmamiProxyBody(req: express.Request): BodyInit | undefined {
if (req.method === "GET" || req.method === "HEAD") return undefined;
if (Buffer.isBuffer(req.body)) return new Uint8Array(req.body);
if (typeof req.body === "string") return req.body;
if (req.body === undefined || req.body === null) return undefined;
if (
typeof req.body === "object" &&
Object.keys(req.body as Record<string, unknown>).length === 0
) {
return undefined;
}
return JSON.stringify(req.body);
}
function copyUmamiResponseHeaders(
upstreamResponse: Response,
res: express.Response,
): void {
for (const [key, value] of upstreamResponse.headers.entries()) {
if (HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) continue;
res.setHeader(key, value);
}
}
function buildUmamiProxyHeaders(req: express.Request): Headers {
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (!value || REQUEST_HEADERS_TO_SKIP.has(key.toLowerCase())) continue;
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
return headers;
}
export function createBasicAuthGuard() {
function isAuthorized(req: express.Request): boolean {
if (!isBasicAuthEnabled()) return false;
const parsed = parseBasicAuthCredentials(req.headers.authorization);
if (!parsed) return false;
return basicAuthMatchesDecodedUserPass(parsed.user, parsed.pass);
}
function isPublicApiGet(path: string): boolean {
const normalizedPath = path.split("?")[0] || path;
if (normalizedPath === "/api/auth/basic-status") return true;
if (normalizedPath === "/api/demo/info") return true;
if (normalizedPath === "/api/visa-sponsors/status") return true;
if (normalizedPath === "/api/profile/status") return true;
if (normalizedPath === "/api/pipeline/status") return true;
return false;
}
function isPublicReadOnlyRoute(method: string, path: string): boolean {
const normalizedMethod = method.toUpperCase();
const normalizedPath = path.split("?")[0] || path;
if (
normalizedMethod === "POST" &&
normalizedPath === "/api/visa-sponsors/search"
)
return true;
return false;
}
function requiresAuth(method: string, path: string): boolean {
if (isPublicReadOnlyRoute(method, path)) return false;
if (isStatsRoute(path)) return false;
const normalizedPath = path.split("?")[0] || path;
if (
method.toUpperCase() === "GET" &&
normalizedPath === "/api/auth/basic-status"
) {
return false;
}
if (path.startsWith("/api/tracer-links")) {
return method.toUpperCase() !== "OPTIONS";
}
const m = method.toUpperCase();
if (
isBasicAuthEnabled() &&
path.startsWith("/api/") &&
(m === "GET" || m === "HEAD")
) {
if (isPublicApiGet(path)) return false;
return true;
}
return !["GET", "HEAD", "OPTIONS"].includes(m);
}
const middleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (!isBasicAuthEnabled() || !requiresAuth(req.method, req.path))
return next();
if (isAuthorized(req)) return next();
fail(res, unauthorized("Authentication required"));
};
return {
middleware,
isAuthorized,
basicAuthEnabled: isBasicAuthEnabled(),
};
}
export function createApp() {
const app = express();
const authGuard = createBasicAuthGuard();
const corsMiddleware = cors();
const handleTracerRedirect = async (
req: express.Request,
res: express.Response,
slug: string,
route: string,
) => {
try {
const redirect = await resolveTracerRedirect({
token: slug,
requestId:
(res.getHeader("x-request-id") as string | undefined) ?? null,
ip: req.ip ?? null,
userAgent: req.header("user-agent") ?? null,
referrer: req.header("referer") ?? null,
});
if (!redirect) {
logger.warn("Tracer link not found", {
route,
token: slug,
});
res.status(404).type("text/plain; charset=utf-8").send("Not found");
return;
}
logger.info("Tracer link redirected", {
route,
token: slug,
jobId: redirect.jobId,
});
res.set("Cache-Control", "no-store");
res.set("Pragma", "no-cache");
res.set("Expires", "0");
res.redirect(302, redirect.destinationUrl);
} catch (error) {
logger.error("Tracer redirect failed", {
route,
token: slug,
error,
});
res.status(500).type("text/plain; charset=utf-8").send("Internal error");
}
};
app.use((req, res, next) => {
if (isStatsRoute(req.path)) {
next();
return;
}
corsMiddleware(req, res, next);
});
app.use(requestContextMiddleware());
app.use("/stats", express.raw({ limit: "1mb", type: "*/*" }));
app.use(express.json({ limit: "5mb" }));
app.use(legacyApiResponseShim());
// Logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
logger.info("HTTP request completed", {
method: req.method,
path: req.path,
status: res.statusCode,
durationMs: duration,
});
});
next();
});
// Optional Basic Auth for write access (read-only by default)
app.use(authGuard.middleware);
app.use(jobOwnerContextMiddleware());
// API routes
app.use("/api", apiRouter);
app.use(notFoundApiHandler());
app.get("/cv/:slug", async (req, res) => {
const slug = req.params.slug?.trim();
if (!slug) {
res.status(404).type("text/plain; charset=utf-8").send("Not found");
return;
}
await handleTracerRedirect(req, res, slug, "GET /cv/:slug");
});
app.all(/^\/stats(?:\/.*)?$/, async (req, res) => {
const upstreamUrl = getUmamiUpstreamUrl(req.originalUrl);
if (!isAllowedUmamiProxyPath(upstreamUrl.pathname)) {
res.status(404).type("text/plain; charset=utf-8").send("Not found");
return;
}
if (!isAllowedUmamiMethod(req.method, upstreamUrl.pathname)) {
res
.setHeader(
"Allow",
getAllowedUmamiMethods(upstreamUrl.pathname).join(", "),
)
.status(405)
.type("text/plain; charset=utf-8")
.send("Method not allowed");
return;
}
try {
const upstreamResponse = await fetch(upstreamUrl, {
method: req.method,
headers: buildUmamiProxyHeaders(req),
body: buildUmamiProxyBody(req),
redirect: "manual",
signal: AbortSignal.timeout(UMAMI_PROXY_TIMEOUT_MS),
});
res.status(upstreamResponse.status);
copyUmamiResponseHeaders(upstreamResponse, res);
if (req.method === "HEAD") {
res.end();
return;
}
if (!upstreamResponse.body) {
res.end();
return;
}
await pipeline(
Readable.fromWeb(upstreamResponse.body as NodeReadableStream),
res,
);
} catch (error) {
if (isUmamiProxyTimeoutError(error)) {
logger.warn("Umami proxy timed out", {
route: req.path,
method: req.method,
upstreamUrl: upstreamUrl.toString(),
requestId:
(res.getHeader("x-request-id") as string | undefined) ?? undefined,
});
res
.status(504)
.type("text/plain; charset=utf-8")
.send("Upstream timeout");
return;
}
logger.error("Umami proxy failed", {
route: req.path,
method: req.method,
upstreamUrl: upstreamUrl.toString(),
requestId:
(res.getHeader("x-request-id") as string | undefined) ?? undefined,
error: sanitizeUnknown(error),
});
res.status(502).type("text/plain; charset=utf-8").send("Upstream error");
}
});
// Serve static files for generated PDFs
const pdfDir = join(getDataDir(), "pdfs");
if (isDemoMode()) {
const demoPdfPath = join(pdfDir, "demo.pdf");
app.get("/pdfs/*", (_req, res) => {
res.sendFile(demoPdfPath, (error) => {
if (error) res.status(404).end();
});
});
}
app.use("/pdfs", express.static(pdfDir));
// Health check
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Serve client app in production
if (process.env.NODE_ENV === "production") {
const packagedDocsDir = join(__dirname, "../../dist/docs");
const workspaceDocsDir = join(__dirname, "../../../docs-site/build");
const docsDir = existsSync(packagedDocsDir)
? packagedDocsDir
: workspaceDocsDir;
const docsIndexPath = join(docsDir, "index.html");
let cachedDocsIndexHtml: string | null = null;
if (existsSync(docsIndexPath)) {
app.use("/docs", express.static(docsDir));
app.get("/docs/*", async (req, res, next) => {
if (!req.accepts("html")) {
next();
return;
}
if (extname(req.path)) {
next();
return;
}
if (!cachedDocsIndexHtml) {
cachedDocsIndexHtml = await readFile(docsIndexPath, "utf-8");
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send(cachedDocsIndexHtml);
});
}
const clientDir = join(__dirname, "../../dist/client");
app.use(express.static(clientDir));
// SPA fallback
const indexPath = join(clientDir, "index.html");
let cachedIndexHtml: string | null = null;
app.get("*", async (req, res) => {
if (!req.accepts("html")) {
res.status(404).end();
return;
}
if (!cachedIndexHtml) {
cachedIndexHtml = await readFile(indexPath, "utf-8");
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send(cachedIndexHtml);
});
}
app.use(apiErrorHandler);
return app;
}