From 2dad7aa352ac96bfdf7ad96601dfc99e13e9565c Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Fri, 28 Aug 2026 14:54:07 +0100 Subject: [PATCH] feat(admin): serve response media from standalone API --- .../src/components/content/ResponseAnswer.tsx | 28 ++------------ apps/admin/src/lib/adminApi.test.ts | 17 ++++++++- apps/admin/src/lib/adminApi.ts | 4 ++ .../admin/src/services/contentReviews.test.ts | 4 +- apps/admin/src/services/contentReviews.ts | 4 +- apps/api/src/domains/admin/repository.ts | 20 ++++++++++ apps/api/src/domains/admin/routes.ts | 20 ++++++++++ apps/api/test/admin.integration.test.ts | 38 +++++++++++++++++++ apps/api/test/admin.routes.test.ts | 24 +++++++++++- 9 files changed, 128 insertions(+), 31 deletions(-) diff --git a/apps/admin/src/components/content/ResponseAnswer.tsx b/apps/admin/src/components/content/ResponseAnswer.tsx index 6f877ab..e8f357b 100644 --- a/apps/admin/src/components/content/ResponseAnswer.tsx +++ b/apps/admin/src/components/content/ResponseAnswer.tsx @@ -3,8 +3,7 @@ import type { AnswerType, QuestionType } from '@sauci/shared'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Image as ImageIcon, Music2 } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; -import { supabaseConfig } from '@/config'; +import { getAdminResponseMedia } from '@/lib/adminApi'; import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; interface ResponseAnswerProps { @@ -62,38 +61,17 @@ function ProtectedResponseMedia({ mediaType: 'photo' | 'audio'; label: string; }) { - const { session } = useAuth(); const [url, setUrl] = useState(null); const [error, setError] = useState(null); useEffect(() => { - if (!session?.access_token) { - setError('Sign in to load media'); - return; - } - let cancelled = false; let objectUrl: string | null = null; const load = async () => { try { setError(null); - const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, { - method: 'POST', - headers: { - apikey: supabaseConfig.anonKey, - Authorization: `Bearer ${session.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ responseId }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body?.error || `Failed to load media (${response.status})`); - } - - objectUrl = URL.createObjectURL(await response.blob()); + objectUrl = URL.createObjectURL(await getAdminResponseMedia(responseId)); if (!cancelled) setUrl(objectUrl); } catch (loadError) { if (!cancelled) { @@ -108,7 +86,7 @@ function ProtectedResponseMedia({ cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [responseId, session?.access_token]); + }, [responseId]); if (error) { return ( diff --git a/apps/admin/src/lib/adminApi.test.ts b/apps/admin/src/lib/adminApi.test.ts index 11dc123..95c5d10 100644 --- a/apps/admin/src/lib/adminApi.test.ts +++ b/apps/admin/src/lib/adminApi.test.ts @@ -6,7 +6,7 @@ vi.mock('@/config', () => ({ authClient: { auth: { getSession } }, })); -import { AdminApiError, adminRequest, queryAdminRows } from './adminApi'; +import { AdminApiError, adminRequest, getAdminResponseMedia, queryAdminRows } from './adminApi'; describe('admin API transport', () => { beforeEach(() => { @@ -38,4 +38,19 @@ describe('admin API transport', () => { })); await expect(adminRequest('/v1/admin/me')).rejects.toThrow('Administrator access denied'); }); + + it('loads protected response media from the standalone API with hosted Auth', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('response-media', { + status: 200, headers: { 'content-type': 'image/jpeg' }, + })); + const responseId = '11111111-1111-4111-8111-111111111111'; + + const blob = await getAdminResponseMedia(responseId); + + expect(await blob.text()).toBe('response-media'); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`http://127.0.0.1:3003/v1/admin/responses/${responseId}/media`); + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer hosted-auth-token'); + expect(init?.body).toBeUndefined(); + }); }); diff --git a/apps/admin/src/lib/adminApi.ts b/apps/admin/src/lib/adminApi.ts index ce1535b..6394c16 100644 --- a/apps/admin/src/lib/adminApi.ts +++ b/apps/admin/src/lib/adminApi.ts @@ -48,6 +48,10 @@ export async function adminBinaryRequest(path: string, options: RequestOptions = return response.blob(); } +export function getAdminResponseMedia(responseId: string): Promise { + return adminBinaryRequest(`/v1/admin/responses/${encodeURIComponent(responseId)}/media`); +} + export type AdminFilter = { column: string; op: 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike'; value: unknown }; export type AdminQuery = { filters?: AdminFilter[]; diff --git a/apps/admin/src/services/contentReviews.test.ts b/apps/admin/src/services/contentReviews.test.ts index 732f89c..28fc8b6 100644 --- a/apps/admin/src/services/contentReviews.test.ts +++ b/apps/admin/src/services/contentReviews.test.ts @@ -3,8 +3,8 @@ import { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus'; const { update } = vi.hoisted(() => ({ update: vi.fn() })); -vi.mock('@/hooks/useAuditedSupabase', () => ({ - auditedSupabase: { update }, +vi.mock('@/hooks/useAuditedAdminData', () => ({ + auditedAdminData: { update }, })); import { updateContentReviewStatus } from './contentReviews'; diff --git a/apps/admin/src/services/contentReviews.ts b/apps/admin/src/services/contentReviews.ts index 9b15736..8e6b308 100644 --- a/apps/admin/src/services/contentReviews.ts +++ b/apps/admin/src/services/contentReviews.ts @@ -1,5 +1,5 @@ import type { ContentReviewStatus } from '@sauci/shared'; -import { auditedSupabase } from '@/hooks/useAuditedSupabase'; +import { auditedAdminData } from '@/hooks/useAuditedAdminData'; export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus'; export type ReviewableContentTable = @@ -20,7 +20,7 @@ export async function updateContentReviewStatus( throw new Error('A review reason is required'); } - const { error } = await auditedSupabase.update(table, id, { + const { error } = await auditedAdminData.update(table, id, { content_status: status, content_review_reason: normalizedReason, }); diff --git a/apps/api/src/domains/admin/repository.ts b/apps/api/src/domains/admin/repository.ts index 06a56f0..99af77f 100644 --- a/apps/api/src/domains/admin/repository.ts +++ b/apps/api/src/domains/admin/repository.ts @@ -131,6 +131,7 @@ export interface AdminRepository { }>>; users(principal: AdminPrincipal, userId?: string): Promise[]>; authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise; + responseMedia(principal: AdminPrincipal, responseId: string): Promise<{ storageKey: string; mimeType: string }>; decryptMessage(principal: AdminPrincipal, messageId: string): Promise<{ content: string | null }>; decryptMedia(principal: AdminPrincipal, messageId: string): Promise<{ bytes: Buffer; mimeType: string }>; close(): Promise; @@ -287,6 +288,25 @@ export class PostgresAdminRepository implements AdminRepository { if (!result.rows[0]) throw new AdminError('media_not_found', 'Media not found', 404); } + async responseMedia(principal: AdminPrincipal, responseId: string): Promise<{ storageKey: string; mimeType: string }> { + requirePermission(principal, 'view_responses'); + const result = await this.pool.query<{ storage_key: string; mime_type: string }>( + `select mo.storage_key,mo.mime_type + from responses r + join media_objects mo + on r.response_data->>'media_path'='media:'||mo.id::text + and mo.kind='response' + and mo.owner_id=r.user_id + and mo.couple_id=r.couple_id + and (mo.question_id is null or mo.question_id=r.question_id) + where r.id=$1 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())`, + [responseId], + ); + const row = result.rows[0]; + if (!row) throw new AdminError('response_media_not_found', 'Response media not found', 404); + return { storageKey: row.storage_key, mimeType: row.mime_type }; + } + private async messageForDecryption(messageId: string) { const result = await this.pool.query<{ content: string | null; encrypted_content: string | null; encryption_iv: string | null; diff --git a/apps/api/src/domains/admin/routes.ts b/apps/api/src/domains/admin/routes.ts index 63dd4b7..656998a 100644 --- a/apps/api/src/domains/admin/routes.ts +++ b/apps/api/src/domains/admin/routes.ts @@ -97,6 +97,26 @@ export function registerAdminRoutes(app: App, repository: AdminRepository, auth? return result.ok ? c.json(result.value, 200, { 'cache-control': 'private, no-store' }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status); }); + app.get('/v1/admin/responses/:responseId/media', async (c) => { + const parsed = z.string().uuid().safeParse(c.req.param('responseId')); + if (!parsed.success) return c.json(failure('invalid_response_id', 'A valid response ID is required'), 400); + if (!mediaStorage) return c.json(failure('media_unavailable', 'Media storage is unavailable'), 503); + const result = await execute(async () => { + const media = await repository.responseMedia(await repository.principal(c.get('identity').id), parsed.data); + const bytes = await mediaStorage.read(media.storageKey).catch(() => { + throw new AdminError('response_media_not_found', 'Response media not found', 404); + }); + return { ...media, bytes }; + }); + if (!result.ok) return c.json(failure(result.cause.code, result.cause.message), result.cause.status); + return c.body(new Uint8Array(result.value.bytes), 200, { + 'content-type': result.value.mimeType, + 'content-length': String(result.value.bytes.byteLength), + 'cache-control': 'private, no-store', + 'x-content-type-options': 'nosniff', + }); + }); + app.post('/v1/admin/users/:userId/gift-premium', async (c) => { const parsed = z.object({ days: z.number().int().min(1).max(3650).nullable(), reason: z.string().trim().max(500).optional() }).strict() .safeParse(await c.req.json().catch(() => ({}))); diff --git a/apps/api/test/admin.integration.test.ts b/apps/api/test/admin.integration.test.ts index a1b1943..2b25de1 100644 --- a/apps/api/test/admin.integration.test.ts +++ b/apps/api/test/admin.integration.test.ts @@ -15,6 +15,12 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi const superUser = '22222222-2222-4222-8222-222222222222'; const ordinaryUser = '33333333-3333-4333-8333-333333333333'; const moderatorUser = '44444444-4444-4444-8444-444444444444'; + const responseCouple = '55555555-5555-4555-8555-555555555555'; + const responseCategory = '66666666-6666-4666-8666-666666666666'; + const responsePack = '77777777-7777-4777-8777-777777777777'; + const responseQuestion = '88888888-8888-4888-8888-888888888888'; + const responseId = '99999999-9999-4999-8999-999999999999'; + const responseMediaId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; let pool: Pool; let repository: PostgresAdminRepository; beforeAll(async () => { @@ -123,6 +129,38 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi await expect(repository.authorizeMedia(moderator, randomUUID())).rejects.toMatchObject({ code: 'media_not_found' }); }); + it('requires view_responses and resolves only media linked to the response owner and couple', async () => { + await pool.query('insert into couples(id,invite_code) values($1,$2)', [responseCouple, 'ADMINMED']); + await pool.query('update profiles set couple_id=$2 where id=$1', [ordinaryUser, responseCouple]); + await pool.query("insert into categories(id,name) values($1,'Response media')", [responseCategory]); + await pool.query("insert into question_packs(id,name,category_id) values($1,'Response media',$2)", [responsePack, responseCategory]); + await pool.query("insert into questions(id,pack_id,text,question_type) values($1,$2,'Share a photo','photo')", [responseQuestion, responsePack]); + await pool.query( + `insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id) + values($1,$2,$3,'response','response/test.jpg','image/jpeg',14,$4)`, + [responseMediaId, ordinaryUser, responseCouple, responseQuestion], + ); + await pool.query( + `insert into responses(id,user_id,question_id,couple_id,answer,response_data) + values($1,$2,$3,$4,'yes',$5)`, + [responseId, ordinaryUser, responseQuestion, responseCouple, { type: 'photo', media_path: `media:${responseMediaId}` }], + ); + + const creator = await repository.principal(creatorUser); + const moderatorWithoutPermission = await repository.principal(moderatorUser); + await expect(repository.responseMedia(creator, responseId)).rejects.toMatchObject({ code: 'forbidden' }); + await expect(repository.responseMedia(moderatorWithoutPermission, responseId)).rejects.toMatchObject({ code: 'forbidden' }); + + await pool.query("update admin_users set permissions=array_append(permissions,'view_responses') where user_id=$1", [moderatorUser]); + const responseViewer = await repository.principal(moderatorUser); + await expect(repository.responseMedia(responseViewer, responseId)).resolves.toEqual({ + storageKey: 'response/test.jpg', mimeType: 'image/jpeg', + }); + + await pool.query('update media_objects set owner_id=$2 where id=$1', [responseMediaId, superUser]); + await expect(repository.responseMedia(responseViewer, responseId)).rejects.toMatchObject({ code: 'response_media_not_found' }); + }); + it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => { const tables = await pool.query<{ table_name: string }>( `select table_name from information_schema.tables where table_schema=current_schema() diff --git a/apps/api/test/admin.routes.test.ts b/apps/api/test/admin.routes.test.ts index 8c90a8f..64d3369 100644 --- a/apps/api/test/admin.routes.test.ts +++ b/apps/api/test/admin.routes.test.ts @@ -18,12 +18,15 @@ function setup(denied = false) { giftPremium: vi.fn(async () => ({ subscription_id: 'sub', expires_at: 'later' })), featureInterestCounts: vi.fn(async () => []), users: vi.fn(async () => []), close: vi.fn(async () => undefined), authorizeMedia: vi.fn(async () => undefined), + responseMedia: vi.fn(async () => ({ storageKey: 'response/test.jpg', mimeType: 'image/jpeg' })), decryptMessage: vi.fn(async () => ({ content: 'plain' })), decryptMedia: vi.fn(async () => ({ bytes: Buffer.from('media'), mimeType: 'image/png' })), }; const app = new Hono<{ Variables: { identity: AuthIdentity } }>(); app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); }); - registerAdminRoutes(app, repository, undefined, new FilesystemMediaStorage('/tmp/admin-route-media', 'test-signing-secret', 'http://localhost:8787')); + const mediaStorage = new FilesystemMediaStorage('/tmp/admin-route-media', 'test-signing-secret', 'http://localhost:8787'); + vi.spyOn(mediaStorage, 'read').mockResolvedValue(Buffer.from('response-media')); + registerAdminRoutes(app, repository, undefined, mediaStorage); return { app, repository }; } @@ -79,4 +82,23 @@ describe('admin routes', () => { expect(response.status).toBe(201); expect(repository.giftPremium).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), identity.id, null, undefined); }); + + it('streams response media only after response-scoped repository authorization', async () => { + const { app, repository } = setup(); + const responseId = '44444444-4444-4444-8444-444444444444'; + const response = await app.request(`/v1/admin/responses/${responseId}/media`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/jpeg'); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + expect(await response.text()).toBe('response-media'); + expect(repository.responseMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), responseId); + }); + + it('rejects malformed response media identifiers before repository access', async () => { + const { app, repository } = setup(); + const response = await app.request('/v1/admin/responses/not-a-uuid/media'); + expect(response.status).toBe(400); + expect(repository.responseMedia).not.toHaveBeenCalled(); + }); });