UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

216 lines (188 loc) 6.39 kB
import type * as fs from "node:fs"; import { createReadStream } from "node:fs"; import { mkdir, readdir, stat, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $atom, $hook, $inject, $state, Alepha, AlephaError, type FileLike, type Static, z, } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { $logger } from "alepha/logger"; import { currentTenantAtom } from "alepha/security"; import { FileDetector, FileSystemProvider } from "alepha/system"; import { FileNotFoundError } from "../errors/FileNotFoundError.ts"; import { $bucket } from "../primitives/$bucket.ts"; import type { FileStorageProvider } from "./FileStorageProvider.ts"; // --------------------------------------------------------------------------------------------------------------------- /** * Local file storage configuration atom */ export const localFileStorageOptions = $atom({ name: "alepha.bucket.local.options", schema: z.object({ storagePath: z .string() .describe("Directory path where files will be stored"), }), default: { storagePath: "node_modules/.alepha/buckets", }, }); export type LocalFileStorageProviderOptions = Static< typeof localFileStorageOptions.schema >; declare module "alepha" { interface State { [localFileStorageOptions.key]: LocalFileStorageProviderOptions; } } // --------------------------------------------------------------------------------------------------------------------- export class LocalFileStorageProvider implements FileStorageProvider { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly fileDetector = $inject(FileDetector); protected readonly fileSystemProvider = $inject(FileSystemProvider); protected readonly crypto = $inject(CryptoProvider); protected readonly options = $state(localFileStorageOptions); protected get storagePath(): string { return this.options.storagePath; } protected readonly onConfigure = $hook({ on: "configure", handler: async () => { if ( this.alepha.isTest() && this.storagePath === localFileStorageOptions.options.default.storagePath ) { this.alepha.store.set(localFileStorageOptions, { storagePath: join(tmpdir(), `alepha-test-${Date.now()}`), }); } }, }); protected readonly onStart = $hook({ on: "start", handler: async () => { try { await mkdir(this.storagePath, { recursive: true }); } catch {} for (const bucket of this.alepha.primitives($bucket)) { if (bucket.provider !== this) { continue; } await mkdir(join(this.storagePath, bucket.name), { recursive: true, }); this.log.debug(`Bucket '${bucket.name}' at ${this.storagePath} OK`); } }, }); public async upload( bucketName: string, file: FileLike, fileId?: string, ): Promise<string> { fileId ??= this.createId(file.type); this.log.trace(`Uploading file to ${bucketName}`); // The per-tenant sub-directory isn't pre-created by `onStart` (which only // knows the un-scoped bucket name), so ensure it exists before writing. await mkdir(this.path(bucketName), { recursive: true }); await this.fileSystemProvider.writeFile( this.path(bucketName, fileId), file, ); return fileId; } public async download(bucketName: string, fileId: string): Promise<FileLike> { const filePath = this.path(bucketName, fileId); try { const stats = await stat(filePath); const mimeType = this.fileDetector.getContentType(fileId); return this.fileSystemProvider.createFile({ stream: createReadStream(filePath), name: fileId, type: mimeType, size: stats.size, }); } catch (error) { if (this.isErrorNoEntry(error)) { throw new FileNotFoundError(`File with ID ${fileId} not found.`); } throw new AlephaError("Invalid file operation", { cause: error }); } } public async exists(bucketName: string, fileId: string): Promise<boolean> { try { await stat(this.path(bucketName, fileId)); return true; } catch (error) { if (this.isErrorNoEntry(error)) { return false; } throw new AlephaError("Error checking file existence", { cause: error }); } } public async delete(bucketName: string, fileId: string): Promise<void> { try { return await unlink(this.path(bucketName, fileId)); } catch (error) { if (this.isErrorNoEntry(error)) { throw new FileNotFoundError(`File with ID ${fileId} not found.`); } throw new AlephaError("Error deleting file", { cause: error }); } } public async deleteMany( bucketName: string, fileIds: string[], ): Promise<void> { await Promise.all( fileIds.map((id) => unlink(this.path(bucketName, id)).catch((error) => { if (this.isErrorNoEntry(error)) return; throw new AlephaError("Error deleting file", { cause: error }); }), ), ); } public async list(bucketName: string): Promise<string[]> { try { const entries = await readdir(this.path(bucketName), { withFileTypes: true, }); return entries .filter((entry) => entry.isFile()) .map((entry) => entry.name); } catch (error) { if (this.isErrorNoEntry(error)) { return []; } throw new AlephaError("Error listing files", { cause: error }); } } protected stat(bucket: string, fileId: string): Promise<fs.Stats> { return stat(this.path(bucket, fileId)); } protected createId(mimeType: string): string { const ext = this.fileDetector.getExtensionFromMimeType(mimeType); return `${this.crypto.randomUUID()}.${ext}`; } protected path(bucket: string, fileId = ""): string { // Per-tenant directory when a tenant is active, mirroring R2/S3 isolation. const tenantId = this.alepha.store.get(currentTenantAtom)?.id; return tenantId ? join(this.storagePath, tenantId, bucket, fileId) : join(this.storagePath, bucket, fileId); } protected isErrorNoEntry(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; } }