Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 137 additions & 14 deletions supabase/functions/_backend/files/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,25 @@ function withFileReadCacheControl(cacheControl: string | null | undefined): stri
return withNoTransformCacheControl(cacheControl)
}

function isHeadRequest(c: Context): boolean {
return c.req.raw.method === 'HEAD'
}

function toHeadersOnlyResponse(response: Response): Response {
return new Response(null, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
})
}

function notFoundAttachmentResponse(c: Context, isHead: boolean): Response {
// HEAD must stay body-less (same HTTP/2 Content-Length trap as 200 hits).
if (isHead)
return new Response(null, { status: 404 })
return c.json({ error: 'not_found', message: 'Not found' }, 404)
}

function ensureNoTransformResponse(response: Response): Response {
const cacheControl = withFileReadCacheControl(response.headers.get('cache-control'))
if (cacheControl === response.headers.get('cache-control')) {
Expand All @@ -281,12 +300,12 @@ function ensureNoTransformResponse(response: Response): Response {
})
}

function withAttachmentResponseHeaders(response: Response, fileId: string): Response {
function withAttachmentResponseHeaders(response: Response, fileId: string, includeBody = true): Response {
const headers = new Headers(response.headers)
headers.set('cache-control', withFileReadCacheControl(headers.get('cache-control')))
headers.set('content-disposition', `attachment; filename="${fileId}"`)

return new Response(response.body, {
return new Response(includeBody ? response.body : null, {
headers,
status: response.status,
statusText: response.statusText,
Expand Down Expand Up @@ -348,6 +367,7 @@ async function saveBandwidthUsage(c: Context, fileSize: number | null | undefine
}

async function getSupabaseStorageResponse(c: Context, fileId: string): Promise<Response> {
const method = c.req.raw.method === 'HEAD' ? 'HEAD' : 'GET'
const { data: signedUrlData, error: signedUrlError } = await supabaseAdmin(c).storage.from('capgo').createSignedUrl(fileId, 60)

if (signedUrlError || !signedUrlData?.signedUrl) {
Expand All @@ -358,14 +378,13 @@ async function getSupabaseStorageResponse(c: Context, fileId: string): Promise<R
error: signedUrlError,
})
if (signedUrlError?.status === 404) {
Comment thread
TorichanCapgo marked this conversation as resolved.
return c.json({ error: 'not_found', message: 'Not found' }, 404)
return notFoundAttachmentResponse(c, method === 'HEAD')
}
throw quickError(503, 'upstream_unavailable', 'File storage temporarily unavailable', { fileId }, signedUrlError, { alert: false })
}

const requestHeaders = new Headers()
const rangeHeader = c.req.header('range')
const method = c.req.raw.method === 'HEAD' ? 'HEAD' : 'GET'
if (method === 'GET' && rangeHeader) {
requestHeaders.set('range', rangeHeader)
}
Expand Down Expand Up @@ -393,19 +412,20 @@ async function getSupabaseStorageResponse(c: Context, fileId: string): Promise<R
responseBody,
})
if (response.status === 404 || responseBody.toLowerCase().includes('not found')) {
return c.json({ error: 'not_found', message: 'Not found' }, 404)
return notFoundAttachmentResponse(c, method === 'HEAD')
}
throw quickError(503, 'upstream_unavailable', 'File storage temporarily unavailable', { fileId, status: response.status }, responseBody, { alert: false })
}

if (method !== 'HEAD') {
await saveBandwidthUsage(c, getTransferredBytesFromResponse(response))
}
return withAttachmentResponseHeaders(response, fileId)
return withAttachmentResponseHeaders(response, fileId, method !== 'HEAD')
}

async function getHandler(c: Context): Promise<Response> {
const fileId = c.get('fileId')
const isHead = isHeadRequest(c)
// File reads stay off the primary DB. A deleted version may still be in the
// edge cache after R2 trash; check the deleted marker or one indexed r2_path
// lookup before serving or restoring that cache entry.
Expand All @@ -420,7 +440,7 @@ async function getHandler(c: Context): Promise<Response> {

if (bucket == null) {
cloudlog({ requestId: c.get('requestId'), message: 'getHandler files bucket is null' })
return c.json({ error: 'not_found', message: 'Not found' }, 404)
return notFoundAttachmentResponse(c, isHead)
}

const cache = await getFileReadCache()
Expand All @@ -431,13 +451,13 @@ async function getHandler(c: Context): Promise<Response> {
if (response != null) {
if (await isAttachmentVersionDeleted(c, fileId)) {
cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache hit for deleted version', fileId })
return c.json({ error: 'not_found', message: 'Not found' }, 404)
return notFoundAttachmentResponse(c, isHead)
}

const cachedResponse = ensureNoTransformResponse(response)
response = cachedResponse
cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache hit' })
if (c.req.raw.method !== 'HEAD') {
if (!isHead) {
await saveBandwidthUsage(c, getTransferredBytesFromResponse(cachedResponse))
}
// Best-effort restore: if a live file is cached but missing in R2, write it back.
Expand Down Expand Up @@ -468,16 +488,16 @@ async function getHandler(c: Context): Promise<Response> {
cloudlog({ requestId: c.get('requestId'), message: 'Failed to restore cached file to R2', fileId, error: String(err) })
}
})
return cachedResponse
return isHead ? toHeadersOnlyResponse(cachedResponse) : cachedResponse
}

if (await isAttachmentVersionDeleted(c, fileId)) {
cloudlog({ requestId: c.get('requestId'), message: 'getHandler files cache miss for deleted version', fileId })
return c.json({ error: 'not_found', message: 'Not found' }, 404)
return notFoundAttachmentResponse(c, isHead)
}

const rangeHeaderFromRequest = c.req.header('range')
if (rangeHeaderFromRequest) {
if (rangeHeaderFromRequest && !isHead) {
cloudlog({ requestId: c.get('requestId'), message: 'getHandler files range request', range: rangeHeaderFromRequest })
try {
const retryBucket = new RetryBucket(bucket, DEFAULT_RETRY_PARAMS)
Expand All @@ -490,7 +510,7 @@ async function getHandler(c: Context): Promise<Response> {
if (rangeStart >= fileSize) {
const emptyHeaders = new Headers()
emptyHeaders.set('Content-Range', `bytes */${fileSize}`)
return new Response(new Uint8Array(0), { status: 206, headers: emptyHeaders })
return new Response(isHead ? null : new Uint8Array(0), { status: 206, headers: emptyHeaders })
}
}
}
Expand All @@ -500,6 +520,41 @@ async function getHandler(c: Context): Promise<Response> {
}
}

if (isHead) {
// HEAD must use R2 metadata only (head()), never bucket.get() — streaming a
// multi-MB body and stripping it breaks HTTP/2 Content-Length on Workers.
let objectInfo: R2Object | null = null
try {
objectInfo = await headFirstExistingAttachmentCandidate(new RetryBucket(bucket, DEFAULT_RETRY_PARAMS), candidateKeys)
}
catch (error) {
cloudlogErr({ requestId: c.get('requestId'), message: 'getHandler files head failed', fileId, error })
throw quickError(503, 'upstream_unavailable', 'File storage temporarily unavailable', { fileId }, error, { alert: false })
}

if (objectInfo == null) {
cloudlog({ requestId: c.get('requestId'), message: 'getHandler files object is null' })
return notFoundAttachmentResponse(c, isHead)
}

const headers = objectHeaders(objectInfo)
headers.set('Content-Disposition', `attachment; filename="${objectInfo.key}"`)

if (rangeHeaderFromRequest) {
const parsedRange = parseAttachmentByteRange(rangeHeaderFromRequest, objectInfo.size)
if (parsedRange.kind === 'invalid') {
return buildInvalidAttachmentRangeResponse(objectInfo.size, false)
}

headers.set('content-length', parsedRange.bytesTransferred.toString())
headers.set('content-range', `bytes ${parsedRange.start}-${parsedRange.end}/${objectInfo.size}`)
return new Response(null, { headers, status: 206 })
}

headers.set('content-length', objectInfo.size.toString())
return new Response(null, { status: 200, headers })
}

let object: R2ObjectBody | null = null
try {
for (const candidateKey of candidateKeys) {
Expand All @@ -516,7 +571,7 @@ async function getHandler(c: Context): Promise<Response> {
}
if (object == null) {
cloudlog({ requestId: c.get('requestId'), message: 'getHandler files object is null' })
return c.json({ error: 'not_found', message: 'Not found' }, 404)
return notFoundAttachmentResponse(c, isHead)
}
const bytesTransferred = calculateBytesTransferred(object.size, object.range)
await saveBandwidthUsage(c, bytesTransferred)
Expand Down Expand Up @@ -592,6 +647,74 @@ export function calculateBytesTransferred(objLen: number, r2Range: R2Range | und
return isPositiveFiniteNumber(bytesTransferred) ? bytesTransferred : objLen
}

type ParsedAttachmentByteRange =
| { kind: 'partial', start: number, end: number, bytesTransferred: number }
| { kind: 'invalid' }

export function parseAttachmentByteRange(rangeHeader: string, fileSize: number): ParsedAttachmentByteRange {
if (!isPositiveFiniteNumber(fileSize)) {
return { kind: 'invalid' }
}

const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim())
if (!match) {
return { kind: 'invalid' }
}

const startRaw = match[1]
const endRaw = match[2]

if (startRaw === '' && endRaw !== '') {
const suffixLength = Number.parseInt(endRaw, 10)
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
return { kind: 'invalid' }
}

if (suffixLength >= fileSize) {
return { kind: 'partial', start: 0, end: fileSize - 1, bytesTransferred: fileSize }
}

const start = fileSize - suffixLength
return { kind: 'partial', start, end: fileSize - 1, bytesTransferred: suffixLength }
}

if (startRaw === '') {
return { kind: 'invalid' }
}

const rangeStart = Number.parseInt(startRaw, 10)
if (!Number.isFinite(rangeStart) || rangeStart < 0) {
return { kind: 'invalid' }
}

if (rangeStart >= fileSize) {
return { kind: 'invalid' }
}

const rangeEnd = endRaw === '' ? fileSize - 1 : Number.parseInt(endRaw, 10)
if (!Number.isFinite(rangeEnd) || rangeEnd < 0) {
return { kind: 'invalid' }
}

const boundedEnd = Math.min(rangeEnd, fileSize - 1)
if (boundedEnd < rangeStart) {
return { kind: 'invalid' }
}

return {
kind: 'partial',
start: rangeStart,
end: boundedEnd,
bytesTransferred: boundedEnd - rangeStart + 1,
}
}

function buildInvalidAttachmentRangeResponse(fileSize: number, includeBody: boolean): Response {
const headers = new Headers()
headers.set('Content-Range', `bytes */${fileSize}`)
return new Response(includeBody ? new Uint8Array(0) : null, { status: 416, headers })
}

function optionsHandler(c: Context) {
cloudlog({ requestId: c.get('requestId'), message: 'optionsHandler files optionsHandler' })
return c.newResponse(null, 204, {
Expand Down
Loading
Loading