OUT-3763 (1/2) | Fall back to comment-scoped path for stale attachment URLs#1251
Conversation
…URLs A past race left ~26 comments with content pointing at the pre-move task-scoped path while the file lives at the comment-scoped path. On read, retry signing against the comment-scoped path when the stored path fails, so the broken comments render until the backfill rewrites their content. Attachment tags are only rewritten when their path lacks the comment-scoped segment — downloads use the path, not the token, so healthy attachment tags stay untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR introduces a read-side mitigation for OUT-3763, where ~26 comments have
Confidence Score: 3/5Merging as-is will leave every comment fetch subject to silent image-signing failures under concurrent load, and will fail to fully rewrite duplicate srcs within a single comment. The shared module-level regex instances with the src/utils/signedUrlReplacer.ts — the two new private functions and their interaction with the module-level regex state. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant signMediaForComments
participant rewriteCommentMediaSrcs
participant signCommentMediaPath
participant getSignedUrl as getSignedUrl (Supabase)
Caller->>signMediaForComments: comments[]
signMediaForComments->>rewriteCommentMediaSrcs: Promise.all per comment (concurrent)
Note over rewriteCommentMediaSrcs: Reset IMG_TAG_REGEX.lastIndex = 0
loop each img src
rewriteCommentMediaSrcs->>signCommentMediaPath: filePath, commentId
signCommentMediaPath->>getSignedUrl: primary filePath
alt file exists at primary path
getSignedUrl-->>signCommentMediaPath: signedUrl
else stale pre-move path
getSignedUrl-->>signCommentMediaPath: undefined
signCommentMediaPath->>getSignedUrl: prefix/comments/commentId/fileName
getSignedUrl-->>signCommentMediaPath: signedUrl (fallback)
end
signCommentMediaPath-->>rewriteCommentMediaSrcs: signedUrl
end
Note over rewriteCommentMediaSrcs: Reset ATTACHMENT_TAG_REGEX.lastIndex = 0
loop stale attachment srcs only
rewriteCommentMediaSrcs->>signCommentMediaPath: filePath, commentId
signCommentMediaPath-->>rewriteCommentMediaSrcs: signedUrl
end
rewriteCommentMediaSrcs-->>signMediaForComments: rewritten HTML
signMediaForComments-->>Caller: comments with updated content
Reviews (1): Last reviewed commit: "fix(OUT-3763): fall back to comment-scop..." | Re-trigger Greptile |
| const IMG_TAG_REGEX = /<img\s+[^>]*src="([^"]+)"[^>]*>/g | ||
| const ATTACHMENT_TAG_REGEX = /<\s*[a-zA-Z]+\s+[^>]*data-type="attachment"[^>]*src="([^"]+)"[^>]*>/g |
There was a problem hiding this comment.
Shared regex state causes concurrent calls to corrupt each other's
lastIndex
IMG_TAG_REGEX and ATTACHMENT_TAG_REGEX are module-level singletons with the /g flag. signMediaForComments calls rewriteCommentMediaSrcs for all comments simultaneously via Promise.all, and each call performs await inside the while loop. At every await suspension point a sibling coroutine can start and reset lastIndex = 0 for its own string — when the first coroutine resumes, lastIndex is whatever the sibling left it at. If the sibling's string is longer and its last match advanced lastIndex past all remaining matches in the first comment's string, exec returns null immediately and the loop exits, silently dropping those images. Move both regex literals inside rewriteCommentMediaSrcs so each invocation owns a fresh instance.
There was a problem hiding this comment.
Thanks for the fix! Moving the regex literals inside rewriteCommentMediaSrcs ensures each invocation gets a fresh instance with lastIndex = 0, eliminating the shared state issue across concurrent Promise.all calls.
| for (const { originalSrc, newUrl } of replacements) { | ||
| htmlString = htmlString.replace(originalSrc, newUrl) | ||
| } | ||
| return htmlString | ||
| } |
There was a problem hiding this comment.
String.replace (string overload) only replaces the first occurrence of originalSrc. The seen set correctly deduplicates signing calls, so only one replacements entry exists per unique URL — but if the same src appears twice in the HTML (the PR explicitly tests this case), only the first <img> gets the fresh signed URL; the second keeps the original stale/expired path. Use replaceAll to fix every occurrence.
| for (const { originalSrc, newUrl } of replacements) { | |
| htmlString = htmlString.replace(originalSrc, newUrl) | |
| } | |
| return htmlString | |
| } | |
| for (const { originalSrc, newUrl } of replacements) { | |
| htmlString = htmlString.replaceAll(originalSrc, newUrl) | |
| } | |
| return htmlString | |
| } |
There was a problem hiding this comment.
Great, that takes care of it. Thanks for the fix!
- Move both regexes inside rewriteCommentMediaSrcs so each invocation owns its own RegExp instance. Module-level /g regexes share lastIndex, and signMediaForComments runs the rewrite concurrently via Promise.all — a sibling resuming after an await could leave lastIndex past the end of another comment's matches, silently dropping unsigned images. - replace → replaceAll so a file embedded more than once in the same comment has all occurrences rewritten, not just the first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Deployment failed with the following error: Learn More: https://vercel.link/multiple-function-regions |
Changes
Patches the immediate user-visible symptom from OUT-3763: ~26 comments have
Comments.contentURLs pointing at the pre-move task-scoped path ({ws}/{taskId}/{file}), while the actual file lives at the comment-scoped path ({ws}/{taskId}/comments/{commentId}/{file}). On read, rewrite their srcs so the broken comments render until the backfill makes the stored content correct.In
src/utils/signedUrlReplacer.ts:signMediaForCommentsroutes through a new comment-scoped signer (signCommentMediaPath) that tries the stored path first, and on failure retries against{prefix}/comments/{commentId}/{fileName}.<img>srcs always go through the signer (images need a fresh token to render).<div data-type="attachment">srcs are only rewritten when their stored path lacks/comments/{commentId}/. Downloads (useDownloadFile) use the path, not the token, so healthy attachment tags don't need a fresh signed URL — and we skip the signing call entirely.replaceImageSrcis untouched and still used by task body and template rewrites — no behavior change for those paths.Marked for removal in the inline comment once the backfill ticket rewrites the affected
Comments.contentrows.Testing Criteria
<div data-type="attachment">srcs — no new signing calls should fire compared to before this PR.seenset).replaceImageSrc).Notes
Comments.contentrows still need a backfill (separate ticket) to permanently rewrite their URLs. Once that lands, the OUT-3763 block insignedUrlReplacer.tscan be removed.Impact & Surface Area of Change
rewriteCommentMediaSrcsinstead ofreplaceImageSrc. For healthy comments: one signing call per<img>(unchanged), zero new calls for<div data-type="attachment">tags. For the 26 affected comments: one extra failing signing call per stale src, then a successful fallback call.replaceImageSrcis unchanged and still consumed by tasks and templates.🤖 Generated with Claude Code