import * as Sentry from '@sentry/node'; import type { StartSpanOptions } from '@sentry/types'; import express from 'express'; import type { Request, Response, NextFunction } from 'express'; import session from 'express-session'; import grant from 'grant'; import { useBase } from 'h3'; import { defineExpressHandler, getH3Event } from 'h3-express'; import buildLocaleList from '../src/buildLocaleList.ts'; import { longtimeCookieSetting } from '../src/cookieSettings.ts'; import formatError from '../src/error.ts'; import './dotenv.ts'; import dbConnection from './db.ts'; import type { Database, SQLQuery } from './db.ts'; import adminRoute from './express/admin.ts'; import calendarRoute from './express/calendar.ts'; import censusRoute from './express/census.ts'; import discord from './express/discord.ts'; import grantOverridesRoute from './express/grantOverrides.ts'; import imagesRoute from './express/images.ts'; import mfaRoute from './express/mfa.ts'; import profileRoute from './express/profile.ts'; import pronounceRoute from './express/pronounce.ts'; import sentryRoute from './express/sentry.ts'; import subscriptionRoute from './express/subscription.ts'; import translationsRoute from './express/translations.ts'; import userRoute from './express/user.ts'; import { config } from './social.ts'; import { closeAuditLogConnection } from '~/server/audit.ts'; import { getLocale } from '~/server/data.ts'; class StorageStore extends session.Store { get(sid: string, callback: (err: unknown, session?: (session.SessionData | null)) => void): void { useStorage('session').getItem(sid) .then((session) => callback(null, session)) .catch((error) => callback(error)); } set(sid: string, session: session.SessionData, callback?: (err?: unknown) => void): void { // unwrap session to make it a primitive object (otherwise unstorage will reject serializing the object) useStorage('session').setItem(sid, { ...session }, { ttl: 24 * 60 * 60 }) .then(() => callback?.()) .catch((error) => callback?.(error)); } destroy(sid: string, callback?: (err?: unknown) => void): void { useStorage('session').removeItem(sid) .then(() => callback?.()) .catch((error) => callback?.(error)); } } const router = express.Router(); router.use(session({ secret: process.env.SECRET!, cookie: { ...longtimeCookieSetting, sameSite: undefined }, // somehow, sameSite=lax breaks sign-in with apple 🙄 resave: false, saveUninitialized: false, store: new StorageStore(), })); export class LazyDatabase implements Database { db: Database | null; constructor() { this.db = null; } async init(): Promise { if (this.db === null) { this.db = await dbConnection(); // https://kerkour.com/sqlite-for-servers await this.db.get('PRAGMA journal_mode = WAL;'); await this.db.get('PRAGMA busy_timeout = 5000;'); await this.db.get('PRAGMA synchronous = NORMAL;'); await this.db.get('PRAGMA cache_size = 1000000000;'); await this.db.get('PRAGMA foreign_keys = true;'); await this.db.get('PRAGMA temp_store = memory;'); } } buildSpanOptions(sql: SQLQuery): StartSpanOptions { return { name: typeof sql === 'string' ? sql : sql.sql, op: 'db', attributes: { 'db.system': 'sqlite', }, }; } async get(sql: SQLQuery, ...args: unknown[]): Promise { await this.init(); return Sentry.startSpan(this.buildSpanOptions(sql), () => this.db!.get(sql, ...args)); } async each(sql: SQLQuery, callback: (err: unknown, row: T) => void): Promise { await this.init(); return Sentry.startSpan(this.buildSpanOptions(sql), () => this.db!.each(sql, callback)); } async all(sql: SQLQuery, ...args: unknown[]): Promise { await this.init(); return Sentry.startSpan(this.buildSpanOptions(sql), () => this.db!.all(sql, ...args)); } async close(): Promise { if (this.db !== null) { try { await this.db.close(); this.db = null; } catch (error) { Sentry.captureException(error); } } } } router.use(async function (req, res, next) { try { const locale = getLocale(getH3Event(req)); const authentication = await useAuthentication(getH3Event(req)); req.rawUser = authentication.rawUser; req.user = authentication.user; req.isGranted = authentication.isGranted; req.locales = buildLocaleList(locale, locale === '_'); req.db = new LazyDatabase(); res.on('finish', async () => { await req.db.close(); await closeAuditLogConnection(); }); res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Headers', 'authorization,content-type'); // necessary for grant and not added by h3-express res.locals ??= {}; next(); } catch (err) { next(err); } }); router.use(grantOverridesRoute); router.use(grant.express()(config)); router.use(sentryRoute); router.use(userRoute); router.use(profileRoute); router.use(adminRoute); router.use(mfaRoute); router.use(pronounceRoute); router.use(censusRoute); router.use(imagesRoute); router.use(calendarRoute); router.use(translationsRoute); router.use(subscriptionRoute); router.use(discord); router.use((err: Error, req: Request, res: Response, _next: NextFunction) => { console.error(formatError(err, req)); res.status(500).send('Unexpected server error'); req.db.close(); closeAuditLogConnection(); }); router.use((_req, res) => res.status(404).send('Not found')); export default useBase('/api', defineExpressHandler(router));