re-vibecode
This commit is contained in:
+29
-66
@@ -1,73 +1,36 @@
|
||||
import { resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
const normalizedEnv = Object.fromEntries(
|
||||
Object.entries(Bun.env).map(([key, value]) => [key, value === "" ? undefined : value])
|
||||
);
|
||||
const env = Bun.env;
|
||||
|
||||
const splitList = (value: string | undefined, fallback: string): string[] =>
|
||||
(value ?? fallback)
|
||||
const parseList = (value: string | undefined, fallback: string[]): string[] =>
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.concat(fallback)
|
||||
.filter((item, index, items) => items.indexOf(item) === index);
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
APP_HOST: z.string().default("0.0.0.0"),
|
||||
APP_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
|
||||
APP_ORIGINS: z.string().default("http://localhost:5173"),
|
||||
APP_EXTERNAL_BASE_URL: z.string().url().default("http://localhost:5173"),
|
||||
DATA_DIR: z.string().default("./data"),
|
||||
MAILSERVER_CONTAINER: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/)
|
||||
.default("mailserver"),
|
||||
MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
|
||||
HOSTED_DOMAINS: z.string().default("example.com"),
|
||||
FORWARDING_ALLOWED_DOMAINS: z.string().optional(),
|
||||
GLOBAL_RATE_LIMIT: z.coerce.number().int().min(10).max(5000).default(240),
|
||||
ROUTE_RATE_LIMIT: z.coerce.number().int().min(5).max(1000).default(90),
|
||||
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(10).max(3600).default(60),
|
||||
LOGIN_RATE_LIMIT: z.coerce.number().int().min(3).max(20).default(5),
|
||||
LOGIN_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86400).default(600),
|
||||
MAX_MESSAGES: z.coerce.number().int().min(1).max(100).default(25),
|
||||
AVATAR_MAX_BYTES: z.coerce.number().int().min(1024).max(5_000_000).default(2_097_152),
|
||||
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
|
||||
APP_ADMIN_EMAIL: z.string().email().optional(),
|
||||
APP_ADMIN_PASSWORD: z.string().min(12).max(256).optional(),
|
||||
TOTP_ENCRYPTION_KEY: z.string().optional(),
|
||||
ALLOW_INSECURE_LOGIN: z.enum(["true", "false"]).default("false")
|
||||
});
|
||||
|
||||
const raw = envSchema.parse(normalizedEnv);
|
||||
|
||||
if ((raw.APP_ADMIN_EMAIL && !raw.APP_ADMIN_PASSWORD) || (!raw.APP_ADMIN_EMAIL && raw.APP_ADMIN_PASSWORD)) {
|
||||
throw new Error("APP_ADMIN_EMAIL and APP_ADMIN_PASSWORD must be provided together.");
|
||||
}
|
||||
const parseNumber = (value: string | undefined, fallback: number): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
export const config = {
|
||||
nodeEnv: raw.NODE_ENV,
|
||||
host: raw.APP_HOST,
|
||||
port: raw.APP_PORT,
|
||||
externalBaseUrl: raw.APP_EXTERNAL_BASE_URL,
|
||||
allowedOrigins: new Set(splitList(raw.APP_ORIGINS, "http://localhost:5173")),
|
||||
dataDir: resolve(process.cwd(), raw.DATA_DIR),
|
||||
avatarDir: resolve(process.cwd(), raw.DATA_DIR, "avatars"),
|
||||
dbPath: resolve(process.cwd(), raw.DATA_DIR, "app.db"),
|
||||
mailserverContainer: raw.MAILSERVER_CONTAINER,
|
||||
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS,
|
||||
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"),
|
||||
forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS),
|
||||
globalRateLimit: raw.GLOBAL_RATE_LIMIT,
|
||||
routeRateLimit: raw.ROUTE_RATE_LIMIT,
|
||||
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000,
|
||||
loginRateLimit: raw.LOGIN_RATE_LIMIT,
|
||||
loginWindowMs: raw.LOGIN_WINDOW_SECONDS * 1000,
|
||||
maxMessages: raw.MAX_MESSAGES,
|
||||
avatarMaxBytes: raw.AVATAR_MAX_BYTES,
|
||||
trustProxy: raw.TRUST_PROXY === "true",
|
||||
adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null,
|
||||
adminPassword: raw.APP_ADMIN_PASSWORD ?? null,
|
||||
totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null,
|
||||
allowInsecureLogin: raw.ALLOW_INSECURE_LOGIN === "true"
|
||||
} as const;
|
||||
nodeEnv: env.NODE_ENV ?? "development",
|
||||
host: env.APP_HOST ?? "0.0.0.0",
|
||||
port: parseNumber(env.APP_PORT, 3001),
|
||||
appOrigins: parseList(env.APP_ORIGINS, ["http://localhost:5173"]),
|
||||
externalBaseUrl: (env.APP_EXTERNAL_BASE_URL ?? "http://localhost:5173").replace(/\/+$/, ""),
|
||||
dbPath: resolve(process.cwd(), env.APP_DATA_PATH ?? "../../data/app.db"),
|
||||
mailserverContainer: env.MAILSERVER_CONTAINER ?? "mailserver",
|
||||
dockerBinary: env.DOCKER_BINARY ?? "docker",
|
||||
mailserverTimeoutMs: parseNumber(env.MAILSERVER_TIMEOUT_MS, 10_000),
|
||||
hostedDomains: parseList(env.HOSTED_DOMAINS, []),
|
||||
sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
|
||||
sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
|
||||
resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000,
|
||||
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
|
||||
appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
|
||||
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
||||
isProduction: (env.NODE_ENV ?? "development") === "production"
|
||||
};
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { config } from "./config";
|
||||
import { HttpError } from "./errors";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let cachedAesKey: CryptoKey | null = null;
|
||||
|
||||
const importAesKey = async (): Promise<CryptoKey> => {
|
||||
if (!config.totpEncryptionKey) {
|
||||
throw new HttpError(503, "Two-factor encryption key is not configured.", { expose: true });
|
||||
}
|
||||
|
||||
if (cachedAesKey) {
|
||||
return cachedAesKey;
|
||||
}
|
||||
|
||||
const rawKey = Buffer.from(config.totpEncryptionKey, "base64");
|
||||
if (rawKey.byteLength !== 32) {
|
||||
throw new Error("TOTP_ENCRYPTION_KEY must decode to 32 bytes.");
|
||||
}
|
||||
|
||||
cachedAesKey = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt", "decrypt"]);
|
||||
return cachedAesKey;
|
||||
};
|
||||
|
||||
export const randomToken = (bytes = 32): string => Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
|
||||
|
||||
export const sha256Hex = async (value: string): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Buffer.from(digest).toString("hex");
|
||||
};
|
||||
|
||||
export const encryptAtRest = async (value: string): Promise<string> => {
|
||||
const key = await importAesKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
encoder.encode(value)
|
||||
);
|
||||
|
||||
return `${Buffer.from(iv).toString("base64")}.${Buffer.from(encrypted).toString("base64")}`;
|
||||
};
|
||||
|
||||
export const decryptAtRest = async (payload: string): Promise<string> => {
|
||||
const [ivPart, dataPart] = payload.split(".");
|
||||
if (!ivPart || !dataPart) {
|
||||
throw new Error("Invalid encrypted payload.");
|
||||
}
|
||||
|
||||
const key = await importAesKey();
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: Buffer.from(ivPart, "base64") },
|
||||
key,
|
||||
Buffer.from(dataPart, "base64")
|
||||
);
|
||||
|
||||
return Buffer.from(decrypted).toString("utf8");
|
||||
};
|
||||
|
||||
+135
-121
@@ -1,20 +1,8 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { config } from "./config";
|
||||
|
||||
export type ProfileRow = {
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_path: string | null;
|
||||
role: "admin" | "user";
|
||||
totp_secret: string | null;
|
||||
totp_enabled: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type AdminRow = {
|
||||
email: string;
|
||||
password_hash: string;
|
||||
@@ -22,31 +10,28 @@ type AdminRow = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type ForwardingRow = {
|
||||
email: string;
|
||||
target_email: string | null;
|
||||
enabled: number;
|
||||
updated_at: number;
|
||||
type SessionRow = {
|
||||
token_hash: string;
|
||||
admin_email: string;
|
||||
expires_at: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export type PasswordResetTokenRow = {
|
||||
token_hash: string;
|
||||
user_email: string;
|
||||
created_by: string;
|
||||
expires_at: number;
|
||||
used_at: number | null;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
mkdirSync(dirname(config.dbPath), { recursive: true });
|
||||
mkdirSync(config.avatarDir, { recursive: true });
|
||||
|
||||
const db = new Database(config.dbPath, { create: true });
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
email TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_path TEXT,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_admins (
|
||||
email TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL,
|
||||
@@ -54,111 +39,140 @@ db.exec(`
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forwarding_rules (
|
||||
email TEXT PRIMARY KEY,
|
||||
target_email TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
CREATE TABLE IF NOT EXISTS app_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
admin_email TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_email TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at
|
||||
ON app_sessions(expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_email
|
||||
ON password_reset_tokens(user_email);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
|
||||
ON password_reset_tokens(expires_at);
|
||||
`);
|
||||
|
||||
const defaultDisplayName = (email: string): string => email.split("@")[0] ?? email;
|
||||
const toAbsoluteAvatarPath = (avatarPath: string | null): string | null =>
|
||||
avatarPath ? (isAbsolute(avatarPath) ? avatarPath : resolve(process.cwd(), avatarPath)) : null;
|
||||
|
||||
const getProfileStatement = db.query("SELECT * FROM user_profiles WHERE email = ?1");
|
||||
const insertProfileStatement = db.query(`
|
||||
INSERT OR IGNORE INTO user_profiles (
|
||||
email,
|
||||
display_name,
|
||||
avatar_path,
|
||||
role,
|
||||
totp_secret,
|
||||
totp_enabled,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?1, ?2, NULL, ?3, NULL, 0, ?4, ?4)
|
||||
`);
|
||||
const updateProfileStatement = db.query(`
|
||||
UPDATE user_profiles
|
||||
SET display_name = ?1, updated_at = ?2
|
||||
WHERE email = ?3
|
||||
`);
|
||||
const updateAvatarStatement = db.query(`
|
||||
UPDATE user_profiles
|
||||
SET avatar_path = ?1, updated_at = ?2
|
||||
WHERE email = ?3
|
||||
`);
|
||||
const updateTotpStatement = db.query(`
|
||||
UPDATE user_profiles
|
||||
SET totp_secret = ?1, totp_enabled = ?2, updated_at = ?3
|
||||
WHERE email = ?4
|
||||
`);
|
||||
const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
|
||||
const upsertAdminStatement = db.query(`
|
||||
INSERT INTO app_admins (email, password_hash, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
password_hash = excluded.password_hash,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1");
|
||||
const upsertForwardingStatement = db.query(`
|
||||
INSERT INTO forwarding_rules (email, target_email, enabled, updated_at)
|
||||
|
||||
const insertSessionStatement = db.query(`
|
||||
INSERT INTO app_sessions (token_hash, admin_email, expires_at, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(email) DO UPDATE SET target_email = excluded.target_email, enabled = excluded.enabled, updated_at = excluded.updated_at
|
||||
`);
|
||||
const getSessionStatement = db.query("SELECT * FROM app_sessions WHERE token_hash = ?1");
|
||||
const deleteSessionStatement = db.query("DELETE FROM app_sessions WHERE token_hash = ?1");
|
||||
const deleteExpiredSessionsStatement = db.query("DELETE FROM app_sessions WHERE expires_at <= ?1");
|
||||
|
||||
const insertResetTokenStatement = db.query(`
|
||||
INSERT INTO password_reset_tokens (token_hash, user_email, created_by, expires_at, used_at, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, NULL, ?5)
|
||||
`);
|
||||
const deleteResetTokenStatement = db.query("DELETE FROM password_reset_tokens WHERE token_hash = ?1");
|
||||
const deleteActiveResetTokensForUserStatement = db.query(`
|
||||
DELETE FROM password_reset_tokens
|
||||
WHERE user_email = ?1
|
||||
AND used_at IS NULL
|
||||
`);
|
||||
const getActiveResetTokenStatement = db.query(`
|
||||
SELECT * FROM password_reset_tokens
|
||||
WHERE token_hash = ?1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > ?2
|
||||
`);
|
||||
const claimResetTokenStatement = db.query(`
|
||||
UPDATE password_reset_tokens
|
||||
SET used_at = ?2
|
||||
WHERE token_hash = ?1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > ?3
|
||||
`);
|
||||
const releaseResetTokenStatement = db.query(`
|
||||
UPDATE password_reset_tokens
|
||||
SET used_at = NULL
|
||||
WHERE token_hash = ?1
|
||||
`);
|
||||
const deleteExpiredResetTokensStatement = db.query(`
|
||||
DELETE FROM password_reset_tokens
|
||||
WHERE expires_at <= ?1
|
||||
OR (used_at IS NOT NULL AND used_at <= ?2)
|
||||
`);
|
||||
|
||||
export const getProfile = (email: string): ProfileRow | null => (getProfileStatement.get(email) as ProfileRow | null) ?? null;
|
||||
|
||||
export const ensureProfile = (email: string, role: "admin" | "user" = "user"): ProfileRow => {
|
||||
const now = Date.now();
|
||||
insertProfileStatement.run(email, defaultDisplayName(email), role, now);
|
||||
return getProfile(email)!;
|
||||
};
|
||||
|
||||
export const updateProfile = (email: string, displayName: string): ProfileRow => {
|
||||
updateProfileStatement.run(displayName, Date.now(), email);
|
||||
return getProfile(email)!;
|
||||
};
|
||||
|
||||
export const setAvatarPath = (email: string, avatarPath: string | null): ProfileRow => {
|
||||
updateAvatarStatement.run(avatarPath, Date.now(), email);
|
||||
return getProfile(email)!;
|
||||
};
|
||||
|
||||
export const setTotpSecret = (email: string, secret: string | null, enabled: boolean): ProfileRow => {
|
||||
updateTotpStatement.run(secret, enabled ? 1 : 0, Date.now(), email);
|
||||
return getProfile(email)!;
|
||||
};
|
||||
|
||||
export const getAdmin = (email: string): AdminRow | null => (getAdminStatement.get(email) as AdminRow | null) ?? null;
|
||||
export const getAdmin = (email: string): AdminRow | null =>
|
||||
(getAdminStatement.get(email) as AdminRow | null) ?? null;
|
||||
|
||||
export const upsertAdmin = (email: string, passwordHash: string): void => {
|
||||
upsertAdminStatement.run(email, passwordHash, Date.now());
|
||||
ensureProfile(email, "admin");
|
||||
};
|
||||
|
||||
export const getForwardingRule = (email: string): ForwardingRow | null =>
|
||||
(getForwardingStatement.get(email) as ForwardingRow | null) ?? null;
|
||||
|
||||
export const upsertForwardingRule = (email: string, targetEmail: string | null, enabled: boolean): ForwardingRow => {
|
||||
const now = Date.now();
|
||||
upsertForwardingStatement.run(email, targetEmail, enabled ? 1 : 0, now);
|
||||
return getForwardingRule(email)!;
|
||||
upsertAdminStatement.run(email, passwordHash, now);
|
||||
};
|
||||
|
||||
export const deleteAvatarFile = async (avatarPath: string | null): Promise<void> => {
|
||||
if (!avatarPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = toAbsoluteAvatarPath(avatarPath);
|
||||
if (!absolutePath || !absolutePath.startsWith(config.avatarDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(absolutePath);
|
||||
} catch {
|
||||
// Best-effort cleanup of replaced avatars.
|
||||
}
|
||||
export const createSession = (tokenHash: string, adminEmail: string, expiresAt: number): void => {
|
||||
insertSessionStatement.run(tokenHash, adminEmail, expiresAt, Date.now());
|
||||
};
|
||||
|
||||
export const getSession = (tokenHash: string): SessionRow | null =>
|
||||
(getSessionStatement.get(tokenHash) as SessionRow | null) ?? null;
|
||||
|
||||
export const deleteSession = (tokenHash: string): void => {
|
||||
deleteSessionStatement.run(tokenHash);
|
||||
};
|
||||
|
||||
export const createPasswordResetToken = (input: {
|
||||
tokenHash: string;
|
||||
userEmail: string;
|
||||
createdBy: string;
|
||||
expiresAt: number;
|
||||
}): void => {
|
||||
insertResetTokenStatement.run(
|
||||
input.tokenHash,
|
||||
input.userEmail,
|
||||
input.createdBy,
|
||||
input.expiresAt,
|
||||
Date.now()
|
||||
);
|
||||
};
|
||||
|
||||
export const deletePasswordResetToken = (tokenHash: string): void => {
|
||||
deleteResetTokenStatement.run(tokenHash);
|
||||
};
|
||||
|
||||
export const deleteActivePasswordResetTokensForUser = (userEmail: string): void => {
|
||||
deleteActiveResetTokensForUserStatement.run(userEmail);
|
||||
};
|
||||
|
||||
export const getActivePasswordResetToken = (tokenHash: string): PasswordResetTokenRow | null =>
|
||||
(getActiveResetTokenStatement.get(tokenHash, Date.now()) as PasswordResetTokenRow | null) ?? null;
|
||||
|
||||
export const claimPasswordResetToken = (tokenHash: string): boolean => {
|
||||
const usedAt = Date.now();
|
||||
const result = claimResetTokenStatement.run(tokenHash, usedAt, usedAt);
|
||||
return result.changes > 0;
|
||||
};
|
||||
|
||||
export const releasePasswordResetToken = (tokenHash: string): void => {
|
||||
releaseResetTokenStatement.run(tokenHash);
|
||||
};
|
||||
|
||||
export const cleanupDatabase = (): void => {
|
||||
const now = Date.now();
|
||||
deleteExpiredSessionsStatement.run(now);
|
||||
deleteExpiredResetTokensStatement.run(now, now - 86_400_000);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly expose: boolean;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(status: number, message: string, options?: { expose?: boolean; details?: unknown }) {
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
this.expose = options?.expose ?? true;
|
||||
this.details = options?.details;
|
||||
}
|
||||
}
|
||||
|
||||
export const isHttpError = (error: unknown): error is HttpError => error instanceof HttpError;
|
||||
|
||||
|
||||
+88
-47
@@ -1,77 +1,118 @@
|
||||
import { ZodError, type ZodSchema } from "zod";
|
||||
import { config } from "./config";
|
||||
import { HttpError, isHttpError } from "./errors";
|
||||
import { buildResponseHeaders } from "./security";
|
||||
import { HttpError } from "./errors";
|
||||
|
||||
export const jsonResponse = (request: Request, status: number, payload: unknown, init?: HeadersInit): Response =>
|
||||
const allowedOrigin = (request: Request): string | null => {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return config.appOrigins.includes(origin.toLowerCase()) ? origin : null;
|
||||
};
|
||||
|
||||
const securityHeaders = (): Record<string, string> => ({
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer"
|
||||
});
|
||||
|
||||
const mergeHeaders = (request: Request, headers?: HeadersInit): Headers => {
|
||||
const result = new Headers(headers);
|
||||
const origin = allowedOrigin(request);
|
||||
|
||||
for (const [key, value] of Object.entries(securityHeaders())) {
|
||||
result.set(key, value);
|
||||
}
|
||||
|
||||
result.set("Vary", "Origin");
|
||||
|
||||
if (origin) {
|
||||
result.set("Access-Control-Allow-Origin", origin);
|
||||
result.set("Access-Control-Allow-Credentials", "true");
|
||||
result.set("Access-Control-Allow-Headers", "Content-Type");
|
||||
result.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const assertAllowedOrigin = (request: Request): void => {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.appOrigins.includes(origin.toLowerCase())) {
|
||||
throw new HttpError(403, "Origin is not allowed.");
|
||||
}
|
||||
};
|
||||
|
||||
export const jsonResponse = (
|
||||
request: Request,
|
||||
status: number,
|
||||
payload: unknown,
|
||||
headers?: HeadersInit
|
||||
): Response =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: buildResponseHeaders(request, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
...init
|
||||
headers: mergeHeaders(request, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...headers
|
||||
})
|
||||
});
|
||||
|
||||
export const emptyResponse = (request: Request, status = 204, init?: HeadersInit): Response =>
|
||||
export const emptyResponse = (request: Request, status = 204, headers?: HeadersInit): Response =>
|
||||
new Response(null, {
|
||||
status,
|
||||
headers: buildResponseHeaders(request, init)
|
||||
headers: mergeHeaders(request, headers)
|
||||
});
|
||||
|
||||
export const fileResponse = (request: Request, body: Blob | Uint8Array, contentType: string, init?: HeadersInit): Response =>
|
||||
new Response(body as BodyInit, {
|
||||
status: 200,
|
||||
headers: buildResponseHeaders(request, {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "private, max-age=300",
|
||||
...init
|
||||
})
|
||||
});
|
||||
export const optionsResponse = (request: Request): Response =>
|
||||
emptyResponse(request, 204);
|
||||
|
||||
export const parseJson = async <T>(request: Request, schema: { parse(data: unknown): T }): Promise<T> => {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
throw new HttpError(415, "Expected application/json request body.");
|
||||
}
|
||||
export const parseJson = async <T>(request: Request, schema: ZodSchema<T>): Promise<T> => {
|
||||
let body: unknown;
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
body = await request.json();
|
||||
} catch {
|
||||
throw new HttpError(400, "Malformed JSON request body.");
|
||||
throw new HttpError(400, "Request body must be valid JSON.");
|
||||
}
|
||||
|
||||
try {
|
||||
return schema.parse(payload);
|
||||
return schema.parse(body);
|
||||
} catch (error) {
|
||||
throw new HttpError(400, "Request validation failed.", { details: error });
|
||||
if (error instanceof ZodError) {
|
||||
throw new HttpError(400, error.issues[0]?.message ?? "Invalid request body.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const errorResponse = (request: Request, error: unknown): Response => {
|
||||
if (isHttpError(error)) {
|
||||
const retryAfterMs =
|
||||
typeof error.details === "object" &&
|
||||
error.details !== null &&
|
||||
"retryAfterMs" in error.details &&
|
||||
typeof (error.details as { retryAfterMs?: unknown }).retryAfterMs === "number"
|
||||
? Math.ceil(((error.details as { retryAfterMs: number }).retryAfterMs ?? 0) / 1000)
|
||||
: undefined;
|
||||
|
||||
return jsonResponse(
|
||||
request,
|
||||
error.status,
|
||||
{
|
||||
error: error.expose ? error.message : "An internal server error occurred."
|
||||
},
|
||||
retryAfterMs ? { "Retry-After": String(retryAfterMs) } : undefined
|
||||
);
|
||||
if (error instanceof HttpError) {
|
||||
return jsonResponse(request, error.status, {
|
||||
error: {
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (config.nodeEnv !== "production") {
|
||||
console.error(error);
|
||||
if (error instanceof ZodError) {
|
||||
return jsonResponse(request, 400, {
|
||||
error: {
|
||||
message: error.issues[0]?.message ?? "Invalid request."
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return jsonResponse(request, 500, {
|
||||
error: "An internal server error occurred."
|
||||
error: {
|
||||
message: "Unexpected server error."
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+42
-105
@@ -1,119 +1,56 @@
|
||||
import { config } from "./config";
|
||||
import { HttpError } from "./errors";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
retryAfterMs: number;
|
||||
};
|
||||
export const hashToken = (value: string): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
type RateBucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
export const createOpaqueToken = (size = 32): string =>
|
||||
randomBytes(size).toString("hex");
|
||||
|
||||
export class SlidingWindowLimiter {
|
||||
private readonly buckets = new Map<string, RateBucket>();
|
||||
export const createTemporaryPassword = (): string =>
|
||||
randomBytes(24).toString("base64url");
|
||||
|
||||
consume(key: string, max: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const current = this.buckets.get(key);
|
||||
|
||||
if (!current || current.resetAt <= now) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
if (current.count >= max) {
|
||||
return { allowed: false, retryAfterMs: Math.max(current.resetAt - now, 1000) };
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
export const buildCookieValue = (
|
||||
name: string,
|
||||
value: string,
|
||||
options: {
|
||||
maxAgeSeconds: number;
|
||||
secure: boolean;
|
||||
}
|
||||
}
|
||||
): string => {
|
||||
const parts = [
|
||||
`${name}=${encodeURIComponent(value)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
`Max-Age=${options.maxAgeSeconds}`
|
||||
];
|
||||
|
||||
export const globalLimiter = new SlidingWindowLimiter();
|
||||
export const routeLimiter = new SlidingWindowLimiter();
|
||||
export const loginLimiter = new SlidingWindowLimiter();
|
||||
if (options.secure) {
|
||||
parts.push("Secure");
|
||||
}
|
||||
|
||||
export const getClientIp = (request: Request): string => {
|
||||
if (config.trustProxy) {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0]!.trim();
|
||||
}
|
||||
return parts.join("; ");
|
||||
};
|
||||
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
if (realIp?.trim()) {
|
||||
return realIp.trim();
|
||||
export const buildClearedCookieValue = (name: string, secure: boolean): string =>
|
||||
buildCookieValue(name, "", { maxAgeSeconds: 0, secure });
|
||||
|
||||
export const readCookie = (request: Request, name: string): string | null => {
|
||||
const raw = request.headers.get("cookie");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of raw.split(";")) {
|
||||
const [cookieName, ...rest] = part.trim().split("=");
|
||||
if (cookieName === name) {
|
||||
return decodeURIComponent(rest.join("="));
|
||||
}
|
||||
}
|
||||
|
||||
return "0.0.0.0";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const assertAllowedOrigin = (request: Request): void => {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) {
|
||||
return;
|
||||
}
|
||||
export const sanitizeHeaderValue = (value: string): string =>
|
||||
value.replace(/[\r\n]+/g, " ").trim();
|
||||
|
||||
if (!config.allowedOrigins.has(origin.toLowerCase())) {
|
||||
throw new HttpError(403, "Origin is not allowed.");
|
||||
}
|
||||
};
|
||||
|
||||
export const buildResponseHeaders = (request: Request, init?: HeadersInit): Headers => {
|
||||
const headers = new Headers(init);
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("Referrer-Policy", "no-referrer");
|
||||
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
headers.set(
|
||||
"Content-Security-Policy",
|
||||
[
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"img-src 'self' data: blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https:"
|
||||
].join("; ")
|
||||
);
|
||||
|
||||
if (config.nodeEnv === "production") {
|
||||
headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
|
||||
}
|
||||
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && config.allowedOrigins.has(origin.toLowerCase())) {
|
||||
headers.set("Access-Control-Allow-Origin", origin);
|
||||
headers.set("Access-Control-Allow-Headers", "Content-Type, X-User-Email");
|
||||
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
|
||||
headers.append("Vary", "Origin");
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const enforceLimiter = (
|
||||
limiter: SlidingWindowLimiter,
|
||||
request: Request,
|
||||
scope: string,
|
||||
max: number,
|
||||
windowMs: number,
|
||||
discriminator?: string
|
||||
): void => {
|
||||
const ip = getClientIp(request);
|
||||
const key = `${scope}:${ip}:${discriminator ?? request.method}`;
|
||||
const result = limiter.consume(key, max, windowMs);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw new HttpError(429, "Too many requests. Please slow down.", {
|
||||
details: { retryAfterMs: result.retryAfterMs }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,105 +1,55 @@
|
||||
import { z } from "zod";
|
||||
import { config } from "./config";
|
||||
import { HttpError } from "./errors";
|
||||
|
||||
const emailRegex =
|
||||
/^(?=.{1,254}$)(?=.{1,64}@)[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+$/i;
|
||||
|
||||
export const strictEmailSchema = z
|
||||
const baseEmailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(emailRegex, "Invalid email address.")
|
||||
.refine((value) => !value.includes(".."), "Invalid email address.");
|
||||
.email("Enter a valid email address.");
|
||||
|
||||
export const hostedMailboxSchema = strictEmailSchema.refine((email) => {
|
||||
const domain = email.split("@")[1] ?? "";
|
||||
return config.hostedDomains.includes(domain);
|
||||
}, "Mailbox domain is not hosted by this server.");
|
||||
const hostedDomainSet = new Set(config.hostedDomains);
|
||||
|
||||
export const hostedEmailSchema = baseEmailSchema.superRefine((value, ctx) => {
|
||||
const domain = value.split("@")[1] ?? "";
|
||||
if (!hostedDomainSet.has(domain)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "The email domain is not managed by this Docker Mailserver."
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(12, "Password must contain at least 12 characters.")
|
||||
.max(128, "Password is too long.");
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: strictEmailSchema,
|
||||
password: z.string().min(8).max(256),
|
||||
totpCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/)
|
||||
.optional()
|
||||
email: baseEmailSchema,
|
||||
password: z.string().min(1, "Password is required.")
|
||||
});
|
||||
|
||||
export const profileSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(80)
|
||||
export const createUserSchema = z.object({
|
||||
email: hostedEmailSchema
|
||||
});
|
||||
|
||||
export const composeSchema = z.object({
|
||||
to: z.array(strictEmailSchema).min(1).max(10),
|
||||
subject: z.string().trim().max(160),
|
||||
textBody: z.string().trim().min(1).max(20_000)
|
||||
export const updatePasswordSchema = z.object({
|
||||
password: passwordSchema
|
||||
});
|
||||
|
||||
export const forwardingSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
target: strictEmailSchema.nullable()
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.enabled && !value.target) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Forwarding target is required." });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
export const totpCodeSchema = z.object({
|
||||
code: z.string().trim().regex(/^\d{6}$/)
|
||||
export const resetTokenSchema = z.object({
|
||||
token: z.string().trim().min(32, "Invalid reset token.")
|
||||
});
|
||||
|
||||
export const messageIdSchema = z.object({
|
||||
id: z.string().trim().regex(/^[a-f0-9]{16,64}$/)
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().trim().min(32, "Invalid reset token."),
|
||||
password: passwordSchema
|
||||
});
|
||||
|
||||
export const ensureForwardingTargetAllowed = (mailboxEmail: string, target: string | null): void => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
export const routeEmailSchema = z.object({
|
||||
email: hostedEmailSchema
|
||||
});
|
||||
|
||||
if (target === mailboxEmail) {
|
||||
throw new HttpError(400, "Forwarding to the same mailbox is not allowed.");
|
||||
}
|
||||
|
||||
const domain = target.split("@")[1] ?? "";
|
||||
if (!config.forwardingAllowedDomains.includes(domain)) {
|
||||
throw new HttpError(400, "Forwarding target domain is not allowed by policy.");
|
||||
}
|
||||
};
|
||||
|
||||
type ImageKind = {
|
||||
ext: string;
|
||||
mime: string;
|
||||
};
|
||||
|
||||
export const detectImageKind = (bytes: Uint8Array): ImageKind | null => {
|
||||
if (bytes.byteLength < 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
||||
return { ext: "png", mime: "image/png" };
|
||||
}
|
||||
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return { ext: "jpg", mime: "image/jpeg" };
|
||||
}
|
||||
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
||||
return { ext: "gif", mime: "image/gif" };
|
||||
}
|
||||
|
||||
const riff = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
|
||||
const webp = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
|
||||
if (riff === "RIFF" && webp === "WEBP") {
|
||||
return { ext: "webp", mime: "image/webp" };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
export const parseRouteEmail = (value: string): string =>
|
||||
routeEmailSchema.parse({ email: decodeURIComponent(value) }).email;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user