165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
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;
|
|
};
|
|
|
|
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 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 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 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.
|
|
}
|
|
};
|