From 548aa94144a1bb89940b8617f001186e4f9b2f2c Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 20 Jul 2026 15:04:58 +0300 Subject: [PATCH 1/7] feat(storage): implement URL support for local storage provider Adds an embedded HTTP file server so the local storage provider can serve presigned-style upload/download URLs, matching the behavior of cloud providers (AWS/Azure/GCS/Aliyun) for local development. - getUploadUrl(), getSignedUrl(), and getPublicUrl() now return working URLs instead of throwing "Method not implemented" - Public containers/files are now supported on local storage - Added CORS, path-traversal protection, and Content-Disposition handling to the embedded file server - Added dispose() to IStorageProvider so the server is cleanly torn down and recreated on config changes without port conflicts - Added local.httpPort and local.httpBaseUrl config options Co-authored-by: Cursor --- modules/storage/src/Storage.ts | 6 + modules/storage/src/config/config.ts | 10 + .../src/interfaces/IStorageProvider.ts | 5 + .../storage/src/interfaces/StorageConfig.ts | 2 + modules/storage/src/providers/local/index.ts | 175 ++++++++++++++++-- 5 files changed, 181 insertions(+), 17 deletions(-) diff --git a/modules/storage/src/Storage.ts b/modules/storage/src/Storage.ts index edcc5be44..242d7990a 100644 --- a/modules/storage/src/Storage.ts +++ b/modules/storage/src/Storage.ts @@ -221,6 +221,9 @@ export default class Storage extends ManagedModule { this._storageAuthzResourceDispose?.(); this._storageAuthzResourceDispose = null; this.disposeDeclaredPeerWatches(); + if (this.storageProvider?.dispose) { + await this.storageProvider.dispose(); + } this.updateHealth(HealthCheckStatus.NOT_SERVING); } else { this.registerDeclaredPeerWatches(); @@ -239,6 +242,9 @@ export default class Storage extends ManagedModule { this._storageAuthzResourceDispose?.(); this._storageAuthzResourceDispose = null; } + if (this.storageProvider?.dispose) { + await this.storageProvider.dispose(); + } this.storageProvider = createStorageProvider(provider, { local, google, diff --git a/modules/storage/src/config/config.ts b/modules/storage/src/config/config.ts index 141eb6877..cfc337f7f 100644 --- a/modules/storage/src/config/config.ts +++ b/modules/storage/src/config/config.ts @@ -77,6 +77,16 @@ export default { format: 'String', default: '/var/tmp', }, + httpPort: { + format: 'Number', + default: 3100, + doc: 'Port for the local storage HTTP file server (serves uploads/downloads)', + }, + httpBaseUrl: { + format: 'String', + default: 'http://localhost:3100', + doc: 'Base URL for local storage file URLs (override for Docker/CI)', + }, }, suffixOnNameConflict: { format: 'Boolean', diff --git a/modules/storage/src/interfaces/IStorageProvider.ts b/modules/storage/src/interfaces/IStorageProvider.ts index e21523954..e811667ac 100644 --- a/modules/storage/src/interfaces/IStorageProvider.ts +++ b/modules/storage/src/interfaces/IStorageProvider.ts @@ -58,4 +58,9 @@ export interface IStorageProvider { getPublicUrl(fileName: string, containerIsPublic?: boolean): Promise; getUploadUrl(fileName: string): Promise; + + /** + * Releases provider resources (e.g. local HTTP file server). + */ + dispose?(): Promise; } diff --git a/modules/storage/src/interfaces/StorageConfig.ts b/modules/storage/src/interfaces/StorageConfig.ts index 4300e2cdf..6652f0994 100644 --- a/modules/storage/src/interfaces/StorageConfig.ts +++ b/modules/storage/src/interfaces/StorageConfig.ts @@ -24,5 +24,7 @@ export interface StorageConfig { }; local: { storagePath: string; + httpPort: number; + httpBaseUrl: string; }; } diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index 8a083f90e..c2abc3abf 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -1,28 +1,73 @@ -import { IStorageProvider, StorageConfig } from '../../interfaces/index.js'; +import { IStorageProvider, StorageConfig, UrlOptions } from '../../interfaces/index.js'; import { access, accessSync, chmod, constants, + createReadStream, + createWriteStream, existsSync, mkdir, + mkdirSync, readFile, rmSync, unlink, writeFile, } from 'fs'; -import { resolve } from 'path'; +import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; +import { dirname, extname, resolve, sep } from 'path'; import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { constructDispositionHeader } from '../../utils/index.js'; + +const MIME_TYPES: Record = { + '.txt': 'text/plain', + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.json': 'application/json', + '.xml': 'application/xml', + '.csv': 'text/csv', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +}; + +function getMimeType(filePath: string): string { + return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'; +} export class LocalStorage implements IStorageProvider { _rootStoragePath: string; _storagePath: string; _activeContainer: string; + private _httpPort: number; + private _httpBaseUrl: string; + private _httpServer: Server | null = null; constructor(options?: StorageConfig) { this._activeContainer = ''; this._rootStoragePath = options && options.local ? options.local.storagePath : ''; this._storagePath = this._rootStoragePath; + this._httpPort = options?.local?.httpPort ?? 3100; + this._httpBaseUrl = + options?.local?.httpBaseUrl ?? `http://localhost:${this._httpPort}`; if (this._storagePath !== '') { try { accessSync(this._storagePath, constants.R_OK | constants.W_OK); @@ -34,11 +79,105 @@ export class LocalStorage implements IStorageProvider { ConduitGrpcSdk.Logger.log('Permissions changed'); }); } + this.startHttpServer(); } } + /** + * Serves uploads/downloads for URLs returned by getUploadUrl(), getSignedUrl() + * and getPublicUrl(). Mirrors the presigned-URL pattern used by cloud providers, + * but reads/writes directly against the local filesystem. + */ + private startHttpServer(): void { + const rootPath = resolve(this._rootStoragePath); + + this._httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-ms-blob-type'); + res.setHeader('Access-Control-Max-Age', '86400'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + return res.end(); + } + + let url: URL; + try { + url = new URL(req.url ?? '/', `http://localhost:${this._httpPort}`); + } catch { + res.writeHead(400); + return res.end('Bad request'); + } + + const relativePath = decodeURIComponent(url.pathname).replace(/^\/+/, ''); + const filePath = resolve(rootPath, relativePath); + + // Path traversal protection: resolved path must stay within rootPath + if (!relativePath || !filePath.startsWith(rootPath + sep)) { + res.writeHead(403); + return res.end('Forbidden'); + } + + if (req.method === 'GET') { + if (!existsSync(filePath)) { + res.writeHead(404); + return res.end('Not found'); + } + res.setHeader('Content-Type', getMimeType(filePath)); + const download = url.searchParams.get('download') === 'true'; + const fileNameParam = url.searchParams.get('fileName') ?? undefined; + const baseName = relativePath.split('/').pop() ?? relativePath; + const disposition = constructDispositionHeader(baseName, { + download, + fileName: fileNameParam, + }); + if (disposition) { + res.setHeader('Content-Disposition', disposition); + } + createReadStream(filePath).pipe(res); + } else if (req.method === 'PUT') { + mkdirSync(dirname(filePath), { recursive: true }); + const writeStream = createWriteStream(filePath); + req.pipe(writeStream); + writeStream.on('finish', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); + writeStream.on('error', err => { + res.writeHead(500); + res.end(err.message); + }); + } else { + res.writeHead(405); + res.end('Method not allowed'); + } + }); + + this._httpServer.on('error', err => { + ConduitGrpcSdk.Logger.error( + new Error(`[LocalStorage] HTTP file server error: ${(err as Error).message}`), + ); + }); + + this._httpServer.listen(this._httpPort, () => { + ConduitGrpcSdk.Logger.log( + `[LocalStorage] HTTP file server listening on port ${this._httpPort}`, + ); + }); + } + + async dispose(): Promise { + if (!this._httpServer) return; + const server = this._httpServer; + this._httpServer = null; + await new Promise(res => { + server.close(() => res()); + }); + } + getUploadUrl(fileName: string): Promise { - throw new Error('Method not implemented.'); + return Promise.resolve(`${this._httpBaseUrl}/${this._activeContainer}/${fileName}`); } deleteContainer(name: string): Promise { @@ -85,7 +224,7 @@ export class LocalStorage implements IStorageProvider { }); } - store(fileName: string, data: any): Promise { + store(fileName: string, data: any, _isPublic?: boolean): Promise { const self = this; let path = self._storagePath + '/' + self._activeContainer + '/'; @@ -172,12 +311,18 @@ export class LocalStorage implements IStorageProvider { }); } - getPublicUrl(_fileName: string, _containerIsPublic?: boolean): Promise { - throw new Error('Method not implemented!'); + getPublicUrl(fileName: string, _containerIsPublic?: boolean): Promise { + return Promise.resolve(`${this._httpBaseUrl}/${this._activeContainer}/${fileName}`); } - getSignedUrl(fileName: string): Promise { - throw new Error('Method not implemented!| Error'); + getSignedUrl(fileName: string, options?: UrlOptions): Promise { + const params = new URLSearchParams(); + if (options?.download) params.set('download', 'true'); + if (options?.fileName) params.set('fileName', options.fileName); + const query = params.toString() ? `?${params.toString()}` : ''; + return Promise.resolve( + `${this._httpBaseUrl}/${this._activeContainer}/${fileName}${query}`, + ); } container(name: string): IStorageProvider { @@ -189,19 +334,15 @@ export class LocalStorage implements IStorageProvider { return this.folderExists(name); } - createContainer(name: string, isPublic?: boolean): Promise { - if (isPublic) { - return Promise.reject( - new Error('Public containers are not supported with local storage provider'), - ); - } + createContainer(name: string, _isPublic?: boolean): Promise { + // Local storage serves all files through the embedded HTTP file server; + // public/private access is enforced at the Conduit handler layer, not + // at the storage-provider layer, so container-level ACLs don't apply here. this._activeContainer = name; return this.createFolder(name); } setContainerPublicAccess(_name: string, _isPublic: boolean): Promise { - return Promise.reject( - new Error('Public containers are not supported with local storage provider'), - ); + return Promise.resolve(true); } } From 292e2cd1984ee74b3e3a7526e7b69aa7c4067e4b Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 20 Jul 2026 17:22:56 +0300 Subject: [PATCH 2/7] fix(storage): fix local provider mkdir races and ensure dirs before write - store(): create parent directories with mkdirSync before writeFile - createFolder(): replace async mkdir race with synchronous recursive mkdir - dispose(): call closeAllConnections() to avoid hanging on teardown - Remove unused mkdir import Co-authored-by: Cursor --- modules/storage/src/providers/local/index.ts | 34 ++++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index c2abc3abf..f03420d06 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -7,7 +7,6 @@ import { createReadStream, createWriteStream, existsSync, - mkdir, mkdirSync, readFile, rmSync, @@ -171,6 +170,7 @@ export class LocalStorage implements IStorageProvider { if (!this._httpServer) return; const server = this._httpServer; this._httpServer = null; + server.closeAllConnections(); await new Promise(res => { server.close(() => res()); }); @@ -231,8 +231,10 @@ export class LocalStorage implements IStorageProvider { if (fileName !== self._activeContainer) { path += fileName; } + const resolvedPath = resolve(path); + mkdirSync(dirname(resolvedPath), { recursive: true }); return new Promise(function (res, reject) { - writeFile(resolve(path), data, function (err) { + writeFile(resolvedPath, data, function (err) { if (err) reject(err); else { res(true); @@ -243,25 +245,15 @@ export class LocalStorage implements IStorageProvider { async createFolder(name: string): Promise { const self = this; - return new Promise(async function (res, reject) { - let path = self._storagePath + '/' + self._activeContainer; - const containerExists = await self.folderExists(self._activeContainer); - if (!containerExists) { - mkdir(path, function (err) { - if (err) reject(err); - }); - } - if (self._activeContainer !== name) { - path = self._storagePath + '/' + self._activeContainer + '/' + name; - mkdir(resolve(path), function (err) { - if (err) reject(err); - else res(true); - }); - } - ConduitGrpcSdk.Metrics?.increment('folders_total'); - ConduitGrpcSdk.Metrics?.increment('containers_total'); - res(true); - }); + let path = self._storagePath + '/' + self._activeContainer; + mkdirSync(resolve(path), { recursive: true }); + if (self._activeContainer !== name) { + path = self._storagePath + '/' + self._activeContainer + '/' + name; + mkdirSync(resolve(path), { recursive: true }); + } + ConduitGrpcSdk.Metrics?.increment('folders_total'); + ConduitGrpcSdk.Metrics?.increment('containers_total'); + return true; } folderExists(name: string): Promise { From 2c34edb68a55d667c44187a52d471149789d2aaf Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 22 Jul 2026 18:32:56 +0300 Subject: [PATCH 3/7] fix(storage): address local storage PR review feedback Auto-derive httpBaseUrl from httpPort when unset. Handle PUT mkdir failures with a 500 response. Reject URL generation after the local HTTP server is disposed. Co-authored-by: Cursor --- modules/storage/src/config/config.ts | 4 ++-- modules/storage/src/providers/local/index.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/modules/storage/src/config/config.ts b/modules/storage/src/config/config.ts index cfc337f7f..ef883c9a4 100644 --- a/modules/storage/src/config/config.ts +++ b/modules/storage/src/config/config.ts @@ -84,8 +84,8 @@ export default { }, httpBaseUrl: { format: 'String', - default: 'http://localhost:3100', - doc: 'Base URL for local storage file URLs (override for Docker/CI)', + default: '', + doc: 'External base URL for local storage file URLs. Leave empty to auto-derive from httpPort (http://localhost:{httpPort}). Override when the storage server is behind a reverse proxy or running in Docker.', }, }, suffixOnNameConflict: { diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index f03420d06..7808d2e43 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -66,7 +66,7 @@ export class LocalStorage implements IStorageProvider { this._storagePath = this._rootStoragePath; this._httpPort = options?.local?.httpPort ?? 3100; this._httpBaseUrl = - options?.local?.httpBaseUrl ?? `http://localhost:${this._httpPort}`; + options?.local?.httpBaseUrl || `http://localhost:${this._httpPort}`; if (this._storagePath !== '') { try { accessSync(this._storagePath, constants.R_OK | constants.W_OK); @@ -136,7 +136,12 @@ export class LocalStorage implements IStorageProvider { } createReadStream(filePath).pipe(res); } else if (req.method === 'PUT') { - mkdirSync(dirname(filePath), { recursive: true }); + try { + mkdirSync(dirname(filePath), { recursive: true }); + } catch { + res.writeHead(500); + return res.end('Failed to create directory'); + } const writeStream = createWriteStream(filePath); req.pipe(writeStream); writeStream.on('finish', () => { @@ -177,6 +182,9 @@ export class LocalStorage implements IStorageProvider { } getUploadUrl(fileName: string): Promise { + if (!this._httpServer) { + return Promise.reject(new Error('Local storage server is not running')); + } return Promise.resolve(`${this._httpBaseUrl}/${this._activeContainer}/${fileName}`); } @@ -304,10 +312,16 @@ export class LocalStorage implements IStorageProvider { } getPublicUrl(fileName: string, _containerIsPublic?: boolean): Promise { + if (!this._httpServer) { + return Promise.reject(new Error('Local storage server is not running')); + } return Promise.resolve(`${this._httpBaseUrl}/${this._activeContainer}/${fileName}`); } getSignedUrl(fileName: string, options?: UrlOptions): Promise { + if (!this._httpServer) { + return Promise.reject(new Error('Local storage server is not running')); + } const params = new URLSearchParams(); if (options?.download) params.set('download', 'true'); if (options?.fileName) params.set('fileName', options.fileName); From 9dbd81dacb25e80dff654d92a1d230f6eb332a9d Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 23 Jul 2026 14:36:11 +0300 Subject: [PATCH 4/7] feat(storage): add HMAC-signed URLs to local storage HTTP server Protect non-public local storage URLs with HMAC-SHA256 signatures and expiry, mirroring cloud presigned URL behavior instead of reusing auth JWTs. - getUploadUrl() and getSignedUrl() now return signed URLs with sig/expires - PUT requests without a valid signature are rejected with 403 - Public URLs from getPublicUrl() remain unsigned for open GET access Co-authored-by: Cursor --- modules/storage/src/providers/local/index.ts | 61 +++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index 7808d2e43..77af4c3e4 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -13,9 +13,11 @@ import { unlink, writeFile, } from 'fs'; +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; import { dirname, extname, resolve, sep } from 'path'; import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { SIGNED_URL_EXPIRY } from '../../constants/expiry.js'; import { constructDispositionHeader } from '../../utils/index.js'; const MIME_TYPES: Record = { @@ -59,6 +61,7 @@ export class LocalStorage implements IStorageProvider { private _httpPort: number; private _httpBaseUrl: string; private _httpServer: Server | null = null; + private _signingSecret: Buffer; constructor(options?: StorageConfig) { this._activeContainer = ''; @@ -67,6 +70,7 @@ export class LocalStorage implements IStorageProvider { this._httpPort = options?.local?.httpPort ?? 3100; this._httpBaseUrl = options?.local?.httpBaseUrl || `http://localhost:${this._httpPort}`; + this._signingSecret = randomBytes(32); if (this._storagePath !== '') { try { accessSync(this._storagePath, constants.R_OK | constants.W_OK); @@ -118,6 +122,19 @@ export class LocalStorage implements IStorageProvider { return res.end('Forbidden'); } + const sig = url.searchParams.get('sig'); + const expires = url.searchParams.get('expires'); + const hasSig = sig !== null && expires !== null; + + if (req.method === 'PUT' && !hasSig) { + res.writeHead(403); + return res.end('Signature required'); + } + if (hasSig && !this.verifyRequest(req.method!, relativePath, sig!, expires!)) { + res.writeHead(403); + return res.end('Invalid or expired signature'); + } + if (req.method === 'GET') { if (!existsSync(filePath)) { res.writeHead(404); @@ -171,6 +188,35 @@ export class LocalStorage implements IStorageProvider { }); } + private signUrl( + relativePath: string, + method: 'GET' | 'PUT', + extraParams?: URLSearchParams, + ): string { + const expires = Date.now() + SIGNED_URL_EXPIRY; + const payload = `${method}:${relativePath}:${expires}`; + const sig = createHmac('sha256', this._signingSecret).update(payload).digest('hex'); + const params = extraParams ?? new URLSearchParams(); + params.set('expires', expires.toString()); + params.set('sig', sig); + return `${this._httpBaseUrl}/${relativePath}?${params.toString()}`; + } + + private verifyRequest( + method: string, + relativePath: string, + sig: string, + expires: string, + ): boolean { + const expiresNum = Number(expires); + if (isNaN(expiresNum) || Date.now() > expiresNum) return false; + const payload = `${method}:${relativePath}:${expires}`; + const expected = createHmac('sha256', this._signingSecret).update(payload).digest(); + const provided = Buffer.from(sig, 'hex'); + if (expected.length !== provided.length) return false; + return timingSafeEqual(expected, provided); + } + async dispose(): Promise { if (!this._httpServer) return; const server = this._httpServer; @@ -185,7 +231,8 @@ export class LocalStorage implements IStorageProvider { if (!this._httpServer) { return Promise.reject(new Error('Local storage server is not running')); } - return Promise.resolve(`${this._httpBaseUrl}/${this._activeContainer}/${fileName}`); + const relativePath = `${this._activeContainer}/${fileName}`; + return Promise.resolve(this.signUrl(relativePath, 'PUT')); } deleteContainer(name: string): Promise { @@ -322,13 +369,11 @@ export class LocalStorage implements IStorageProvider { if (!this._httpServer) { return Promise.reject(new Error('Local storage server is not running')); } - const params = new URLSearchParams(); - if (options?.download) params.set('download', 'true'); - if (options?.fileName) params.set('fileName', options.fileName); - const query = params.toString() ? `?${params.toString()}` : ''; - return Promise.resolve( - `${this._httpBaseUrl}/${this._activeContainer}/${fileName}${query}`, - ); + const relativePath = `${this._activeContainer}/${fileName}`; + const extraParams = new URLSearchParams(); + if (options?.download) extraParams.set('download', 'true'); + if (options?.fileName) extraParams.set('fileName', options.fileName); + return Promise.resolve(this.signUrl(relativePath, 'GET', extraParams)); } container(name: string): IStorageProvider { From 2f743bc4e7dbe8fa1643a5ae557e2f4c8908abe2 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 23 Jul 2026 15:32:13 +0300 Subject: [PATCH 5/7] fix(storage): require signed URLs for local storage GETs Close the unsigned GET security gap and return long-lived signed public URLs for cloud parity. Add optional signingSecret config so URLs survive restarts. Co-authored-by: Cursor --- modules/storage/src/config/config.ts | 5 +++++ .../storage/src/interfaces/StorageConfig.ts | 1 + modules/storage/src/providers/local/index.ts | 19 ++++++++++++++----- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/modules/storage/src/config/config.ts b/modules/storage/src/config/config.ts index ef883c9a4..cafedf947 100644 --- a/modules/storage/src/config/config.ts +++ b/modules/storage/src/config/config.ts @@ -87,6 +87,11 @@ export default { default: '', doc: 'External base URL for local storage file URLs. Leave empty to auto-derive from httpPort (http://localhost:{httpPort}). Override when the storage server is behind a reverse proxy or running in Docker.', }, + signingSecret: { + format: 'String', + default: '', + doc: 'Hex-encoded HMAC secret for signing file URLs. If empty, a random secret is generated on startup (URLs will not survive restarts).', + }, }, suffixOnNameConflict: { format: 'Boolean', diff --git a/modules/storage/src/interfaces/StorageConfig.ts b/modules/storage/src/interfaces/StorageConfig.ts index 6652f0994..4cfc5fd1e 100644 --- a/modules/storage/src/interfaces/StorageConfig.ts +++ b/modules/storage/src/interfaces/StorageConfig.ts @@ -26,5 +26,6 @@ export interface StorageConfig { storagePath: string; httpPort: number; httpBaseUrl: string; + signingSecret: string; }; } diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index 77af4c3e4..a8c6c5e5c 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -55,6 +55,8 @@ function getMimeType(filePath: string): string { } export class LocalStorage implements IStorageProvider { + private static readonly PUBLIC_URL_EXPIRY = 1000 * 60 * 60 * 24 * 365 * 99; + _rootStoragePath: string; _storagePath: string; _activeContainer: string; @@ -70,7 +72,10 @@ export class LocalStorage implements IStorageProvider { this._httpPort = options?.local?.httpPort ?? 3100; this._httpBaseUrl = options?.local?.httpBaseUrl || `http://localhost:${this._httpPort}`; - this._signingSecret = randomBytes(32); + const configSecret = options?.local?.signingSecret; + this._signingSecret = configSecret + ? Buffer.from(configSecret, 'hex') + : randomBytes(32); if (this._storagePath !== '') { try { accessSync(this._storagePath, constants.R_OK | constants.W_OK); @@ -126,11 +131,11 @@ export class LocalStorage implements IStorageProvider { const expires = url.searchParams.get('expires'); const hasSig = sig !== null && expires !== null; - if (req.method === 'PUT' && !hasSig) { + if (!hasSig) { res.writeHead(403); return res.end('Signature required'); } - if (hasSig && !this.verifyRequest(req.method!, relativePath, sig!, expires!)) { + if (!this.verifyRequest(req.method!, relativePath, sig!, expires!)) { res.writeHead(403); return res.end('Invalid or expired signature'); } @@ -192,8 +197,9 @@ export class LocalStorage implements IStorageProvider { relativePath: string, method: 'GET' | 'PUT', extraParams?: URLSearchParams, + expiryMs: number = SIGNED_URL_EXPIRY, ): string { - const expires = Date.now() + SIGNED_URL_EXPIRY; + const expires = Date.now() + expiryMs; const payload = `${method}:${relativePath}:${expires}`; const sig = createHmac('sha256', this._signingSecret).update(payload).digest('hex'); const params = extraParams ?? new URLSearchParams(); @@ -362,7 +368,10 @@ export class LocalStorage implements IStorageProvider { if (!this._httpServer) { return Promise.reject(new Error('Local storage server is not running')); } - return Promise.resolve(`${this._httpBaseUrl}/${this._activeContainer}/${fileName}`); + const relativePath = `${this._activeContainer}/${fileName}`; + return Promise.resolve( + this.signUrl(relativePath, 'GET', undefined, LocalStorage.PUBLIC_URL_EXPIRY), + ); } getSignedUrl(fileName: string, options?: UrlOptions): Promise { From 1aee1771850153a8ed6370f87561cad190f9e931 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 23 Jul 2026 15:36:44 +0300 Subject: [PATCH 6/7] fix(storage): fail URL generation when local HTTP server is not listening Check server.listening before returning upload, signed, or public URLs so bind failures like EADDRINUSE fail fast instead of returning unreachable URLs. Co-authored-by: Cursor --- modules/storage/src/providers/local/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index a8c6c5e5c..4a07f358d 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -234,7 +234,7 @@ export class LocalStorage implements IStorageProvider { } getUploadUrl(fileName: string): Promise { - if (!this._httpServer) { + if (!this._httpServer?.listening) { return Promise.reject(new Error('Local storage server is not running')); } const relativePath = `${this._activeContainer}/${fileName}`; @@ -365,7 +365,7 @@ export class LocalStorage implements IStorageProvider { } getPublicUrl(fileName: string, _containerIsPublic?: boolean): Promise { - if (!this._httpServer) { + if (!this._httpServer?.listening) { return Promise.reject(new Error('Local storage server is not running')); } const relativePath = `${this._activeContainer}/${fileName}`; @@ -375,7 +375,7 @@ export class LocalStorage implements IStorageProvider { } getSignedUrl(fileName: string, options?: UrlOptions): Promise { - if (!this._httpServer) { + if (!this._httpServer?.listening) { return Promise.reject(new Error('Local storage server is not running')); } const relativePath = `${this._activeContainer}/${fileName}`; From f15b4f8b11cf853052c996422f6f33da2e696309 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 23 Jul 2026 15:53:10 +0300 Subject: [PATCH 7/7] fix(storage): harden local storage config and GET error handling Validate signingSecret in preConfig, document URL persistence requirements, normalize httpBaseUrl trailing slashes, trim secret on decode, and handle read-stream errors on GET. Co-authored-by: Cursor --- modules/storage/src/Storage.ts | 8 ++++++++ modules/storage/src/config/config.ts | 2 +- modules/storage/src/providers/local/index.ts | 15 +++++++++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/modules/storage/src/Storage.ts b/modules/storage/src/Storage.ts index 242d7990a..b006168c3 100644 --- a/modules/storage/src/Storage.ts +++ b/modules/storage/src/Storage.ts @@ -209,6 +209,14 @@ export default class Storage extends ManagedModule { config.aws.usePathStyle = false; } } + if (config.provider === 'local') { + const secret = config.local?.signingSecret?.trim() ?? ''; + if (secret && !/^[0-9a-fA-F]{64}$/.test(secret)) { + throw new Error( + "local.signingSecret must be a 64-character hex string (32 bytes). Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", + ); + } + } return config; } diff --git a/modules/storage/src/config/config.ts b/modules/storage/src/config/config.ts index cafedf947..3dfefc8ca 100644 --- a/modules/storage/src/config/config.ts +++ b/modules/storage/src/config/config.ts @@ -90,7 +90,7 @@ export default { signingSecret: { format: 'String', default: '', - doc: 'Hex-encoded HMAC secret for signing file URLs. If empty, a random secret is generated on startup (URLs will not survive restarts).', + doc: 'Hex-encoded HMAC secret (64 hex chars / 32 bytes) for signing file URLs. Required for URLs to survive restarts — without it, a random secret is generated on startup and any public file URLs already stored in the database (sourceUrl/url) become invalid.', }, }, suffixOnNameConflict: { diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index 4a07f358d..6cb5eeb93 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -70,9 +70,9 @@ export class LocalStorage implements IStorageProvider { this._rootStoragePath = options && options.local ? options.local.storagePath : ''; this._storagePath = this._rootStoragePath; this._httpPort = options?.local?.httpPort ?? 3100; - this._httpBaseUrl = - options?.local?.httpBaseUrl || `http://localhost:${this._httpPort}`; - const configSecret = options?.local?.signingSecret; + const rawBaseUrl = options?.local?.httpBaseUrl?.replace(/\/+$/, '') ?? ''; + this._httpBaseUrl = rawBaseUrl || `http://localhost:${this._httpPort}`; + const configSecret = options?.local?.signingSecret?.trim(); this._signingSecret = configSecret ? Buffer.from(configSecret, 'hex') : randomBytes(32); @@ -156,7 +156,14 @@ export class LocalStorage implements IStorageProvider { if (disposition) { res.setHeader('Content-Disposition', disposition); } - createReadStream(filePath).pipe(res); + const stream = createReadStream(filePath); + stream.on('error', () => { + if (!res.headersSent) { + res.writeHead(500); + } + res.end(); + }); + stream.pipe(res); } else if (req.method === 'PUT') { try { mkdirSync(dirname(filePath), { recursive: true });