diff --git a/apps/web/src/lib/server/config-file/__tests__/schema.test.ts b/apps/web/src/lib/server/config-file/__tests__/schema.test.ts index 35fb15649..76d0467df 100644 --- a/apps/web/src/lib/server/config-file/__tests__/schema.test.ts +++ b/apps/web/src/lib/server/config-file/__tests__/schema.test.ts @@ -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) diff --git a/apps/web/src/lib/server/config-file/schema.ts b/apps/web/src/lib/server/config-file/schema.ts index 71037a8e6..73d7c37e2 100644 --- a/apps/web/src/lib/server/config-file/schema.ts +++ b/apps/web/src/lib/server/config-file/schema.ts @@ -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({ diff --git a/apps/web/src/routes/api/storage/$.ts b/apps/web/src/routes/api/storage/$.ts index eab5ba524..6b68c7b1f 100644 --- a/apps/web/src/routes/api/storage/$.ts +++ b/apps/web/src/routes/api/storage/$.ts @@ -5,39 +5,27 @@ import { createFileRoute } from '@tanstack/react-router' const proxyCache = new Map() 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() ?? '') } 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 { - const mediaType = getMediaType(contentType) const headers: Record = { + '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' } @@ -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 { - 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: { @@ -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') + + 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 }) + } + }, }, }, }) diff --git a/apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts b/apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts index e26d32286..83906d137 100644 --- a/apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts +++ b/apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts @@ -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', () => ({ @@ -29,9 +27,7 @@ beforeEach(() => { 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', diff --git a/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts b/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts index ff7dd1260..31b3bc8a4 100644 --- a/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts +++ b/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts @@ -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')