This commit is contained in:
Anaïs Saraiva
2026-03-29 18:45:25 +02:00
parent 0f7d692d12
commit afc839d43d
42 changed files with 3809 additions and 58 deletions
+72
View File
@@ -0,0 +1,72 @@
import { resolve } from "node:path";
import { z } from "zod";
const splitList = (value: string | undefined, fallback: string): string[] =>
(value ?? fallback)
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
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(),
SESSION_TTL_MINUTES: z.coerce.number().int().min(5).max(1440).default(15),
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()
});
const raw = envSchema.parse(Bun.env);
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.");
}
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),
sessionTtlMs: raw.SESSION_TTL_MINUTES * 60_000,
cookieName: raw.NODE_ENV === "production" ? "__Host-webmail_session" : "webmail_session",
secureCookies: raw.NODE_ENV === "production",
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
} as const;
+60
View File
@@ -0,0 +1,60 @@
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");
};
+228
View File
@@ -0,0 +1,228 @@
import { mkdirSync } from "node:fs";
import { unlink } from "node:fs/promises";
import { dirname, isAbsolute, resolve } 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;
};
export type SessionRow = {
id: string;
email: string;
role: "admin" | "user";
csrf_token: string;
expires_at: number;
created_at: number;
last_seen_at: number;
user_agent_hash: string;
ip_hash: string;
};
type AdminRow = {
email: string;
password_hash: string;
created_at: number;
updated_at: number;
};
type ForwardingRow = {
email: string;
target_email: string | null;
enabled: number;
updated_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,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
csrf_token TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
user_agent_hash TEXT NOT NULL,
ip_hash TEXT 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
);
`);
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
`);
const insertSessionStatement = db.query(`
INSERT INTO sessions (id, email, role, csrf_token, expires_at, created_at, last_seen_at, user_agent_hash, ip_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8)
`);
const getSessionStatement = db.query("SELECT * FROM sessions WHERE id = ?1");
const touchSessionStatement = db.query(`
UPDATE sessions
SET expires_at = ?1, last_seen_at = ?2, ip_hash = ?3
WHERE id = ?4
`);
const deleteSessionStatement = db.query("DELETE FROM sessions WHERE id = ?1");
const deleteExpiredSessionsStatement = db.query("DELETE FROM sessions WHERE expires_at <= ?1");
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)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(email) DO UPDATE SET target_email = excluded.target_email, enabled = excluded.enabled, updated_at = excluded.updated_at
`);
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 upsertAdmin = (email: string, passwordHash: string): void => {
upsertAdminStatement.run(email, passwordHash, Date.now());
ensureProfile(email, "admin");
};
export const createSession = (session: SessionRow): void => {
insertSessionStatement.run(
session.id,
session.email,
session.role,
session.csrf_token,
session.expires_at,
session.created_at,
session.user_agent_hash,
session.ip_hash
);
};
export const getSession = (id: string): SessionRow | null => (getSessionStatement.get(id) as SessionRow | null) ?? null;
export const touchSession = (id: string, expiresAt: number, ipHash: string): void => {
touchSessionStatement.run(expiresAt, Date.now(), ipHash, id);
};
export const deleteSession = (id: string): void => {
deleteSessionStatement.run(id);
};
export const deleteExpiredSessions = (): void => {
deleteExpiredSessionsStatement.run(Date.now());
};
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)!;
};
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.
}
};
+16
View File
@@ -0,0 +1,16 @@
export class HttpError extends Error {
readonly status: number;
readonly expose: boolean;
readonly details?: unknown;
constructor(status: number, message: string, options?: { expose?: boolean; details?: unknown }) {
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;
+77
View File
@@ -0,0 +1,77 @@
import { config } from "./config";
import { HttpError, isHttpError } from "./errors";
import { buildResponseHeaders } from "./security";
export const jsonResponse = (request: Request, status: number, payload: unknown, init?: HeadersInit): Response =>
new Response(JSON.stringify(payload), {
status,
headers: buildResponseHeaders(request, {
"Content-Type": "application/json; charset=utf-8",
...init
})
});
export const emptyResponse = (request: Request, status = 204, init?: HeadersInit): Response =>
new Response(null, {
status,
headers: buildResponseHeaders(request, init)
});
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 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.");
}
let payload: unknown;
try {
payload = await request.json();
} catch {
throw new HttpError(400, "Malformed JSON request body.");
}
try {
return schema.parse(payload);
} catch (error) {
throw new HttpError(400, "Request validation failed.", { details: 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 (config.nodeEnv !== "production") {
console.error(error);
}
return jsonResponse(request, 500, {
error: "An internal server error occurred."
});
};
+183
View File
@@ -0,0 +1,183 @@
import { config } from "./config";
import { HttpError } from "./errors";
type RateLimitResult = {
allowed: boolean;
retryAfterMs: number;
};
type RateBucket = {
count: number;
resetAt: number;
};
export class SlidingWindowLimiter {
private readonly buckets = new Map<string, RateBucket>();
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 globalLimiter = new SlidingWindowLimiter();
export const routeLimiter = new SlidingWindowLimiter();
export const loginLimiter = new SlidingWindowLimiter();
export const parseCookies = (cookieHeader: string | null): Record<string, string> => {
if (!cookieHeader) {
return {};
}
return cookieHeader
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
.reduce<Record<string, string>>((accumulator, entry) => {
const separatorIndex = entry.indexOf("=");
if (separatorIndex === -1) {
return accumulator;
}
const key = entry.slice(0, separatorIndex).trim();
const value = entry.slice(separatorIndex + 1).trim();
accumulator[key] = decodeURIComponent(value);
return accumulator;
}, {});
};
export const serializeSessionCookie = (value: string): string => {
const pieces = [
`${config.cookieName}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${Math.floor(config.sessionTtlMs / 1000)}`
];
if (config.secureCookies) {
pieces.push("Secure");
}
return pieces.join("; ");
};
export const serializeExpiredCookie = (): string => {
const pieces = [
`${config.cookieName}=`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
"Max-Age=0"
];
if (config.secureCookies) {
pieces.push("Secure");
}
return pieces.join("; ");
};
export const getClientIp = (request: Request): string => {
if (config.trustProxy) {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]!.trim();
}
const realIp = request.headers.get("x-real-ip");
if (realIp?.trim()) {
return realIp.trim();
}
}
return "0.0.0.0";
};
export const assertAllowedOrigin = (request: Request): void => {
const origin = request.headers.get("origin");
if (!origin) {
return;
}
if (!config.allowedOrigins.has(origin.toLowerCase())) {
throw new HttpError(403, "Origin is not allowed.");
}
};
export const assertCsrf = (request: Request, expectedToken: string): void => {
const csrfToken = request.headers.get("x-csrf-token");
if (!csrfToken || csrfToken !== expectedToken) {
throw new HttpError(403, "CSRF validation failed.");
}
};
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-Credentials", "true");
headers.set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token");
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
headers.append("Vary", "Origin");
}
headers.append("Vary", "Cookie");
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 }
});
}
};
+105
View File
@@ -0,0 +1,105 @@
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
.string()
.trim()
.toLowerCase()
.regex(emailRegex, "Invalid email address.")
.refine((value) => !value.includes(".."), "Invalid 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.");
export const loginSchema = z.object({
email: strictEmailSchema,
password: z.string().min(8).max(256),
totpCode: z
.string()
.trim()
.regex(/^\d{6}$/)
.optional()
});
export const profileSchema = z.object({
displayName: z.string().trim().min(1).max(80)
});
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 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 messageIdSchema = z.object({
id: z.string().trim().regex(/^[a-f0-9]{16,64}$/)
});
export const ensureForwardingTargetAllowed = (mailboxEmail: string, target: string | null): void => {
if (!target) {
return;
}
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;
};