T-HUB/README.md

319 lines
14 KiB
Markdown

# T-HUB
<p align="center"><img src="docs/brand/t-hub-lockup.png" width="520" alt="T-HUB"></p>
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: fondo blanco, tinta negra, neón `#00e5ff` en sombras y foco, títulos en Anton. Logo de tres tiendas cableadas a un switch con antena, dibujado como el de la acampada (`docs/brand/`).
| 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<br/>Next.js]
end
subgraph Control
API[apps/api<br/>NestJS]
SCH[apps/scheduler<br/>Temporal worker]
REG[ConnectorRegistry<br/>packages/connector-*]
end
subgraph Datos
PG[(PostgreSQL<br/>RLS por colectivo)]
RD[(Redis<br/>estado OAuth 10 min)]
V[Vault<br/>AES-256-GCM + KEK]
S3[(S3 / R2<br/>medios cifrados)]
end
subgraph Medios
MW[apps/media-worker<br/>FFmpeg]
PT[PeerTube<br/>tube.occupymedia.cc]
end
subgraph Redes
MA[Mastodon API]
IG[Graph API<br/>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<br/>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 e imágenes (con texto alternativo) | Sin claves de servidor; cada cuenta registra la app en su instancia. |
| Telegram | Sí | Texto e imágenes | 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.
Built for collectives that need to coordinate media across platforms without
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
- **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).
- **Column wall** — live multi-platform feed columns, TweetDeck-style. Safe rendering (hostile-HTML tested). Per-source cooldown + adaptive backoff.
- **Live Control Room** — browser multiview, TAKE/CUT, preview/program, lower-thirds, scrolling chyrons, audio mixing, tally.
- **Auto-Director** — priority rules switch sources automatically so the stream never dies mid-action.
- **Chat governance** — bridged chats (Telegram/Twitch/Discord) into one overlay. Polls with configurable quorum. Approval gates on every post. Machine-readable reason codes.
- **Distributed transcode workers** — laptops and GPUs join as render workers over Tailscale. Per-job sandboxed FFmpeg (UID/seccomp/no-network).
- **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
```bash
pnpm install
pnpm lint && pnpm typecheck && pnpm build
# infrastructure
docker compose -f infra/docker-compose.yml up -d postgres redis temporal minio otel-collector prometheus grafana
# everything incl. media stub + mock live source + mock RTMP sink
docker compose -f infra/docker-compose.yml --profile dev up -d
# apps
pnpm dev # api :3000, web :3001(adjust if colliding), scheduler, media-worker :3100
curl localhost:3000/healthz
curl localhost:3000/readyz
```
### Documentation
| Section | Path |
|---|---|
| Engineering guide | [`docs/engineering-guide.md`](docs/engineering-guide.md) |
| Admin guide | [`docs/admin-guide.md`](docs/admin-guide.md) |
| User manual | [`docs/user-manual-v2.md`](docs/user-manual-v2.md) |
| Design docs | [`docs/design/`](docs/design/) |
| 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
```bash
pnpm --filter @tactical-media-broadcast-hub/media-worker soak -- --hours 8 --out
```
### 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
encrypted vault the database never sees in plaintext; every editorial action in
a hash-chained audit log; anonymous-infrastructure friendly (the host carries
boxes, not content — storage is ciphertext).