mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(admin): serve response media from standalone API
This commit is contained in:
1 parent
be20391788
commit
2dad7aa352
9 files changed
+128
-31
No files matched your search
@@ -3,8 +3,7 @@ import type { AnswerType, QuestionType } from '@sauci/shared';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Image as ImageIcon, Music2 } from 'lucide-react';
|
import { Image as ImageIcon, Music2 } from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { getAdminResponseMedia } from '@/lib/adminApi';
|
||||||
import { supabaseConfig } from '@/config';
|
|
||||||
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
||||||
|
|
||||||
interface ResponseAnswerProps {
|
interface ResponseAnswerProps {
|
||||||
@@ -62,38 +61,17 @@ function ProtectedResponseMedia({
|
|||||||
mediaType: 'photo' | 'audio';
|
mediaType: 'photo' | 'audio';
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
const { session } = useAuth();
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.access_token) {
|
|
||||||
setError('Sign in to load media');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let objectUrl: string | null = null;
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, {
|
objectUrl = URL.createObjectURL(await getAdminResponseMedia(responseId));
|
||||||
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());
|
|
||||||
if (!cancelled) setUrl(objectUrl);
|
if (!cancelled) setUrl(objectUrl);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -108,7 +86,7 @@ function ProtectedResponseMedia({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
};
|
};
|
||||||
}, [responseId, session?.access_token]);
|
}, [responseId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vi.mock('@/config', () => ({
|
|||||||
authClient: { auth: { getSession } },
|
authClient: { auth: { getSession } },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { AdminApiError, adminRequest, queryAdminRows } from './adminApi';
|
import { AdminApiError, adminRequest, getAdminResponseMedia, queryAdminRows } from './adminApi';
|
||||||
|
|
||||||
describe('admin API transport', () => {
|
describe('admin API transport', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,4 +38,19 @@ describe('admin API transport', () => {
|
|||||||
}));
|
}));
|
||||||
await expect(adminRequest('/v1/admin/me')).rejects.toThrow('Administrator access denied');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -48,6 +48,10 @@ export async function adminBinaryRequest(path: string, options: RequestOptions =
|
|||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAdminResponseMedia(responseId: string): Promise<Blob> {
|
||||||
|
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 AdminFilter = { column: string; op: 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike'; value: unknown };
|
||||||
export type AdminQuery = {
|
export type AdminQuery = {
|
||||||
filters?: AdminFilter[];
|
filters?: AdminFilter[];
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
|
|||||||
|
|
||||||
const { update } = vi.hoisted(() => ({ update: vi.fn() }));
|
const { update } = vi.hoisted(() => ({ update: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/hooks/useAuditedSupabase', () => ({
|
vi.mock('@/hooks/useAuditedAdminData', () => ({
|
||||||
auditedSupabase: { update },
|
auditedAdminData: { update },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { updateContentReviewStatus } from './contentReviews';
|
import { updateContentReviewStatus } from './contentReviews';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ContentReviewStatus } from '@sauci/shared';
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
|
export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
|
||||||
|
|
||||||
export type ReviewableContentTable =
|
export type ReviewableContentTable =
|
||||||
@@ -20,7 +20,7 @@ export async function updateContentReviewStatus(
|
|||||||
throw new Error('A review reason is required');
|
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_status: status,
|
||||||
content_review_reason: normalizedReason,
|
content_review_reason: normalizedReason,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export interface AdminRepository {
|
|||||||
}>>;
|
}>>;
|
||||||
users(principal: AdminPrincipal, userId?: string): Promise<Record<string, unknown>[]>;
|
users(principal: AdminPrincipal, userId?: string): Promise<Record<string, unknown>[]>;
|
||||||
authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void>;
|
authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void>;
|
||||||
|
responseMedia(principal: AdminPrincipal, responseId: string): Promise<{ storageKey: string; mimeType: string }>;
|
||||||
decryptMessage(principal: AdminPrincipal, messageId: string): Promise<{ content: string | null }>;
|
decryptMessage(principal: AdminPrincipal, messageId: string): Promise<{ content: string | null }>;
|
||||||
decryptMedia(principal: AdminPrincipal, messageId: string): Promise<{ bytes: Buffer; mimeType: string }>;
|
decryptMedia(principal: AdminPrincipal, messageId: string): Promise<{ bytes: Buffer; mimeType: string }>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
@@ -287,6 +288,25 @@ export class PostgresAdminRepository implements AdminRepository {
|
|||||||
if (!result.rows[0]) throw new AdminError('media_not_found', 'Media not found', 404);
|
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) {
|
private async messageForDecryption(messageId: string) {
|
||||||
const result = await this.pool.query<{
|
const result = await this.pool.query<{
|
||||||
content: string | null; encrypted_content: string | null; encryption_iv: string | null;
|
content: string | null; encrypted_content: string | null; encryption_iv: string | null;
|
||||||
|
|||||||
@@ -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);
|
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) => {
|
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()
|
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(() => ({})));
|
.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi
|
|||||||
const superUser = '22222222-2222-4222-8222-222222222222';
|
const superUser = '22222222-2222-4222-8222-222222222222';
|
||||||
const ordinaryUser = '33333333-3333-4333-8333-333333333333';
|
const ordinaryUser = '33333333-3333-4333-8333-333333333333';
|
||||||
const moderatorUser = '44444444-4444-4444-8444-444444444444';
|
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;
|
let pool: Pool; let repository: PostgresAdminRepository;
|
||||||
|
|
||||||
beforeAll(async () => {
|
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' });
|
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 () => {
|
it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => {
|
||||||
const tables = await pool.query<{ table_name: string }>(
|
const tables = await pool.query<{ table_name: string }>(
|
||||||
`select table_name from information_schema.tables where table_schema=current_schema()
|
`select table_name from information_schema.tables where table_schema=current_schema()
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ function setup(denied = false) {
|
|||||||
giftPremium: vi.fn(async () => ({ subscription_id: 'sub', expires_at: 'later' })),
|
giftPremium: vi.fn(async () => ({ subscription_id: 'sub', expires_at: 'later' })),
|
||||||
featureInterestCounts: vi.fn(async () => []), users: vi.fn(async () => []), close: vi.fn(async () => undefined),
|
featureInterestCounts: vi.fn(async () => []), users: vi.fn(async () => []), close: vi.fn(async () => undefined),
|
||||||
authorizeMedia: 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' })),
|
decryptMessage: vi.fn(async () => ({ content: 'plain' })),
|
||||||
decryptMedia: vi.fn(async () => ({ bytes: Buffer.from('media'), mimeType: 'image/png' })),
|
decryptMedia: vi.fn(async () => ({ bytes: Buffer.from('media'), mimeType: 'image/png' })),
|
||||||
};
|
};
|
||||||
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||||
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
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 };
|
return { app, repository };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,4 +82,23 @@ describe('admin routes', () => {
|
|||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(repository.giftPremium).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), identity.id, null, undefined);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user