/** * Local Filesystem Storage Implementation * * For development and testing. Stores files in a local directory. */ import { createReadStream, existsSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { Readable } from "node:stream"; import mime from "mime/lite"; /** Type guard for Node.js ErrnoException */ function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } import type { Storage, LocalStorageConfig, UploadResult, DownloadResult, ListResult, ListOptions, SignedUploadUrl, SignedUploadOptions, } from "./types.js"; import { EmDashStorageError } from "./types.js"; /** Pattern to remove leading slashes */ const LEADING_SLASH_PATTERN = /^\//; /** Pattern to remove trailing slashes */ const TRAILING_SLASH_PATTERN = /\/$/; /** * Local filesystem storage implementation */ export class LocalStorage implements Storage { /** Resolved absolute base directory for all stored files */ private directory: string; private baseUrl: string; constructor(config: LocalStorageConfig) { this.directory = path.resolve(config.directory); this.baseUrl = config.baseUrl.replace(TRAILING_SLASH_PATTERN, ""); } /** * Resolve a storage key to an absolute file path, ensuring it stays * within the configured storage directory. Uses path.resolve() for * canonical resolution rather than regex stripping. * * @throws EmDashStorageError if the resolved path escapes the base directory */ private getFilePath(key: string): string { const normalizedKey = key.replace(LEADING_SLASH_PATTERN, ""); const resolved = path.resolve(this.directory, normalizedKey); // Verify the resolved path is within the base directory if (!resolved.startsWith(this.directory + path.sep) && resolved !== this.directory) { throw new EmDashStorageError("Invalid file path", "INVALID_PATH"); } return resolved; } async upload(options: { key: string; body: Buffer | Uint8Array | ReadableStream; contentType: string; }): Promise { try { const filePath = this.getFilePath(options.key); const dir = path.dirname(filePath); // Ensure directory exists await fs.mkdir(dir, { recursive: true }); // Convert body to buffer let buffer: Buffer; if (options.body instanceof ReadableStream) { const chunks: Uint8Array[] = []; const reader = options.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } buffer = Buffer.concat(chunks); } else if (options.body instanceof Uint8Array) { buffer = Buffer.from(options.body); } else { buffer = options.body; } await fs.writeFile(filePath, buffer); return { key: options.key, url: this.getPublicUrl(options.key), size: buffer.length, }; } catch (error) { throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error); } } async download(key: string): Promise { try { const filePath = this.getFilePath(key); if (!existsSync(filePath)) { throw new EmDashStorageError(`File not found: ${key}`, "NOT_FOUND"); } const stat = await fs.stat(filePath); const nodeStream = createReadStream(filePath); // Convert Node.js stream to web ReadableStream // Readable.toWeb returns ReadableStream (which is ReadableStream), // but Node ReadStreams produce Buffer/Uint8Array chunks // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Readable.toWeb returns ReadableStream; Node ReadStreams produce Uint8Array chunks const webStream: ReadableStream = Readable.toWeb( nodeStream, ) as ReadableStream; // Infer content type from extension const ext = path.extname(key).toLowerCase(); const contentType = getContentType(ext); return { body: webStream, contentType, size: stat.size, }; } catch (error) { if (error instanceof EmDashStorageError) throw error; throw new EmDashStorageError(`Failed to download file: ${key}`, "DOWNLOAD_FAILED", error); } } async delete(key: string): Promise { try { const filePath = this.getFilePath(key); await fs.unlink(filePath); } catch (error) { // Ignore "file not found" errors (idempotent delete) if (!isNodeError(error) || error.code !== "ENOENT") { throw new EmDashStorageError(`Failed to delete file: ${key}`, "DELETE_FAILED", error); } } } async exists(key: string): Promise { try { const filePath = this.getFilePath(key); await fs.access(filePath); return true; } catch { return false; } } async list(options: ListOptions = {}): Promise { try { const prefix = options.prefix || ""; const searchDir = path.resolve(this.directory, path.dirname(prefix)); // Validate the search directory stays within the base directory if (!searchDir.startsWith(this.directory + path.sep) && searchDir !== this.directory) { throw new EmDashStorageError("Invalid list prefix", "INVALID_PATH"); } const prefixBase = path.basename(prefix); // Ensure directory exists try { await fs.access(searchDir); } catch { return { files: [] }; } const entries = await fs.readdir(searchDir, { withFileTypes: true }); const files: ListResult["files"] = []; for (const entry of entries) { if (entry.isFile() && entry.name.startsWith(prefixBase)) { const key = path.join(path.dirname(prefix), entry.name); const filePath = path.join(searchDir, entry.name); const stat = await fs.stat(filePath); files.push({ key, size: stat.size, lastModified: stat.mtime, }); } } // Sort by last modified (newest first) files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); // Apply limit and cursor (simple implementation) const startIndex = options.cursor ? parseInt(options.cursor, 10) : 0; const limit = options.limit || 1000; const paginatedFiles = files.slice(startIndex, startIndex + limit); const hasMore = startIndex + limit < files.length; return { files: paginatedFiles, nextCursor: hasMore ? String(startIndex + limit) : undefined, }; } catch (error) { throw new EmDashStorageError("Failed to list files", "LIST_FAILED", error); } } async getSignedUploadUrl(_options: SignedUploadOptions): Promise { // Local storage doesn't support signed URLs throw new EmDashStorageError( "Local storage does not support signed upload URLs. " + "Upload files directly through the API.", "NOT_SUPPORTED", ); } getPublicUrl(key: string): string { return `${this.baseUrl}/${key}`; } } /** * Get content type from file extension */ function getContentType(ext: string): string { return mime.getType(ext) ?? "application/octet-stream"; } /** * Create local storage adapter * This is the factory function called at runtime */ export function createStorage(config: Record): Storage { const directory = typeof config.directory === "string" ? config.directory : ""; const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : ""; return new LocalStorage({ directory, baseUrl }); }