287 lines · 9.3 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:offimport * as NodeCrypto from "node:crypto";import * as NodeFS from "node:fs";import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; import {  normalizeAttachmentRelativePath,  resolveAttachmentRelativePath,} from "./attachmentPaths.ts";import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}";const ATTACHMENT_ID_PATTERN = new RegExp(  `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`,  "i",); export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending";export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; export function toSafeThreadAttachmentSegment(threadId: string): string | null {  const segment = threadId    .trim()    .toLowerCase()    .replace(/[^a-z0-9_-]+/gi, "-")    .replace(/-+/g, "-")    .replace(/^[-_]+|[-_]+$/g, "")    .slice(0, ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS)    .replace(/[-_]+$/g, "");  if (segment.length === 0) {    return null;  }  return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;} export function attachmentFileExtension(fileName: string): string {  const extension = NodePath.extname(fileName).toLowerCase();  // ".part" is reserved for in-flight uploads; a stored "archive.part" would  // look stale to sweepStalePendingAttachments and get deleted.  if (extension === ".part" || !/^\.[a-z0-9]{1,10}$/.test(extension)) {    return ".bin";  }  return extension;} function attachmentIdExtensionSuffix(extension: string | undefined): string {  if (!extension) {    return "";  }  const normalized = extension.replace(/^\./, "").toLowerCase();  return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized)    ? `-${normalized}`    : "-bin";} export function createPendingAttachmentId(extension?: string): string {  return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;} export function parseAttachmentUuid(attachmentId: string): string | null {  const normalizedId = normalizeAttachmentRelativePath(attachmentId);  if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {    return null;  }  return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;} export function parseAttachmentFileExtension(attachmentId: string): string | null {  const normalizedId = normalizeAttachmentRelativePath(attachmentId);  if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {    return null;  }  return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null;} export function createAttachmentId(threadId: string, extension?: string): string | null {  const threadSegment = toSafeThreadAttachmentSegment(threadId);  if (!threadSegment) {    return null;  }  return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;} export function createDeterministicAttachmentId(  threadId: string,  stableKey: string,): string | null {  const threadSegment = toSafeThreadAttachmentSegment(threadId);  if (!threadSegment) return null;  const hash = NodeCrypto.createHash("sha256")    .update(JSON.stringify([threadId, stableKey]))    .digest("hex")    .slice(0, 32);  const uuid = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20)}`;  return `${threadSegment}-${uuid}`;} export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {  const normalizedId = normalizeAttachmentRelativePath(attachmentId);  if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {    return null;  }  const match = normalizedId.match(ATTACHMENT_ID_PATTERN);  if (!match) {    return null;  }  return match[1]?.toLowerCase() ?? null;} /** Null for attachment types this build does not know; callers skip those. */export function attachmentRelativePath(attachment: ChatAttachment): string | null {  switch (attachment.type) {    case "image": {      const extension = inferImageExtension({        mimeType: attachment.mimeType,        fileName: attachment.name,      });      return `${attachment.id}${extension}`;    }    case "file":      return `${attachment.id}${attachmentFileExtension(attachment.name)}`;    default:      return null;  }} export function resolveAttachmentPath(input: {  readonly attachmentsDir: string;  readonly attachment: ChatAttachment;}): string | null {  const relativePath = attachmentRelativePath(input.attachment);  if (!relativePath) {    return null;  }  return resolveAttachmentRelativePath({    attachmentsDir: input.attachmentsDir,    relativePath,  });} export function resolveAttachmentPathById(input: {  readonly attachmentsDir: string;  readonly attachmentId: string;}): string | null {  const normalizedId = normalizeAttachmentRelativePath(input.attachmentId);  if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {    return null;  }  const fileExtension = parseAttachmentFileExtension(normalizedId);  if (fileExtension) {    const filePath = resolveAttachmentRelativePath({      attachmentsDir: input.attachmentsDir,      relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`,    });    return filePath && NodeFS.existsSync(filePath) ? filePath : null;  }  for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {    const maybePath = resolveAttachmentRelativePath({      attachmentsDir: input.attachmentsDir,      relativePath: `${normalizedId}${extension}`,    });    if (maybePath && NodeFS.existsSync(maybePath)) {      return maybePath;    }  }  return null;} export type AttachmentClaimPlan =  | {      readonly ok: true;      readonly finalId: string;      readonly currentPath: string;      readonly finalPath: string;    }  | { readonly ok: false; readonly reason: string }; export function planAttachmentClaim(input: {  readonly attachmentsDir: string;  readonly threadId: string;  readonly attachmentId: string;}): AttachmentClaimPlan {  const uuid = parseAttachmentUuid(input.attachmentId);  const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId);  if (!uuid || !requestedSegment) {    return { ok: false, reason: "invalid attachment id" };  }   if (!toSafeThreadAttachmentSegment(input.threadId)) {    return { ok: false, reason: "invalid thread id" };  }  if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) {    return { ok: false, reason: "attachment must be a pending upload" };  }   const currentPath = resolveAttachmentPathById({    attachmentsDir: input.attachmentsDir,    attachmentId: input.attachmentId,  });  if (!currentPath) {    return { ok: false, reason: "attachment not found (removed or expired)" };  }  const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined;  const finalId = createAttachmentId(input.threadId, fileExtension);  if (!finalId) {    return { ok: false, reason: "failed to create attachment id" };  }   const expectedFinalPath = resolveAttachmentRelativePath({    attachmentsDir: input.attachmentsDir,    relativePath: `${finalId}${NodePath.extname(currentPath)}`,  });  if (!expectedFinalPath) {    return { ok: false, reason: "failed to resolve attachment path" };  }  return {    ok: true,    finalId,    currentPath,    finalPath: expectedFinalPath,  };} export function sweepStalePendingAttachments(input: {  readonly attachmentsDir: string;  readonly nowMs: number;}): { readonly deleted: number } {  let entries: string[];  try {    entries = NodeFS.readdirSync(input.attachmentsDir);  } catch {    return { deleted: 0 };  }   let deleted = 0;  for (const entry of entries) {    const isPartial = entry.endsWith(".part");    if (!isPartial) {      const attachmentId = parseAttachmentIdFromRelativePath(entry);      if (        !attachmentId ||        parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT      ) {        continue;      }    }     const resolved = resolveAttachmentRelativePath({      attachmentsDir: input.attachmentsDir,      relativePath: entry,    });    if (!resolved) {      continue;    }    try {      const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS;      if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) {        NodeFS.unlinkSync(resolved);        deleted += 1;      }    } catch {      continue;    }  }   return { deleted };} export function parseAttachmentIdFromRelativePath(relativePath: string): string | null {  const normalized = normalizeAttachmentRelativePath(relativePath);  if (!normalized || normalized.includes("/")) {    return null;  }  const extensionIndex = normalized.lastIndexOf(".");  if (extensionIndex <= 0) {    return null;  }  const id = normalized.slice(0, extensionIndex);  return id.length > 0 && !id.includes(".") ? id : null;}