feat: implement PI-090 product tools
This commit is contained in:
78
electron/coding-projects/attachment-store.ts
Normal file
78
electron/coding-projects/attachment-store.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user