diff --git a/README.md b/README.md index 650f286..0513976 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,259 @@ -# Tactical Media Broadcast Hub +# T-HUB + +

T-HUB

+ +Copia de trabajo del [Tactical Media Broadcast Hub](https://gitlab.moxexley.com/mediatools/tacticalmediahub) +para la red de acampadas: [acampada26s.net](https://acampada26s.net), +[tomalacalle.net](https://tomalacalle.net) y [takethestreet.net](https://takethestreet.net). +El despliegue en uso es [app.occupymedia.cc](https://app.occupymedia.cc). + +Seguimos la rama `main` de upstream y mandamos allí los cambios que les sirvan. +Lo propio de T-HUB va en este repo. Para revisar y validar los cambios, ver +[docs/T-HUB-VALIDACION.txt](docs/T-HUB-VALIDACION.txt). + +Cambios respecto a upstream: + +- Instagram registrado en conexión y publicación; claves de la app de Meta solo en el entorno del servidor. +- Panel **Outreach** para escribir por privado a mano, contacto a contacto, con seguimiento. +- RLS forzado en `media_assets` (migración 042). +- Estilo de acampada26s.net en la web: negro, blanco, neón `#00e5ff` y títulos en Anton. + +| Remoto | URL | +|---|---| +| `origin` | `https://gitea.laenre.net/hacklab/T-HUB.git` | +| `upstream` | `https://gitlab.moxexley.com/mediatools/tacticalmediahub.git` | + +```bash +git fetch upstream && git merge upstream/main +``` + +## Arquitectura + +### 1. Una cuenta + +Una persona conecta una cuenta del colectivo una sola vez. A partir de ahí +redacta, alguien aprueba y el Hub publica. La contraseña de la red social no +pasa nunca por el Hub: la red devuelve un token y el Hub lo guarda cifrado. + +```mermaid +flowchart LR + P([Persona]) -->|1. Conectar| H[T-HUB] + H -->|2. Pantalla de permiso| R[(Red social)] + R -->|3. Token| H + P -->|4. Redacta| H + H -->|5. Aprobación| A([Otra persona del colectivo]) + A -->|6. Visto bueno| H + H -->|7. Publica a su hora| R +``` + +### 2. Varias cuentas compartidas + +Cada colectivo tiene sus cuentas y sus reglas de aprobación. Un borrador puede +ir a varias cuentas a la vez: el Hub crea una copia por destino y cada una pasa +la regla de su cuenta (automática, una persona, mayoría o consenso). Lo que deba +salir en toda la red sube a la cola del colectivo padre. + +```mermaid +flowchart TB + subgraph TTS[TAKE THE STREET] + Q[Cola común] --> IG1[Instagram internacional] + Q --> M1[Mastodon @takethestreet] + end + subgraph S26[ACAMPADA 26S] + D[Borrador] --> IG2[Instagram @acampada26s] + D --> M2[Mastodon @26s] + D --> T2[Telegram acampada26s] + end + subgraph SUB[Acampadas locales] + B[Barcelona] --> IG3[Instagram propio] + S[Sevilla] --> IG4[Instagram propio] + end + S26 -->|Sube para toda la red| Q + SUB -->|Sube a 26S| D + IG2 -.->|Regla: 2 aprobaciones| G{{Aprobación}} + M2 -.->|Regla: automática| G +``` + +### 3. Parte técnica + +La web solo habla con la API. La API guarda el estado en Postgres con +aislamiento por colectivo (RLS), los tokens van en un vault cifrado y el +envío programado lo hace el scheduler con Temporal. Cada red es un conector; +fuera de los conectores no hay código que distinga redes. + +```mermaid +flowchart LR + subgraph Cliente + W[apps/web
Next.js] + end + subgraph Control + API[apps/api
NestJS] + SCH[apps/scheduler
Temporal worker] + REG[ConnectorRegistry
packages/connector-*] + end + subgraph Datos + PG[(PostgreSQL
RLS por colectivo)] + RD[(Redis
estado OAuth 10 min)] + V[Vault
AES-256-GCM + KEK] + S3[(S3 / R2
medios cifrados)] + end + subgraph Medios + MW[apps/media-worker
FFmpeg] + PT[PeerTube
tube.occupymedia.cc] + end + subgraph Redes + MA[Mastodon API] + IG[Graph API
Instagram] + TG[Telegram Bot API] + end + + W -->|HTTPS + sesión| API + API --> PG + API --> RD + API --> V + V --> PG + API -->|connect / identity| REG + SCH -->|publish| REG + SCH --> PG + REG -->|providerFetch
control de salida| MA + REG --> IG + REG --> TG + MW --> S3 + MW -->|RTMP| PT +``` + +Conectar una cuenta (OAuth con `state` de un solo uso y PKCE donde la red lo admite): + +1. `POST /connections/authorize`: la API guarda en Redis, cifrado, el estado de la petición y devuelve la URL de la red. +2. La persona acepta en la red y la red vuelve a `GET /connections/callback`. +3. La API comprueba `state`, cookie y permisos, canjea el código y guarda el token en el vault. + +## Estado por red + +| Red | Conectar | Publicar | Notas | +|---|---|---|---| +| Mastodon | Sí | Texto | Sin claves de servidor; cada cuenta registra la app en su instancia. | +| Telegram | Sí | Texto | Token del bot de BotFather. | +| Instagram | Sí, con `INSTAGRAM_CLIENT_ID/SECRET` | No todavía | Meta descarga la imagen de una URL pública y los medios están cifrados: falta un endpoint temporal. Ver [docs/oauth-connect.md](docs/oauth-connect.md). | +| PeerTube | No | Directo sí | Falta el puente OAuth en tube.occupymedia.cc. | +| X | No | No | Sin conector. API de pago por uso. | +| TikTok | No | No | Fuera de alcance: la API exige auditoría y sin ella solo publica en privado. | + +## Panel Outreach (mensajes privados) + +Lista de cuentas públicas (artistas, colectivos, medios) a las que escribir por +privado para pedir difusión. Cada contacto tiene su mensaje, se puede rellenar +con una plantilla (`{name}`, `{handle}`) y se marca enviado, respondido o +rechazado. Se importa en CSV: + +``` +name,platform,handle,profileUrl,category,language +Climáximo,bluesky,climaximopt.bsky.social,https://bsky.app/profile/climaximopt.bsky.social,clima,pt +``` + +El Hub **no envía** los mensajes. "Copy & open profile" copia el texto y abre el +perfil; la persona lo pega y lo envía desde la propia red. Motivos: + +| Red | Abrir un privado por API | Problema | +|---|---|---| +| Instagram | No | La API solo deja responder a quien te escribió en las últimas 24 h. | +| X | Sí, de pago | Mensajes en masa a desconocidos van contra sus normas de automatización: suspensión. | +| Mastodon | Sí (mención privada) | Las instancias silencian o bloquean cuentas que lo hacen en masa; puede caer la instancia entera. | +| Bluesky | Sí | Mismo criterio de spam; los chats de desconocidos suelen estar cerrados. | + +El contador de la pantalla avisa a partir de 20 primeros mensajes al día por +colectivo. Repartidlos entre varias personas y días, y personalizad cada uno. + +## Requisitos + +Medido en el despliegue actual (ver +[docs/ops/2026-10-12-cloud-hosting-requirements.md](docs/ops/2026-10-12-cloud-hosting-requirements.md)). +Los medios van a R2, así que el disco no crece con los vídeos. + +| Nivel | CPU | RAM | Disco | Para qué | +|---|---|---|---|---| +| Mínimo | 4 vCPU | 8 GB | 160 GB SSD | Todo el stack, transcodificación fuera de la máquina | +| Recomendado | 8 vCPU | 16 GB | 240 GB SSD | Directos, transcodificación en la máquina, Collabora | +| Nodo de render (opcional) | 4+ núcleos o GPU con NVENC | 8 GB | 50 GB | Se une por Tailscale; nunca guarda claves | + +Uso real hoy: unos 2,6 GB de RAM entre todos los servicios. + +Ancho de banda: + +| Uso | Necesario | +|---|---| +| Panel, publicación, APIs de redes | < 1 Mbps sostenido | +| Un directo saliente (1080p30 hacia PeerTube) | 6 Mbps de subida por directo; 3 Mbps a 720p | +| Espectadores | Los sirve Cloudflare desde la caché de HLS; el origen no escala con la audiencia | +| Subidas de vídeo | Picos de lo que dé la línea; límite de 2 GiB por archivo | + +Coste orientativo: VPS de 30 €/mes + R2 de 1 a 5 $/mes. Cloudflare en plan gratuito. + +## Red y puertos + +Entrada desde internet: **ninguna** directa. Todo entra por el túnel de +Cloudflare; el nginx de borde solo acepta rangos de Cloudflare en 80/443. + +| Puerto | Servicio | Exponer a | +|---|---|---| +| 443/tcp | nginx de borde | Solo rangos de Cloudflare | +| 80/tcp | nginx de borde (redirección) | Solo rangos de Cloudflare | +| 3000/tcp | API | Solo 127.0.0.1 | +| 3001/tcp | Web | Solo 127.0.0.1 | +| 9000/tcp | PeerTube | Solo 127.0.0.1 o Tailscale | +| 1935/tcp | RTMP de PeerTube | Solo Tailscale (worker de medios) | +| 6167/tcp | Matrix (conduwuit) | Solo 127.0.0.1 | +| 9200, 9980/tcp | oCIS, Collabora | Solo 127.0.0.1 | +| 5432, 6379, 7233 | Postgres, Redis, Temporal | Nunca fuera de la red de Docker | + +Salida que hace falta: 443/tcp a las APIs de las redes y a R2, 7844/tcp+udp +para `cloudflared`, 41641/udp para Tailscale. + +Docker publica puertos saltándose ufw. Para cerrarlos: publicar en `127.0.0.1:PUERTO` +o en la IP de Tailscale, o añadir reglas en la cadena `DOCKER-USER`. + +## Seguridad y monitorización + +Prácticas para cualquier despliegue. Los fallos de seguridad se comunican en +privado a quien mantiene el código, no en issues públicas. + +- **Registro solo por invitación.** Un registro abierto da a cualquiera un colectivo propio y permisos de Owner sobre él. +- **Un secreto por servicio.** La web y los workers no necesitan `SUPERPOSTER_KEK`, `S3_KEK` ni `SESSION_SECRET`. Claves distintas para el vault y para los medios. +- **FFmpeg siempre en sandbox** (`NODE_ENV=production`, `MEDIA_SANDBOX=on`), sin red y sin variables de entorno heredadas. +- **Cabeceras**: CSP estricta, HSTS, `X-Content-Type-Options`, `Referrer-Policy` en web y API. +- **Límites de peticiones** en `/auth/*`, invitaciones, OIDC y subidas públicas, por `CF-Connecting-IP`. +- **Metadatos fuera**: quitar EXIF/GPS de fotos y vídeos antes de guardarlos o publicarlos. +- **Copias cifradas** (`age` o GPG) y fuera de la máquina; probar la restauración. +- **Auditoría inalterable**: quitar `UPDATE`/`DELETE` sobre `audit_log` al rol `sp_app` y anclar fuera la cabeza de la cadena. + +Qué vigilar (Prometheus + Grafana ya están en `infra/`; Grafana nunca con acceso anónimo): + +| Señal | Alerta si | +|---|---| +| `401`/`403` en `/auth/login` por IP | > 20 en 5 min | +| Registros nuevos / invitaciones canjeadas | Cualquiera fuera de lo previsto | +| Eventos `audit_log`: `member.*`, `connection.removed`, `group.deleted`, `outreach.removed` | Picos o actores raros | +| `connections` en estado `needs_credentials` | Cualquiera: token revocado o caducado | +| Errores 5xx de la API | > 1 % durante 5 min | +| Memoria de API y workers | > 80 % | +| Cola de publicación `failed`/`unknown` | Cualquiera | +| Logins al panel de Cloudflare y cambios de DNS | Siempre | + +## Desarrollo + +```bash +corepack enable +pnpm install +pnpm exec turbo run build typecheck --filter=@tactical-media-broadcast-hub/api... +pnpm --filter @tactical-media-broadcast-hub/connector-instagram test +``` + +Los tests de integración de `apps/api` que usan Postgres se saltan si no hay +base de datos configurada. + +--- + +## Upstream: Tactical Media Broadcast Hub Self-hosted multi-tenant social / video / livestream management platform with a generic connector system and a Virtual Channel / Auto Director subsystem. @@ -8,7 +263,7 @@ concentrating access in one person's hands. Every credential lives in an encrypted vault. Every editorial decision lands in a tamper-evident audit log. Every feature is multi-tenant with row-level security. -## Features +### Features - **Publishing pipeline** — drafts → group approval → schedule → fenced dispatch → receipts. Per-connection publish governance: `auto`, one-admin, majority or consensus sign-off per account — boosts included. Multi-platform fan-out (Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord, Slack). DST-proof scheduling. - **Files SSO** — the Hub is the OIDC issuer for oCIS Drive: one sign-in, auto-provisioned personal drives, rotating refresh tokens that die with your Hub session (logout propagates). @@ -20,7 +275,7 @@ Every feature is multi-tenant with row-level security. - **Archive** — content-addressed encrypted S3, dedup, full-text search, CSV/JSON export. Deleted platform content stays citable. - **Analytics** — month-partitioned snapshots, current + previous month with deltas, per-provider breakdown. -## Quickstart +### Quickstart ```bash pnpm install @@ -38,7 +293,7 @@ curl localhost:3000/healthz curl localhost:3000/readyz ``` -## Documentation +### Documentation | Section | Path | |---|---| @@ -49,13 +304,13 @@ curl localhost:3000/readyz | Streaming architecture | [`docs/design/streaming-architecture.md`](docs/design/streaming-architecture.md) | | Deployment (occupymedia.cc) | [`infra/occupymedia/DEPLOY.md`](infra/occupymedia/DEPLOY.md) | -## Deterministic live-source testing +### Deterministic live-source testing ```bash pnpm --filter @tactical-media-broadcast-hub/media-worker soak -- --hours 8 --out ``` -## Security & trust model +### Security & trust model Self-hosted, end to end — no corporate cloud, no accounts needed to run the system itself. Multi-tenant with row-level security; platform credentials in an diff --git a/docs/T-HUB-VALIDACION.txt b/docs/T-HUB-VALIDACION.txt new file mode 100644 index 0000000..4d7e7e4 --- /dev/null +++ b/docs/T-HUB-VALIDACION.txt @@ -0,0 +1,156 @@ +T-HUB - Guía de validación de cambios +===================================== +Para: quien revise y valide los cambios de T-HUB antes de llevarlos a +upstream (gitlab.moxexley.com/mediatools/tacticalmediahub) o a producción. +Base: upstream main a6843e9. + +Commits a revisar (en orden): + + feat: wire the Instagram connector into connect and publishing + feat: outreach contacts - track hand-written DMs per collective + chore(db): force row-level security on media_assets + style / docs: estilo de la web, logo y documentación + +Cada commit se puede revisar y aplicar por separado. + + +0. PREPARACIÓN +-------------- + corepack enable + pnpm install --frozen-lockfile + pnpm exec turbo run build typecheck --filter=@tactical-media-broadcast-hub/api... + +Para los tests que usan Postgres hacen falta las variables *_IT_DATABASE_URL +de cada suite; sin ellas se saltan (comportamiento de upstream). + +Lint: apps/api ya tenía 54 errores antes de estos cambios y sigue teniendo +los mismos 54. Los archivos tocados pasan eslint sin errores. + + +1. INSTAGRAM +---------------------- +Qué cambia + - createInstagramConnector registrado en + apps/api/src/connections/connector-registry.ts y en connectorFor de + apps/api/src/publishing/publishing.service.ts. + - Política de publicación instagram: { idempotency: "none" } en + packages/connector-runtime/src/policy.ts (Graph no tiene clave de + idempotencia; un envío dudoso no se repite). + - Las credenciales de la app de Meta se leen SOLO de + INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET. Se quitan los campos + clientId/clientSecret del formulario y ya no se copian al vault. + - El proveedor sale desactivado (connectEnabled=false) sin esas dos + variables, con una nota que lo explica. + - Si la persona autoriza varias cuentas de Instagram en el diálogo de + Meta, la conexión falla en vez de elegir la primera. + +Tests automáticos + pnpm --filter @tactical-media-broadcast-hub/connector-instagram test + esperado: 37 pass + cd apps/api && npx vitest run src/connections + esperado: 18 pass (incluye "offers Instagram only with the Hub's + Facebook app configured, and keeps the app secret out of the browser") + +Prueba manual (entorno de pruebas, app de Meta en modo desarrollo) + 1. Sin variables: GET /connections/providers -> instagram enabled=false. + 2. Con INSTAGRAM_CLIENT_ID/SECRET: enabled=true. + 3. En la app de Meta, Valid OAuth Redirect URI = + ${API_URL}/connections/callback (exacto). + 4. Canales -> Conectar Instagram con una cuenta Tester Business/Creator + vinculada a una página -> vuelve con connect=success y la conexión + sale healthy con @usuario. + 5. Autorizar dos cuentas a la vez -> connect=failure. + +Límites conocidos (no resueltos en este commit) + - Publicar imágenes NO funciona todavía: Meta descarga la imagen de una + URL pública y los medios están cifrados en el almacén. Hace falta un + endpoint temporal que sirva el JPEG descifrado (TTL corto, solo + borradores aprobados). Además la regla del proyecto es que los bytes + no pasen por la API. Decisión de diseño pendiente. + - refreshCredentials usa grant_type=ig_refresh_token contra + graph.facebook.com, que es el método de "Instagram Login", no de + Facebook Login. Comprobar con un token real; posible caducidad a 60 + días. + + +2. OUTREACH +--------------------- +Qué cambia + - Migración 041_outreach.sql: tabla outreach_contacts con RLS + ENABLE+FORCE y WITH CHECK, índice único (workspace, platform, + lower(handle)), trigger de workspace inmutable, permisos + outreach.view (Owner, Admin, Editor, Moderator) y outreach.manage + (Owner, Admin, Editor). + - API: GET/POST/PATCH/DELETE /outreach/contacts (apps/api/src/outreach). + Importación de hasta 500 por petición; las cuentas ya existentes se + saltan. La auditoría guarda altas, bajas y cambios de estado, nunca el + texto del mensaje. + - Web: /outreach (barra lateral y "More"). Importar CSV, plantilla con + {name} y {handle}, filtros, "Copy & open profile", contador diario + con aviso a partir de 20. + - El Hub no envía mensajes. No hay llamadas a APIs de redes en este + módulo. Ver README, sección "Panel Outreach", para el motivo. + +Tests automáticos + cd apps/api && npx vitest run src/outreach esperado: 8 pass + pnpm --filter @tactical-media-broadcast-hub/web test:outreach + esperado: 4 pass + +Verificado además (fuera del repo): la migración 041 y todas las +consultas del servicio se ejecutaron sobre PGlite (Postgres compilado a WASM) +como rol sin privilegios con app.workspace_id fijado: aislamiento entre +colectivos, salto de duplicados en mayúsculas/minúsculas, sellos de +sent_at/replied_at, vuelta a "todo" limpia los sellos. + +Prueba manual + 1. Aplicar migraciones (src/db/migrate.cli.ts). + 2. Como Editor: importar + name,platform,handle,profileUrl,category,language + Prueba,bluesky,prueba.bsky.social,https://bsky.app/profile/prueba.bsky.social,test,es + -> "1 contacts added". Reimportar -> "0 added, 1 already on the list". + 3. Plantilla "Hola {name}" -> el contacto pendiente recibe "Hola Prueba". + 4. Copy & open profile -> abre el perfil en otra pestaña con el texto + en el portapapeles. + 5. Mark sent -> estado sent y sello de fecha; aparece outreach.sent en + /audit sin el texto del mensaje. + 6. Como Analyst (sin outreach.view) -> GET /outreach/contacts da 403. + 7. Desde otro colectivo -> la lista no muestra los contactos del primero + y PATCH sobre su id da 404. + 8. profileUrl con http:// o javascript: -> 400. + + +3. RLS EN media_assets +---------------------- +Qué cambia + - 042_media_assets_rls.sql: ENABLE + FORCE RLS y política con + WITH CHECK. + - Las consultas a media_assets (media.service.ts, publishing.service.ts) + filtran también por app.workspace_id. + +Verificación + - cd apps/api && npx vitest run src/media src/publishing + esperado: 114 pass, 49 skipped (necesitan Postgres). + - En la base de datos, después de migrar: + SELECT relrowsecurity, relforcerowsecurity + FROM pg_class WHERE relname = 'media_assets'; -> t, t + - Prueba manual: subir un medio en el colectivo A y pedir + GET /media/ con sesión del colectivo B -> 404. + + +4. ESTILO Y LOGO +---------------- + - apps/web/src/app/globals.css: paleta de acampada26s.net sobre fondo + oscuro (negro, blanco, neón #00e5ff), títulos h1/h2 en Anton en + mayúsculas. Regla que pone texto negro en botones neón. + - Fuente Anton en apps/web/public/fonts (licencia SIL OFL incluida). + - Logo: docs/brand/t-hub.svg, también como favicon. + - Página pública de buzón (/submit/[token]) con el mismo color. +Comprobar visualmente: login, Compose, Approvals, Channels, Outreach, +Control Room y /submit/, en escritorio y móvil. Buscar textos +blancos sobre neón o bloques que hayan quedado con el rojo anterior. + + +5. SEGURIDAD +------------ +La revisión de seguridad se entrega en privado a quien mantiene el +despliegue. Los fallos se comunican por canal privado, no en issues.