420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
import { extname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import argon2 from "argon2";
|
|
import cookie from "@fastify/cookie";
|
|
import rateLimit from "@fastify/rate-limit";
|
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
|
import { z } from "zod";
|
|
import type { AppConfig } from "./config.js";
|
|
import { openDatabase, type CompanionDatabase } from "./database.js";
|
|
import { JobRunner } from "./jobs.js";
|
|
import {
|
|
clientAddress,
|
|
CSRF_COOKIE,
|
|
hashToken,
|
|
randomToken,
|
|
safeEqual,
|
|
SESSION_COOKIE
|
|
} from "./security.js";
|
|
import { collectSystemStatus } from "./status.js";
|
|
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
|
import "./types.js";
|
|
|
|
const usernameSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(3, "Username must contain at least 3 characters")
|
|
.max(48, "Username must contain at most 48 characters")
|
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores");
|
|
|
|
const loginCredentialsSchema = z.object({
|
|
username: usernameSchema,
|
|
password: z.string().min(1).max(256)
|
|
});
|
|
|
|
const setupCredentialsSchema = loginCredentialsSchema.extend({
|
|
password: z
|
|
.string()
|
|
.min(8, "Password must contain at least 8 characters")
|
|
.max(256)
|
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
.regex(/[0-9]/, "Password must contain at least one number")
|
|
});
|
|
|
|
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
|
|
type SessionUserRow = { id: number; username: string; role: "admin" };
|
|
|
|
function audit(
|
|
database: CompanionDatabase,
|
|
event: {
|
|
actorUserId?: number;
|
|
action: string;
|
|
result: "success" | "failure";
|
|
ipAddress?: string;
|
|
details?: Record<string, string | number | boolean>;
|
|
}
|
|
): void {
|
|
database
|
|
.prepare(
|
|
`INSERT INTO audit_events
|
|
(actor_user_id, action, result, ip_address, details_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
event.actorUserId ?? null,
|
|
event.action,
|
|
event.result,
|
|
event.ipAddress ?? null,
|
|
JSON.stringify(event.details ?? {}),
|
|
new Date().toISOString()
|
|
);
|
|
}
|
|
|
|
function cookieOptions(config: AppConfig, httpOnly: boolean) {
|
|
return {
|
|
path: "/",
|
|
httpOnly,
|
|
sameSite: "strict" as const,
|
|
secure: config.cookieSecure
|
|
};
|
|
}
|
|
|
|
function requireUser(request: FastifyRequest, reply: FastifyReply): boolean {
|
|
if (request.authUser) return true;
|
|
void reply.code(401).send({ error: { code: "AUTH_REQUIRED", message: "Sign in to continue" } });
|
|
return false;
|
|
}
|
|
|
|
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|
const app = Fastify({
|
|
logger:
|
|
config.nodeEnv === "test"
|
|
? false
|
|
: {
|
|
level: config.nodeEnv === "production" ? "info" : "debug",
|
|
redact: ["req.headers.cookie", "req.headers.authorization", "req.headers.x-csrf-token"]
|
|
}
|
|
});
|
|
const database = openDatabase(config.databasePath);
|
|
const jobRunner = new JobRunner(database, undefined, config.adb);
|
|
|
|
app.decorate("database", database);
|
|
app.decorate("jobRunner", jobRunner);
|
|
app.decorateRequest("authUser", null);
|
|
await app.register(cookie);
|
|
await app.register(rateLimit, { global: false });
|
|
|
|
app.addHook("onClose", async () => {
|
|
jobRunner.close();
|
|
database.close();
|
|
});
|
|
app.addHook("onSend", async (_request, reply) => {
|
|
reply.header("X-Content-Type-Options", "nosniff");
|
|
reply.header("Referrer-Policy", "no-referrer");
|
|
reply.header("X-Frame-Options", "DENY");
|
|
reply.header(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
|
);
|
|
});
|
|
|
|
app.addHook("onRequest", async (request) => {
|
|
request.authUser = null;
|
|
const token = request.cookies[SESSION_COOKIE];
|
|
if (!token) return;
|
|
|
|
const now = new Date().toISOString();
|
|
const user = database
|
|
.prepare(
|
|
`SELECT users.id, users.username, users.role
|
|
FROM sessions JOIN users ON users.id = sessions.user_id
|
|
WHERE sessions.id_hash = ? AND sessions.expires_at > ?`
|
|
)
|
|
.get(hashToken(token), now) as SessionUserRow | undefined;
|
|
if (user) request.authUser = user;
|
|
});
|
|
|
|
app.addHook("preValidation", async (request, reply) => {
|
|
if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) return;
|
|
|
|
const origin = request.headers.origin;
|
|
let sameHostOrigin = false;
|
|
if (origin && request.headers.host) {
|
|
try {
|
|
const parsedOrigin = new URL(origin);
|
|
sameHostOrigin = ["http:", "https:"].includes(parsedOrigin.protocol) && parsedOrigin.host === request.headers.host;
|
|
} catch {
|
|
sameHostOrigin = false;
|
|
}
|
|
}
|
|
if (origin && !sameHostOrigin && !config.allowedOrigins.has(origin)) {
|
|
return reply.code(403).send({ error: { code: "ORIGIN_REJECTED", message: "Request origin is not trusted" } });
|
|
}
|
|
|
|
const cookieToken = request.cookies[CSRF_COOKIE];
|
|
const headerToken = request.headers["x-csrf-token"];
|
|
if (!cookieToken || typeof headerToken !== "string" || !safeEqual(cookieToken, headerToken)) {
|
|
return reply.code(403).send({ error: { code: "CSRF_REJECTED", message: "Refresh the page and try again" } });
|
|
}
|
|
});
|
|
|
|
app.get("/healthz", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
|
|
|
app.get("/api/auth/state", async (request, reply) => {
|
|
let csrfToken = request.cookies[CSRF_COOKIE];
|
|
if (!csrfToken) {
|
|
csrfToken = randomToken();
|
|
reply.setCookie(CSRF_COOKIE, csrfToken, cookieOptions(config, false));
|
|
}
|
|
const row = database.prepare("SELECT COUNT(*) AS count FROM users").get() as { count: number };
|
|
return {
|
|
setupRequired: row.count === 0,
|
|
user: request.authUser,
|
|
csrfToken
|
|
};
|
|
});
|
|
|
|
app.post(
|
|
"/api/auth/setup",
|
|
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
|
async (request, reply) => {
|
|
const parsed = setupCredentialsSchema.safeParse(request.body);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({
|
|
error: { code: "INVALID_CREDENTIALS", message: parsed.error.issues[0]?.message ?? "Invalid account details" }
|
|
});
|
|
}
|
|
|
|
const passwordHash = await argon2.hash(parsed.data.password, { type: argon2.argon2id });
|
|
const createAdmin = database.transaction(() => {
|
|
const row = database.prepare("SELECT COUNT(*) AS count FROM users").get() as { count: number };
|
|
if (row.count !== 0) return null;
|
|
const result = database
|
|
.prepare("INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, 'admin', ?)")
|
|
.run(parsed.data.username, passwordHash, new Date().toISOString());
|
|
return Number(result.lastInsertRowid);
|
|
});
|
|
const userId = createAdmin();
|
|
if (userId === null) {
|
|
audit(database, {
|
|
action: "auth.setup",
|
|
result: "failure",
|
|
ipAddress: clientAddress(request),
|
|
details: { reason: "setup_already_complete" }
|
|
});
|
|
return reply.code(409).send({ error: { code: "SETUP_COMPLETE", message: "Initial setup is already complete" } });
|
|
}
|
|
|
|
audit(database, {
|
|
actorUserId: userId,
|
|
action: "auth.setup",
|
|
result: "success",
|
|
ipAddress: clientAddress(request)
|
|
});
|
|
return reply.code(201).send({ created: true });
|
|
}
|
|
);
|
|
|
|
app.post(
|
|
"/api/auth/login",
|
|
{ config: { rateLimit: { max: 8, timeWindow: "1 minute" } } },
|
|
async (request, reply) => {
|
|
const parsed = loginCredentialsSchema.safeParse(request.body);
|
|
const user = parsed.success
|
|
? (database
|
|
.prepare("SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE")
|
|
.get(parsed.data.username) as UserRow | undefined)
|
|
: undefined;
|
|
const valid = user && parsed.success ? await argon2.verify(user.password_hash, parsed.data.password) : false;
|
|
|
|
if (!user || !valid) {
|
|
audit(database, {
|
|
action: "auth.login",
|
|
result: "failure",
|
|
ipAddress: clientAddress(request),
|
|
details: { reason: "invalid_credentials" }
|
|
});
|
|
return reply.code(401).send({ error: { code: "LOGIN_FAILED", message: "Username or password is incorrect" } });
|
|
}
|
|
|
|
database.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(new Date().toISOString());
|
|
const token = randomToken();
|
|
const expiresAt = new Date(Date.now() + config.sessionTtlMs);
|
|
database
|
|
.prepare("INSERT INTO sessions (id_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
.run(hashToken(token), user.id, expiresAt.toISOString(), new Date().toISOString());
|
|
reply.setCookie(SESSION_COOKIE, token, {
|
|
...cookieOptions(config, true),
|
|
expires: expiresAt
|
|
});
|
|
audit(database, {
|
|
actorUserId: user.id,
|
|
action: "auth.login",
|
|
result: "success",
|
|
ipAddress: clientAddress(request)
|
|
});
|
|
return { user: { id: user.id, username: user.username, role: user.role } };
|
|
}
|
|
);
|
|
|
|
app.post("/api/auth/logout", async (request, reply) => {
|
|
const token = request.cookies[SESSION_COOKIE];
|
|
if (token) database.prepare("DELETE FROM sessions WHERE id_hash = ?").run(hashToken(token));
|
|
if (request.authUser) {
|
|
audit(database, {
|
|
actorUserId: request.authUser.id,
|
|
action: "auth.logout",
|
|
result: "success",
|
|
ipAddress: clientAddress(request)
|
|
});
|
|
}
|
|
reply.clearCookie(SESSION_COOKIE, cookieOptions(config, true));
|
|
return { loggedOut: true };
|
|
});
|
|
|
|
app.get("/api/status", async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
return collectSystemStatus(config.adb);
|
|
});
|
|
|
|
app.get("/api/jobs", async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
return { jobs: jobRunner.listJobs() };
|
|
});
|
|
|
|
app.get("/api/job-runs", async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
return { runs: jobRunner.listRuns() };
|
|
});
|
|
|
|
app.get<{ Params: { id: string } }>("/api/job-runs/:id", async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
const run = jobRunner.getRun(request.params.id);
|
|
if (!run) return reply.code(404).send({ error: { code: "RUN_NOT_FOUND", message: "Job run not found" } });
|
|
return run;
|
|
});
|
|
|
|
app.post<{ Params: { jobId: string } }>("/api/jobs/:jobId/runs", async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
if (!jobRunner.has(request.params.jobId)) {
|
|
return reply.code(404).send({ error: { code: "JOB_NOT_FOUND", message: "Job is not available" } });
|
|
}
|
|
const run = jobRunner.enqueue(request.params.jobId, request.authUser!.id);
|
|
audit(database, {
|
|
actorUserId: request.authUser!.id,
|
|
action: "job.enqueue",
|
|
result: "success",
|
|
ipAddress: clientAddress(request),
|
|
details: { jobId: request.params.jobId, runId: run.id }
|
|
});
|
|
return reply.code(202).send(run);
|
|
});
|
|
|
|
app.get("/api/system/update", async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
return readUpdateStatus(config);
|
|
});
|
|
|
|
app.post(
|
|
"/api/system/update",
|
|
{ config: { rateLimit: { max: 2, timeWindow: "10 minutes" } } },
|
|
async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
const current = await readUpdateStatus(config);
|
|
if (!current.supported) {
|
|
return reply.code(503).send({
|
|
error: { code: "UPDATES_UNAVAILABLE", message: "Install the system service before using automatic updates" }
|
|
});
|
|
}
|
|
if (["checking", "building"].includes(current.state)) {
|
|
return reply.code(409).send({ error: { code: "UPDATE_IN_PROGRESS", message: "An update is already running" } });
|
|
}
|
|
|
|
try {
|
|
await triggerSystemUpdate();
|
|
audit(database, {
|
|
actorUserId: request.authUser!.id,
|
|
action: "system.update",
|
|
result: "success",
|
|
ipAddress: clientAddress(request),
|
|
details: { state: "queued" }
|
|
});
|
|
return reply.code(202).send({ queued: true });
|
|
} catch {
|
|
audit(database, {
|
|
actorUserId: request.authUser!.id,
|
|
action: "system.update",
|
|
result: "failure",
|
|
ipAddress: clientAddress(request),
|
|
details: { reason: "update_service_unavailable" }
|
|
});
|
|
return reply.code(503).send({
|
|
error: { code: "UPDATE_START_FAILED", message: "The update service could not be started" }
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
app.get("/api/events", async (request, reply) => {
|
|
if (!requireUser(request, reply)) return;
|
|
reply.hijack();
|
|
reply.raw.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no"
|
|
});
|
|
let closed = false;
|
|
const sendStatus = async () => {
|
|
if (closed) return;
|
|
try {
|
|
const status = await collectSystemStatus(config.adb);
|
|
reply.raw.write(`event: status\ndata: ${JSON.stringify(status)}\n\n`);
|
|
} catch {
|
|
reply.raw.write(`event: collection-error\ndata: {"code":"STATUS_COLLECTION_FAILED"}\n\n`);
|
|
}
|
|
};
|
|
await sendStatus();
|
|
const interval = setInterval(() => void sendStatus(), 10_000);
|
|
const stopJobEvents = jobRunner.onRun((run) => {
|
|
if (!closed) reply.raw.write(`event: job-run\ndata: ${JSON.stringify(run)}\n\n`);
|
|
});
|
|
request.raw.on("close", () => {
|
|
closed = true;
|
|
clearInterval(interval);
|
|
stopJobEvents();
|
|
});
|
|
});
|
|
|
|
const webRoot = resolve(fileURLToPath(new URL("../../web/dist", import.meta.url)));
|
|
if (config.nodeEnv === "production" && existsSync(webRoot)) {
|
|
app.get("/", async (_request, reply) => {
|
|
reply.type("text/html; charset=utf-8");
|
|
return reply.send(createReadStream(resolve(webRoot, "index.html")));
|
|
});
|
|
app.get<{ Params: { file: string } }>("/assets/:file", async (request, reply) => {
|
|
if (!/^[a-zA-Z0-9._-]+$/.test(request.params.file)) {
|
|
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } });
|
|
}
|
|
const assetPath = resolve(webRoot, "assets", request.params.file);
|
|
if (!existsSync(assetPath) || !statSync(assetPath).isFile()) {
|
|
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } });
|
|
}
|
|
const contentTypes: Record<string, string> = {
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".map": "application/json; charset=utf-8",
|
|
".png": "image/png",
|
|
".svg": "image/svg+xml",
|
|
".woff2": "font/woff2"
|
|
};
|
|
reply.type(contentTypes[extname(assetPath)] ?? "application/octet-stream");
|
|
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
return reply.send(createReadStream(assetPath));
|
|
});
|
|
}
|
|
|
|
return app;
|
|
}
|