94 lines
3.3 KiB
TypeScript
94 lines
3.3 KiB
TypeScript
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { requireUser, type AuthError } from '../_shared/auth.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
import {
|
|
ContentReportRequestError,
|
|
mapContentReportRpcError,
|
|
parseContentReportReceipt,
|
|
parseContentReportRequest,
|
|
parseIdempotencyKey,
|
|
} from '../_shared/content-report-contract.ts'
|
|
|
|
const MAX_REQUEST_BYTES = 25_000
|
|
|
|
function jsonResponse(body: unknown, status: number): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
Pragma: 'no-cache',
|
|
},
|
|
})
|
|
}
|
|
|
|
async function readRequestJson(req: Request): Promise<unknown> {
|
|
const contentType = req.headers.get('content-type')?.toLowerCase() ?? ''
|
|
if (contentType.split(';', 1)[0].trim() !== 'application/json') {
|
|
throw new ContentReportRequestError('content_type_must_be_json', 415)
|
|
}
|
|
const declaredLength = Number(req.headers.get('content-length'))
|
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_REQUEST_BYTES) {
|
|
throw new ContentReportRequestError('request_too_large', 413)
|
|
}
|
|
const rawBody = await req.text()
|
|
if (new TextEncoder().encode(rawBody).byteLength > MAX_REQUEST_BYTES) {
|
|
throw new ContentReportRequestError('request_too_large', 413)
|
|
}
|
|
try {
|
|
return JSON.parse(rawBody) as unknown
|
|
} catch {
|
|
throw new ContentReportRequestError('invalid_json')
|
|
}
|
|
}
|
|
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) {
|
|
const headers = new Headers(preflight.headers)
|
|
headers.set('Cache-Control', 'no-store')
|
|
return new Response(preflight.body, { status: preflight.status, headers })
|
|
}
|
|
|
|
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
|
|
|
try {
|
|
const user = await requireUser(req)
|
|
const idempotencyKey = parseIdempotencyKey(req.headers.get('Idempotency-Key'))
|
|
const body = parseContentReportRequest(await readRequestJson(req))
|
|
const serviceClient = createServiceRoleClient()
|
|
|
|
const { data, error } = await serviceClient.rpc('submit_content_report_v1', {
|
|
p_actor_id: user.id,
|
|
p_idempotency_key: idempotencyKey,
|
|
p_kind: body.kind,
|
|
p_source_type: body.source.type,
|
|
p_source_id: body.source.generationId,
|
|
p_reason: body.reason,
|
|
p_comment: body.comment ?? null,
|
|
p_snapshot: body.snapshot,
|
|
})
|
|
|
|
if (error) {
|
|
const mapped = mapContentReportRpcError(error)
|
|
if (mapped.status === 500) {
|
|
console.error('content-report RPC failed', { code: error.code ?? 'unknown' })
|
|
}
|
|
return jsonResponse({ error: mapped.code }, mapped.status)
|
|
}
|
|
|
|
const receipt = parseContentReportReceipt(data)
|
|
return jsonResponse(receipt, receipt.idempotent ? 200 : 201)
|
|
} catch (error) {
|
|
if (error instanceof ContentReportRequestError) {
|
|
return jsonResponse({ error: error.code }, error.status)
|
|
}
|
|
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
|
const authError = error as AuthError
|
|
return jsonResponse({ error: authError.message }, authError.status)
|
|
}
|
|
console.error('content-report failed', { code: 'internal_error' })
|
|
return jsonResponse({ error: 'internal_error' }, 500)
|
|
}
|
|
})
|