Skip to content

Commit 3e48f5d

Browse files
OhYeeclaude
andcommitted
fix(drawings): rewrite preview SVG so it doesn't inline base64 after S3 upload
Symptom: every save produced a megabyte-scale `Drawing.preview` column. The SVG had `<image href="data:image/png;base64,...">` for each image element instead of the S3 URL. Root cause: the frontend generates the preview SVG from the canvas state at save time, before the round-trip uploads files to S3. The SVG embeds whatever dataURL the file currently has, which at save time is still base64. processFilesForS3 only rewrites the dataURL inside `Drawing.files`, never the parallel preview SVG, so the inlined base64 stays in the preview forever. Add a rewritePreviewForS3 helper in fileProcessing.ts that diffs the original-vs-processed files map and applies the resulting URL substitutions to the preview string. Wire it into the POST and PUT handlers so the preview saved alongside the upload reflects the same S3 URLs as Drawing.files. Best-effort string substitution: it works because the same dataURL string is character-identical in both `files[fileId].dataURL` and the preview SVG's href. If frontend encoding ever diverges, the worst case is the preview is left as-is — never crashes. Change-Id: I427edabcccc1453bf0f69f5dde33c9648cfcb370 Co-developed-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 32a06a0 commit 3e48f5d

2 files changed

Lines changed: 71 additions & 4 deletions

File tree

backend/src/fileProcessing.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,47 @@ export const processFilesForS3 = async (
113113

114114
return result;
115115
};
116+
117+
/**
118+
* Rewrite an Excalidraw preview SVG so any base64 dataURL that has just
119+
* been uploaded to S3 is replaced by the resulting S3 / redirect URL.
120+
*
121+
* The frontend generates the preview SVG from the canvas state at save
122+
* time, *before* the round-trip to the backend uploads the files; the
123+
* SVG embeds whatever dataURL the file currently has in `Drawing.files`.
124+
* Without this rewrite, every save produces a megabyte-scale preview
125+
* with the full image base64 inlined, even though the image itself is
126+
* already in S3 (the diff between Drawing.files's processed entries
127+
* and the preview field gets ever larger over time).
128+
*
129+
* Best-effort string substitution: works because the same dataURL
130+
* string is character-identical in both `files[fileId].dataURL` and
131+
* the preview SVG's `<image href="...">` attribute. If frontend
132+
* encoding ever diverges, the worst case is the preview is left as-is.
133+
*/
134+
export const rewritePreviewForS3 = (
135+
preview: unknown,
136+
originalFiles: Record<string, any>,
137+
processedFiles: Record<string, any>,
138+
): unknown => {
139+
if (typeof preview !== "string" || preview.length === 0) {
140+
return preview;
141+
}
142+
let rewritten = preview;
143+
for (const fileId of Object.keys(processedFiles)) {
144+
const original = originalFiles[fileId];
145+
const processed = processedFiles[fileId];
146+
if (
147+
!original ||
148+
!processed ||
149+
typeof original.dataURL !== "string" ||
150+
typeof processed.dataURL !== "string" ||
151+
original.dataURL === processed.dataURL ||
152+
!original.dataURL.startsWith("data:")
153+
) {
154+
continue;
155+
}
156+
rewritten = rewritten.split(original.dataURL).join(processed.dataURL);
157+
}
158+
return rewritten;
159+
};

backend/src/routes/dashboard/drawings.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
copyS3Object,
99
drawingS3Prefix,
1010
} from "../../s3";
11+
import { rewritePreviewForS3 } from "../../fileProcessing";
1112
import { DashboardRouteDeps, SortDirection, SortField } from "./types";
1213
import {
1314
getUserTrashCollectionId,
@@ -408,11 +409,20 @@ export const registerDrawingRoutes = (
408409
}
409410

410411
const drawingId = crypto.randomUUID();
412+
const originalFiles = (payload.files ?? {}) as Record<string, any>;
411413
const processedFiles = await processFilesForS3(
412-
(payload.files ?? {}) as Record<string, any>,
414+
originalFiles,
413415
req.user.id,
414416
drawingId
415417
);
418+
// Rewrite the preview SVG so it points at the just-uploaded S3 URLs
419+
// instead of inlining the megabyte-scale base64 dataURL the frontend
420+
// generated before the upload completed.
421+
const processedPreview = rewritePreviewForS3(
422+
payload.preview ?? null,
423+
originalFiles,
424+
processedFiles,
425+
) as string | null | undefined;
416426

417427
const newDrawing = await prisma.drawing.create({
418428
data: {
@@ -422,7 +432,7 @@ export const registerDrawingRoutes = (
422432
appState: JSON.stringify(payload.appState),
423433
userId: req.user.id,
424434
collectionId: targetCollectionId,
425-
preview: payload.preview ?? null,
435+
preview: processedPreview ?? null,
426436
files: JSON.stringify(processedFiles),
427437
},
428438
});
@@ -521,14 +531,27 @@ export const registerDrawingRoutes = (
521531
}
522532
}
523533

534+
const originalFiles = payload.files as Record<string, any>;
524535
const processedFiles = await processFilesForS3(
525-
payload.files as Record<string, any>,
536+
originalFiles,
526537
existingDrawing.userId,
527538
id
528539
);
529540
data.files = JSON.stringify(processedFiles);
541+
542+
if (payload.preview !== undefined) {
543+
// Rewrite preview so the SVG embeds the new S3 URLs instead of
544+
// the original base64 dataURLs (the frontend generates the
545+
// preview before this round-trip uploads the files).
546+
data.preview = rewritePreviewForS3(
547+
payload.preview,
548+
originalFiles,
549+
processedFiles,
550+
) as string | null | undefined;
551+
}
552+
} else if (payload.preview !== undefined) {
553+
data.preview = payload.preview;
530554
}
531-
if (payload.preview !== undefined) data.preview = payload.preview;
532555

533556
if (payload.collectionId !== undefined) {
534557
if (!isOwnerAccess(access)) {

0 commit comments

Comments
 (0)