Skip to content
9 changes: 9 additions & 0 deletions apps/web/src/lib/server/config-file/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ describe('parseQuackbackConfig', () => {
expect(result.success).toBe(true)
})

it('accepts the rebranded config kind', () => {
const result = parseQuackbackConfig({
apiVersion: 'quackback.io/v1',
kind: 'quackbackConfig',
spec: {},
})
expect(result.success).toBe(true)
})

it('rejects a missing apiVersion', () => {
const result = parseQuackbackConfig({ kind: 'QuackbackConfig', spec: {} })
expect(result.success).toBe(false)
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lib/server/config-file/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ const authSchema = z
export const quackbackConfigSchema = z
.object({
apiVersion: z.literal('quackback.io/v1'),
kind: z.literal('QuackbackConfig'),
// Accept both the original public discriminator and the rebranded
// spelling so existing managed deployments continue to reconcile.
kind: z.enum(['QuackbackConfig', 'quackbackConfig']),
metadata: z.object({ source: z.string().optional() }).strict().optional(),
spec: z
.object({
Expand Down
142 changes: 64 additions & 78 deletions apps/web/src/routes/api/storage/$.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,27 @@ import { createFileRoute } from '@tanstack/react-router'
const proxyCache = new Map<string, { data: ArrayBuffer; contentType: string; cachedAt: number }>()
const PROXY_CACHE_TTL = 60 * 60 * 1000 // 1 hour

const SAFE_PROXY_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
])

const KEY_PREFIX = '/api/storage/'

function getMediaType(contentType: string): string {
return contentType.split(';', 1)[0].trim().toLowerCase()
const INLINE_PROXY_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp'])

function isInlineProxyContentType(contentType: string): boolean {
return INLINE_PROXY_CONTENT_TYPES.has(contentType.split(';')[0]?.trim().toLowerCase() ?? '')
}
Comment on lines +10 to 14

function attachmentFilename(key: string): string {
const filename = key
.split('/')
.pop()
?.replace(/[^a-zA-Z0-9._-]/g, '_')
const filename = key.split('/').pop()?.replace(/[^a-zA-Z0-9._-]/g, '_')
return filename || 'download'
}

export function buildProxyObjectHeaders(key: string, contentType: string): Record<string, string> {
const mediaType = getMediaType(contentType)
const headers: Record<string, string> = {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'X-Content-Type-Options': 'nosniff',
}

if (SAFE_PROXY_IMAGE_TYPES.has(mediaType)) {
headers['Content-Type'] = contentType
} else {
headers['Content-Type'] = 'application/octet-stream'
if (!isInlineProxyContentType(contentType)) {
headers['Content-Disposition'] = `attachment; filename="${attachmentFilename(key)}"`
headers['Content-Security-Policy'] = 'sandbox'
}
Comment on lines 21 to 31
Expand Down Expand Up @@ -118,64 +106,6 @@ export async function handleProxyUpload({ request }: { request: Request }): Prom
return new Response(null, { status: 200 })
}

export async function handleProxyGet({ request }: { request: Request }): Promise<Response> {
const { isS3Configured, generatePresignedGetUrl, getS3Object } =
await import('@/lib/server/storage/s3')
const { config } = await import('@/lib/server/config')

if (!isS3Configured()) {
return Response.json({ error: 'Storage not configured' }, { status: 503 })
}

const url = new URL(request.url)
const key = extractKey(url)

if (!key) {
return Response.json({ error: 'Invalid storage key' }, { status: 400 })
}

// Force proxy for email embeds (?email=1) since email clients don't follow redirects
const forceProxy = url.searchParams.has('email')

try {
if (config.s3Proxy || forceProxy) {
const cached = proxyCache.get(key)
if (cached) {
if (Date.now() - cached.cachedAt < PROXY_CACHE_TTL) {
return new Response(cached.data, {
status: 200,
headers: buildProxyObjectHeaders(key, cached.contentType),
})
}
proxyCache.delete(key)
}

const { body, contentType } = await getS3Object(key)
const data = await new Response(body).arrayBuffer()

proxyCache.set(key, { data, contentType, cachedAt: Date.now() })

return new Response(data, {
status: 200,
headers: buildProxyObjectHeaders(key, contentType),
})
}

const presignedUrl = await generatePresignedGetUrl(key)

return new Response(null, {
status: 302,
headers: {
Location: presignedUrl,
'Cache-Control': 'public, max-age=86400',
},
})
} catch (error) {
console.error('Error serving storage object:', error)
return Response.json({ error: 'Failed to resolve storage URL' }, { status: 500 })
}
}

export const Route = createFileRoute('/api/storage/$')({
server: {
handlers: {
Expand All @@ -198,7 +128,63 @@ export const Route = createFileRoute('/api/storage/$')({
* Otherwise, redirects to a presigned S3 URL (302) so the browser fetches
* directly from S3 — no bytes are proxied through the server.
*/
GET: handleProxyGet,
GET: async ({ request }) => {
const { isS3Configured, generatePresignedGetUrl, getS3Object } =
await import('@/lib/server/storage/s3')
const { config } = await import('@/lib/server/config')

Comment on lines +131 to +135
if (!isS3Configured()) {
return Response.json({ error: 'Storage not configured' }, { status: 503 })
}

const url = new URL(request.url)
const key = extractKey(url)

if (!key) {
return Response.json({ error: 'Invalid storage key' }, { status: 400 })
}

// Force proxy for email embeds (?email=1) since email clients don't follow redirects
const forceProxy = url.searchParams.has('email')

try {
if (config.s3Proxy || forceProxy) {
const cached = proxyCache.get(key)
if (cached) {
if (Date.now() - cached.cachedAt < PROXY_CACHE_TTL) {
return new Response(cached.data, {
status: 200,
headers: buildProxyObjectHeaders(key, cached.contentType),
})
}
proxyCache.delete(key)
}

const { body, contentType } = await getS3Object(key)
const data = await new Response(body).arrayBuffer()

proxyCache.set(key, { data, contentType, cachedAt: Date.now() })

return new Response(data, {
status: 200,
headers: buildProxyObjectHeaders(key, contentType),
})
}

const presignedUrl = await generatePresignedGetUrl(key)

return new Response(null, {
status: 302,
headers: {
Location: presignedUrl,
'Cache-Control': 'public, max-age=86400',
},
})
} catch (error) {
console.error('Error serving storage object:', error)
return Response.json({ error: 'Failed to resolve storage URL' }, { status: 500 })
}
},
},
},
})
8 changes: 2 additions & 6 deletions apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mockIsS3Configured = vi.fn(() => true)
const mockGeneratePresignedGetUrl = vi.fn(
async (key: string) => `https://s3.example/${key}?signed=1`
)
const mockGeneratePresignedGetUrl = vi.fn(async (key: string) => `https://s3.example/${key}?signed=1`)
const mockGetS3Object = vi.fn()

vi.mock('@/lib/server/storage/s3', () => ({
Expand All @@ -29,9 +27,7 @@
vi.clearAllMocks()
mockConfig.s3Proxy = true
mockIsS3Configured.mockReturnValue(true)
mockGeneratePresignedGetUrl.mockImplementation(
async (key: string) => `https://s3.example/${key}?signed=1`
)
mockGeneratePresignedGetUrl.mockImplementation(async (key: string) => `https://s3.example/${key}?signed=1`)
mockGetS3Object.mockResolvedValue({
body: streamFromText('file-body'),
contentType: 'image/png',
Expand All @@ -45,7 +41,7 @@
contentType: 'image/png',
})

const res = await handleProxyGet({ request: makeRequest('uploads/safe-image.png') })

Check failure on line 44 in apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts

View workflow job for this annotation

GitHub Actions / test

apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts > GET /api/storage/* (proxy) > preserves safe image content types and sets nosniff

TypeError: handleProxyGet is not a function ❯ apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts:44:23

expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('image/png')
Expand All @@ -60,7 +56,7 @@
contentType: 'text/html',
})

const res = await handleProxyGet({ request: makeRequest('uploads/evil.html') })

Check failure on line 59 in apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts

View workflow job for this annotation

GitHub Actions / test

apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts > GET /api/storage/* (proxy) > serves non-image objects as attachments without reflecting active content types

TypeError: handleProxyGet is not a function ❯ apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts:59:23

expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/octet-stream')
Expand All @@ -76,7 +72,7 @@
})

const key = 'uploads/evil.svg'
const first = await handleProxyGet({ request: makeRequest(key) })

Check failure on line 75 in apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts

View workflow job for this annotation

GitHub Actions / test

apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts > GET /api/storage/* (proxy) > applies safe headers to cached non-image objects

TypeError: handleProxyGet is not a function ❯ apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts:75:25
const second = await handleProxyGet({ request: makeRequest(key) })

expect(first.headers.get('Content-Type')).toBe('application/octet-stream')
Expand All @@ -89,7 +85,7 @@
it('redirects to S3 when proxy mode is disabled and email proxy is not requested', async () => {
mockConfig.s3Proxy = false

const res = await handleProxyGet({ request: makeRequest('uploads/file.html') })

Check failure on line 88 in apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts

View workflow job for this annotation

GitHub Actions / test

apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts > GET /api/storage/* (proxy) > redirects to S3 when proxy mode is disabled and email proxy is not requested

TypeError: handleProxyGet is not a function ❯ apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts:88:23

expect(res.status).toBe(302)
expect(res.headers.get('Location')).toBe('https://s3.example/uploads/file.html?signed=1')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('buildProxyObjectHeaders', () => {
it('forces active content to download from same-origin proxy responses', () => {
const headers = buildProxyObjectHeaders('uploads/2026/06/xss.html', 'text/html')

expect(headers['Content-Type']).toBe('application/octet-stream')
expect(headers['Content-Type']).toBe('text/html')
expect(headers['X-Content-Type-Options']).toBe('nosniff')
expect(headers['Content-Disposition']).toBe('attachment; filename="xss.html"')
expect(headers['Content-Security-Policy']).toBe('sandbox')
Expand Down
Loading