Files
bknd/app/src/adapter/node/storage/StorageLocalAdapter.ts
T
dswbx 55082e9d0e feat: improve media handling
added local range requests, fix mime type detection, improve uploading using FormData, correctly use mime type allow list, added previews for audio, pdf and text
2025-09-29 14:55:19 +02:00

181 lines
5.6 KiB
TypeScript

import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
import { StorageAdapter, guessMimeType } from "bknd";
import { parse, s, isFile } from "bknd/utils";
export const localAdapterConfig = s.object(
{
path: s.string({ default: "./" }),
},
{ title: "Local", description: "Local file system storage", additionalProperties: false },
);
export type LocalAdapterConfig = s.Static<typeof localAdapterConfig>;
export class StorageLocalAdapter extends StorageAdapter {
private config: LocalAdapterConfig;
constructor(config: Partial<LocalAdapterConfig> = {}) {
super();
this.config = parse(localAdapterConfig, config);
}
getSchema() {
return localAdapterConfig;
}
getName(): string {
return "local";
}
async listObjects(prefix?: string): Promise<FileListObject[]> {
const files = await readdir(this.config.path);
const fileStats = await Promise.all(
files
.filter((file) => !prefix || file.startsWith(prefix))
.map(async (file) => {
const stats = await stat(`${this.config.path}/${file}`);
return {
key: file,
last_modified: stats.mtime,
size: stats.size,
};
}),
);
return fileStats;
}
private async computeEtag(body: FileBody): Promise<string> {
const content = isFile(body) ? body : new Response(body);
const hashBuffer = await crypto.subtle.digest("SHA-256", await content.arrayBuffer());
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
// Wrap the hex string in quotes for ETag format
return `"${hashHex}"`;
}
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
if (body === null) {
throw new Error("Body is empty");
}
const filePath = `${this.config.path}/${key}`;
await writeFile(filePath, isFile(body) ? body.stream() : body);
return await this.computeEtag(body);
}
async deleteObject(key: string): Promise<void> {
try {
await unlink(`${this.config.path}/${key}`);
} catch (e) {}
}
async objectExists(key: string): Promise<boolean> {
try {
const stats = await stat(`${this.config.path}/${key}`);
return stats.isFile();
} catch (error) {
return false;
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// Parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// Validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const filePath = `${this.config.path}/${key}`;
const stats = await stat(filePath);
const fileSize = stats.size;
const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// Invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const content = await readFile(filePath, { encoding: null });
const chunk = content.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.length.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// Normal request - return entire file
const content = await readFile(filePath);
responseHeaders.set("Content-Length", content.length.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch (error) {
// Handle file reading errors
return new Response("", { status: 404 });
}
}
getObjectUrl(key: string): string {
throw new Error("Method not implemented.");
}
async getObjectMeta(key: string): Promise<FileMeta> {
const stats = await stat(`${this.config.path}/${key}`);
return {
type: guessMimeType(key) || "application/octet-stream",
size: stats.size,
};
}
toJSON(secrets?: boolean) {
return {
type: this.getName(),
config: this.config,
};
}
}