Skip to content

Commit a6410aa

Browse files
OhYeeclaude
andcommitted
fix(drawings): copy S3 storage on duplicate so the original can be deleted
POST /drawings/:id/duplicate previously copied elements/appState/files verbatim, including the dataURLs that point at the *original* drawing's S3 prefix (`/api/files/origId/fileId` or `https://.../excalidash/owner/origId/fileId.ext`). The duplicate's images therefore pointed at storage owned by the original. Deleting the original then ran the new prefix-scoped S3 cleanup and silently broke every image in the duplicate. Now the duplicate handler does a server-side CopyObject for every S3File row of the original under the new drawingId path, writes a fresh (newDrawingId, fileId) S3File row, and rewrites the dataURLs in the new drawing's files JSON to match the new drawing id. Each drawing — original and duplicate — owns its own storage from the moment of duplication, so DELETE /drawings/:origId can no longer strand the duplicate. Adds a bucket-internal copyS3Object() helper to s3.ts (uses S3 CopyObject; no download/re-upload). Change-Id: If57d006fce8964fe04e702680e96866a3ec3b6f0 Co-developed-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a98133b commit a6410aa

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

backend/src/routes/dashboard/drawings.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isS3Enabled,
66
listS3Objects,
77
deleteS3Object,
8+
copyS3Object,
89
drawingS3Prefix,
910
} from "../../s3";
1011
import { DashboardRouteDeps, SortDirection, SortField } from "./types";
@@ -705,14 +706,84 @@ export const registerDrawingRoutes = (
705706
version: 1,
706707
},
707708
});
709+
710+
// Copy S3 storage so the duplicate stops sharing objects with the
711+
// original. Without this, deleting the original (which prunes the
712+
// original's S3 prefix) would silently break every image in the
713+
// duplicate's scene.
714+
let duplicatedFilesJson = newDrawing.files;
715+
if (isS3Enabled()) {
716+
const sourceFiles = await prisma.s3File.findMany({
717+
where: { drawingId: original.id },
718+
});
719+
720+
if (sourceFiles.length > 0) {
721+
const filesObj = parseJsonField<Record<string, any>>(
722+
newDrawing.files,
723+
{},
724+
);
725+
726+
for (const src of sourceFiles) {
727+
// Replace `/{originalId}/` with `/{newId}/` exactly once at
728+
// the per-drawing folder boundary in the s3Key.
729+
const destKey = src.s3Key.replace(
730+
`/${original.id}/`,
731+
`/${newDrawing.id}/`,
732+
);
733+
734+
try {
735+
await copyS3Object(src.s3Key, destKey, src.mimeType);
736+
} catch (err) {
737+
console.error(
738+
`[drawings/duplicate] Failed to copy ${src.s3Key} -> ${destKey}`,
739+
err,
740+
);
741+
continue;
742+
}
743+
744+
await prisma.s3File.create({
745+
data: {
746+
drawingId: newDrawing.id,
747+
fileId: src.fileId,
748+
userId: req.user.id,
749+
s3Key: destKey,
750+
mimeType: src.mimeType,
751+
},
752+
});
753+
754+
// Rewrite dataURL so private-bucket redirects and public CDN
755+
// links point at the new object instead of the original's.
756+
const file = filesObj[src.fileId];
757+
if (file && typeof file.dataURL === "string") {
758+
const next = file.dataURL
759+
.replace(
760+
`/api/files/${original.id}/`,
761+
`/api/files/${newDrawing.id}/`,
762+
)
763+
.replace(`/${original.id}/`, `/${newDrawing.id}/`);
764+
if (next !== file.dataURL) {
765+
filesObj[src.fileId] = { ...file, dataURL: next };
766+
}
767+
}
768+
}
769+
770+
const serialised = JSON.stringify(filesObj);
771+
await prisma.drawing.update({
772+
where: { id: newDrawing.id },
773+
data: { files: serialised },
774+
});
775+
duplicatedFilesJson = serialised;
776+
}
777+
}
778+
708779
invalidateDrawingsCache();
709780

710781
return res.json({
711782
...newDrawing,
712783
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
713784
elements: parseJsonField(newDrawing.elements, []),
714785
appState: parseJsonField(newDrawing.appState, {}),
715-
files: parseJsonField(newDrawing.files, {}),
786+
files: parseJsonField(duplicatedFilesJson, {}),
716787
});
717788
}));
718789

backend/src/s3.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
GetObjectCommand,
99
DeleteObjectCommand,
1010
ListObjectsV2Command,
11+
CopyObjectCommand,
1112
} from "@aws-sdk/client-s3";
1213
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
1314

@@ -233,6 +234,32 @@ export const listS3Objects = async (
233234
return results;
234235
};
235236

237+
/**
238+
* Copy an object inside the same bucket (server-side copy — no
239+
* download/re-upload). Used by the duplicate-drawing path so each
240+
* drawing owns its own object under its own (drawingId) prefix.
241+
*/
242+
export const copyS3Object = async (
243+
sourceKey: string,
244+
destKey: string,
245+
mimeType?: string,
246+
): Promise<void> => {
247+
if (!s3Client || !s3Config) {
248+
throw new Error("S3 is not configured");
249+
}
250+
251+
const command = new CopyObjectCommand({
252+
Bucket: s3Config.bucket,
253+
Key: destKey,
254+
CopySource: `${s3Config.bucket}/${sourceKey}`,
255+
ContentType: mimeType,
256+
CacheControl: "public, max-age=31536000, immutable",
257+
MetadataDirective: mimeType ? "REPLACE" : "COPY",
258+
});
259+
260+
await s3Client.send(command);
261+
};
262+
236263
/**
237264
* Delete an object from S3. Best-effort — errors are thrown to the caller.
238265
*/

0 commit comments

Comments
 (0)