This commit is contained in:
Eric Gullickson
2025-11-01 21:27:42 -05:00
parent 20953c6dee
commit 046c66fc7d
203 changed files with 5699 additions and 404943 deletions

View File

@@ -0,0 +1,93 @@
import type { StorageService, HeadObjectResult, SignedUrlOptions, ObjectBody } from '../storage.service';
import * as fs from 'fs/promises';
import * as path from 'path';
import { createReadStream, createWriteStream } from 'fs';
import type { Readable } from 'stream';
import { pipeline } from 'stream/promises';
export class FilesystemAdapter implements StorageService {
private basePath: string;
constructor(basePath: string = '/app/data/documents') {
this.basePath = basePath;
}
async putObject(
_bucket: string,
key: string,
body: ObjectBody,
contentType?: string,
metadata?: Record<string, string>
): Promise<void> {
const filePath = path.join(this.basePath, key);
await fs.mkdir(path.dirname(filePath), { recursive: true });
if (Buffer.isBuffer(body) || typeof body === 'string') {
// For Buffer or string, write directly
await fs.writeFile(filePath, body);
} else {
// For Readable stream, pipe to file
const writeStream = createWriteStream(filePath);
await pipeline(body as Readable, writeStream);
}
// Store metadata in a sidecar file if provided
if (metadata || contentType) {
const metaPath = `${filePath}.meta.json`;
const meta: Record<string, string> = { ...(metadata || {}) };
if (contentType) {
meta['content-type'] = contentType;
}
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
}
}
async getObjectStream(_bucket: string, key: string): Promise<Readable> {
const filePath = path.join(this.basePath, key);
return createReadStream(filePath);
}
async deleteObject(_bucket: string, key: string): Promise<void> {
const filePath = path.join(this.basePath, key);
await fs.unlink(filePath);
// Also delete metadata file if it exists
const metaPath = `${filePath}.meta.json`;
try {
await fs.unlink(metaPath);
} catch {
// Ignore if metadata file doesn't exist
}
}
async headObject(_bucket: string, key: string): Promise<HeadObjectResult> {
const filePath = path.join(this.basePath, key);
const stats = await fs.stat(filePath);
// Try to read metadata file
let contentType: string | undefined;
let metadata: Record<string, string> | undefined;
const metaPath = `${filePath}.meta.json`;
try {
const metaContent = await fs.readFile(metaPath, 'utf-8');
const meta = JSON.parse(metaContent);
contentType = meta['content-type'] || meta['Content-Type'];
metadata = meta;
} catch {
// Ignore if metadata file doesn't exist
}
return {
size: stats.size,
lastModified: stats.mtime,
contentType,
metadata,
};
}
async getSignedUrl(_bucket: string, key: string, _options?: SignedUrlOptions): Promise<string> {
// For filesystem storage, we don't use signed URLs
// The documents controller will handle serving the file directly
return `/api/documents/download/${key}`;
}
}

View File

@@ -1,66 +0,0 @@
import { Client as MinioClient } from 'minio';
import type { Readable } from 'stream';
import { appConfig } from '../../config/config-loader';
import type { HeadObjectResult, SignedUrlOptions, StorageService } from '../storage.service';
export function createMinioAdapter(): StorageService {
const { endpoint, port, accessKey, secretKey } = appConfig.getMinioConfig();
const client = new MinioClient({
endPoint: endpoint,
port,
useSSL: false,
accessKey,
secretKey,
});
const normalizeMeta = (contentType?: string, metadata?: Record<string, string>) => {
const meta: Record<string, string> = { ...(metadata || {}) };
if (contentType) meta['Content-Type'] = contentType;
return meta;
};
const adapter: StorageService = {
async putObject(bucket, key, body, contentType, metadata) {
const meta = normalizeMeta(contentType, metadata);
// For Buffer or string, size is known. For Readable, omit size for chunked encoding.
if (Buffer.isBuffer(body) || typeof body === 'string') {
await client.putObject(bucket, key, body as any, (body as any).length ?? undefined, meta);
} else {
await client.putObject(bucket, key, body as Readable, undefined, meta);
}
},
async getObjectStream(bucket, key) {
return client.getObject(bucket, key);
},
async deleteObject(bucket, key) {
await client.removeObject(bucket, key);
},
async headObject(bucket, key): Promise<HeadObjectResult> {
const stat = await client.statObject(bucket, key);
// minio types: size, etag, lastModified, metaData
return {
size: stat.size,
etag: stat.etag,
lastModified: stat.lastModified ? new Date(stat.lastModified) : undefined,
contentType: (stat.metaData && (stat.metaData['content-type'] || stat.metaData['Content-Type'])) || undefined,
metadata: stat.metaData || undefined,
};
},
async getSignedUrl(bucket, key, options?: SignedUrlOptions) {
const expires = Math.max(1, Math.min(7 * 24 * 3600, options?.expiresSeconds ?? 300));
if (options?.method === 'PUT') {
// MinIO SDK has presignedPutObject for PUT
return client.presignedPutObject(bucket, key, expires);
}
// Default GET
return client.presignedGetObject(bucket, key, expires);
},
};
return adapter;
}

View File

@@ -1,9 +1,9 @@
/**
* Provider-agnostic storage facade with S3-compatible surface.
* Initial implementation backed by MinIO using the official SDK.
* Provider-agnostic storage facade with filesystem storage.
* Uses local filesystem for document storage in /app/data/documents.
*/
import type { Readable } from 'stream';
import { createMinioAdapter } from './adapters/minio.adapter';
import { FilesystemAdapter } from './adapters/filesystem.adapter';
export type ObjectBody = Buffer | Readable | string;
@@ -38,11 +38,11 @@ export interface StorageService {
getSignedUrl(bucket: string, key: string, options?: SignedUrlOptions): Promise<string>;
}
// Simple factory — currently only MinIO; can add S3 in future without changing feature code
// Simple factory — uses filesystem storage
let singleton: StorageService | null = null;
export function getStorageService(): StorageService {
if (!singleton) {
singleton = createMinioAdapter();
singleton = new FilesystemAdapter('/app/data/documents');
}
return singleton;
}