Files
siprouter/ts/storage.ts
T

251 lines
9.1 KiB
TypeScript

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<string, IFaxMessage[]>;
voicemailMessagesByBox: Record<string, IVoicemailMessage[]>;
}
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<typeof plugins.smartdata.SmartdataDb>;
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<void> {
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<void> {
if (this.db) {
await this.db.close();
}
}
public async getAppConfig(): Promise<IAppConfig> {
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<void> {
await this.writeKey('appConfig', normalizeConfig(configArg));
}
public async getFaxJobs(): Promise<IFaxJob[]> {
return (await this.readKey('faxJobs')) || [];
}
public async writeFaxJobs(jobsArg: IFaxJob[]): Promise<void> {
await this.writeKey('faxJobs', jobsArg);
}
public async getVoicemailMessages(boxIdArg: string): Promise<IVoicemailMessage[]> {
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
return allMessages[boxIdArg] || [];
}
public async writeVoicemailMessages(boxIdArg: string, messagesArg: IVoicemailMessage[]): Promise<void> {
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
allMessages[boxIdArg] = messagesArg;
await this.writeKey('voicemailMessagesByBox', allMessages);
}
public async getFaxMessages(boxIdArg: string): Promise<IFaxMessage[]> {
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
return allMessages[boxIdArg] || [];
}
public async writeFaxMessages(boxIdArg: string, messagesArg: IFaxMessage[]): Promise<void> {
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
allMessages[boxIdArg] = messagesArg;
await this.writeKey('faxMessagesByBox', allMessages);
}
public async putFileObject(objectKeyArg: string, filePathArg: string): Promise<string> {
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<string> {
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<string | null> {
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<void> {
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<void> {
await fsPromises.rm(this.getCachePath(objectKeyArg), { force: true }).catch(() => {});
}
private async readLegacyConfig(): Promise<IAppConfig | null> {
try {
const raw = await fsPromises.readFile(legacyConfigPath, 'utf8');
return normalizeConfig(JSON.parse(raw) as IAppConfig);
} catch {
return null;
}
}
private async readKey<TKey extends keyof ISiprouterDataStore>(keyArg: TKey): Promise<ISiprouterDataStore[TKey] | undefined> {
try {
return await this.store.readKey(keyArg) as ISiprouterDataStore[TKey] | undefined;
} catch {
return undefined;
}
}
private async writeKey<TKey extends keyof ISiprouterDataStore>(
keyArg: TKey,
valueArg: ISiprouterDataStore[TKey],
): Promise<void> {
await this.store.writeKey(keyArg, valueArg);
}
private getMongoDescriptor(): Record<string, string> {
const mongoDbUrl = requiredEnv([
'SIPROUTER_MONGODB_URL',
'MONGODB_URI',
'MONGODB_URL',
]);
const descriptor: Record<string, string> = {
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<string, string | number | boolean> {
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 } : {}),
};
}
}