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

89 lines
3.5 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, stat, 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) | undefined;
private readonly maxBytes: number;
constructor(
private readonly rootDir: string,
options: CodingAttachmentStoreOptions = {},
) {
this.createId = options.createId;
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');
}
// Live events and session hydration repeat the same large image. Reuse its
// stored bytes, including the original upload, instead of writing a new copy.
const attachmentId = this.createId?.() ?? createHash('sha256')
.update(normalizedMime).update('\0').update(data).digest('hex');
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`);
if (!this.createId) {
const existing = await stat(metadataPath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null;
throw error;
});
if (existing) return { attachmentId, mime: normalizedMime, byteLength: data.byteLength };
}
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 };
}
}