Refactor Attchemnt Upload and fix bug with signing
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user