import fs from 'node:fs'; import * as fsPromises from 'node:fs/promises'; import path from 'node:path'; import * as plugins from './plugins.ts'; import { createInitialConfigFromEnv, normalizeConfig, type IAppConfig, } from './config.ts'; import type { IFaxMessage } from './faxbox.ts'; import type { IFaxJob } from './faxjobs.ts'; import type { IVoicemailMessage } from './voicebox.ts'; interface ISiprouterDataStore { appConfig: IAppConfig; faxJobs: IFaxJob[]; faxMessagesByBox: Record; voicemailMessagesByBox: Record; } type TLogFunction = (messageArg: string) => void; const legacyConfigPath = path.join(process.cwd(), '.nogit', 'config.json'); function requiredEnv(keysArg: string[]): string { for (const key of keysArg) { const value = process.env[key]; if (value) return value; } throw new Error(`Missing required environment variable: ${keysArg.join(' or ')}`); } function optionalNumber(valueArg: string | undefined, fallbackArg?: number): number | undefined { if (!valueArg) return fallbackArg; const parsed = Number(valueArg); return Number.isFinite(parsed) ? parsed : fallbackArg; } function optionalBoolean(valueArg: string | undefined, fallbackArg?: boolean): boolean | undefined { if (valueArg === undefined) return fallbackArg; return !['0', 'false', 'no', 'off'].includes(valueArg.toLowerCase()); } function normalizeObjectKey(keyArg: string): string { const normalizedKey = keyArg.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/'); if (normalizedKey.split('/').includes('..')) { throw new Error(`Invalid object key: ${keyArg}`); } return normalizedKey; } export class SiprouterStorage { private db!: InstanceType; private store!: any; private bucket!: any; private readonly cacheDir = path.join(process.cwd(), '.nogit', 'cache'); private readonly log: TLogFunction; constructor(logArg: TLogFunction) { this.log = logArg; } public async init(): Promise { this.db = new plugins.smartdata.SmartdataDb(this.getMongoDescriptor() as any); await this.db.init(); this.store = await this.db.createEasyStore('siprouter-data'); const smartBucket = new plugins.smartbucket.SmartBucket(this.getS3Descriptor() as any); const bucketName = requiredEnv(['SIPROUTER_S3_BUCKET', 'S3_BUCKET']); this.bucket = await smartBucket.bucketExists(bucketName) ? await smartBucket.getBucketByName(bucketName) : await smartBucket.createBucket(bucketName); await fsPromises.mkdir(this.cacheDir, { recursive: true }); this.log('[storage] smartdata and smartbucket initialized'); } public async close(): Promise { if (this.db) { await this.db.close(); } } public async getAppConfig(): Promise { const storedConfig = await this.readKey('appConfig'); if (storedConfig) { return normalizeConfig(storedConfig); } const legacyConfig = await this.readLegacyConfig(); const initialConfig = legacyConfig || createInitialConfigFromEnv(); await this.writeAppConfig(initialConfig); this.log(legacyConfig ? '[storage] imported legacy .nogit/config.json into smartdata' : '[storage] created initial smartdata config'); return initialConfig; } public async writeAppConfig(configArg: IAppConfig): Promise { await this.writeKey('appConfig', normalizeConfig(configArg)); } public async getFaxJobs(): Promise { return (await this.readKey('faxJobs')) || []; } public async writeFaxJobs(jobsArg: IFaxJob[]): Promise { await this.writeKey('faxJobs', jobsArg); } public async getVoicemailMessages(boxIdArg: string): Promise { const allMessages = (await this.readKey('voicemailMessagesByBox')) || {}; return allMessages[boxIdArg] || []; } public async writeVoicemailMessages(boxIdArg: string, messagesArg: IVoicemailMessage[]): Promise { const allMessages = (await this.readKey('voicemailMessagesByBox')) || {}; allMessages[boxIdArg] = messagesArg; await this.writeKey('voicemailMessagesByBox', allMessages); } public async getFaxMessages(boxIdArg: string): Promise { const allMessages = (await this.readKey('faxMessagesByBox')) || {}; return allMessages[boxIdArg] || []; } public async writeFaxMessages(boxIdArg: string, messagesArg: IFaxMessage[]): Promise { const allMessages = (await this.readKey('faxMessagesByBox')) || {}; allMessages[boxIdArg] = messagesArg; await this.writeKey('faxMessagesByBox', allMessages); } public async putFileObject(objectKeyArg: string, filePathArg: string): Promise { const objectKey = normalizeObjectKey(objectKeyArg); const contents = await fsPromises.readFile(filePathArg); await this.bucket.fastPut({ path: objectKey, contents, overwrite: true }); await this.removeCachedObject(objectKey); return objectKey; } public async putBufferObject(objectKeyArg: string, bufferArg: Buffer): Promise { const objectKey = normalizeObjectKey(objectKeyArg); await this.bucket.fastPut({ path: objectKey, contents: bufferArg, overwrite: true }); await this.removeCachedObject(objectKey); return objectKey; } public async getObjectAsCachedFile(objectKeyArg: string, fileNameArg?: string): Promise { const objectKey = normalizeObjectKey(objectKeyArg); const cachePath = this.getCachePath(objectKey); try { if (fs.existsSync(cachePath)) { return cachePath; } const contents = await this.bucket.fastGet({ path: objectKey }); await fsPromises.mkdir(path.dirname(cachePath), { recursive: true }); await fsPromises.writeFile(cachePath, contents); return cachePath; } catch { if (fileNameArg) { const fallbackPath = path.join(this.cacheDir, path.basename(fileNameArg)); return fs.existsSync(fallbackPath) ? fallbackPath : null; } return null; } } public async removeObject(objectKeyArg: string | undefined): Promise { if (!objectKeyArg) return; const objectKey = normalizeObjectKey(objectKeyArg); try { await this.bucket.fastRemove({ path: objectKey }); } catch { // Missing objects are harmless during metadata cleanup. } await this.removeCachedObject(objectKey); } private getCachePath(objectKeyArg: string): string { return path.join(this.cacheDir, normalizeObjectKey(objectKeyArg)); } private async removeCachedObject(objectKeyArg: string): Promise { await fsPromises.rm(this.getCachePath(objectKeyArg), { force: true }).catch(() => {}); } private async readLegacyConfig(): Promise { try { const raw = await fsPromises.readFile(legacyConfigPath, 'utf8'); return normalizeConfig(JSON.parse(raw) as IAppConfig); } catch { return null; } } private async readKey(keyArg: TKey): Promise { try { return await this.store.readKey(keyArg) as ISiprouterDataStore[TKey] | undefined; } catch { return undefined; } } private async writeKey( keyArg: TKey, valueArg: ISiprouterDataStore[TKey], ): Promise { await this.store.writeKey(keyArg, valueArg); } private getMongoDescriptor(): Record { const mongoDbUrl = requiredEnv([ 'SIPROUTER_MONGODB_URL', 'MONGODB_URI', 'MONGODB_URL', ]); const descriptor: Record = { mongoDbUrl, mongoDbName: process.env.SIPROUTER_MONGODB_NAME || process.env.MONGODB_DATABASE || process.env.MONGODB_NAME || 'siprouter', }; const mongoDbUser = process.env.SIPROUTER_MONGODB_USER || process.env.MONGODB_USERNAME || process.env.MONGODB_USER; const mongoDbPass = process.env.SIPROUTER_MONGODB_PASS || process.env.MONGODB_PASSWORD || process.env.MONGODB_PASS; if (mongoDbUser) descriptor.mongoDbUser = mongoDbUser; if (mongoDbPass) descriptor.mongoDbPass = mongoDbPass; return descriptor; } private getS3Descriptor(): Record { const rawEndpoint = requiredEnv(['SIPROUTER_S3_ENDPOINT', 'S3_ENDPOINT', 'AWS_ENDPOINT_URL']); let endpoint = rawEndpoint; let port = optionalNumber(process.env.SIPROUTER_S3_PORT || process.env.S3_PORT); let useSsl = optionalBoolean(process.env.SIPROUTER_S3_USESSL || process.env.S3_USESSL || process.env.S3_USE_SSL); if (/^https?:\/\//.test(rawEndpoint)) { const url = new URL(rawEndpoint); endpoint = url.hostname; port = url.port ? Number(url.port) : port; useSsl = url.protocol === 'https:'; } return { endpoint, accessKey: requiredEnv(['SIPROUTER_S3_ACCESS_KEY', 'S3_ACCESS_KEY', 'AWS_ACCESS_KEY_ID']), accessSecret: requiredEnv(['SIPROUTER_S3_SECRET_KEY', 'S3_SECRET_KEY', 'AWS_SECRET_ACCESS_KEY']), region: process.env.SIPROUTER_S3_REGION || process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1', ...(port ? { port } : {}), ...(useSsl !== undefined ? { useSsl } : {}), }; } }