alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
216 lines (188 loc) • 6.39 kB
text/typescript
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";
}
}