src/services/storage.ts
Rama main · réplica de lectura del repositorio original.
import { defaultApiHost, useConsole } from './console'
import { resolveConsoleApiBase } from './consoleAuth'
const SIGNED_URL_TTL_SECONDS = 60 * 60 * 24 * 7
const SIGNED_URL_CACHE_MAX_AGE_MS = 15 * 60 * 1000
const MAX_CACHE_ENTRIES = 500
// Fetch a new image token this long before the current one expires, so URLs
// already on screen keep loading.
const IMAGE_TOKEN_RENEW_MARGIN_MS = 60 * 60 * 1000
const STORAGE_URL_REGEX = /\/storage\/v1\/object(?:\/(?:public|sign))?\/images\/./
const CONSOLE_READ_PATH = '/private/console/images/read'
const signedUrlCache = new Map<string, { url: string, expiresAt: number }>()
interface ImageToken {
userId: string
token: string
expiresAt: number
}
let imageToken: ImageToken | null = null
let pendingImageToken: { userId: string, promise: Promise<string | null> } | null = null
function consoleApiBase() {
return resolveConsoleApiBase(defaultApiHost)
}
// Only our own image relay URLs carry a storage path worth re-signing; an external
// URL that merely ends with the same path is left alone.
function isConsoleReadUrl(url: URL) {
if (!url.pathname.endsWith(CONSOLE_READ_PATH) || !url.searchParams.get('path') || !(url.searchParams.get('t') || url.searchParams.get('token')))
return false
try {
return url.origin === new URL(consoleApiBase()).origin
}
catch {
return false
}
}
function decodePathname(pathname: string) {
try {
return decodeURIComponent(pathname)
}
catch {
return pathname
}
}
export function resolveImagePath(raw?: string | null) {
if (!raw)
return { normalized: '', shouldSign: false }
const trimmed = raw.trim()
if (!trimmed)
return { normalized: '', shouldSign: false }
try {
const url = new URL(trimmed)
// A URL this console signed earlier carries the storage path; re-sign from it.
if (isConsoleReadUrl(url))
return { normalized: url.searchParams.get('path')!.replace(/^\/+/, ''), shouldSign: true }
// Storage URLs (encoded routes included) are signed by the API, which checks
// the URL comes from Capgo storage before extracting its key.
if (STORAGE_URL_REGEX.test(decodePathname(url.pathname))) {
return {
normalized: trimmed,
shouldSign: true,
}
}
return {
normalized: trimmed,
shouldSign: false,
}
}
catch {
return {
// Same order as the API's normalizeImagePath, so `/images/x` becomes `x`.
normalized: trimmed.replace(/^\/+/, '').replace(/^images\//, '').replace(/^\/+/, ''),
shouldSign: true,
}
}
}
export function getImmediateImageUrl(raw?: string | null) {
const { normalized, shouldSign } = resolveImagePath(raw)
return shouldSign ? '' : normalized
}
/** True for an image URL served by the console image relay. */
export function isConsoleImageUrl(raw: string) {
try {
return isConsoleReadUrl(new URL(raw))
}
catch {
return false
}
}
async function fetchImageToken(userId: string) {
const { data: response } = await useConsole().functions.invoke<{ data: { token: string, expiresAt: number } | null }>('private/console/images/token', { body: {} })
const issued = response?.data
if (!issued?.token)
return null
imageToken = { userId, token: issued.token, expiresAt: issued.expiresAt }
return issued.token
}
/**
* The caller's image token: one JWT-like credential that can only read images,
* issued once and reused for every image URL until it nears expiry. Image
* elements cannot send the session bearer header, so it rides in the URL.
*/
async function getImageToken(forceRefresh: boolean) {
const { data } = await useConsole().auth.getSession()
const userId = data.session?.user?.id
if (!userId) {
imageToken = null
return null
}
if (!forceRefresh && imageToken?.userId === userId && imageToken.expiresAt * 1000 - Date.now() > IMAGE_TOKEN_RENEW_MARGIN_MS)
return imageToken.token
if (pendingImageToken?.userId === userId)
return pendingImageToken.promise
const promise = fetchImageToken(userId).finally(() => {
if (pendingImageToken?.promise === promise)
pendingImageToken = null
})
pendingImageToken = { userId, promise }
return promise
}
function buildImageReadUrl(path: string, token: string) {
const url = new URL(`${consoleApiBase()}${CONSOLE_READ_PATH}`)
url.searchParams.set('path', path)
url.searchParams.set('t', token)
return url.href
}
// Legacy values stored as full storage URLs need the API to verify their origin
// and extract the object path, so they still go through the sign endpoint.
async function signLegacyStorageUrl(normalized: string, forceRefresh: boolean) {
const cacheKey = `images:${normalized}`
if (forceRefresh) {
signedUrlCache.delete(cacheKey)
}
else {
const cached = signedUrlCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now())
return cached.url
if (cached)
signedUrlCache.delete(cacheKey)
}
const { data, error } = await useConsole()
.storage
.from('images')
.createSignedUrl(normalized, SIGNED_URL_TTL_SECONDS)
if (error || !data?.signedUrl)
return ''
signedUrlCache.set(cacheKey, {
url: data.signedUrl,
expiresAt: Date.now() + Math.min(SIGNED_URL_TTL_SECONDS * 1000, SIGNED_URL_CACHE_MAX_AGE_MS),
})
if (signedUrlCache.size > MAX_CACHE_ENTRIES) {
const oldestKey = signedUrlCache.keys().next().value
if (oldestKey)
signedUrlCache.delete(oldestKey)
}
return data.signedUrl
}
export async function createSignedImageUrl(path?: string | null, options: { forceRefresh?: boolean } = {}) {
const { normalized, shouldSign } = resolveImagePath(path)
if (!normalized)
return ''
if (!shouldSign)
return normalized
if (normalized.includes('://'))
return signLegacyStorageUrl(normalized, options.forceRefresh ?? false)
const token = await getImageToken(options.forceRefresh ?? false)
return token ? buildImageReadUrl(normalized, token) : ''
}
const IMAGE_RETRIED_ATTRIBUTE = 'data-capgo-image-retried'
/**
* One capture-phase handler for every console image. An `<img>` rendered from a
* URL whose image token has expired gets one retry with a current token; an
* image that is really gone (or not readable) is hidden instead of showing the
* browser's broken-image icon.
*/
export function installConsoleImageFallback(target: Document = document) {
target.addEventListener('error', (event) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || !isConsoleImageUrl(image.currentSrc || image.src))
return
const failedUrl = image.src
if (image.getAttribute(IMAGE_RETRIED_ATTRIBUTE) === failedUrl) {
image.style.visibility = 'hidden'
return
}
// Rebuilding the URL renews the token only when it is near or past expiry,
// so a page of missing images does not fetch a token per image.
void createSignedImageUrl(failedUrl).then((freshUrl) => {
// Vue may have pointed the element at another image meanwhile; leave that one alone.
if (image.src !== failedUrl)
return
if (freshUrl && freshUrl !== failedUrl) {
image.setAttribute(IMAGE_RETRIED_ATTRIBUTE, freshUrl)
image.src = freshUrl
}
else {
image.setAttribute(IMAGE_RETRIED_ATTRIBUTE, failedUrl)
image.style.visibility = 'hidden'
}
})
}, true)
// Vue reuses elements: once one shows a working image again, undo the fallback.
target.addEventListener('load', (event) => {
const image = event.target
if (image instanceof HTMLImageElement && image.hasAttribute(IMAGE_RETRIED_ATTRIBUTE)) {
image.removeAttribute(IMAGE_RETRIED_ATTRIBUTE)
image.style.visibility = ''
}
}, true)
}