87 lines
2.6 KiB
JavaScript
87 lines
2.6 KiB
JavaScript
import moment from 'moment'
|
|
import { apiClient } from './ApiClient'
|
|
|
|
const isPlusAccount = userProfile => {
|
|
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
|
|
}
|
|
|
|
const resolvePhotoURL = url => {
|
|
if (!url) return ''
|
|
if (url.startsWith('http') || url.startsWith('https')) {
|
|
return url
|
|
}
|
|
return apiClient.getAssetURL(url)
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
}
|
|
return null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
}
|