// ── POST /api/csp-report — Content-Security-Policy violation sink ─────────────
//
// The target policy ships on Content-Security-Policy-Report-Only for one release
// before it is enforced (docs/website/API_V2_PLAN.md § Phase 1). That soak is only
// worth anything if the reports land somewhere a human reads, so `report-to` /
// `report-uri` point here (see config/csp.js) and this writes them to the `csp` log
// tag. It is deliberately same-origin: reports describe attacks against this site
// and must not be handed to a third-party collector.
//
// This is an unauthenticated public POST — browsers send reports with no session and
// no CSRF token, and gating it would silence exactly the anonymous visitors whose
// pages we most want to hear about. So treat every field as hostile:
//   • the body is parsed under a small cap (browsers send a few KB),
//   • the rate limiter blunts a flood, since each accepted report writes a log line,
//   • every logged field is truncated, and only a fixed allowlist of fields is read.
// Nothing is echoed back and nothing is persisted to the database.
//
// Retiring this: when the tightened policy flips to enforced and the report-only
// twin is removed, this endpoint goes with it — unless a `report-to` group is kept
// on the enforced policy, which is a reasonable thing to want.

const express = require('express')

const log = require('../utils/logger')('csp')

// Browsers send a few KB at most. A cap this low means a junk POST is rejected by
// the parser before any of this code runs.
const BODY_LIMIT = '16kb'

// Keep log lines bounded: `script-sample` in particular is attacker-influenced and
// can carry a whole inline script.
const MAX_FIELD = 200
const clip = (value) => {
  if (value == null) return undefined
  const s = String(value)
  return s.length > MAX_FIELD ? `${s.slice(0, MAX_FIELD)}…` : s
}

/**
 * Normalize the two wire formats into one shape.
 *
 * `report-uri` (Firefox, Safari) POSTs `application/csp-report` with a single
 * `{ "csp-report": { … } }` object and hyphenated keys. `report-to` (Chrome) POSTs
 * `application/reports+json` with an *array* of envelopes whose `body` uses camelCase
 * keys. Reading only one of them would silently drop half the browsers.
 */
function normalize(body) {
  if (Array.isArray(body)) {
    return body
      .filter((entry) => entry && entry.type === 'csp-violation' && entry.body)
      .map((entry) => ({
        documentUrl: entry.body.documentURL || entry.url,
        directive: entry.body.effectiveDirective,
        blockedUrl: entry.body.blockedURL,
        disposition: entry.body.disposition,
        sample: entry.body.sample,
      }))
  }
  if (body && typeof body === 'object' && body['csp-report']) {
    const r = body['csp-report']
    return [
      {
        documentUrl: r['document-uri'],
        directive: r['effective-directive'] || r['violated-directive'],
        blockedUrl: r['blocked-uri'],
        disposition: r.disposition,
        sample: r['script-sample'],
      },
    ]
  }
  return []
}

// POST /api/csp-report
function receive(req, res) {
  /* #swagger.tags = ['Health']
     #swagger.summary = 'Content-Security-Policy violation report sink'
     #swagger.description = 'Receives CSP violation reports from browsers (both the `report-uri` `application/csp-report` format and the Reporting API `application/reports+json` format). Unauthenticated by necessity — browsers send reports with no session. Reports are logged, never stored or echoed. Always answers 204.'
     #swagger.security = []
     #swagger.responses[204] = { description: 'Report accepted (or ignored). No content.' }
     #swagger.responses[429] = { description: 'Too many reports from this address.' }
  */

  // 204 regardless of what arrived. A browser cannot act on an error here, and a
  // non-2xx would only make it retry or log noise in the user's console.
  for (const v of normalize(req.body)) {
    if (!v.directive) continue
    log.warn('csp violation', {
      // 'report' = the report-only policy fired (expected during the soak);
      // 'enforce' = the live policy actually blocked something.
      disposition: clip(v.disposition) || 'report',
      directive: clip(v.directive),
      blocked: clip(v.blockedUrl),
      document: clip(v.documentUrl),
      sample: clip(v.sample),
      ip: req.ip,
    })
  }
  res.status(204).end()
}

/**
 * The full middleware chain for the endpoint. Both content types get their own
 * parser because express.json() only matches `application/json` by default, and a
 * report that arrives unparsed is a report silently discarded.
 */
const parsers = [
  express.json({ limit: BODY_LIMIT, type: 'application/csp-report' }),
  express.json({ limit: BODY_LIMIT, type: 'application/reports+json' }),
  express.json({ limit: BODY_LIMIT }),
  // Swallow malformed / oversized bodies here rather than letting them reach the
  // global error handler, which would answer 400 and write an ERROR line quoting the
  // junk — turning "POST garbage at the open endpoint" into a log-flood primitive.
  // A browser has nothing useful to do with a 4xx from a report sink anyway.
  // eslint-disable-next-line no-unused-vars
  (err, req, res, next) => res.status(204).end(),
]

module.exports = { receive, parsers, normalize }
