Refactor Attchemnt Upload and fix bug with signing

This commit is contained in:
Mo Tarbin
2026-07-18 02:16:42 -04:00
parent bf6474a488
commit a6fb3c87c4
15 changed files with 601 additions and 163 deletions

View File

@@ -13,111 +13,74 @@ const resolvePhotoURL = url => {
return apiClient.getAssetURL(url)
}
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
const isCloudSignedUrl = url => {
if (!url) return false
// Returns the expiry of a signed asset URL in epoch ms, or null when the URL
// carries no expiry (public assets, plain paths, OIDC picture URLs).
// Understands the backend's local signer (`expires` in unix seconds) and
// S3 presigned URLs (`X-Amz-Date` + `X-Amz-Expires`).
const getSignedUrlExpiry = url => {
if (!url) return null
try {
return (
url.includes('X-Amz-Signature') ||
url.includes('X-Amz-Expires') ||
url.includes('X-Goog-Expires') ||
url.includes('expires')
)
} catch(e) {
return false
}
}
// Extract the storage key from a cloud signed URL so we can route it through
// the backend proxy (which re-signs on every request and never expires).
//
// Handles:
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
// GCS: https://storage.googleapis.com/{bucket}/{key}?...
// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
//
// The app stores files under an "assets/" prefix in the bucket but the backend
// proxy already mounts at /assets/, so we strip that leading segment when present.
const extractStorageKey = url => {
try {
const u = new URL(url)
const host = u.hostname
const rawPath = u.pathname.replace(/^\//, '')
let key
if (host.endsWith('.r2.cloudflarestorage.com')) {
// Virtual-hosted R2: bucket is in the host, key is the full path
key = rawPath
} else if (host.endsWith('.amazonaws.com')) {
if (host.startsWith('s3') || host.includes('.s3.')) {
// Path-style S3: first segment is the bucket — strip it
key = rawPath.split('/').slice(1).join('/')
} else {
// Virtual-hosted S3: bucket is in the host, path is the key
key = rawPath
const query = new URL(url, 'http://relative.local').searchParams
if (query.has('expires')) {
const expires = parseInt(query.get('expires'), 10)
return Number.isFinite(expires) ? expires * 1000 : null
}
if (query.has('X-Amz-Expires') && query.has('X-Amz-Date')) {
const iso = query
.get('X-Amz-Date')
.replace(
/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/,
'$1-$2-$3T$4:$5:$6Z',
)
const start = Date.parse(iso)
const validFor = parseInt(query.get('X-Amz-Expires'), 10)
if (Number.isFinite(start) && Number.isFinite(validFor)) {
return start + validFor * 1000
}
} else if (host === 'storage.googleapis.com') {
// First segment is the bucket
key = rawPath.split('/').slice(1).join('/')
} else if (host.endsWith('.blob.core.windows.net')) {
// First segment is the container name
key = rawPath.split('/').slice(1).join('/')
} else {
key = rawPath
}
// The bucket stores files under an "assets/" prefix; the backend /assets/
// endpoint already adds that prefix, so strip it to avoid duplication.
if (key.startsWith('assets/')) {
key = key.slice('assets/'.length)
}
return key || null
return null
} catch {
return null
}
}
// Scan an HTML string for <img> tags whose src is a cloud signed URL and
// replace them with backend proxy URLs (which generate fresh signed URLs on
// each request). Returns the patched HTML, or the original if nothing changed.
const refreshSignedUrlsInHtml = html => {
if (!html) return html
if (
!html.includes('dt-data-path') &&
!html.includes('X-Amz-') &&
!html.includes('X-Goog-') &&
!html.includes('sig') &&
!html.includes('.blob.core.windows.net')
) {
return html
}
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const imgs = doc.querySelectorAll('img[src]')
let changed = false
imgs.forEach(img => {
const stablePath = img.getAttribute('dt-data-path')
const src = img.getAttribute('src')
let nextSrc = src
if (stablePath) {
nextSrc = resolvePhotoURL(stablePath)
} else if (isCloudSignedUrl(src)) {
nextSrc = resolvePhotoURL(extractStorageKey(src))
}
if (nextSrc && nextSrc !== src) {
img.setAttribute('src', nextSrc)
changed = true
}
})
return changed ? doc.body.innerHTML : html
// One minute of clock skew so we refresh before the server starts rejecting.
const isSignedUrlExpired = url => {
const expiry = getSignedUrlExpiry(url)
return expiry != null && Date.now() > expiry - 60_000
}
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }
// Fetches a fresh signed URL for a stored asset path via the authed sign
// endpoint. Memoized per path until the signed URL nears expiry, with
// in-flight de-duplication so a burst of images signs each path once.
const signedUrlCache = new Map()
const getSignedAssetUrl = async path => {
if (!path) return ''
const hit = signedUrlCache.get(path)
if (hit?.url && !isSignedUrlExpired(hit.url)) return hit.url
if (hit?.promise) return hit.promise
const promise = (async () => {
const response = await apiClient.get(
`/files/sign?path=${encodeURIComponent(path)}`,
)
if (!response?.ok) {
throw new Error(`Failed to sign asset path: ${path}`)
}
const data = await response.json()
const url = resolvePhotoURL(data.url)
signedUrlCache.set(path, { url })
return url
})()
promise.catch(() => signedUrlCache.delete(path))
signedUrlCache.set(path, { promise })
return promise
}
export {
getSignedAssetUrl,
getSignedUrlExpiry,
isPlusAccount,
isSignedUrlExpired,
resolvePhotoURL,
}