79 lines
2.9 KiB
TypeScript
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 };
|
|
}
|
|
}
|