added udner construction for file compaction, planning for unbloating
This commit is contained in:
@@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit";
|
||||
import fastifyCookie from "@fastify/cookie";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import argon2 from "argon2";
|
||||
import { randomUUID, createHash, randomInt, randomBytes } from "node:crypto";
|
||||
import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
import { env } from "./env.js";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
@@ -17,6 +17,9 @@ export type AppConfig = typeof env;
|
||||
|
||||
const openPaths = new Set([
|
||||
"/health",
|
||||
"/site-access/status",
|
||||
"/site-access/unlock",
|
||||
"/site-access/lock",
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
"/auth/verify",
|
||||
@@ -108,6 +111,8 @@ const isInternalClientIp = (ip: string) => {
|
||||
};
|
||||
const CSRF_COOKIE = "csrf";
|
||||
const CSRF_HEADER = "x-csrf-token";
|
||||
const SITE_ACCESS_COOKIE = "skymoney_site_access";
|
||||
const SITE_ACCESS_MAX_AGE_SECONDS = 12 * 60 * 60;
|
||||
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 19_456,
|
||||
@@ -130,6 +135,18 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
|
||||
const isProd = config.NODE_ENV === "production";
|
||||
const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE;
|
||||
const cookieDomain = config.COOKIE_DOMAIN || undefined;
|
||||
const siteAccessExpectedToken =
|
||||
config.BREAK_GLASS_VERIFY_CODE && config.BREAK_GLASS_VERIFY_CODE.length >= 32
|
||||
? createHash("sha256")
|
||||
.update(`${config.COOKIE_SECRET}:${config.BREAK_GLASS_VERIFY_CODE}`)
|
||||
.digest("hex")
|
||||
: null;
|
||||
|
||||
const hasSiteAccessBypass = (req: { cookies?: Record<string, unknown> }) => {
|
||||
const candidate = req.cookies?.[SITE_ACCESS_COOKIE];
|
||||
if (!siteAccessExpectedToken || typeof candidate !== "string") return false;
|
||||
return safeEqual(candidate, siteAccessExpectedToken);
|
||||
};
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
@@ -310,6 +327,13 @@ function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a, "utf8");
|
||||
const right = Buffer.from(b, "utf8");
|
||||
if (left.length !== right.length) return false;
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
async function issueEmailToken(
|
||||
userId: string,
|
||||
type: EmailTokenType,
|
||||
@@ -754,6 +778,25 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
app.addHook("onRequest", async (req, reply) => {
|
||||
reply.header("x-request-id", String(req.id ?? ""));
|
||||
const path = pathOf(req.url ?? "");
|
||||
const isSiteAccessPath =
|
||||
path === "/site-access/status" ||
|
||||
path === "/site-access/unlock" ||
|
||||
path === "/site-access/lock";
|
||||
|
||||
if (
|
||||
config.UNDER_CONSTRUCTION_ENABLED &&
|
||||
!isSiteAccessPath &&
|
||||
path !== "/health" &&
|
||||
path !== "/health/db" &&
|
||||
!hasSiteAccessBypass(req)
|
||||
) {
|
||||
return reply.code(503).send({
|
||||
ok: false,
|
||||
code: "UNDER_CONSTRUCTION",
|
||||
message: "SkyMoney is temporarily under construction.",
|
||||
requestId: String(req.id ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
// Open paths don't require authentication
|
||||
if (openPaths.has(path)) {
|
||||
@@ -838,6 +881,8 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
path === "/site-access/unlock" ||
|
||||
path === "/site-access/lock" ||
|
||||
path === "/auth/login" ||
|
||||
path === "/auth/register" ||
|
||||
path === "/auth/verify" ||
|
||||
@@ -1503,6 +1548,65 @@ app.get("/auth/session", async (req, reply) => {
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/site-access/status", async (req) => {
|
||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
||||
return { ok: true, enabled: false, unlocked: true };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
enabled: true,
|
||||
unlocked: hasSiteAccessBypass(req),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/site-access/unlock", authRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
code: z.string().min(1).max(512),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" });
|
||||
}
|
||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
||||
return { ok: true, enabled: false, unlocked: true };
|
||||
}
|
||||
if (!config.BREAK_GLASS_VERIFY_ENABLED || !siteAccessExpectedToken) {
|
||||
return reply.code(503).send({
|
||||
ok: false,
|
||||
code: "UNDER_CONSTRUCTION_MISCONFIGURED",
|
||||
message: "Under-construction access is not configured.",
|
||||
});
|
||||
}
|
||||
if (!safeEqual(parsed.data.code, config.BREAK_GLASS_VERIFY_CODE!)) {
|
||||
return reply.code(401).send({
|
||||
ok: false,
|
||||
code: "INVALID_ACCESS_CODE",
|
||||
message: "Invalid access code.",
|
||||
});
|
||||
}
|
||||
|
||||
reply.setCookie(SITE_ACCESS_COOKIE, siteAccessExpectedToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: SITE_ACCESS_MAX_AGE_SECONDS,
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
return { ok: true, enabled: true, unlocked: true };
|
||||
});
|
||||
|
||||
app.post("/site-access/lock", mutationRateLimit, async (_req, reply) => {
|
||||
reply.clearCookie(SITE_ACCESS_COOKIE, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.NODE_ENV === "production",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
return { ok: true, enabled: config.UNDER_CONSTRUCTION_ENABLED, unlocked: false };
|
||||
});
|
||||
|
||||
app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
version: z.coerce.number().int().nonnegative().optional(),
|
||||
|
||||
Reference in New Issue
Block a user