diff --git a/src/dispatchers.ts b/src/dispatchers.ts index d4efcee3c..7c33efd97 100644 --- a/src/dispatchers.ts +++ b/src/dispatchers.ts @@ -104,8 +104,22 @@ export const actorDispatcher = (hostDataContextLoader: HostDataContextLoader) => export const keypairDispatcher = ( accountService: AccountService, hostDataContextLoader: HostDataContextLoader, -) => - async function keypairDispatcher(ctx: FedifyContext, identifier: string) { +) => { + const MAX_CACHE_SIZE = 1000; + const KEY_TTL_MS = 30 * 60 * 1000; // 30 minutes + const cryptoKeyCache = new Map< + number, + { + publicKey: CryptoKey; + privateKey: CryptoKey; + createdAt: number; + } + >(); + + return async function keypairDispatcher( + ctx: FedifyContext, + identifier: string, + ) { const hostData = await hostDataContextLoader.loadDataForHost(ctx.host); if (isError(hostData)) { @@ -145,6 +159,14 @@ export const keypairDispatcher = ( const { account } = getValue(hostData); + const cached = cryptoKeyCache.get(account.id); + if (cached) { + if (Date.now() - cached.createdAt < KEY_TTL_MS) { + return [cached]; + } + cryptoKeyCache.delete(account.id); + } + const keyPair = await accountService.getKeyPair(account.id); if (isError(keyPair)) { @@ -176,18 +198,47 @@ export const keypairDispatcher = ( const { publicKey, privateKey } = getValue(keyPair); try { - return [ - { - publicKey: await importJwk( - JSON.parse(publicKey) as JsonWebKey, - 'public', - ), - privateKey: await importJwk( - JSON.parse(privateKey) as JsonWebKey, - 'private', - ), - }, - ]; + const imported = { + publicKey: await importJwk( + JSON.parse(publicKey) as JsonWebKey, + 'public', + ), + privateKey: await importJwk( + JSON.parse(privateKey) as JsonWebKey, + 'private', + ), + }; + + const now = Date.now(); + + // Evict expired entries + for (const [key, entry] of cryptoKeyCache) { + if (now - entry.createdAt >= KEY_TTL_MS) { + cryptoKeyCache.delete(key); + } + } + + // If still full, evict the oldest entry + if (cryptoKeyCache.size >= MAX_CACHE_SIZE) { + let oldestKey: number | undefined; + let oldestTime = Infinity; + for (const [key, entry] of cryptoKeyCache) { + if (entry.createdAt < oldestTime) { + oldestTime = entry.createdAt; + oldestKey = key; + } + } + if (oldestKey !== undefined) { + cryptoKeyCache.delete(oldestKey); + } + } + + cryptoKeyCache.set(account.id, { + ...imported, + createdAt: now, + }); + + return [imported]; } catch (error) { ctx.data.logger.error( 'Could not parse keypair for {host} (identifier: {identifier}): {error}', @@ -200,6 +251,7 @@ export const keypairDispatcher = ( return []; } }; +}; export function createAcceptHandler(accountService: AccountService) { return async function handleAccept(ctx: FedifyContext, accept: Accept) { diff --git a/src/http/host-data-context-loader.ts b/src/http/host-data-context-loader.ts index 49e6fa206..be0f7ed13 100644 --- a/src/http/host-data-context-loader.ts +++ b/src/http/host-data-context-loader.ts @@ -3,23 +3,60 @@ import type { Knex } from 'knex'; import type { Account } from '@/account/account.entity'; import type { KnexAccountRepository } from '@/account/account.repository.knex'; import type { Result } from '@/core/result'; -import { error, ok } from '@/core/result'; +import { error, isError, ok } from '@/core/result'; import type { Site } from '@/site/site.service'; +type HostDataResult = Result< + { site: Site; account: Account }, + 'site-not-found' | 'account-not-found' | 'multiple-users-for-site' +>; + +interface CacheEntry { + result: HostDataResult; + expiry: number; +} + export class HostDataContextLoader { + private static readonly CACHE_TTL_MS = 60_000; + private static readonly MAX_CACHE_SIZE = 1000; + private static readonly CACHE_ENABLED = + process.env.NODE_ENV === 'production'; + private readonly cache = new Map(); + constructor( readonly db: Knex, readonly accountRepository: KnexAccountRepository, ) {} - async loadDataForHost( - host: string, - ): Promise< - Result< - { site: Site; account: Account }, - 'site-not-found' | 'account-not-found' | 'multiple-users-for-site' - > - > { + async loadDataForHost(host: string): Promise { + if (!HostDataContextLoader.CACHE_ENABLED) { + return this.queryDataForHost(host); + } + + const now = Date.now(); + const cached = this.cache.get(host); + + if (cached && cached.expiry > now) { + return cached.result; + } + + const result = await this.queryDataForHost(host); + + if (!isError(result)) { + if (this.cache.size >= HostDataContextLoader.MAX_CACHE_SIZE) { + this.cache.delete(this.cache.keys().next().value!); + } + + this.cache.set(host, { + result, + expiry: now + HostDataContextLoader.CACHE_TTL_MS, + }); + } + + return result; + } + + private async queryDataForHost(host: string): Promise { const results = await this.db('sites') .leftJoin('users', 'users.site_id', 'sites.id') .leftJoin('accounts', 'accounts.id', 'users.account_id')