v3: search engine, responsive header with compact user menu

This commit is contained in:
khannurien
2026-03-29 11:56:31 +00:00
parent f0f6472db6
commit cbb3505139
31 changed files with 1206 additions and 178 deletions

View File

@@ -1,11 +1,69 @@
export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http";
export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost";
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || "";
export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
// GERBEUR_LISTEN_HOST controls the network interface Oak binds to.
// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box.
// Set to 127.0.0.1 to restrict to loopback only.
export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
export const DB_PATH = "api/sql/gerbeur.db";
// Upload/files
export const UPLOADS_DIR = "api/uploads";
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export const ALLOWED_IMAGE_MIMES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]);
export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
export const DUMP_ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
export const DUMP_ALLOWED_MIME_TYPES = new Set([
"application/pdf",
"application/json",
"application/zip",
"application/x-zip-compressed",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
]);
// Pagination
export const PAGINATION_DEFAULT_LIMIT = 20;
export const PAGINATION_MAX_LIMIT = 100;
// Startup housekeeping
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
export const UNUSED_INVITES_RETENTION_DAYS = 7;
// Validation constraints
export const VALIDATION = {
USERNAME_MIN: 1,
USERNAME_MAX: 32,
PASSWORD_MIN: 8,
PASSWORD_MAX: 128,
DUMP_TITLE_MAX: 200,
DUMP_COMMENT_MAX: 5000,
PLAYLIST_TITLE_MAX: 100,
PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000,
} as const;
// SEO/OG
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
"http://localhost:3000";

View File

@@ -7,14 +7,14 @@ import {
isAuthPayload,
isInvitePayload,
} from "../model/interfaces.ts";
import { JWT_SECRET } from "../config.ts";
const jwtSecret = Deno.env.get("GERBEUR_JWT_SECRET");
if (!jwtSecret) {
if (!JWT_SECRET) {
throw new Error(
"GERBEUR_JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32",
);
}
const JWT_KEY = new TextEncoder().encode(jwtSecret);
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
// ── Invite tokens ─────────────────────────────────────────────────────────────

View File

@@ -1,3 +1,8 @@
import {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from "../config.ts";
/**
* Parses page/limit query parameters with sensible defaults and bounds.
* page: clamped to [1, ∞)
@@ -5,7 +10,7 @@
*/
export function parsePagination(
params: URLSearchParams,
defaultLimit = 20,
defaultLimit = PAGINATION_DEFAULT_LIMIT,
): { page: number; limit: number } {
const page = Math.max(
1,
@@ -16,7 +21,7 @@ export function parsePagination(
1,
parseInt(params.get("limit") ?? String(defaultLimit)) || defaultLimit,
),
100,
PAGINATION_MAX_LIMIT,
);
return { page, limit };
}

View File

@@ -1,20 +1,23 @@
import type { Context } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import {
ALLOWED_IMAGE_MIMES,
ATTACHMENTS_DIR,
AVATARS_DIR,
DUMPS_DIR,
MAX_IMAGE_SIZE_BYTES,
PLAYLIST_IMAGES_DIR,
UPLOADS_DIR,
} from "../config.ts";
export const UPLOADS_DIR = "api/uploads";
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
export const ALLOWED_IMAGE_MIMES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]);
export {
ALLOWED_IMAGE_MIMES,
ATTACHMENTS_DIR,
AVATARS_DIR,
DUMPS_DIR,
PLAYLIST_IMAGES_DIR,
UPLOADS_DIR,
};
/** Detect image MIME type from magic bytes, ignoring the browser-declared type. */
export function detectImageMime(data: Uint8Array): string | null {
@@ -39,7 +42,7 @@ export function detectImageMime(data: Uint8Array): string | null {
/** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */
export function validateImageUpload(data: Uint8Array): string {
if (data.length > MAX_IMAGE_SIZE) {
if (data.length > MAX_IMAGE_SIZE_BYTES) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,

View File

@@ -13,6 +13,7 @@ import commentsRouter from "./routes/comments.ts";
import followsRouter from "./routes/follows.ts";
import notificationsRouter from "./routes/notifications.ts";
import invitesRouter from "./routes/invites.ts";
import searchRouter from "./routes/search.ts";
import { ALLOWED_ORIGINS, BASE_URL, LISTEN_HOST, PORT } from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts";
@@ -83,6 +84,10 @@ app.use(
invitesRouter.routes(),
invitesRouter.allowedMethods(),
);
app.use(
searchRouter.routes(),
searchRouter.allowedMethods(),
);
app.use(routeStaticFilesFrom([
`${Deno.cwd()}/dist`,
`${Deno.cwd()}/public`,

View File

@@ -2,6 +2,7 @@ import { Context, Next } from "@oak/oak";
import { getDump } from "../services/dump-service.ts";
import { getUserByUsername } from "../services/user-service.ts";
import { getPlaylistById } from "../services/playlist-service.ts";
import { OG_SITE_NAME } from "../config.ts";
interface OGMeta {
title: string;
@@ -10,8 +11,6 @@ interface OGMeta {
url: string;
}
const SITE_NAME = "gerbeur";
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&")
@@ -24,7 +23,7 @@ function buildTags(meta: OGMeta): string {
const card = meta.imageUrl ? "summary_large_image" : "summary";
const tags = [
`<title>${escapeAttr(meta.title)}</title>`,
`<meta property="og:site_name" content="${SITE_NAME}" />`,
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeAttr(meta.url)}" />`,
`<meta property="og:title" content="${escapeAttr(meta.title)}" />`,

View File

@@ -9,26 +9,31 @@ import {
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("api/sql/gerbeur.db");
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', '-7 days');`,
`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', '-1 hour');`,
`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) {
const { ATTACHMENTS_DIR } = await import("../lib/upload.ts");
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', '-1 hour');`,
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
).run();
}

View File

@@ -1,21 +1,9 @@
import { VALIDATION } from "../config.ts";
/**
* Backend
*/
// ── Validation constants (shared with frontend via src/config/api.ts) ──────────
export const VALIDATION = {
USERNAME_MIN: 1,
USERNAME_MAX: 32,
PASSWORD_MIN: 8,
PASSWORD_MAX: 128,
DUMP_TITLE_MAX: 200,
DUMP_COMMENT_MAX: 5000,
PLAYLIST_TITLE_MAX: 100,
PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000,
} as const;
export interface RichContent {
type: string;
url: string;

47
api/routes/search.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Router } from "@oak/oak";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { searchDumps } from "../services/dump-service.ts";
import { searchUsersGlobal } from "../services/user-service.ts";
import { searchPlaylists } from "../services/playlist-service.ts";
const router = new Router({ prefix: "/api/search" });
router.get("/", async (ctx) => {
const params = ctx.request.url.searchParams;
const q = (params.get("q") ?? "").trim();
const { page, limit } = parsePagination(params);
const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined;
if (!q) {
ctx.response.body = {
success: true,
data: {
dumps: { items: [], total: 0, hasMore: false },
users: [],
playlists: [],
},
};
return;
}
const { items: dumpItems, total: dumpTotal } = searchDumps(
q,
page,
limit,
requestingUserId,
);
const users = searchUsersGlobal(q, 20);
const playlists = searchPlaylists(q, 20, requestingUserId);
ctx.response.body = {
success: true,
data: {
dumps: { items: dumpItems, total: dumpTotal, hasMore: page * limit < dumpTotal },
users,
playlists,
},
};
});
export default router;

View File

@@ -17,28 +17,17 @@ import {
notifyUserFollowersNewDump,
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
import { DUMPS_DIR } from "../lib/upload.ts";
import {
DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES,
DUMP_MAX_FILE_SIZE_BYTES,
DUMPS_DIR,
} from "../config.ts";
import { linkAttachments } from "./attachment-service.ts";
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
const ALLOWED_MIME_TYPES = new Set([
"application/pdf",
"application/json",
"application/zip",
"application/x-zip-compressed",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
]);
function isAllowedMime(mime: string): boolean {
return ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
ALLOWED_MIME_TYPES.has(mime);
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
DUMP_ALLOWED_MIME_TYPES.has(mime);
}
function titleFromUrl(url: string): string {
@@ -128,7 +117,7 @@ export async function createFileDump(
`File type '${file.type}' is not allowed`,
);
}
if (file.size > MAX_FILE_SIZE) {
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
@@ -216,6 +205,39 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
return dump;
}
export function searchDumps(
query: string,
page: number,
limit: number,
requestingUserId?: string,
): { items: Dump[]; total: number } {
if (!query.trim()) return { items: [], total: 0 };
const pattern = `%${query}%`;
const offset = (page - 1) * limit;
const searchClause =
`(title LIKE ? OR comment LIKE ? OR json_extract(rich_content,'$.title') LIKE ? OR json_extract(rich_content,'$.description') LIKE ?)`;
const searchParams = [pattern, pattern, pattern, pattern] as const;
if (requestingUserId) {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(requestingUserId, ...searchParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 };
} else {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(...searchParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined;
return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 };
}
}
export function listDumps(
page: number,
limit: number,
@@ -367,7 +389,7 @@ export async function replaceFileDump(
`File type '${file.type}' is not allowed`,
);
}
if (file.size > MAX_FILE_SIZE) {
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,

View File

@@ -0,0 +1,93 @@
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
import { SMTPS_URL } from "../config.ts";
export type EmailRecipient = string | string[];
export interface EmailMessage {
from: string;
to: EmailRecipient;
subject: string;
text?: string;
html?: string;
cc?: EmailRecipient;
bcc?: EmailRecipient;
replyTo?: string;
}
export interface EmailSendResult {
messageId: string;
accepted: string[];
rejected: string[];
}
let transporter: Transporter | null = null;
export function isEmailEnabled(): boolean {
return SMTPS_URL.length > 0;
}
function normalizeRecipients(
recipient: string | readonly (string | { address?: string })[] | undefined,
): string[] {
if (!recipient) return [];
if (typeof recipient === "string") {
return recipient.split(",").map((s) => s.trim()).filter(Boolean);
}
return recipient.flatMap((entry) =>
typeof entry === "string" ? entry.trim() : (entry.address?.trim() ?? "")
).filter(Boolean);
}
function getTransporter(): Transporter {
if (!isEmailEnabled()) {
throw new Error(
"GERBEUR_SMTPS_URL environment variable is required to send emails.",
);
}
if (!transporter) {
transporter = nodemailer.createTransport(SMTPS_URL);
}
return transporter;
}
export async function verifyEmailTransport(): Promise<boolean> {
if (!isEmailEnabled()) return false;
await getTransporter().verify();
return true;
}
export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> {
if (normalizeRecipients(message.to).length === 0) {
throw new Error("Email recipient is required.");
}
if (!message.subject.trim()) {
throw new Error("Email subject is required.");
}
if (!message.text?.trim() && !message.html?.trim()) {
throw new Error("Email body is required (text and/or html).");
}
const options: SendMailOptions = {
from: message.from,
to: message.to,
cc: message.cc,
bcc: message.bcc,
subject: message.subject,
text: message.text,
html: message.html,
replyTo: message.replyTo,
};
const info = await getTransporter().sendMail(options);
return {
messageId: info.messageId,
accepted: normalizeRecipients(info.accepted),
rejected: normalizeRecipients(info.rejected),
};
}

View File

@@ -112,6 +112,26 @@ export function getPlaylist(
return { ...playlist, dumps: visibleDumps };
}
export function searchPlaylists(
query: string,
limit: number,
requestingUserId?: string,
): Playlist[] {
if (!query.trim()) return [];
const pattern = `%${query}%`;
const searchClause = `(p.title LIKE ? OR p.description LIKE ?)`;
const rows = requestingUserId
? db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(requestingUserId, pattern, pattern, limit)
: db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(pattern, pattern, limit);
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
export function listPlaylistsByUser(
userId: string,
requestingUserId: string | null,

View File

@@ -1,6 +1,6 @@
import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts";
import { fetchWithTimeout } from "../rich-content-service.ts";
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
function extractVideoId(url: string): string | null {
try {
@@ -24,38 +24,123 @@ function extractVideoId(url: string): string | null {
return null;
}
function extractPlaylistId(url: string): string | null {
try {
return new URL(url).searchParams.get("list");
} catch {
return null;
}
}
/** Matches /channel/UC…, /@handle, /c/name, /user/name */
function extractChannelPath(url: string): string | null {
try {
const u = new URL(url);
if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") {
if (
u.pathname.startsWith("/channel/") ||
u.pathname.startsWith("/@") ||
u.pathname.startsWith("/c/") ||
u.pathname.startsWith("/user/")
) {
return u.pathname;
}
}
} catch {
// invalid URL
}
return null;
}
async function fetchOEmbed(
url: string,
): Promise<{ title?: string; thumbnailUrl?: string }> {
try {
const oembedUrl =
`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const res = await fetchWithTimeout(oembedUrl);
if (res.ok) {
const data = await res.json() as {
title?: string;
thumbnail_url?: string;
};
return { title: data.title, thumbnailUrl: data.thumbnail_url };
}
} catch {
// oembed failed — carry on
}
return {};
}
export const youtubeProvider: RichContentProvider = {
name: "youtube",
matches(url: string): boolean {
return extractVideoId(url) !== null;
return (
extractVideoId(url) !== null ||
extractPlaylistId(url) !== null ||
extractChannelPath(url) !== null
);
},
async fetch(url: string): Promise<RichContent> {
const videoId = extractVideoId(url)!;
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
let title: string | undefined;
const videoId = extractVideoId(url);
const listId = extractPlaylistId(url);
const channelPath = extractChannelPath(url);
try {
const oembedUrl =
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
const res = await fetchWithTimeout(oembedUrl);
if (res.ok) {
const data = await res.json() as { title?: string };
title = data.title;
// ── Channel ───────────────────────────────────────────────────────────────
if (channelPath && !videoId) {
// oEmbed doesn't support channel URLs — scrape og:image from the page instead
let title: string | undefined;
let thumbnailUrl: string | undefined;
try {
const res = await fetchWithTimeout(url);
if (res.ok) {
const html = await res.text();
title = extractOgTag(html, "title");
thumbnailUrl = extractOgTag(html, "image");
}
} catch {
// scrape failed — carry on with undefined values
}
} catch {
// oembed failed — thumbnail still works
return {
type: "youtube",
siteName: "YouTube",
url,
title,
thumbnailUrl,
// channels are not embeddable as a player
};
}
// ── Playlist (no specific video) ──────────────────────────────────────────
if (listId && !videoId) {
const { title, thumbnailUrl } = await fetchOEmbed(url);
return {
type: "youtube",
siteName: "YouTube",
url,
title,
thumbnailUrl,
embedUrl: `https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`,
};
}
// ── Video (with optional playlist context) ────────────────────────────────
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
const { title } = await fetchOEmbed(url);
const embedParams = new URLSearchParams({ rel: "0" });
if (listId) embedParams.set("list", listId);
return {
type: "youtube",
siteName: "YouTube",
url,
videoId,
videoId: videoId!,
title,
thumbnailUrl,
embedUrl: `https://www.youtube.com/embed/${videoId}?rel=0`,
embedUrl: `https://www.youtube.com/embed/${videoId}?${embedParams}`,
};
},
};

View File

@@ -102,6 +102,15 @@ export function searchUsers(
}));
}
export function searchUsersGlobal(query: string, limit: number): User[] {
if (!query.trim()) return [];
const pattern = `%${query}%`;
const rows = db.prepare(
`${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`,
).all(pattern, pattern, limit);
return rows.filter(isUserRow).map(userRowToApi);
}
export function listUsers(): User[] {
const userRows = db.prepare(
`${USER_SELECT}`,

View File

@@ -1,14 +1,13 @@
import { DatabaseSync } from "node:sqlite";
const DB_FILE = "api/sql/gerbeur.db";
import { DB_PATH } from "../config.ts";
try {
await Deno.stat(DB_FILE);
await Deno.stat(DB_PATH);
console.log("Database already exists, skipping initialization.");
} catch {
console.log("Initializing database from schema...");
const schema = Deno.readTextFileSync("api/sql/schema.sql");
const db = new DatabaseSync(DB_FILE);
const db = new DatabaseSync(DB_PATH);
db.exec(schema);
db.close();
console.log("Database initialized.");