authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 19:01:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
logd58af33fba73936659158ba2167a00475889d1e5
tree559a9e8b6911edf3cf4968be9e2ed8123cbde5c4
parenta56418f8bc9866ab114c85b4e73a7f95987c3469

Fix attachment cleanup to match exact thread segments

- Parse thread segments from attachment IDs using a UUID-aware pattern - Prevent revert/delete cleanup from removing files belonging to similarly prefixed threads - Add tests for prefix-collision cases in attachment store and projection pipeline

4 files changed, 72 insertions(+), 9 deletions(-)

apps/server/src/attachmentStore.test.ts+14-2
......@@ -4,7 +4,11 @@ import path from "node:path";
44
55import { describe, expect, it } from "vitest";
66
7import { createAttachmentId, resolveAttachmentPathById } from "./attachmentStore.ts";
7import {
8 createAttachmentId,
9 parseThreadSegmentFromAttachmentId,
10 resolveAttachmentPathById,
11} from "./attachmentStore.ts";
812
913describe("attachmentStore", () => {
1014 it("sanitizes thread ids when creating attachment ids", () => {
......@@ -14,7 +18,7 @@ describe("attachmentStore", () => {
1418 return;
1519 }
1620
17 const [threadSegment] = attachmentId.split("-", 1);
21 const threadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
1822 expect(threadSegment).toBeTruthy();
1923 expect(threadSegment).toMatch(/^[a-z0-9_-]+$/i);
2024 expect(threadSegment).not.toContain(".");
......@@ -22,6 +26,14 @@ describe("attachmentStore", () => {
2226 expect(threadSegment).not.toContain("/");
2327 });
2428
29 it("parses exact thread segments from attachment ids without prefix collisions", () => {
30 const fooId = "foo-00000000-0000-4000-8000-000000000001";
31 const fooBarId = "foo-bar-00000000-0000-4000-8000-000000000002";
32
33 expect(parseThreadSegmentFromAttachmentId(fooId)).toBe("foo");
34 expect(parseThreadSegmentFromAttachmentId(fooBarId)).toBe("foo-bar");
35 });
36
2537 it("resolves attachment path by id using the extension that exists on disk", () => {
2638 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-"));
2739 try {
apps/server/src/attachmentStore.ts+19
......@@ -12,6 +12,13 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"
1212
1313const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
1414const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
15const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
16const ATTACHMENT_ID_UUID_PATTERN =
17 "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
18const ATTACHMENT_ID_PATTERN = new RegExp(
19 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
20 "i",
21);
1522
1623export function toSafeThreadAttachmentSegment(threadId: string): string | null {
1724 const segment = threadId
......@@ -35,6 +42,18 @@ export function createAttachmentId(threadId: string): string | null {
3542 return `${threadSegment}-${randomUUID()}`;
3643}
3744
45export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
46 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
47 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
48 return null;
49 }
50 const match = normalizedId.match(ATTACHMENT_ID_PATTERN);
51 if (!match) {
52 return null;
53 }
54 return match[1]?.toLowerCase() ?? null;
55}
56
3857export function attachmentRelativePath(attachment: ChatAttachment): string {
3958 switch (attachment.type) {
4059 case "image": {
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+19-3
......@@ -734,8 +734,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
734734 const eventStore = yield* OrchestrationEventStore;
735735 const now = new Date().toISOString();
736736 const threadId = ThreadId.makeUnsafe("thread revert.files");
737 const keepAttachmentId = "thread-revert-files-keep";
738 const removeAttachmentId = "thread-revert-files-remove";
737 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
738 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
739 const otherThreadAttachmentId =
740 "thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
739741
740742 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
741743 eventStore
......@@ -904,8 +906,11 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
904906 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
905907 fs.writeFileSync(keepPath, Buffer.from("keep"));
906908 fs.writeFileSync(removePath, Buffer.from("remove"));
909 const otherThreadPath = path.join(stateDir, "attachments", `${otherThreadAttachmentId}.png`);
910 fs.writeFileSync(otherThreadPath, Buffer.from("other"));
907911 assert.equal(fs.existsSync(keepPath), true);
908912 assert.equal(fs.existsSync(removePath), true);
913 assert.equal(fs.existsSync(otherThreadPath), true);
909914
910915 yield* appendAndProject({
911916 type: "thread.reverted",
......@@ -925,6 +930,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
925930
926931 assert.equal(fs.existsSync(keepPath), true);
927932 assert.equal(fs.existsSync(removePath), false);
933 assert.equal(fs.existsSync(otherThreadPath), true);
928934 }).pipe(
929935 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
930936 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
......@@ -943,7 +949,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
943949 const eventStore = yield* OrchestrationEventStore;
944950 const now = new Date().toISOString();
945951 const threadId = ThreadId.makeUnsafe("thread delete.files");
946 const attachmentId = "thread-delete-files-att-1";
952 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
953 const otherThreadAttachmentId =
954 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
947955
948956 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
949957 eventStore
......@@ -1025,9 +1033,16 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10251033 });
10261034
10271035 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);
1036 const otherThreadAttachmentPath = path.join(
1037 stateDir,
1038 "attachments",
1039 `${otherThreadAttachmentId}.png`,
1040 );
10281041 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
10291042 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));
1043 fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread"));
10301044 assert.equal(fs.existsSync(threadAttachmentPath), true);
1045 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
10311046
10321047 yield* appendAndProject({
10331048 type: "thread.deleted",
......@@ -1046,6 +1061,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10461061 });
10471062
10481063 assert.equal(fs.existsSync(threadAttachmentPath), false);
1064 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
10491065 }).pipe(
10501066 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
10511067 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+20-4
......@@ -37,7 +37,12 @@ import {
3737 OrchestrationProjectionPipeline,
3838 type OrchestrationProjectionPipelineShape,
3939} from "../Services/ProjectionPipeline.ts";
40import { attachmentRelativePath, toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
40import {
41 attachmentRelativePath,
42 parseAttachmentIdFromRelativePath,
43 parseThreadSegmentFromAttachmentId,
44 toSafeThreadAttachmentSegment,
45} from "../../attachmentStore.ts";
4146
4247export const ORCHESTRATION_PROJECTOR_NAMES = {
4348 projects: "projection.projects",
......@@ -198,7 +203,8 @@ function collectThreadAttachmentRelativePaths(
198203 if (attachment.type !== "image") {
199204 continue;
200205 }
201 if (!attachment.id.startsWith(`${threadSegment}-`)) {
206 const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id);
207 if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
202208 continue;
203209 }
204210 relativePaths.add(attachmentRelativePath(attachment));
......@@ -236,7 +242,12 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
236242 if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) {
237243 return;
238244 }
239 if (!normalizedEntry.startsWith(`${threadSegment}-`)) {
245 const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry);
246 if (!attachmentId) {
247 return;
248 }
249 const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
250 if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
240251 return;
241252 }
242253 yield* fileSystem.remove(path.join(attachmentsRootDir, normalizedEntry), {
......@@ -272,7 +283,12 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
272283 if (relativePath.length === 0 || relativePath.includes("/")) {
273284 return;
274285 }
275 if (!relativePath.startsWith(`${threadSegment}-`)) {
286 const attachmentId = parseAttachmentIdFromRelativePath(relativePath);
287 if (!attachmentId) {
288 return;
289 }
290 const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
291 if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
276292 return;
277293 }
278294