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
+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.
}
};