Skip to content
Source
Sign inSign up

attachmentStore.ts

321 lines10.4 KB

1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5​
6import type { ChatAttachment } from "@t3tools/contracts";
7import {
8 htmlRenderFromToolItem,
9 mcpAppFromToolItem,
10 previewRecordingIdFromToolItem,
11} from "@t3tools/shared/toolOutput";
12​
13import {
14 normalizeAttachmentRelativePath,
15 resolveAttachmentRelativePath,
16} from "./attachmentPaths.ts";
17import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
18​
19const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
20export const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
21const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
22const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
23const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}";
24const ATTACHMENT_ID_PATTERN = new RegExp(
25 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`,
26 "i",
27);
28​
29export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending";
30const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
31const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000;
32​
33export function toSafeThreadAttachmentSegment(threadId: string): string | null {
34 const segment = threadId
35 .trim()
36 .toLowerCase()
37 .replace(/[^a-z0-9_-]+/gi, "-")
38 .replace(/-+/g, "-")
39 .replace(/^[-_]+|[-_]+$/g, "")
40 .slice(0, ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS)
41 .replace(/[-_]+$/g, "");
42 if (segment.length === 0) {
43 return null;
44 }
45 return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
47​
48export function attachmentFileExtension(fileName: string): string {
49 const extension = NodePath.extname(fileName).toLowerCase();
50 // .part is reserved for pending uploads; the stale-upload sweep would delete stored files.
51 if (extension === ".part" || !/^\.[a-z0-9]{1,10}$/.test(extension)) {
52 return ".bin";
53 }
54 return extension;
56​
57function attachmentIdExtensionSuffix(extension: string | undefined): string {
58 if (!extension) {
59 return "";
60 }
61 const normalized = extension.replace(/^\./, "").toLowerCase();
62 return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized)
63 ? `-${normalized}`
64 : "-bin";
66​
67export function createPendingAttachmentId(extension?: string): string {
68 return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
70​
71export function parseAttachmentUuid(attachmentId: string): string | null {
72 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
73 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
74 return null;
75 }
76 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;
78​
79export function parseAttachmentFileExtension(attachmentId: string): string | null {
80 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
81 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
82 return null;
83 }
84 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null;
86​
87export function createAttachmentId(threadId: string, extension?: string): string | null {
88 const threadSegment = toSafeThreadAttachmentSegment(threadId);
89 if (!threadSegment) {
90 return null;
91 }
92 return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
94​
95export function createDeterministicAttachmentId(
96 threadId: string,
97 stableKey: string,
98): string | null {
99 const threadSegment = toSafeThreadAttachmentSegment(threadId);
100 if (!threadSegment) return null;
101 const hash = NodeCrypto.createHash("sha256")
102 .update(JSON.stringify([threadId, stableKey]))
103 .digest("hex")
104 .slice(0, 32);
105 const uuid = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20)}`;
106 return `${threadSegment}-${uuid}`;
108​
109/** Retries keep ids; changed bytes must get new ids because clients cache images by id. */
110export function chatAttachmentStableKey(
111 messageId: string,
112 index: number,
113 bytes: Uint8Array,
114): string {
115 const digest = NodeCrypto.createHash("sha256").update(bytes).digest("hex");
116 return `${messageId}:${index}:${digest}`;
118​
119export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
120 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
121 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
122 return null;
123 }
124 const match = normalizedId.match(ATTACHMENT_ID_PATTERN);
125 if (!match) {
126 return null;
127 }
128 return match[1]?.toLowerCase() ?? null;
130​
131/** Only this thread's ids qualify, preserving source pages when deleting a fork. */
132export function threadHtmlRenderAttachmentIds(
133 threadId: string,
134 items: Iterable<{ readonly toolName: string | null | undefined; readonly output?: unknown }>,
135) {
136 const segment = toSafeThreadAttachmentSegment(threadId);
137 if (segment === null) return [];
138 return Array.from(items).flatMap((item) => {
139 const attachmentId =
140 htmlRenderFromToolItem(item)?.attachmentId ??
141 mcpAppFromToolItem(item)?.attachmentId ??
142 previewRecordingIdFromToolItem(item);
143 return attachmentId !== undefined &&
144 parseThreadSegmentFromAttachmentId(attachmentId) === segment
145 ? [attachmentId]
146 : [];
147 });
149​
150export function attachmentRelativePath(attachment: ChatAttachment): string | null {
151 switch (attachment.type) {
152 case "image": {
153 const extension = inferImageExtension({
154 mimeType: attachment.mimeType,
155 fileName: attachment.name,
156 });
157 return `${attachment.id}${extension}`;
158 }
159 case "file":
160 return `${attachment.id}${attachmentFileExtension(attachment.name)}`;
161 default:
162 return null;
163 }
165​
166export function resolveAttachmentPath(input: {
167 readonly attachmentsDir: string;
168 readonly attachment: ChatAttachment;
169}): string | null {
170 const relativePath = attachmentRelativePath(input.attachment);
171 if (!relativePath) {
172 return null;
173 }
174 return resolveAttachmentRelativePath({
175 attachmentsDir: input.attachmentsDir,
176 relativePath,
177 });
179​
180export function resolveAttachmentPathById(input: {
181 readonly attachmentsDir: string;
182 readonly attachmentId: string;
183}): string | null {
184 const normalizedId = normalizeAttachmentRelativePath(input.attachmentId);
185 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
186 return null;
187 }
188 const fileExtension = parseAttachmentFileExtension(normalizedId);
189 if (fileExtension) {
190 const filePath = resolveAttachmentRelativePath({
191 attachmentsDir: input.attachmentsDir,
192 relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`,
193 });
194 return filePath && NodeFS.existsSync(filePath) ? filePath : null;
195 }
196 for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {
197 const maybePath = resolveAttachmentRelativePath({
198 attachmentsDir: input.attachmentsDir,
199 relativePath: `${normalizedId}${extension}`,
200 });
201 if (maybePath && NodeFS.existsSync(maybePath)) {
202 return maybePath;
203 }
204 }
205 return null;
207​
208export type AttachmentClaimPlan =
209 | {
210 readonly ok: true;
211 readonly finalId: string;
212 readonly currentPath: string;
213 readonly finalPath: string;
214 }
215 | { readonly ok: false; readonly reason: string };
216​
217export function planAttachmentClaim(input: {
218 readonly attachmentsDir: string;
219 readonly threadId: string;
220 readonly attachmentId: string;
221}): AttachmentClaimPlan {
222 const uuid = parseAttachmentUuid(input.attachmentId);
223 const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId);
224 if (!uuid || !requestedSegment) {
225 return { ok: false, reason: "invalid attachment id" };
226 }
227​
228 if (!toSafeThreadAttachmentSegment(input.threadId)) {
229 return { ok: false, reason: "invalid thread id" };
230 }
231 if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) {
232 return { ok: false, reason: "attachment must be a pending upload" };
233 }
234​
235 const currentPath = resolveAttachmentPathById({
236 attachmentsDir: input.attachmentsDir,
237 attachmentId: input.attachmentId,
238 });
239 if (!currentPath) {
240 return { ok: false, reason: "attachment not found (removed or expired)" };
241 }
242 const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined;
243 const finalId = createAttachmentId(input.threadId, fileExtension);
244 if (!finalId) {
245 return { ok: false, reason: "failed to create attachment id" };
246 }
247​
248 const expectedFinalPath = resolveAttachmentRelativePath({
249 attachmentsDir: input.attachmentsDir,
250 relativePath: `${finalId}${NodePath.extname(currentPath)}`,
251 });
252 if (!expectedFinalPath) {
253 return { ok: false, reason: "failed to resolve attachment path" };
254 }
255 return {
256 ok: true,
257 finalId,
258 currentPath,
259 finalPath: expectedFinalPath,
260 };
262​
263export function sweepStalePendingAttachments(input: {
264 readonly attachmentsDir: string;
265 readonly nowMs: number;
266}): { readonly deleted: number } {
267 let entries: string[];
268 try {
269 entries = NodeFS.readdirSync(input.attachmentsDir);
270 } catch {
271 return { deleted: 0 };
272 }
273​
274 let deleted = 0;
275 for (const entry of entries) {
276 const isPartial = entry.endsWith(".part");
277 if (!isPartial) {
278 const attachmentId = parseAttachmentIdFromRelativePath(
279 entry.endsWith(".txt") && entry.slice(0, -4).includes(".") ? entry.slice(0, -4) : entry,
280 );
281 if (
282 !attachmentId ||
283 parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT
284 ) {
285 continue;
286 }
287 }
288​
289 const resolved = resolveAttachmentRelativePath({
290 attachmentsDir: input.attachmentsDir,
291 relativePath: entry,
292 });
293 if (!resolved) {
294 continue;
295 }
296 try {
297 const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS;
298 if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) {
299 NodeFS.unlinkSync(resolved);
300 deleted += 1;
301 }
302 } catch {
303 continue;
304 }
305 }
306​
307 return { deleted };
309​
310function parseAttachmentIdFromRelativePath(relativePath: string): string | null {
311 const normalized = normalizeAttachmentRelativePath(relativePath);
312 if (!normalized || normalized.includes("/")) {
313 return null;
314 }
315 const extensionIndex = normalized.lastIndexOf(".");
316 if (extensionIndex <= 0) {
317 return null;
318 }
319 const id = normalized.slice(0, extensionIndex);
320 return id.length > 0 && !id.includes(".") ? id : null;