PronounsPage/server/index.ts
2025-03-23 11:44:39 +01:00

175 lines
5.9 KiB
TypeScript

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<session.SessionData>(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<void> {
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<T = unknown>(sql: SQLQuery, ...args: unknown[]): Promise<T | undefined> {
await this.init();
return Sentry.startSpan(this.buildSpanOptions(sql), () => this.db!.get(sql, ...args));
}
async each<T = unknown>(sql: SQLQuery, callback: (err: unknown, row: T) => void): Promise<number> {
await this.init();
return Sentry.startSpan(this.buildSpanOptions(sql), () => this.db!.each(sql, callback));
}
async all<T = unknown>(sql: SQLQuery, ...args: unknown[]): Promise<T[]> {
await this.init();
return Sentry.startSpan(this.buildSpanOptions(sql), () => this.db!.all(sql, ...args));
}
async close(): Promise<void> {
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));