import { randomBytes, scryptSync } from "node:crypto"; import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { type Comment, Dump, type Notification, type NotificationType, type Playlist, type RichContent, type User, } from "./interfaces.ts"; import { ATTACHMENTS_DIR, DB_PATH, ORPHANED_ATTACHMENTS_RETENTION_HOURS, UNUSED_INVITES_RETENTION_DAYS, } from "../config.ts"; export const db = new DatabaseSync(DB_PATH); db.exec("PRAGMA foreign_keys = ON;"); // Purge expired unused invites on startup db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, ).run(); // Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour const orphanedAttachments = db.prepare( `SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, ).all() as { id: string }[]; if (orphanedAttachments.length > 0) { for (const { id } of orphanedAttachments) { await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {}); } db.prepare( `DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, ).run(); } // Create default admin user if no users exist const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as { count: number; }; if (userCount.count === 0) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync("admin", salt, 64).toString("hex"); const passwordHash = `${hash}.${salt}`; db.prepare( `INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, 'admin', ?, 1, datetime('now'))`, ).run(crypto.randomUUID(), passwordHash); console.log("Created default admin user (username: admin, password: admin)"); } /** * Database Row Types */ export interface DumpRow { id: string; kind: string; title: string; comment: string | null; user_id: string; created_at: string; updated_at: string | null; slug: string | null; url: string | null; rich_content: string | null; file_name: string | null; file_mime: string | null; file_size: number | null; vote_count: number; comment_count: number; is_private: number; [key: string]: SQLOutputValue; // Index signature } export interface UserRow { id: string; username: string; password_hash: string; is_admin: number; created_at: string; updated_at: string | null; avatar_mime: string | null; description: string | null; invited_by: string | null; // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by invited_by_username: string | null; [key: string]: SQLOutputValue; // Index signature } /** * Type Guards */ export function isDumpRow(obj: unknown): obj is DumpRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && "kind" in obj && typeof obj.kind === "string" && "title" in obj && typeof obj.title === "string" && "comment" in obj && (typeof obj.comment === "string" || obj.comment === null) && "user_id" in obj && typeof obj.user_id === "string" && "created_at" in obj && typeof obj.created_at === "string" && "updated_at" in obj && (typeof obj.updated_at === "string" || obj.updated_at === null) && "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && "url" in obj && (typeof obj.url === "string" || obj.url === null) && "rich_content" in obj && (typeof obj.rich_content === "string" || obj.rich_content === null) && "file_name" in obj && (typeof obj.file_name === "string" || obj.file_name === null) && "file_mime" in obj && (typeof obj.file_mime === "string" || obj.file_mime === null) && "file_size" in obj && (typeof obj.file_size === "number" || obj.file_size === null) && "vote_count" in obj && typeof obj.vote_count === "number" && "comment_count" in obj && typeof obj.comment_count === "number" && "is_private" in obj && typeof obj.is_private === "number"; } export function isUserRow(obj: unknown): obj is UserRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && "username" in obj && typeof obj.username === "string" && "password_hash" in obj && typeof obj.password_hash === "string" && "is_admin" in obj && typeof obj.is_admin === "number" && "created_at" in obj && typeof obj.created_at === "string" && "updated_at" in obj && (typeof obj.updated_at === "string" || obj.updated_at === null) && "avatar_mime" in obj && (typeof obj.avatar_mime === "string" || obj.avatar_mime === null) && "description" in obj && (typeof obj.description === "string" || obj.description === null) && "invited_by" in obj && (typeof obj.invited_by === "string" || obj.invited_by === null); } /** * Conversion Helpers */ export function dumpRowToApi(row: DumpRow): Dump { return { id: row.id, kind: row.kind as "url" | "file", title: row.title, slug: row.slug ?? undefined, url: row.url ?? undefined, comment: row.comment ?? undefined, userId: row.user_id, createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, richContent: row.rich_content ? (JSON.parse(row.rich_content) as RichContent) : undefined, fileName: row.file_name ?? undefined, fileMime: row.file_mime ?? undefined, fileSize: row.file_size ?? undefined, voteCount: row.vote_count, commentCount: row.comment_count, isPrivate: Boolean(row.is_private), }; } export function dumpApiToRow(dump: Dump): DumpRow { return { id: dump.id, kind: dump.kind, title: dump.title, slug: dump.slug ?? null, comment: dump.comment ?? null, user_id: dump.userId, created_at: dump.createdAt.toISOString(), updated_at: dump.updatedAt?.toISOString() ?? null, url: dump.url ?? null, rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null, file_name: dump.fileName ?? null, file_mime: dump.fileMime ?? null, file_size: dump.fileSize ?? null, vote_count: dump.voteCount, comment_count: dump.commentCount, is_private: dump.isPrivate ? 1 : 0, }; } export function userRowToApi(row: UserRow): User { return { id: row.id, username: row.username, passwordHash: row.password_hash, isAdmin: Boolean(row.is_admin), createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, avatarMime: row.avatar_mime ?? undefined, description: row.description ?? undefined, invitedByUsername: typeof row.invited_by_username === "string" ? row.invited_by_username : undefined, }; } export function userApiToRow(user: User): UserRow { return { id: user.id, username: user.username, password_hash: user.passwordHash, is_admin: user.isAdmin ? 1 : 0, created_at: user.createdAt.toISOString(), updated_at: user.updatedAt?.toISOString() ?? null, avatar_mime: user.avatarMime ?? null, description: user.description ?? null, invited_by: null, invited_by_username: null, }; } export interface CommentRow { id: string; dump_id: string; user_id: string; parent_id: string | null; body: string; created_at: string; updated_at: string | null; deleted: number; author_username: string; author_avatar_mime: string | null; [key: string]: SQLOutputValue; } export function isCommentRow( obj: unknown, ): obj is CommentRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && "dump_id" in obj && typeof obj.dump_id === "string" && "user_id" in obj && typeof obj.user_id === "string" && "parent_id" in obj && (typeof obj.parent_id === "string" || obj.parent_id === null) && "body" in obj && typeof obj.body === "string" && "created_at" in obj && typeof obj.created_at === "string" && "updated_at" in obj && (typeof obj.updated_at === "string" || obj.updated_at === null) && "deleted" in obj && typeof obj.deleted === "number" && "author_username" in obj && typeof obj.author_username === "string" && "author_avatar_mime" in obj && (typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null); } export function commentRowToApi(row: CommentRow): Comment { return { id: row.id, dumpId: row.dump_id, userId: row.user_id, parentId: row.parent_id ?? undefined, body: row.body, createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, deleted: Boolean(row.deleted), authorUsername: row.author_username, authorAvatarMime: row.author_avatar_mime ?? undefined, }; } export interface PlaylistRow { id: string; user_id: string; title: string; slug: string | null; description: string | null; is_public: number; created_at: string; updated_at: string | null; image_mime: string | null; [key: string]: SQLOutputValue; } export function isPlaylistRow( obj: unknown, ): obj is PlaylistRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && "user_id" in obj && typeof obj.user_id === "string" && "title" in obj && typeof obj.title === "string" && "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && "description" in obj && (typeof obj.description === "string" || obj.description === null) && "is_public" in obj && typeof obj.is_public === "number" && "created_at" in obj && typeof obj.created_at === "string" && "updated_at" in obj && (typeof obj.updated_at === "string" || obj.updated_at === null) && "image_mime" in obj && (typeof obj.image_mime === "string" || obj.image_mime === null); } export function playlistRowToApi(row: PlaylistRow): Playlist { return { id: row.id, userId: row.user_id, title: row.title, slug: row.slug ?? undefined, description: row.description ?? undefined, isPublic: Boolean(row.is_public), createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, imageMime: row.image_mime ?? undefined, dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, ownerUsername: typeof row.owner_username === "string" ? row.owner_username : undefined, }; } export interface FollowRow { id: string; follower_id: string; followed_user_id: string | null; followed_playlist_id: string | null; created_at: string; [key: string]: SQLOutputValue; } export function isFollowRow( obj: unknown, ): obj is FollowRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && "follower_id" in obj && typeof obj.follower_id === "string" && "created_at" in obj && typeof obj.created_at === "string" && "followed_user_id" in obj && (obj.followed_user_id === null || typeof obj.followed_user_id === "string") && "followed_playlist_id" in obj && (obj.followed_playlist_id === null || typeof obj.followed_playlist_id === "string"); } // ── Notifications ───────────────────────────────────────────────────────────── export interface NotificationRow { id: string; user_id: string; type: string; data: string; read: number; created_at: string; source_key: string | null; [key: string]: SQLOutputValue; } export function isNotificationRow( obj: unknown, ): obj is NotificationRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && "user_id" in obj && typeof obj.user_id === "string" && "type" in obj && typeof obj.type === "string" && "data" in obj && typeof obj.data === "string" && "read" in obj && typeof obj.read === "number" && "created_at" in obj && typeof obj.created_at === "string" && "source_key" in obj && (typeof obj.source_key === "string" || obj.source_key === null); } export function notificationRowToApi(row: NotificationRow): Notification { return { id: row.id, userId: row.user_id, type: row.type as NotificationType, data: JSON.parse(row.data), read: Boolean(row.read), createdAt: new Date(row.created_at), }; } // ── Invites ─────────────────────────────────────────────────────────────────── export interface InviteRow { token: string; inviter_id: string; used_at: string | null; created_at: string; [key: string]: SQLOutputValue; } export function isInviteRow( obj: unknown, ): obj is InviteRow { return !!obj && typeof obj === "object" && "token" in obj && typeof obj.token === "string" && "inviter_id" in obj && typeof obj.inviter_id === "string" && "created_at" in obj && typeof obj.created_at === "string" && "used_at" in obj && (obj.used_at === null || typeof obj.used_at === "string"); }