teqo-backup/src/lib/privateMedia.ts
Francisco Solla dda55adc27 feat(S27): Central de Conteúdos pública — Peça voto pra Solla 1313
/conteudos + /conteudos/<slug>: catálogo filtrável (Tipo · Cidade · Região ·
Tema · Instituição + termo na URL canônica), play sob demanda (Vídeo/Áudio só
carregam no toque; Foto com preview leve), Baixar e Compartilhar do próprio
card com a mensagem de voto por tipo (wa.me sem destinatário, editável; "A
mídia" usa a folha nativa e cai em download onde o aparelho não suportar) e o
card personalizado como peça sintética do catálogo → /cards.

- leitura pública nova (contentPieceReads) cacheada na tag contentPieces com o
  gate where status=publicado + contentPieceIsPublic; a rota GET
  /conteudos/<slug>/midia reusa buildPrivateMediaResponse (range, disposition,
  private no-store, 404 uniforme) com download jorge-solla-1313-<slug>.<ext> e
  a allowlist inline do dono estendida (webp/gif/avif + áudios das peças)
- contrato público puro do catálogo (filtros/VM/download) + mensagens de voto
  (contentPieceShare); OG com fallback do global; conteudos entra nos slugs
  reservados; kill switch por peça vale na hora (lista, página, rodapé/selo);
  descoberta condicional no CampaignFooter e no header local
- UI portada classe-a-classe do design aprovado (cenas 01–08, com as cenas
  06–08 estendidas no trigger a); sem migration, sem Consent e sem access novo;
  contentMedia segue privada (sem segunda cópia de mídia)
- testes: 2 unit specs novos, 18 casos int (leitura pública + rota de mídia) e
  e2e frontendConteudos (5 casos); manifest e curado atualizados
- follow-up de engenharia: #1276 (docs/plans/escala-dry-pos-s27.md)

Closes #1255
2026-09-23 11:13:57 -03:00

96 lines
3.4 KiB
TypeScript

/**
* C193/C199 — pure response rules of a private media artifact: the safe content
* type, the content disposition and the range/private headers. No I/O and no
* `server-only`: the streaming utility and the unit tests share this module.
*
* The owner serves every private upload collection (`reelMedia`, `recordingMedia`)
* through the authenticated `/campanha` routes; the public `media` contract
* never touches this module.
*/
/**
* Only these may render inline; anything else is forced to download. C199 adds
* the video containers the recordings actually arrive in (MOV/MKV/WebM) — all
* inert media types, never markup executed in the campaign origin. S27 adds the
* image and audio families the content pieces accept (a published photo must
* render and an audio piece must play; both were degrading to octet-stream and
* turning into a download). HEIC/HEIF stay out on purpose (browsers do not
* render them) and `image/svg+xml` never enters (markup).
*/
const PRIVATE_MEDIA_INLINE_MIME_TYPES = [
'video/mp4',
'video/quicktime',
'video/x-matroska',
'video/webm',
'audio/mpeg',
'audio/mp4',
'audio/aac',
'audio/ogg',
'audio/opus',
'audio/wav',
'audio/webm',
'audio/flac',
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
'image/avif',
] as const
export const PRIVATE_MEDIA_FALLBACK_MIME_TYPE = 'application/octet-stream'
export const PRIVATE_MEDIA_CACHE_CONTROL = 'private, no-store'
export type PrivateMediaRange = {
status: number
headers: Record<string, string>
}
const isInlineSafe = (mimeType: unknown): mimeType is string =>
typeof mimeType === 'string' &&
(PRIVATE_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType)
/**
* The served content type is the stored one only when it is safe to render
* inline; everything else degrades to `application/octet-stream` (never an
* attacker-chosen type executed in the campaign origin).
*/
export const privateMediaContentType = (mimeType: string | null | undefined): string =>
isInlineSafe(mimeType) ? mimeType : PRIVATE_MEDIA_FALLBACK_MIME_TYPE
/** Both `filename` and `filename*` (RFC 5987) so non-ASCII names survive. */
export const privateMediaContentDisposition = (
filename: string | null | undefined,
download: boolean,
): string => {
const name = filename?.trim() || 'arquivo'
const asciiFallback = name.replace(/[^\w.-]+/g, '_')
const disposition = download ? 'attachment' : 'inline'
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
/**
* The response headers of one artifact: the range headers computed from the
* real file size plus the private streaming contract. Pure.
*/
export const privateMediaHeaders = ({
range,
mimeType,
filename,
download,
}: {
range: PrivateMediaRange
mimeType: string | null | undefined
filename: string | null | undefined
download: boolean
}): Headers => {
const headers = new Headers(range.headers)
const contentType = privateMediaContentType(mimeType)
const forceDownload = download || contentType === PRIVATE_MEDIA_FALLBACK_MIME_TYPE
headers.set('Content-Type', contentType)
headers.set('Content-Disposition', privateMediaContentDisposition(filename, forceDownload))
headers.set('Cache-Control', PRIVATE_MEDIA_CACHE_CONTROL)
headers.set('X-Content-Type-Options', 'nosniff')
if (forceDownload) headers.set('Content-Security-Policy', "default-src 'none'")
return headers
}