Files
makelore/electron/coding-projects/attachment-store.ts

79 lines
2.9 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
const MIME_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
const DEFAULT_MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
export interface CodingAttachmentRef {
attachmentId: string;
mime: string;
byteLength: number;
}
export interface CodingAttachmentRecord extends CodingAttachmentRef {
data: Buffer;
}
export interface CodingAttachmentStoreOptions {
createId?: () => string;
maxBytes?: number;
}
export class CodingAttachmentStore {
private readonly createId: () => string;
private readonly maxBytes: number;
constructor(
private readonly rootDir: string,
options: CodingAttachmentStoreOptions = {},
) {
this.createId = options.createId ?? randomUUID;
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
}
async put(data: Uint8Array, mime: string): Promise<CodingAttachmentRef> {
const normalizedMime = mime.trim().toLowerCase();
if (!MIME_PATTERN.test(normalizedMime)) throw new Error('Attachment MIME type is invalid');
if (data.byteLength === 0 || data.byteLength > this.maxBytes) {
throw new Error('Attachment size is invalid');
}
const attachmentId = this.createId();
if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) throw new Error('Attachment id is invalid');
await mkdir(this.rootDir, { recursive: true });
const dataPath = path.join(this.rootDir, `${attachmentId}.bin`);
const metadataPath = path.join(this.rootDir, `${attachmentId}.json`);
const temporaryDataPath = `${dataPath}.${randomUUID()}.tmp`;
const temporaryMetadataPath = `${metadataPath}.${randomUUID()}.tmp`;
const reference: CodingAttachmentRef = {
attachmentId,
mime: normalizedMime,
byteLength: data.byteLength,
};
await writeFile(temporaryDataPath, data);
await writeFile(temporaryMetadataPath, `${JSON.stringify(reference)}\n`, 'utf8');
await rename(temporaryDataPath, dataPath);
await rename(temporaryMetadataPath, metadataPath);
return reference;
}
async read(attachmentId: string): Promise<CodingAttachmentRecord> {
if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) throw new Error('Attachment id is invalid');
const metadata = JSON.parse(await readFile(
path.join(this.rootDir, `${attachmentId}.json`),
'utf8',
)) as CodingAttachmentRef;
if (metadata.attachmentId !== attachmentId
|| !MIME_PATTERN.test(metadata.mime)
|| !Number.isSafeInteger(metadata.byteLength)
|| metadata.byteLength <= 0
|| metadata.byteLength > this.maxBytes) {
throw new Error('Attachment metadata is invalid');
}
const data = await readFile(path.join(this.rootDir, `${attachmentId}.bin`));
if (data.byteLength !== metadata.byteLength) throw new Error('Attachment data is incomplete');
return { ...metadata, data };
}
}