251 lines
9.1 KiB
TypeScript
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 } : {}),
|
|
};
|
|
}
|
|
}
|