Compare commits
11 commits
main
...
moxexley-t
| Author | SHA1 | Date | |
|---|---|---|---|
| ae67ddf100 | |||
| c779c3b79d | |||
| 0c1a0c7b28 | |||
| b47cac40ce | |||
| c87371c08f | |||
| cfabbf20ef | |||
| 533a0fb1c0 | |||
| d2a4209b0a | |||
| 206306e5e2 | |||
| d310385d29 | |||
| c96704fb6b |
267
README.md
|
|
@ -1,4 +1,259 @@
|
|||
# Tactical Media Broadcast Hub
|
||||
# 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
51
apps/api/migrations/041_outreach.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- 041: outreach — per-workspace list of public accounts to contact by hand.
|
||||
--
|
||||
-- The Hub never sends these messages. A person opens the contact's profile,
|
||||
-- pastes the drafted text into the platform's own DM box and marks the row
|
||||
-- sent; replies are marked the same way. Only public accounts belong here:
|
||||
-- names and contact details of private persons stay out of the Hub.
|
||||
|
||||
CREATE TABLE outreach_contacts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
|
||||
platform text NOT NULL CHECK (platform ~ '^[a-z0-9][a-z0-9-]{1,31}$'),
|
||||
handle text NOT NULL CHECK (char_length(handle) BETWEEN 1 AND 200),
|
||||
profile_url text NOT NULL CHECK (profile_url ~ '^https://[^\s]+$' AND char_length(profile_url) <= 500),
|
||||
category text CHECK (category IS NULL OR char_length(category) <= 100),
|
||||
language text CHECK (language IS NULL OR language ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$'),
|
||||
message text NOT NULL DEFAULT '' CHECK (char_length(message) <= 4000),
|
||||
notes text NOT NULL DEFAULT '' CHECK (char_length(notes) <= 2000),
|
||||
status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'sent', 'replied', 'declined')),
|
||||
sent_at timestamptz,
|
||||
sent_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
replied_at timestamptz,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (id, workspace_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX outreach_contacts_account ON outreach_contacts (workspace_id, platform, lower(handle));
|
||||
CREATE INDEX outreach_contacts_status ON outreach_contacts (workspace_id, status);
|
||||
|
||||
CREATE TRIGGER trg_outreach_contacts_ws_immutable BEFORE UPDATE ON outreach_contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE outreach_contacts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE outreach_contacts FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON outreach_contacts
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON outreach_contacts TO sp_app;
|
||||
|
||||
-- Reads for anyone who already sees the inbox; edits for the roles that write drafts.
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Owner', 'outreach.view'
|
||||
UNION ALL SELECT 'Admin', 'outreach.view'
|
||||
UNION ALL SELECT 'Editor', 'outreach.view'
|
||||
UNION ALL SELECT 'Moderator', 'outreach.view'
|
||||
UNION ALL SELECT 'Owner', 'outreach.manage'
|
||||
UNION ALL SELECT 'Admin', 'outreach.manage'
|
||||
UNION ALL SELECT 'Editor', 'outreach.manage'
|
||||
ON CONFLICT DO NOTHING;
|
||||
11
apps/api/migrations/042_media_assets_rls.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- 042: media_assets row-level security.
|
||||
--
|
||||
-- Enable and force the tenant_isolation policy declared in 038, with an
|
||||
-- explicit WITH CHECK. All media_assets queries run inside withTenant.
|
||||
|
||||
ALTER TABLE media_assets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE media_assets FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON media_assets;
|
||||
CREATE POLICY tenant_isolation ON media_assets
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"dependencies": {
|
||||
"@tactical-media-broadcast-hub/connector-sdk": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/connector-instagram": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/core": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.1146.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1146.0",
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js";
|
|||
import { OidcModule } from "./oidc/oidc.module.js";
|
||||
import { SubmissionsModule } from "./submissions/submissions.module.js";
|
||||
import { MediaModule } from "./media/media.module.js";
|
||||
import { OutreachModule } from "./outreach/outreach.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule],
|
||||
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule, OutreachModule],
|
||||
controllers: [
|
||||
DemoController,
|
||||
PublishingController,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
|
|||
import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js";
|
||||
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
||||
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
|
||||
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
|
||||
import { connectionRegistry } from "./connector-registry.js";
|
||||
import { Vault, type SealedCredential } from "../vault/vault.js";
|
||||
import type { SessionPayload } from "../auth/session.js";
|
||||
import { RbacGuard } from "../rbac/rbac.guard.js";
|
||||
|
|
@ -105,12 +107,13 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
|
|||
let issued = 0;
|
||||
let failExchange = false;
|
||||
let bridgeConfigured = false;
|
||||
let instagramConfigured = false;
|
||||
const botToken = "12345:bot_token_secret";
|
||||
|
||||
beforeEach(() => {
|
||||
store.rows.clear(); store.audit.length = 0; store.authorized = true;
|
||||
redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0;
|
||||
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false;
|
||||
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; instagramConfigured = false;
|
||||
warn.mockClear(); info.mockClear();
|
||||
});
|
||||
|
||||
|
|
@ -180,6 +183,9 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
|
|||
}));
|
||||
const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init);
|
||||
registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 }));
|
||||
registry.register(createInstagramConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000,
|
||||
env: instagramConfigured ? { INSTAGRAM_CLIENT_ID: "hub-app-id", INSTAGRAM_CLIENT_SECRET: "hub-app-secret" } : {},
|
||||
}));
|
||||
return registry;
|
||||
},
|
||||
};
|
||||
|
|
@ -342,6 +348,25 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
|
|||
bridgeConfigured = false;
|
||||
});
|
||||
|
||||
it("offers Instagram only with the Hub's Facebook app configured, and keeps the app secret out of the browser", async () => {
|
||||
expect(connectionRegistry({} as never).has("instagram")).toBe(true);
|
||||
const providersOf = async () => await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>;
|
||||
expect((await providersOf()).find((provider) => provider.id === "instagram")).toMatchObject({ enabled: false, note: expect.stringContaining("INSTAGRAM_CLIENT_ID") });
|
||||
expect((await api("/authorize", "POST", { provider: "instagram" })).status).toBe(400);
|
||||
instagramConfigured = true;
|
||||
expect((await providersOf()).find((provider) => provider.id === "instagram")?.enabled).toBe(true);
|
||||
const response = await api("/authorize", "POST", { provider: "instagram" });
|
||||
expect(response.status).toBe(201);
|
||||
const { redirectUrl } = await response.json() as { redirectUrl: string };
|
||||
const dialog = new URL(redirectUrl);
|
||||
expect(dialog.origin + dialog.pathname).toBe("https://www.facebook.com/v21.0/dialog/oauth");
|
||||
expect(dialog.searchParams.get("client_id")).toBe("hub-app-id");
|
||||
expect(dialog.searchParams.get("redirect_uri")).toBe(`${apiOrigin}/connections/callback`);
|
||||
expect(dialog.searchParams.get("state")).toMatch(/.{32,}/);
|
||||
expect(redirectUrl).not.toContain("hub-app-secret");
|
||||
instagramConfigured = false;
|
||||
});
|
||||
|
||||
it("sets, validates and clears per-connection publish governance", async () => {
|
||||
await finish(await begin());
|
||||
const id = (await listed())[0]?.id ?? "";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
|
||||
|
|
@ -13,5 +15,6 @@ export function connectionRegistry(credentials: CredentialAccessor, fetchImpl: t
|
|||
registry.register(createMastodonConnector(options));
|
||||
registry.register(createPeerTubeConnector(options));
|
||||
registry.register(createTelegramConnector(options));
|
||||
registry.register(createInstagramConnector(options));
|
||||
return registry;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export class MediaService {
|
|||
/** Auth-scoped read: RLS already fences this to the caller's workspace. */
|
||||
async readAsset(workspaceId: string, id: string): Promise<MediaAssetRecord & { bytes: Buffer }> {
|
||||
const asset = await withTenant(workspaceId, async (client) => {
|
||||
const result = await client.query<MediaRow>("SELECT * FROM media_assets WHERE id = $1", [id]);
|
||||
const result = await client.query<MediaRow>("SELECT * FROM media_assets WHERE id = $1 AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid", [id]);
|
||||
const row = result.rows[0];
|
||||
if (row === undefined) throw new NotFoundException(`media asset ${id} not found`);
|
||||
return fromRow(row);
|
||||
|
|
@ -192,7 +192,7 @@ export class MediaService {
|
|||
): Promise<void> {
|
||||
if (mediaAssetIds.length === 0) return;
|
||||
const result = await client.query(
|
||||
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[])",
|
||||
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[]) AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid",
|
||||
[mediaAssetIds],
|
||||
);
|
||||
const count = Number(result.rows[0]?.count ?? 0);
|
||||
|
|
|
|||
141
apps/api/src/outreach/outreach.controller.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Outreach contacts (manual DM campaign tracking).
|
||||
*
|
||||
* GET /outreach/contacts?status= outreach.view — list (≤ 2000)
|
||||
* POST /outreach/contacts outreach.manage — { contacts: [...] } bulk add, ≤ 500
|
||||
* PATCH /outreach/contacts/:id outreach.manage — { message?, notes?, status? }
|
||||
* DELETE /outreach/contacts/:id outreach.manage
|
||||
*
|
||||
* No route sends a message: see OutreachService.
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { requireObject, requireSession } from "../http/validation.js";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import {
|
||||
OUTREACH_MAX_IMPORT,
|
||||
OutreachService,
|
||||
isOutreachStatus,
|
||||
type OutreachContact,
|
||||
type OutreachContactInput,
|
||||
type OutreachContactPatch,
|
||||
type OutreachStatus,
|
||||
} from "./outreach.service.js";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const PLATFORM = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
||||
const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
|
||||
|
||||
@Controller("outreach")
|
||||
@UseGuards(RbacGuard)
|
||||
export class OutreachController {
|
||||
constructor(@Inject(OutreachService) private readonly outreach: OutreachService) {}
|
||||
|
||||
@Get("contacts")
|
||||
@RequirePermission("outreach.view")
|
||||
async list(@Req() request: AuthenticatedRequest, @Query("status") status?: string): Promise<OutreachContact[]> {
|
||||
const session = requireSession(request);
|
||||
if (status !== undefined && !isOutreachStatus(status)) throw new BadRequestException("unknown status");
|
||||
return this.outreach.list(session.workspaceId, status as OutreachStatus | undefined);
|
||||
}
|
||||
|
||||
@Post("contacts")
|
||||
@RequirePermission("outreach.manage")
|
||||
async import(@Req() request: AuthenticatedRequest, @Body() body: unknown): Promise<{ added: number; skipped: number }> {
|
||||
const session = requireSession(request);
|
||||
const raw = requireObject(body, "body").contacts;
|
||||
if (!Array.isArray(raw) || raw.length === 0 || raw.length > OUTREACH_MAX_IMPORT) {
|
||||
throw new BadRequestException(`contacts must be an array of 1 to ${OUTREACH_MAX_IMPORT} items`);
|
||||
}
|
||||
const contacts = raw.map((item, index) => parseContact(item, `contacts[${index}]`));
|
||||
return this.outreach.import(session.workspaceId, contacts, { userId: session.userId, role: session.role });
|
||||
}
|
||||
|
||||
@Patch("contacts/:id")
|
||||
@RequirePermission("outreach.manage")
|
||||
async update(@Req() request: AuthenticatedRequest, @Param("id") id: string, @Body() body: unknown): Promise<OutreachContact> {
|
||||
const session = requireSession(request);
|
||||
if (!UUID.test(id)) throw new BadRequestException("id must be a UUID");
|
||||
return this.outreach.update(session.workspaceId, id, parsePatch(body), { userId: session.userId, role: session.role });
|
||||
}
|
||||
|
||||
@Delete("contacts/:id")
|
||||
@HttpCode(204)
|
||||
@RequirePermission("outreach.manage")
|
||||
async remove(@Req() request: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
|
||||
const session = requireSession(request);
|
||||
if (!UUID.test(id)) throw new BadRequestException("id must be a UUID");
|
||||
await this.outreach.remove(session.workspaceId, id, { userId: session.userId, role: session.role });
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, field: string, max: number, required: boolean): string | undefined {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
if (required) throw new BadRequestException(`${field} is required`);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string") throw new BadRequestException(`${field} must be a string`);
|
||||
const trimmed = value.trim();
|
||||
if (required && trimmed === "") throw new BadRequestException(`${field} is required`);
|
||||
if (trimmed.length > max) throw new BadRequestException(`${field} must be at most ${max} characters`);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseContact(value: unknown, field: string): OutreachContactInput {
|
||||
const item = requireObject(value, field);
|
||||
const name = text(item.name, `${field}.name`, 200, true) as string;
|
||||
const platform = (text(item.platform, `${field}.platform`, 32, true) as string).toLowerCase();
|
||||
if (!PLATFORM.test(platform)) throw new BadRequestException(`${field}.platform must be a lowercase id`);
|
||||
const handle = text(item.handle, `${field}.handle`, 200, true) as string;
|
||||
const profileUrl = text(item.profileUrl, `${field}.profileUrl`, 500, true) as string;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(profileUrl);
|
||||
} catch {
|
||||
throw new BadRequestException(`${field}.profileUrl must be a URL`);
|
||||
}
|
||||
if (url.protocol !== "https:" || /\s/.test(profileUrl)) throw new BadRequestException(`${field}.profileUrl must be an https URL`);
|
||||
const language = text(item.language, `${field}.language`, 12, false);
|
||||
if (language !== undefined && !LANGUAGE.test(language)) throw new BadRequestException(`${field}.language must be a language tag such as es or pt-BR`);
|
||||
const contact: OutreachContactInput = { name, platform, handle, profileUrl };
|
||||
const category = text(item.category, `${field}.category`, 100, false);
|
||||
const message = text(item.message, `${field}.message`, 4000, false);
|
||||
const notes = text(item.notes, `${field}.notes`, 2000, false);
|
||||
if (category !== undefined) contact.category = category;
|
||||
if (language !== undefined) contact.language = language;
|
||||
if (message !== undefined) contact.message = message;
|
||||
if (notes !== undefined) contact.notes = notes;
|
||||
return contact;
|
||||
}
|
||||
|
||||
export function parsePatch(value: unknown): OutreachContactPatch {
|
||||
const body = requireObject(value, "body");
|
||||
const patch: OutreachContactPatch = {};
|
||||
if (body.message !== undefined) {
|
||||
if (typeof body.message !== "string" || body.message.length > 4000) throw new BadRequestException("message must be a string of at most 4000 characters");
|
||||
patch.message = body.message;
|
||||
}
|
||||
if (body.notes !== undefined) {
|
||||
if (typeof body.notes !== "string" || body.notes.length > 2000) throw new BadRequestException("notes must be a string of at most 2000 characters");
|
||||
patch.notes = body.notes;
|
||||
}
|
||||
if (body.status !== undefined) {
|
||||
if (!isOutreachStatus(body.status)) throw new BadRequestException("status must be todo, sent, replied or declined");
|
||||
patch.status = body.status;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) throw new BadRequestException("nothing to update");
|
||||
return patch;
|
||||
}
|
||||
13
apps/api/src/outreach/outreach.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { OutreachController } from "./outreach.controller.js";
|
||||
import { OutreachService } from "./outreach.service.js";
|
||||
|
||||
/**
|
||||
* Outreach contacts: the list of public accounts a collective writes to by
|
||||
* hand, with the drafted message and the sent/replied state per contact.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [OutreachController],
|
||||
providers: [OutreachService],
|
||||
})
|
||||
export class OutreachModule {}
|
||||
229
apps/api/src/outreach/outreach.service.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Outreach: a workspace's list of public accounts to contact one by one.
|
||||
*
|
||||
* The Hub stores the contact, a drafted message and where the conversation
|
||||
* stands (todo → sent → replied/declined). It never sends anything: the
|
||||
* person copies the text, opens the profile and writes from the platform's
|
||||
* own DM box. Platforms forbid or rate-limit unsolicited bulk DMs, so
|
||||
* sending stays human and the API has no provider calls here.
|
||||
*
|
||||
* Audit entries record status changes and membership of the list, never the
|
||||
* message text.
|
||||
*/
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
import { withTenant } from "../db/client.js";
|
||||
|
||||
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
|
||||
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
|
||||
|
||||
/** One import call; larger lists go in several requests. */
|
||||
export const OUTREACH_MAX_IMPORT = 500;
|
||||
export const OUTREACH_MAX_LIST = 2000;
|
||||
|
||||
export interface OutreachContactInput {
|
||||
name: string;
|
||||
platform: string;
|
||||
handle: string;
|
||||
profileUrl: string;
|
||||
category?: string;
|
||||
language?: string;
|
||||
message?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface OutreachContactPatch {
|
||||
message?: string;
|
||||
notes?: string;
|
||||
status?: OutreachStatus;
|
||||
}
|
||||
|
||||
export interface OutreachContact {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
handle: string;
|
||||
profileUrl: string;
|
||||
category: string | null;
|
||||
language: string | null;
|
||||
message: string;
|
||||
notes: string;
|
||||
status: OutreachStatus;
|
||||
sentAt: string | null;
|
||||
repliedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OutreachActor {
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface OutreachRow {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
handle: string;
|
||||
profile_url: string;
|
||||
category: string | null;
|
||||
language: string | null;
|
||||
message: string;
|
||||
notes: string;
|
||||
status: OutreachStatus;
|
||||
sent_at: Date | null;
|
||||
replied_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const COLUMNS = "id, name, platform, handle, profile_url, category, language, message, notes, status, sent_at, replied_at, created_at, updated_at";
|
||||
|
||||
function fromRow(row: OutreachRow): OutreachContact {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
platform: row.platform,
|
||||
handle: row.handle,
|
||||
profileUrl: row.profile_url,
|
||||
category: row.category,
|
||||
language: row.language,
|
||||
message: row.message,
|
||||
notes: row.notes,
|
||||
status: row.status,
|
||||
sentAt: row.sent_at?.toISOString() ?? null,
|
||||
repliedAt: row.replied_at?.toISOString() ?? null,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function isOutreachStatus(value: unknown): value is OutreachStatus {
|
||||
return typeof value === "string" && (OUTREACH_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OutreachService {
|
||||
async list(workspaceId: string, status?: OutreachStatus): Promise<OutreachContact[]> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const result = await client.query<OutreachRow>(
|
||||
`SELECT ${COLUMNS} FROM outreach_contacts
|
||||
WHERE ($1::text IS NULL OR status = $1)
|
||||
ORDER BY lower(name), platform
|
||||
LIMIT ${OUTREACH_MAX_LIST}`,
|
||||
[status ?? null],
|
||||
);
|
||||
return result.rows.map(fromRow);
|
||||
});
|
||||
}
|
||||
|
||||
/** Bulk add; an account already on the list (same platform + handle) is skipped. */
|
||||
async import(workspaceId: string, contacts: OutreachContactInput[], actor: OutreachActor): Promise<{ added: number; skipped: number }> {
|
||||
if (contacts.length === 0) throw new BadRequestException("contacts must not be empty");
|
||||
if (contacts.length > OUTREACH_MAX_IMPORT) {
|
||||
throw new BadRequestException(`at most ${OUTREACH_MAX_IMPORT} contacts per import`);
|
||||
}
|
||||
const added = await withTenant(workspaceId, async (client) => {
|
||||
const result = await client.query<{ id: string }>(
|
||||
`INSERT INTO outreach_contacts
|
||||
(workspace_id, name, platform, handle, profile_url, category, language, message, notes, created_by)
|
||||
SELECT $1, c.name, c.platform, c.handle, c.profile_url, c.category, c.language,
|
||||
coalesce(c.message, ''), coalesce(c.notes, ''), $2
|
||||
FROM jsonb_to_recordset($3::jsonb) AS c(
|
||||
name text, platform text, handle text, profile_url text,
|
||||
category text, language text, message text, notes text)
|
||||
ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING
|
||||
RETURNING id`,
|
||||
[workspaceId, actor.userId, JSON.stringify(contacts.map(toRecord))],
|
||||
);
|
||||
return result.rowCount ?? result.rows.length;
|
||||
});
|
||||
if (added > 0) {
|
||||
await appendTenantAudit(workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "outreach.imported",
|
||||
targetType: "outreach_contact",
|
||||
targetId: workspaceId,
|
||||
diff: { added, skipped: contacts.length - added },
|
||||
});
|
||||
}
|
||||
return { added, skipped: contacts.length - added };
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the draft/notes or move the status. Marking `sent` stamps who and
|
||||
* when; `replied` stamps when; going back to `todo` clears both.
|
||||
*/
|
||||
async update(workspaceId: string, id: string, patch: OutreachContactPatch, actor: OutreachActor): Promise<OutreachContact> {
|
||||
const { contact, previous } = await withTenant(workspaceId, async (client) => {
|
||||
const current = await client.query<{ status: OutreachStatus }>(
|
||||
"SELECT status FROM outreach_contacts WHERE id = $1 FOR UPDATE",
|
||||
[id],
|
||||
);
|
||||
const before = current.rows[0];
|
||||
if (before === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
|
||||
const status = patch.status ?? before.status;
|
||||
const changed = status !== before.status;
|
||||
const result = await client.query<OutreachRow>(
|
||||
`UPDATE outreach_contacts SET
|
||||
message = coalesce($2, message),
|
||||
notes = coalesce($3, notes),
|
||||
status = $4,
|
||||
sent_at = CASE WHEN NOT $5 THEN sent_at WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN now() ELSE coalesce(sent_at, now()) END,
|
||||
sent_by = CASE WHEN NOT $5 THEN sent_by WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN $6::uuid ELSE coalesce(sent_by, $6::uuid) END,
|
||||
replied_at = CASE WHEN NOT $5 THEN replied_at WHEN $4 = 'replied' THEN now() ELSE NULL END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING ${COLUMNS}`,
|
||||
[id, patch.message ?? null, patch.notes ?? null, status, changed, actor.userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (row === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
|
||||
return { contact: fromRow(row), previous: before.status };
|
||||
});
|
||||
if (contact.status !== previous) {
|
||||
await appendTenantAudit(workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: `outreach.${contact.status}`,
|
||||
targetType: "outreach_contact",
|
||||
targetId: contact.id,
|
||||
diff: { from: previous, to: contact.status, platform: contact.platform, handle: contact.handle },
|
||||
});
|
||||
}
|
||||
return contact;
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, id: string, actor: OutreachActor): Promise<void> {
|
||||
const removed = await withTenant(workspaceId, async (client) => {
|
||||
const result = await client.query<{ platform: string; handle: string }>(
|
||||
"DELETE FROM outreach_contacts WHERE id = $1 RETURNING platform, handle",
|
||||
[id],
|
||||
);
|
||||
return result.rows[0];
|
||||
});
|
||||
if (removed === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
|
||||
await appendTenantAudit(workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "outreach.removed",
|
||||
targetType: "outreach_contact",
|
||||
targetId: id,
|
||||
diff: { platform: removed.platform, handle: removed.handle },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(contact: OutreachContactInput): Record<string, string | null> {
|
||||
return {
|
||||
name: contact.name,
|
||||
platform: contact.platform,
|
||||
handle: contact.handle,
|
||||
profile_url: contact.profileUrl,
|
||||
category: contact.category ?? null,
|
||||
language: contact.language ?? null,
|
||||
message: contact.message ?? null,
|
||||
notes: contact.notes ?? null,
|
||||
};
|
||||
}
|
||||
136
apps/api/src/outreach/outreach.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import "reflect-metadata";
|
||||
import type { PoolClient } from "pg";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
import { parseContact, parsePatch } from "./outreach.controller.js";
|
||||
import { OutreachService } from "./outreach.service.js";
|
||||
|
||||
/**
|
||||
* Outreach service + request parsing with the DB faked. RLS and the migration
|
||||
* shape belong to a live-DB suite.
|
||||
*/
|
||||
|
||||
const dbMock = vi.hoisted(() => ({ withTenant: vi.fn() }));
|
||||
vi.mock("../db/client.js", () => ({ withTenant: dbMock.withTenant }));
|
||||
vi.mock("../audit/audit.service.js", () => ({ appendTenantAudit: vi.fn(async () => "audit-id") }));
|
||||
|
||||
const WS = "0d9a1b40-6f0f-4b1e-8f0e-3f3c9e6b4a10";
|
||||
const ID = "11111111-2222-4333-8444-555555555555";
|
||||
const ACTOR = { userId: "8f7f6e50-7b0f-4a2e-9d3e-2f2d1a0c5b21", role: "Editor" };
|
||||
|
||||
const row = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: ID,
|
||||
name: "Climáximo",
|
||||
platform: "bluesky",
|
||||
handle: "climaximopt.bsky.social",
|
||||
profile_url: "https://bsky.app/profile/climaximopt.bsky.social",
|
||||
category: "clima",
|
||||
language: "pt",
|
||||
message: "Olá!",
|
||||
notes: "",
|
||||
status: "todo",
|
||||
sent_at: null,
|
||||
replied_at: null,
|
||||
created_at: new Date("2026-10-15T10:00:00Z"),
|
||||
updated_at: new Date("2026-10-15T10:00:00Z"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type Query = (sql: string, values?: unknown[]) => Promise<{ rows: unknown[]; rowCount?: number }>;
|
||||
|
||||
function tenant(query: Query): Array<{ sql: string; values: unknown[] }> {
|
||||
const calls: Array<{ sql: string; values: unknown[] }> = [];
|
||||
dbMock.withTenant.mockImplementation(async (_ws: string, fn: (client: PoolClient) => Promise<unknown>) =>
|
||||
fn({
|
||||
query: async (sql: string, values: unknown[] = []) => {
|
||||
calls.push({ sql, values });
|
||||
return query(sql, values);
|
||||
},
|
||||
} as unknown as PoolClient),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.withTenant.mockReset();
|
||||
vi.mocked(appendTenantAudit).mockClear();
|
||||
});
|
||||
|
||||
describe("parseContact", () => {
|
||||
const valid = { name: " DAL ", platform: "Instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" };
|
||||
|
||||
it("trims and lowercases the platform id", () => {
|
||||
expect(parseContact(valid, "c")).toEqual({ name: "DAL", platform: "instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" });
|
||||
});
|
||||
|
||||
it("rejects non-https profile links and odd platform ids", () => {
|
||||
expect(() => parseContact({ ...valid, profileUrl: "http://example.org/x" }, "c")).toThrow(/https/);
|
||||
expect(() => parseContact({ ...valid, profileUrl: "javascript:alert(1)" }, "c")).toThrow(/https/);
|
||||
expect(() => parseContact({ ...valid, platform: "x y" }, "c")).toThrow(/platform/);
|
||||
expect(() => parseContact({ ...valid, language: "spanish" }, "c")).toThrow(/language/);
|
||||
expect(() => parseContact({ ...valid, name: " " }, "c")).toThrow(/name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePatch", () => {
|
||||
it("accepts the known statuses only and refuses an empty patch", () => {
|
||||
expect(parsePatch({ status: "sent" })).toEqual({ status: "sent" });
|
||||
expect(() => parsePatch({ status: "spam" })).toThrow(/status/);
|
||||
expect(() => parsePatch({})).toThrow(/nothing/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OutreachService", () => {
|
||||
const service = new OutreachService();
|
||||
|
||||
it("imports in one statement, skips known accounts and audits counts only", async () => {
|
||||
const calls = tenant(async () => ({ rows: [{ id: ID }], rowCount: 1 }));
|
||||
const result = await service.import(WS, [
|
||||
{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a", message: "private draft" },
|
||||
{ name: "B", platform: "bluesky", handle: "b", profileUrl: "https://bsky.app/profile/b" },
|
||||
], ACTOR);
|
||||
expect(result).toEqual({ added: 1, skipped: 1 });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.sql).toContain("ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING");
|
||||
expect(JSON.parse(String(calls[0]?.values[2]))).toHaveLength(2);
|
||||
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.imported", diff: { added: 1, skipped: 1 } }));
|
||||
expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("private draft");
|
||||
});
|
||||
|
||||
it("does not audit an import that added nothing", async () => {
|
||||
tenant(async () => ({ rows: [], rowCount: 0 }));
|
||||
expect(await service.import(WS, [{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a" }], ACTOR)).toEqual({ added: 0, skipped: 1 });
|
||||
expect(appendTenantAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a contact sent and audits the transition without the message", async () => {
|
||||
const calls = tenant(async (sql) => {
|
||||
if (sql.startsWith("SELECT status")) return { rows: [{ status: "todo" }] };
|
||||
if (sql.startsWith("UPDATE outreach_contacts")) return { rows: [row({ status: "sent", message: "Olá, secret text", sent_at: new Date("2026-10-15T11:00:00Z") })] };
|
||||
throw new Error(`unexpected query: ${sql}`);
|
||||
});
|
||||
const contact = await service.update(WS, ID, { status: "sent", message: "Olá, secret text" }, ACTOR);
|
||||
expect(contact.status).toBe("sent");
|
||||
expect(contact.sentAt).toBe("2026-10-15T11:00:00.000Z");
|
||||
expect(calls[1]?.values).toEqual([ID, "Olá, secret text", null, "sent", true, ACTOR.userId]);
|
||||
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.sent", diff: expect.objectContaining({ from: "todo", to: "sent" }) }));
|
||||
expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("secret text");
|
||||
});
|
||||
|
||||
it("edits the draft without a status change and without an audit entry", async () => {
|
||||
const calls = tenant(async (sql) => {
|
||||
if (sql.startsWith("SELECT status")) return { rows: [{ status: "sent" }] };
|
||||
return { rows: [row({ status: "sent", message: "v2" })] };
|
||||
});
|
||||
await service.update(WS, ID, { message: "v2" }, ACTOR);
|
||||
expect(calls[1]?.values).toEqual([ID, "v2", null, "sent", false, ACTOR.userId]);
|
||||
expect(appendTenantAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s on an unknown contact", async () => {
|
||||
tenant(async () => ({ rows: [] }));
|
||||
await expect(service.update(WS, ID, { status: "sent" }, ACTOR)).rejects.toThrow(/not found/);
|
||||
await expect(service.remove(WS, ID, ACTOR)).rejects.toThrow(/not found/);
|
||||
expect(appendTenantAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
||||
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
|
||||
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
|
@ -129,6 +131,7 @@ function connectorFor(provider: string): PlatformConnector {
|
|||
};
|
||||
registry.register(createMastodonConnector(options));
|
||||
registry.register(createTelegramConnector(options));
|
||||
registry.register(createInstagramConnector(options));
|
||||
return registry;
|
||||
})();
|
||||
return publishingConnectors.get(provider);
|
||||
|
|
@ -654,7 +657,7 @@ export class PublishingService {
|
|||
async function assertMediaAssetsExist(client: PoolClient, mediaAssetIds: string[]): Promise<void> {
|
||||
if (mediaAssetIds.length === 0) return;
|
||||
const result = await client.query<{ count: string | number }>(
|
||||
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[])",
|
||||
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[]) AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid",
|
||||
[mediaAssetIds],
|
||||
);
|
||||
if (Number(result.rows[0]?.count ?? 0) !== mediaAssetIds.length) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface FakeVideoDatabase {
|
|||
transitions?: string[];
|
||||
/** Raw INSERT statements seen, for asserting what intake wrote. */
|
||||
statements?: Array<{ sql: string; values: readonly unknown[] }>;
|
||||
/** Image storage keys this workspace holds (logo overlay ownership). */
|
||||
ownedImageKeys?: string[];
|
||||
}
|
||||
|
||||
function result(rows: Array<Record<string, unknown>>): QueryResult<Record<string, unknown>> {
|
||||
|
|
@ -88,6 +90,9 @@ export function serviceWithDatabase(
|
|||
if (sql === "SELECT * FROM video_assets WHERE id = $1") {
|
||||
return result(database.asset === undefined ? [] : [database.asset]);
|
||||
}
|
||||
if (sql.includes("branding->>'logoAssetKey'")) {
|
||||
return result(database.ownedImageKeys?.includes(String(values[0])) === true ? [{}] : []);
|
||||
}
|
||||
if (sql.includes("SET state = 'probing'")) {
|
||||
if (database.job?.state !== "queued") return result([]);
|
||||
database.job.state = "probing";
|
||||
|
|
|
|||
|
|
@ -148,6 +148,19 @@ describe("video byte ingest and processing-job state machine", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("accepts a logo overlay only when the image belongs to the workspace", async () => {
|
||||
const owned = `sha256/${"a".repeat(64)}.png`;
|
||||
const foreign = `sha256/${"b".repeat(64)}.png`;
|
||||
const database: FakeVideoDatabase = { ownedImageKeys: [owned] };
|
||||
const { service } = serviceWithDatabase(database, async () => ({}));
|
||||
await service.uploadVideoBytes(workspaceId, Buffer.from("source"), "video/mp4");
|
||||
const logo = (key: string): ProduceRequest => ({ overlays: [{ kind: "logo", logoStorageKey: key, position: { x: 90, y: 5 } }] });
|
||||
await expect(service.produceVideo(assetId, workspaceId, logo(foreign), "u")).rejects.toMatchObject({ status: 400 });
|
||||
expect(database.statements?.some((s) => s.sql.includes("INSERT INTO video_processing_jobs") && String(s.values[3]).includes(foreign))).toBe(false);
|
||||
const produced = await service.produceVideo(assetId, workspaceId, logo(owned), "u");
|
||||
expect(produced.job.state).toBe("queued");
|
||||
});
|
||||
|
||||
it("refuses to produce from an unknown asset", async () => {
|
||||
const { service } = serviceWithDatabase({}, async () => ({}));
|
||||
await expect(service.produceVideo("30000000-0000-4000-8000-000000000005", workspaceId, {}, "u")).rejects.toMatchObject({ status: 404 });
|
||||
|
|
|
|||
|
|
@ -381,6 +381,7 @@ export class VideoService {
|
|||
const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
|
||||
const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]);
|
||||
if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
|
||||
await assertLogoKeysOwned(client, workspaceId, request);
|
||||
const jobResult = await client.query(
|
||||
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json)
|
||||
VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`,
|
||||
|
|
@ -595,3 +596,25 @@ function decodeCursor(cursor: string): VideoCursor {
|
|||
function isUuid(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage keys are content-addressed in one shared bucket, so a well-formed key
|
||||
* alone proves nothing about who owns the object. A logo overlay may only use
|
||||
* an image this workspace holds: an attachment, a group asset or its branding.
|
||||
*/
|
||||
async function assertLogoKeysOwned(
|
||||
client: { query: (sql: string, values: unknown[]) => Promise<{ rowCount: number | null }> },
|
||||
workspaceId: string,
|
||||
request: ProduceRequest,
|
||||
): Promise<void> {
|
||||
const keys = [...new Set((request.overlays ?? []).flatMap((overlay) => (overlay.logoStorageKey === undefined ? [] : [overlay.logoStorageKey])))];
|
||||
for (const key of keys) {
|
||||
const owned = await client.query(
|
||||
`SELECT 1 WHERE EXISTS (SELECT 1 FROM media_assets WHERE storage_key = $1 AND workspace_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM group_assets WHERE asset_id = $1 AND workspace_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM workspaces WHERE id = $2 AND branding->>'logoAssetKey' = $1)`,
|
||||
[key, workspaceId],
|
||||
);
|
||||
if (owned.rowCount !== 1) throw new BadRequestException("logoStorageKey is not an image of this collective");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:manual": "node --require ./scripts/control-room-register.cjs --test src/lib/participation-manual.test.ts",
|
||||
"test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts"
|
||||
"test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts",
|
||||
"test:outreach": "node --require ./scripts/control-room-register.cjs --test src/lib/outreach-types.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tactical-media-broadcast-hub/connector-sdk": "workspace:^",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 1700 1700">
|
||||
<g transform="rotate(-17.5 850 850)" opacity="0.25">
|
||||
<circle cx="850" cy="850" r="350" fill="none" stroke="#FF1610" stroke-width="60" stroke-linecap="round"/>
|
||||
<circle cx="850" cy="850" r="350" fill="none" stroke="#000000" stroke-width="60" stroke-linecap="round"/>
|
||||
<path d="M 850 405 L 464.77 1269.52 M 850 405 L 1235.23 1269.52 M 345 850 L 1355 850"
|
||||
fill="none" stroke="#FF1610" stroke-width="75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
fill="none" stroke="#000000" stroke-width="75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<polygon points="1450,850 1550,810 1550,890" fill="#FF1610"/>
|
||||
<line x1="1280" y1="850" x2="1470" y2="850" stroke="#FF1610" stroke-width="30" stroke-linecap="round"/>
|
||||
<polygon points="1450,850 1550,810 1550,890" fill="#000000"/>
|
||||
<line x1="1280" y1="850" x2="1470" y2="850" stroke="#000000" stroke-width="30" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 665 B After Width: | Height: | Size: 665 B |
|
|
@ -1,7 +1,44 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<circle cx="850" cy="850" r="350" fill="none" stroke="#FF1610" stroke-width="74" stroke-linecap="round"/>
|
||||
<path d="M 850 405 L 464.77 1269.52 M 850 405 L 1235.23 1269.52 M 345 850 L 1355 850"
|
||||
fill="none" stroke="#FF1610" stroke-width="88" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 400" width="400" height="400" role="img" aria-labelledby="t">
|
||||
<title id="t">T-HUB</title>
|
||||
<defs>
|
||||
<!-- Dome tent in the hand-inked style of the acampada26s.net mark:
|
||||
curved poles with ball ends, centre seam, hatched panel, door.
|
||||
Base midpoint at 0,0. -->
|
||||
<g id="tienda" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" stroke-width="7"/>
|
||||
<path fill="none" d="M-64 1 Q0 -3 64 1" stroke-width="6"/>
|
||||
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" stroke-width="4.5"/>
|
||||
<path fill="none" d="M44 0 Q30 -58 -1 -72" stroke-width="4.5"/>
|
||||
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" stroke-width="4"/>
|
||||
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" stroke-width="2.6"/>
|
||||
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" stroke-width="2.6"/>
|
||||
<circle cx="-64" cy="1" r="5.5" stroke="none"/>
|
||||
<circle cx="64" cy="1" r="5.5" stroke="none"/>
|
||||
<circle cx="-1" cy="-73" r="5.5" stroke="none"/>
|
||||
</g>
|
||||
<g id="marca" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- cables: out of each tent door to the switch -->
|
||||
<path fill="none" d="M105 150 Q112 206 168 214" stroke-width="5"/>
|
||||
<path fill="none" d="M295 150 Q288 206 232 214" stroke-width="5"/>
|
||||
<path fill="none" d="M200 318 Q197 280 200 236" stroke-width="5"/>
|
||||
<!-- antenna -->
|
||||
<path fill="none" d="M200 200 Q198 160 200 118" stroke-width="6"/>
|
||||
<path fill="none" d="M184 98 Q200 82 216 98" stroke-width="5"/>
|
||||
<path fill="none" d="M171 88 Q200 58 229 88" stroke-width="5"/>
|
||||
<circle cx="200" cy="112" r="7" stroke="none"/>
|
||||
<!-- switch -->
|
||||
<path d="M168 201 L233 200 L234 235 L167 236 Z" stroke-width="6" fill="#fff"/>
|
||||
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none"/>
|
||||
<use xlink:href="#tienda" transform="translate(105,150)"/>
|
||||
<use xlink:href="#tienda" transform="translate(295,150)"/>
|
||||
<use xlink:href="#tienda" transform="translate(200,340)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<!-- neon offset underneath, ink on top, as on acampada26s.net -->
|
||||
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff">
|
||||
<use xlink:href="#marca"/>
|
||||
</g>
|
||||
</svg>
|
||||
<g stroke="#000" fill="#000">
|
||||
<use xlink:href="#marca"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 447 B After Width: | Height: | Size: 2.4 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Bluesky</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 350 450 Q 400 350 500 400 L 600 480 L 700 400 Q 800 350 850 450 Q 850 550 750 650 L 600 780 L 450 650 Q 350 550 350 450 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 350 450 Q 400 350 500 400 L 600 480 L 700 400 Q 800 350 850 450 Q 850 550 750 650 L 600 780 L 450 650 Q 350 550 350 450 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 483 B After Width: | Height: | Size: 483 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Discord</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 450 450 Q 550 400 600 400 Q 650 400 750 450 L 800 500 Q 850 600 800 700 L 750 750 L 700 700 L 650 720 L 550 720 L 500 700 L 450 750 L 400 700 Q 350 600 400 500 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/><circle cx="520" cy="570" r="18" fill="#FF1610"/><circle cx="680" cy="570" r="18" fill="#FF1610"/>
|
||||
<path d="M 450 450 Q 550 400 600 400 Q 650 400 750 450 L 800 500 Q 850 600 800 700 L 750 750 L 700 700 L 650 720 L 550 720 L 500 700 L 450 750 L 400 700 Q 350 600 400 500 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linejoin="round"/><circle cx="520" cy="570" r="18" fill="#00E5FF"/><circle cx="680" cy="570" r="18" fill="#00E5FF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 598 B After Width: | Height: | Size: 598 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Facebook</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 500 450 L 500 550 L 450 550 L 450 650 L 500 650 L 500 850 L 600 850 L 600 650 L 680 650 L 700 550 L 600 550 L 600 480 Q 600 450 630 450 L 700 450 L 700 380 L 620 380 Q 500 380 500 450 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 500 450 L 500 550 L 450 550 L 450 650 L 500 650 L 500 850 L 600 850 L 600 650 L 680 650 L 700 550 L 600 550 L 600 480 Q 600 450 630 450 L 700 450 L 700 380 L 620 380 Q 500 380 500 450 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 548 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Instagram</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<rect x="350" y="350" width="500" height="500" rx="120" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="600" cy="600" r="130" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="770" cy="430" r="25" fill="#FF1610"/>
|
||||
<rect x="350" y="350" width="500" height="500" rx="120" fill="none" stroke="#00E5FF" stroke-width="35"/><circle cx="600" cy="600" r="130" fill="none" stroke="#00E5FF" stroke-width="35"/><circle cx="770" cy="430" r="25" fill="#00E5FF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 491 B After Width: | Height: | Size: 491 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Linkedin</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<rect x="350" y="350" width="500" height="100" rx="20" fill="none" stroke="#FF1610" stroke-width="35"/><line x1="400" y1="500" x2="400" y2="750" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="500" y1="500" x2="500" y2="750" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><path d="M 500 500 Q 550 450 600 500 L 600 600 Q 650 550 700 600 Q 750 650 700 700 Q 650 750 500 750" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="350" y="350" width="500" height="100" rx="20" fill="none" stroke="#00E5FF" stroke-width="35"/><line x1="400" y1="500" x2="400" y2="750" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/><line x1="500" y1="500" x2="500" y2="750" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/><path d="M 500 500 Q 550 450 600 500 L 600 600 Q 650 550 700 600 Q 750 650 700 700 Q 650 750 500 750" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 757 B After Width: | Height: | Size: 757 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Mastodon</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 400 500 L 400 700 Q 400 780 480 780 L 520 780 Q 560 780 560 740 L 560 560 Q 560 540 580 540 Q 600 540 600 560 L 600 740 Q 600 780 640 780 L 680 780 Q 760 780 760 700 L 760 500 Q 760 420 680 420 L 520 420 Q 440 420 400 500 Z M 400 500 L 360 460 Q 320 420 320 500 L 320 660 Q 320 740 380 760" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 400 500 L 400 700 Q 400 780 480 780 L 520 780 Q 560 780 560 740 L 560 560 Q 560 540 580 540 Q 600 540 600 560 L 600 740 Q 600 780 640 780 L 680 780 Q 760 780 760 700 L 760 500 Q 760 420 680 420 L 520 420 Q 440 420 400 500 Z M 400 500 L 360 460 Q 320 420 320 500 L 320 660 Q 320 740 380 760" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 652 B After Width: | Height: | Size: 652 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Peertube</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 400 350 L 400 850 L 800 600 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/>
|
||||
<path d="M 400 350 L 400 850 L 800 600 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 369 B After Width: | Height: | Size: 369 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Reddit</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<circle cx="600" cy="500" r="50" fill="#FF1610"/><path d="M 550 500 L 400 450" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/><path d="M 650 500 L 800 450" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/><ellipse cx="600" cy="650" rx="200" ry="140" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="500" cy="620" r="20" fill="#FF1610"/><circle cx="700" cy="620" r="20" fill="#FF1610"/><path d="M 520 700 Q 600 740 680 700" fill="none" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/>
|
||||
<circle cx="600" cy="500" r="50" fill="#00E5FF"/><path d="M 550 500 L 400 450" stroke="#00E5FF" stroke-width="25" stroke-linecap="round"/><path d="M 650 500 L 800 450" stroke="#00E5FF" stroke-width="25" stroke-linecap="round"/><ellipse cx="600" cy="650" rx="200" ry="140" fill="none" stroke="#00E5FF" stroke-width="35"/><circle cx="500" cy="620" r="20" fill="#00E5FF"/><circle cx="700" cy="620" r="20" fill="#00E5FF"/><path d="M 520 700 Q 600 740 680 700" fill="none" stroke="#00E5FF" stroke-width="25" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 780 B After Width: | Height: | Size: 780 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Slack</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<rect x="450" y="350" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="670" y="350" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="350" y="530" width="140" height="80" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="710" y="530" width="140" height="80" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="450" y="700" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="670" y="700" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><path d="M 530 570 L 670 570" stroke="#FF1610" stroke-width="30" stroke-linecap="round"/>
|
||||
<rect x="450" y="350" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="670" y="350" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="350" y="530" width="140" height="80" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="710" y="530" width="140" height="80" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="450" y="700" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="670" y="700" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><path d="M 530 570 L 670 570" stroke="#00E5FF" stroke-width="30" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 953 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Telegram</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 300 600 L 850 400 L 700 800 L 550 650 L 300 600 Z M 550 650 L 550 750 L 620 680" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 300 600 L 850 400 L 700 800 L 550 650 L 300 600 Z M 550 650 L 550 750 L 620 680" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 442 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Threads</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 600 350 Q 450 350 400 500 Q 350 650 400 750 Q 450 850 600 850 Q 750 850 800 750 Q 850 650 800 500 Q 750 350 600 350 Z M 550 550 Q 600 500 650 550 Q 700 600 650 650 Q 600 700 550 650 Q 500 600 550 550 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 600 350 Q 450 350 400 500 Q 350 650 400 750 Q 450 850 600 850 Q 750 850 800 750 Q 850 650 800 500 Q 750 350 600 350 Z M 550 550 Q 600 500 650 550 Q 700 600 650 650 Q 600 700 550 650 Q 500 600 550 550 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 563 B After Width: | Height: | Size: 563 B |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Twitch</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 400 350 L 800 350 L 800 600 L 650 750 L 500 750 L 400 850 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/><line x1="550" y1="450" x2="550" y2="550" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="670" y1="450" x2="670" y2="550" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
|
||||
<path d="M 400 350 L 800 350 L 800 600 L 650 750 L 500 750 L 400 850 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linejoin="round"/><line x1="550" y1="450" x2="550" y2="550" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/><line x1="670" y1="450" x2="670" y2="550" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 599 B After Width: | Height: | Size: 599 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Analytics</title>
|
||||
<line x1="350" y1="850" x2="850" y2="850" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/><line x1="420" y1="700" x2="420" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="600" y1="500" x2="600" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="780" y1="400" x2="780" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
|
||||
<line x1="350" y1="850" x2="850" y2="850" stroke="#000000" stroke-width="40" stroke-linecap="round"/><line x1="420" y1="700" x2="420" y2="850" stroke="#000000" stroke-width="35" stroke-linecap="round"/><line x1="600" y1="500" x2="600" y2="850" stroke="#000000" stroke-width="35" stroke-linecap="round"/><line x1="780" y1="400" x2="780" y2="850" stroke="#000000" stroke-width="35" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 532 B After Width: | Height: | Size: 532 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Approvals</title>
|
||||
<path d="M 240 640 L 500 880 L 960 320" fill="none" stroke="#FF1610" stroke-width="90" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 240 640 L 500 880 L 960 320" fill="none" stroke="#000000" stroke-width="90" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 264 B After Width: | Height: | Size: 264 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Audit</title>
|
||||
<path d="M 500 350 L 700 350 L 700 500 L 800 500 L 600 850 L 400 500 L 500 500 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/>
|
||||
<path d="M 500 350 L 700 350 L 700 500 L 800 500 L 600 850 L 400 500 L 500 500 Z" fill="none" stroke="#000000" stroke-width="35" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 278 B After Width: | Height: | Size: 278 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Calendar</title>
|
||||
<rect x="350" y="400" width="500" height="450" rx="30" fill="none" stroke="#FF1610" stroke-width="40"/><line x1="350" y1="520" x2="850" y2="520" stroke="#FF1610" stroke-width="30"/><line x1="450" y1="400" x2="450" y2="350" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="750" y1="400" x2="750" y2="350" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><circle cx="500" cy="620" r="20" fill="#FF1610"/><circle cx="600" cy="620" r="20" fill="#FF1610"/><circle cx="700" cy="620" r="20" fill="#FF1610"/>
|
||||
<rect x="350" y="400" width="500" height="450" rx="30" fill="none" stroke="#000000" stroke-width="40"/><line x1="350" y1="520" x2="850" y2="520" stroke="#000000" stroke-width="30"/><line x1="450" y1="400" x2="450" y2="350" stroke="#000000" stroke-width="35" stroke-linecap="round"/><line x1="750" y1="400" x2="750" y2="350" stroke="#000000" stroke-width="35" stroke-linecap="round"/><circle cx="500" cy="620" r="20" fill="#000000"/><circle cx="600" cy="620" r="20" fill="#000000"/><circle cx="700" cy="620" r="20" fill="#000000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 657 B After Width: | Height: | Size: 657 B |
|
|
@ -1,7 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Chat</title>
|
||||
<path d="M 220 300 A 80 80 0 0 1 300 220 L 900 220 A 80 80 0 0 1 980 300 L 980 660 A 80 80 0 0 1 900 740 L 520 740 L 340 920 L 340 740 L 300 740 A 80 80 0 0 1 220 660 Z" fill="none" stroke="#FF1610" stroke-width="55" stroke-linejoin="round"/>
|
||||
<circle cx="450" cy="480" r="45" fill="#FF1610"/>
|
||||
<circle cx="600" cy="480" r="45" fill="#FF1610"/>
|
||||
<circle cx="750" cy="480" r="45" fill="#FF1610"/>
|
||||
<path d="M 220 300 A 80 80 0 0 1 300 220 L 900 220 A 80 80 0 0 1 980 300 L 980 660 A 80 80 0 0 1 900 740 L 520 740 L 340 920 L 340 740 L 300 740 A 80 80 0 0 1 220 660 Z" fill="none" stroke="#000000" stroke-width="55" stroke-linejoin="round"/>
|
||||
<circle cx="450" cy="480" r="45" fill="#000000"/>
|
||||
<circle cx="600" cy="480" r="45" fill="#000000"/>
|
||||
<circle cx="750" cy="480" r="45" fill="#000000"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 522 B After Width: | Height: | Size: 522 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Compose</title>
|
||||
<path d="M 400 800 L 350 850 L 400 850 L 850 400 L 800 350 L 350 800" fill="none" stroke="#FF1610" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/><line x1="750" y1="400" x2="800" y2="450" stroke="#FF1610" stroke-width="30"/>
|
||||
<path d="M 400 800 L 350 850 L 400 850 L 850 400 L 800 350 L 350 800" fill="none" stroke="#000000" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/><line x1="750" y1="400" x2="800" y2="450" stroke="#000000" stroke-width="30"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 369 B After Width: | Height: | Size: 369 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Connections</title>
|
||||
<circle cx="450" cy="500" r="80" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="750" cy="700" r="80" fill="none" stroke="#FF1610" stroke-width="35"/><line x1="500" y1="550" x2="700" y2="650" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
|
||||
<circle cx="450" cy="500" r="80" fill="none" stroke="#000000" stroke-width="35"/><circle cx="750" cy="700" r="80" fill="none" stroke="#000000" stroke-width="35"/><line x1="500" y1="550" x2="700" y2="650" stroke="#000000" stroke-width="35" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 393 B After Width: | Height: | Size: 393 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Files</title>
|
||||
<path d="M 220 380 L 470 380 L 560 470 L 980 470 L 980 860 A 60 60 0 0 1 920 920 L 280 920 A 60 60 0 0 1 220 860 Z" fill="none" stroke="#FF1610" stroke-width="60" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M 220 380 L 470 380 L 560 470 L 980 470 L 980 860 A 60 60 0 0 1 920 920 L 280 920 A 60 60 0 0 1 220 860 Z" fill="none" stroke="#000000" stroke-width="60" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 336 B After Width: | Height: | Size: 336 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Home</title>
|
||||
<path d="M 600 350 L 850 550 L 850 850 L 350 850 L 350 550 Z" fill="none" stroke="#FF1610" stroke-width="40" stroke-linejoin="round"/><line x1="600" y1="350" x2="600" y2="850" stroke="#FF1610" stroke-width="30"/>
|
||||
<path d="M 600 350 L 850 550 L 850 850 L 350 850 L 350 550 Z" fill="none" stroke="#000000" stroke-width="40" stroke-linejoin="round"/><line x1="600" y1="350" x2="600" y2="850" stroke="#000000" stroke-width="30"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 335 B After Width: | Height: | Size: 335 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Inbox</title>
|
||||
<path d="M 350 400 L 850 400 L 850 700 L 700 700 L 600 800 L 500 700 L 350 700 Z" fill="none" stroke="#FF1610" stroke-width="40" stroke-linejoin="round"/>
|
||||
<path d="M 350 400 L 850 400 L 850 700 L 700 700 L 600 800 L 500 700 L 350 700 Z" fill="none" stroke="#000000" stroke-width="40" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 278 B After Width: | Height: | Size: 278 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav More</title>
|
||||
<circle cx="500" cy="600" r="30" fill="#FF1610"/><circle cx="600" cy="600" r="30" fill="#FF1610"/><circle cx="700" cy="600" r="30" fill="#FF1610"/>
|
||||
<circle cx="500" cy="600" r="30" fill="#000000"/><circle cx="600" cy="600" r="30" fill="#000000"/><circle cx="700" cy="600" r="30" fill="#000000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 270 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Search</title>
|
||||
<circle cx="550" cy="550" r="200" fill="none" stroke="#FF1610" stroke-width="40"/><line x1="700" y1="700" x2="850" y2="850" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/>
|
||||
<circle cx="550" cy="550" r="200" fill="none" stroke="#000000" stroke-width="40"/><line x1="700" y1="700" x2="850" y2="850" stroke="#000000" stroke-width="40" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 308 B After Width: | Height: | Size: 308 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Settings</title>
|
||||
<circle cx="600" cy="600" r="150" fill="none" stroke="#FF1610" stroke-width="40"/><g stroke="#FF1610" stroke-width="35" stroke-linecap="round"><line x1="600" y1="350" x2="600" y2="420"/><line x1="600" y1="780" x2="600" y2="850"/><line x1="350" y1="600" x2="420" y2="600"/><line x1="780" y1="600" x2="850" y2="600"/><line x1="420" y1="420" x2="470" y2="470"/><line x1="730" y1="730" x2="780" y2="780"/><line x1="420" y1="780" x2="470" y2="730"/><line x1="730" y1="470" x2="780" y2="420"/></g>
|
||||
<circle cx="600" cy="600" r="150" fill="none" stroke="#000000" stroke-width="40"/><g stroke="#000000" stroke-width="35" stroke-linecap="round"><line x1="600" y1="350" x2="600" y2="420"/><line x1="600" y1="780" x2="600" y2="850"/><line x1="350" y1="600" x2="420" y2="600"/><line x1="780" y1="600" x2="850" y2="600"/><line x1="420" y1="420" x2="470" y2="470"/><line x1="730" y1="730" x2="780" y2="780"/><line x1="420" y1="780" x2="470" y2="730"/><line x1="730" y1="470" x2="780" y2="420"/></g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 618 B After Width: | Height: | Size: 618 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Team</title>
|
||||
<circle cx="500" cy="500" r="70" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="700" cy="500" r="70" fill="none" stroke="#FF1610" stroke-width="35"/><path d="M 400 750 Q 400 650 500 650 Q 600 650 600 750" fill="none" stroke="#FF1610" stroke-width="35"/><path d="M 600 750 Q 600 650 700 650 Q 800 650 800 750" fill="none" stroke="#FF1610" stroke-width="35"/>
|
||||
<circle cx="500" cy="500" r="70" fill="none" stroke="#000000" stroke-width="35"/><circle cx="700" cy="500" r="70" fill="none" stroke="#000000" stroke-width="35"/><path d="M 400 750 Q 400 650 500 650 Q 600 650 600 750" fill="none" stroke="#000000" stroke-width="35"/><path d="M 600 750 Q 600 650 700 650 Q 800 650 800 750" fill="none" stroke="#000000" stroke-width="35"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 493 B After Width: | Height: | Size: 493 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Video</title>
|
||||
<rect x="260" y="400" width="680" height="400" rx="70" fill="none" stroke="#FF1610" stroke-width="70"/><path d="M 940 600 L 1120 480 L 1120 720 Z" fill="#FF1610"/><path d="M 360 470 L 360 730" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/><path d="M 480 470 L 480 730" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/>
|
||||
<rect x="260" y="400" width="680" height="400" rx="70" fill="none" stroke="#000000" stroke-width="70"/><path d="M 940 600 L 1120 480 L 1120 720 Z" fill="#000000"/><path d="M 360 470 L 360 730" stroke="#000000" stroke-width="40" stroke-linecap="round"/><path d="M 480 470 L 480 730" stroke="#000000" stroke-width="40" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 466 B After Width: | Height: | Size: 466 B |
|
|
@ -3,8 +3,8 @@
|
|||
<defs>
|
||||
<g id="ca">
|
||||
<g transform="rotate(-17.5 0 0)">
|
||||
<circle cx="0" cy="0" r="45" fill="none" stroke="#FF1610" stroke-width="10"/>
|
||||
<path d="M 0 -38 L -28 38 M 0 -38 L 28 38 M -34 0 L 34 0" fill="none" stroke="#FF1610" stroke-width="12" stroke-linecap="round"/>
|
||||
<circle cx="0" cy="0" r="45" fill="none" stroke="#000000" stroke-width="10"/>
|
||||
<path d="M 0 -38 L -28 38 M 0 -38 L 28 38 M -34 0 L 34 0" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</defs>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,8 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 1700 1700">
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<path d="M 850 500 A 350 350 0 0 1 1200 850" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round"/>
|
||||
<path d="M 1200 850 A 350 350 0 0 1 850 1200" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
<path d="M 850 1200 A 350 350 0 0 1 500 850" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
<path d="M 500 850 A 350 350 0 0 1 850 500" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
<path d="M 850 500 A 350 350 0 0 1 1200 850" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round"/>
|
||||
<path d="M 1200 850 A 350 350 0 0 1 850 1200" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
<path d="M 850 1200 A 350 350 0 0 1 500 850" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
<path d="M 500 850 A 350 350 0 0 1 850 500" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 667 B |
44
apps/web/public/brand/t-hub.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 400" width="400" height="400" role="img" aria-labelledby="t">
|
||||
<title id="t">T-HUB</title>
|
||||
<defs>
|
||||
<!-- Dome tent in the hand-inked style of the acampada26s.net mark:
|
||||
curved poles with ball ends, centre seam, hatched panel, door.
|
||||
Base midpoint at 0,0. -->
|
||||
<g id="tienda" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" stroke-width="7"/>
|
||||
<path fill="none" d="M-64 1 Q0 -3 64 1" stroke-width="6"/>
|
||||
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" stroke-width="4.5"/>
|
||||
<path fill="none" d="M44 0 Q30 -58 -1 -72" stroke-width="4.5"/>
|
||||
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" stroke-width="4"/>
|
||||
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" stroke-width="2.6"/>
|
||||
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" stroke-width="2.6"/>
|
||||
<circle cx="-64" cy="1" r="5.5" stroke="none"/>
|
||||
<circle cx="64" cy="1" r="5.5" stroke="none"/>
|
||||
<circle cx="-1" cy="-73" r="5.5" stroke="none"/>
|
||||
</g>
|
||||
<g id="marca" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- cables: out of each tent door to the switch -->
|
||||
<path fill="none" d="M105 150 Q112 206 168 214" stroke-width="5"/>
|
||||
<path fill="none" d="M295 150 Q288 206 232 214" stroke-width="5"/>
|
||||
<path fill="none" d="M200 318 Q197 280 200 236" stroke-width="5"/>
|
||||
<!-- antenna -->
|
||||
<path fill="none" d="M200 200 Q198 160 200 118" stroke-width="6"/>
|
||||
<path fill="none" d="M184 98 Q200 82 216 98" stroke-width="5"/>
|
||||
<path fill="none" d="M171 88 Q200 58 229 88" stroke-width="5"/>
|
||||
<circle cx="200" cy="112" r="7" stroke="none"/>
|
||||
<!-- switch -->
|
||||
<path d="M168 201 L233 200 L234 235 L167 236 Z" stroke-width="6" fill="#fff"/>
|
||||
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none"/>
|
||||
<use xlink:href="#tienda" transform="translate(105,150)"/>
|
||||
<use xlink:href="#tienda" transform="translate(295,150)"/>
|
||||
<use xlink:href="#tienda" transform="translate(200,340)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<!-- neon offset underneath, ink on top, as on acampada26s.net -->
|
||||
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff">
|
||||
<use xlink:href="#marca"/>
|
||||
</g>
|
||||
<g stroke="#000" fill="#000">
|
||||
<use xlink:href="#marca"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
93
apps/web/public/fonts/Anton-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
apps/web/public/fonts/anton.woff2
Normal file
|
|
@ -13,4 +13,4 @@
|
|||
}
|
||||
.control-room label { max-width: 100%; }
|
||||
.control-room button { min-width: 44px; }
|
||||
.control-room :focus-visible { outline: 2px solid #ff1610; outline-offset: 3px; }
|
||||
.control-room :focus-visible { outline: 2px solid #00e5ff; outline-offset: 3px; }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
|
|||
const titles: Record<string, string> = {
|
||||
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
|
||||
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
|
||||
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat",
|
||||
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat", outreach: "Outreach",
|
||||
};
|
||||
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
|
||||
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
|
||||
|
|
@ -49,7 +49,7 @@ function Shell({ children }: { children: ReactNode }): React.ReactElement {
|
|||
scrollPositions.set(pathname, { main: main.scrollTop, nested: scrollableChildren(main).map((element) => element.scrollTop) });
|
||||
};
|
||||
}, [pathname, mobile]);
|
||||
const title = titles[pathname.split("/")[1] ?? ""] ?? "Tactical Media Broadcast Hub";
|
||||
const title = titles[pathname.split("/")[1] ?? ""] ?? "T-HUB";
|
||||
return (
|
||||
<div className="flex h-dvh min-w-0 overflow-hidden lg:h-screen">
|
||||
<Sidebar />
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default function MorePage(): React.ReactElement {
|
|||
<section className="flex items-center gap-4 px-5 py-5">
|
||||
<div className="scale-125"><Avatar handle={session?.user.displayName || session?.user.username || "AP"} /></div>
|
||||
<div className="min-w-0 flex-1 pl-2">
|
||||
<h1 className="truncate text-xl font-bold">{session?.user.displayName || session?.user.username || "Tactical Media Broadcast Hub"}</h1>
|
||||
<h1 className="truncate text-xl font-bold">{session?.user.displayName || session?.user.username || "T-HUB"}</h1>
|
||||
<p className="truncate text-xs text-text-faint">@{session?.user.username || "offline"}{session?.user.email ? ` · ${session.user.email}` : ""}</p>
|
||||
<div className="mt-2 flex items-center gap-2"><span className="rounded-full bg-accent px-2.5 py-1 text-[11px] font-bold uppercase text-white">{session?.role || "Preview"}</span><span className="truncate text-xs text-text-dim">{session?.workspace.name || "Studio"}</span></div>
|
||||
</div>
|
||||
|
|
@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {
|
|||
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Settings</h2>
|
||||
<div className="border-y border-border-subtle bg-surface">
|
||||
<Row href="/connections" title="Connections" detail="Accounts and health" />
|
||||
<Row href="/outreach" title="Outreach" detail="Contacts to write to by hand" />
|
||||
<Row href="/settings" title="Settings" />
|
||||
<Row href="/settings" title="Security & sessions" />
|
||||
<Row href="/calendar" title="Calendar" />
|
||||
|
|
|
|||
|
|
@ -221,15 +221,15 @@ export default function OnboardingPage(): React.ReactElement {
|
|||
<div className="mb-5">
|
||||
<div className="-m-6 mb-5 overflow-hidden bg-black">
|
||||
<div className="flex items-center gap-5 px-8 py-8">
|
||||
<LogoMark size={96} className="shrink-0 text-accent" />
|
||||
<LogoMark size={96} className="shrink-0 text-white" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xl font-semibold leading-tight text-white">Horizontal Collaborative Broadcast System</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-wider text-text-faint">Tactical Media Broadcast Hub</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-wider text-[#00e5ff]">T-HUB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Tactical Media Broadcast Hub</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">T-HUB</h1>
|
||||
<p className="mt-1 text-2xs text-text-faint">Your platform. Their feeds. No algorithm in between.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
325
apps/web/src/app/(main)/outreach/page.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Button, Card } from "../../../components/ui/primitives";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import {
|
||||
OUTREACH_DAILY_SOFT_CAP,
|
||||
OUTREACH_STATUSES,
|
||||
fillTemplate,
|
||||
parseImport,
|
||||
sentToday,
|
||||
type OutreachContact,
|
||||
type OutreachStatus,
|
||||
} from "../../../lib/outreach-types";
|
||||
|
||||
/**
|
||||
* Outreach — write to artists, collectives and public figures one by one.
|
||||
*
|
||||
* The list lives in GET /outreach/contacts. For each contact the screen keeps
|
||||
* a drafted message; "Copy & open" puts it on the clipboard and opens the
|
||||
* profile so a person sends it from the platform's own DM box, then marks the
|
||||
* contact sent. Nothing is sent by the Hub: unsolicited bulk DMs get accounts
|
||||
* suspended, and a personal message is what gets an answer.
|
||||
*/
|
||||
|
||||
const SAMPLE: OutreachContact[] = [
|
||||
{
|
||||
id: "sample-1", name: "Sample collective", platform: "example", handle: "@sample@example.social",
|
||||
profileUrl: "https://example.social/@sample", category: "housing", language: "en", message: "", notes: "",
|
||||
status: "todo", sentAt: null, repliedAt: null, createdAt: "2026-10-15T00:00:00Z", updatedAt: "2026-10-15T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const identity = (contacts: OutreachContact[]): OutreachContact[] => contacts;
|
||||
|
||||
const STATUS_TONE: Record<OutreachStatus, "neutral" | "accent" | "live" | "danger"> = {
|
||||
todo: "neutral", sent: "accent", replied: "live", declined: "danger",
|
||||
};
|
||||
|
||||
const TEXTAREA = "w-full rounded-md border border-border bg-surface-2 p-2 text-sm focus:border-accent focus:outline-none";
|
||||
|
||||
export default function OutreachPage(): React.ReactElement {
|
||||
const { can } = useAuth();
|
||||
const contacts = useApiResource<OutreachContact[], OutreachContact[]>("/outreach/contacts", identity, SAMPLE);
|
||||
const live = contacts.source === "live";
|
||||
const canManage = live && can("outreach.manage");
|
||||
const [filter, setFilter] = useState<OutreachStatus | "all">("todo");
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const categories = useMemo(
|
||||
() => [...new Set(contacts.data.map((contact) => contact.category).filter((value): value is string => value !== null))].sort(),
|
||||
[contacts.data],
|
||||
);
|
||||
const visible = contacts.data.filter(
|
||||
(contact) => (filter === "all" || contact.status === filter) && (category === "all" || contact.category === category),
|
||||
);
|
||||
const counts = Object.fromEntries(
|
||||
OUTREACH_STATUSES.map((status) => [status, contacts.data.filter((contact) => contact.status === status).length]),
|
||||
) as Record<OutreachStatus, number>;
|
||||
const today = sentToday(contacts.data);
|
||||
|
||||
function replace(updated: OutreachContact): void {
|
||||
contacts.setData((current) => current.map((contact) => (contact.id === updated.id ? updated : contact)));
|
||||
}
|
||||
|
||||
async function patch(id: string, body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>): Promise<void> {
|
||||
try {
|
||||
replace(await api.patch<OutreachContact>(`/outreach/contacts/${id}`, body));
|
||||
} catch (err) {
|
||||
setNotice(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAndOpen(contact: OutreachContact): Promise<void> {
|
||||
if (contact.message.trim() !== "") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(contact.message);
|
||||
setNotice(`Message for ${contact.name} copied. Paste it in their DMs, then mark it sent.`);
|
||||
} catch {
|
||||
setNotice("Could not copy to the clipboard. Select the text and copy it by hand.");
|
||||
}
|
||||
}
|
||||
window.open(contact.profileUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async function remove(contact: OutreachContact): Promise<void> {
|
||||
if (!window.confirm(`Remove ${contact.name} from the list?`)) return;
|
||||
try {
|
||||
await api.del(`/outreach/contacts/${contact.id}`);
|
||||
contacts.setData((current) => current.filter((row) => row.id !== contact.id));
|
||||
} catch (err) {
|
||||
setNotice(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTemplate(template: string): Promise<void> {
|
||||
const targets = visible.filter((contact) => contact.status === "todo" && contact.message.trim() === "");
|
||||
if (targets.length === 0) {
|
||||
setNotice("Every pending contact in this view already has a message.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
for (const contact of targets) await patch(contact.id, { message: fillTemplate(template, contact) });
|
||||
setBusy(false);
|
||||
setNotice(`Template applied to ${targets.length} contacts. Review each one before sending.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Outreach</h1>
|
||||
<OfflineBadge source={contacts.source} />
|
||||
{!live && <Badge tone="warn">read-only sample</Badge>}
|
||||
</div>
|
||||
<p className="mb-4 max-w-2xl text-xs leading-relaxed text-text-faint">
|
||||
Public accounts to write to by hand. Draft a message per contact, copy it, open the profile and send it from
|
||||
the platform. Mark who answered so nobody writes twice.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{(["all", ...OUTREACH_STATUSES] as const).map((status) => (
|
||||
<Button key={status} size="sm" variant={filter === status ? "outline" : "ghost"} onClick={() => setFilter(status)}>
|
||||
{status} {status === "all" ? contacts.data.length : counts[status]}
|
||||
</Button>
|
||||
))}
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-2xs"
|
||||
aria-label="Category"
|
||||
>
|
||||
<option value="all">all categories</option>
|
||||
{categories.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span className="ml-auto text-2xs text-text-faint">
|
||||
Sent today: <span className={today >= OUTREACH_DAILY_SOFT_CAP ? "text-warn" : "text-text-dim"}>{today}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{today >= OUTREACH_DAILY_SOFT_CAP && (
|
||||
<Card className="mb-4 p-3">
|
||||
<p className="text-xs text-warn">
|
||||
{today} first messages today from this collective. Spread the rest over the next days: platforms
|
||||
restrict accounts that send many first messages in a short time.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(notice !== null || contacts.error !== null) && (
|
||||
<Card className="mb-4 flex items-start justify-between gap-3 p-3">
|
||||
<p className={`text-xs ${contacts.error !== null ? "text-danger" : "text-text-dim"}`}>{contacts.error ?? notice}</p>
|
||||
{notice !== null && <Button size="sm" onClick={() => setNotice(null)}>Dismiss</Button>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canManage && <Tools busy={busy} onImported={contacts.reload} onTemplate={applyTemplate} onNotice={setNotice} />}
|
||||
|
||||
{contacts.source === "loading" ? (
|
||||
<div className="flex justify-center py-10"><Loading size={28} label="Loading contacts" /></div>
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState title="No contacts here" hint={canManage ? "Import a list above or change the filter." : "Change the filter."} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{visible.map((contact) => (
|
||||
<ContactRow
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
open={openId === contact.id}
|
||||
canManage={canManage}
|
||||
onToggle={() => setOpenId(openId === contact.id ? null : contact.id)}
|
||||
onPatch={(body) => patch(contact.id, body)}
|
||||
onCopyOpen={() => copyAndOpen(contact)}
|
||||
onRemove={() => remove(contact)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactRow({ contact, open, canManage, onToggle, onPatch, onCopyOpen, onRemove }: {
|
||||
contact: OutreachContact;
|
||||
open: boolean;
|
||||
canManage: boolean;
|
||||
onToggle: () => void;
|
||||
onPatch: (body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>) => Promise<void>;
|
||||
onCopyOpen: () => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const [message, setMessage] = useState(contact.message);
|
||||
const [notes, setNotes] = useState(contact.notes);
|
||||
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<button type="button" onClick={onToggle} className="flex w-full flex-wrap items-center gap-2 text-left">
|
||||
<span className="text-sm font-medium">{contact.name}</span>
|
||||
<span className="text-2xs text-text-faint">{contact.platform} · {contact.handle}</span>
|
||||
{contact.category !== null && <Badge>{contact.category}</Badge>}
|
||||
{contact.language !== null && <Badge>{contact.language}</Badge>}
|
||||
<span className="ml-auto"><Badge tone={STATUS_TONE[contact.status]}>{contact.status}</Badge></span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
onBlur={() => { if (canManage && message !== contact.message) void onPatch({ message }); }}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
readOnly={!canManage}
|
||||
placeholder="Message for this contact"
|
||||
className={TEXTAREA}
|
||||
/>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
onBlur={() => { if (canManage && notes !== contact.notes) void onPatch({ notes }); }}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
readOnly={!canManage}
|
||||
placeholder="Notes (who wrote, what they said)"
|
||||
className={TEXTAREA}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={() => void onCopyOpen()}>Copy & open profile</Button>
|
||||
{canManage && contact.status !== "sent" && <Button variant="outline" size="sm" onClick={() => void onPatch({ status: "sent" })}>Mark sent</Button>}
|
||||
{canManage && contact.status !== "replied" && <Button size="sm" onClick={() => void onPatch({ status: "replied" })}>Replied</Button>}
|
||||
{canManage && contact.status !== "declined" && <Button size="sm" onClick={() => void onPatch({ status: "declined" })}>Declined</Button>}
|
||||
{canManage && contact.status !== "todo" && <Button size="sm" onClick={() => void onPatch({ status: "todo" })}>Back to pending</Button>}
|
||||
{canManage && <Button variant="danger" size="sm" className="ml-auto" onClick={() => void onRemove()}>Remove</Button>}
|
||||
</div>
|
||||
{contact.sentAt !== null && (
|
||||
<p className="text-2xs text-text-faint">
|
||||
Sent {new Date(contact.sentAt).toLocaleString()}
|
||||
{contact.repliedAt !== null && ` · replied ${new Date(contact.repliedAt).toLocaleString()}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Tools({ busy, onImported, onTemplate, onNotice }: {
|
||||
busy: boolean;
|
||||
onImported: () => void;
|
||||
onTemplate: (template: string) => Promise<void>;
|
||||
onNotice: (message: string) => void;
|
||||
}): React.ReactElement {
|
||||
const [csv, setCsv] = useState("");
|
||||
const [template, setTemplate] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const { rows, errors } = parseImport(csv);
|
||||
if (errors.length > 0) {
|
||||
onNotice(errors.slice(0, 3).join("; "));
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (let start = 0; start < rows.length; start += 500) {
|
||||
const result = await api.post<{ added: number; skipped: number }>("/outreach/contacts", { contacts: rows.slice(start, start + 500) });
|
||||
added += result.added;
|
||||
skipped += result.skipped;
|
||||
}
|
||||
onNotice(`${added} contacts added, ${skipped} already on the list.`);
|
||||
setCsv("");
|
||||
onImported();
|
||||
} catch (err) {
|
||||
onNotice(errorMessage(err));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 grid gap-3 lg:grid-cols-2">
|
||||
<Card className="p-3">
|
||||
<h2 className="mb-1 text-xs font-semibold">Import</h2>
|
||||
<p className="mb-2 text-2xs text-text-faint">One per line: name, platform, handle, profileUrl, category, language</p>
|
||||
<textarea
|
||||
value={csv}
|
||||
onChange={(event) => setCsv(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="Name,platform,handle,https://profile-url,category,es"
|
||||
className={`${TEXTAREA} font-mono text-2xs`}
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="mt-2" disabled={importing || csv.trim() === ""} onClick={() => void submit()}>
|
||||
{importing ? "Importing…" : "Import"}
|
||||
</Button>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<h2 className="mb-1 text-xs font-semibold">Template</h2>
|
||||
<p className="mb-2 text-2xs text-text-faint">
|
||||
Fills pending contacts in the current view that have no message yet. Use {"{name}"} and {"{handle}"}.
|
||||
</p>
|
||||
<textarea
|
||||
value={template}
|
||||
onChange={(event) => setTemplate(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="Hola {name}, os escribimos desde la Acampada 26S…"
|
||||
className={TEXTAREA}
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="mt-2" disabled={busy || template.trim() === ""} onClick={() => void onTemplate(template)}>
|
||||
{busy ? "Applying…" : "Apply to pending"}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +1,36 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Anton (SIL OFL 1.1, public/fonts/Anton-OFL.txt) — same title face as acampada26s.net. */
|
||||
@font-face {
|
||||
font-family: "Anton";
|
||||
src: url("/fonts/anton.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-bg: oklch(0.11 0.008 25);
|
||||
--color-surface: oklch(0.15 0.01 25);
|
||||
--color-surface-2: oklch(0.19 0.012 25);
|
||||
--color-border: oklch(0.26 0.02 25);
|
||||
--color-border-subtle: oklch(0.22 0.015 25);
|
||||
--color-text: oklch(0.93 0.005 25);
|
||||
--color-text-dim: oklch(0.62 0.02 25);
|
||||
--color-text-faint: oklch(0.46 0.02 25);
|
||||
--color-accent: #ff1610;
|
||||
--color-accent-hover: #e3130e;
|
||||
--color-accent-dim: rgb(255 22 16 / 0.15);
|
||||
--color-live: oklch(0.72 0.19 145);
|
||||
--color-warn: oklch(0.78 0.16 85);
|
||||
--color-danger: oklch(0.55 0.24 15);
|
||||
--color-added: oklch(0.72 0.16 145);
|
||||
--color-removed: oklch(0.55 0.24 15);
|
||||
/* T-HUB: the acampada26s.net palette. White page, black ink and borders,
|
||||
neon #00e5ff for hover, focus and the offset shadows. */
|
||||
--color-bg: #fff;
|
||||
--color-surface: #fff;
|
||||
--color-surface-2: #f2f2f2;
|
||||
--color-border: #000;
|
||||
--color-border-subtle: #d4d4d4;
|
||||
--color-text: #000;
|
||||
--color-text-dim: #333;
|
||||
--color-text-faint: #666;
|
||||
--color-accent: #000;
|
||||
--color-accent-hover: #222;
|
||||
--color-accent-dim: rgb(0 229 255 / 0.28);
|
||||
--color-neon: #00e5ff;
|
||||
--color-live: #007a3d;
|
||||
--color-warn: #8a5a00;
|
||||
--color-danger: #c4001a;
|
||||
--color-added: #007a3d;
|
||||
--color-removed: #c4001a;
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--font-title: "Anton", Impact, "Arial Narrow", sans-serif;
|
||||
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
|
|
@ -42,7 +53,7 @@
|
|||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -55,6 +66,30 @@ body {
|
|||
|
||||
html, body { max-width: 100%; }
|
||||
|
||||
/* Titles as on acampada26s.net: Anton, uppercase, hard neon offset. */
|
||||
h1, h2 {
|
||||
font-family: var(--font-title);
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.05;
|
||||
text-shadow: 0.01em 0.01em 0 var(--color-neon), 0.02em 0.02em 0 var(--color-neon), 0.03em 0.03em 0 var(--color-neon), 0.04em 0.04em 0 var(--color-neon);
|
||||
}
|
||||
|
||||
/* Buttons as on acampada26s.net: hard offset shadow, neon on hover. */
|
||||
button.bg-accent, a.bg-accent {
|
||||
box-shadow: 0 3px 0 var(--color-neon);
|
||||
}
|
||||
button.bg-accent:hover, a.bg-accent:hover, button.bg-accent:focus-visible, a.bg-accent:focus-visible {
|
||||
box-shadow: 0 5px 0 var(--color-neon);
|
||||
}
|
||||
:focus-visible {
|
||||
outline-color: var(--color-neon);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
body { overflow-x: hidden; }
|
||||
[data-mobile-main] :is(button, a[href], input, select, textarea, [role="tab"]) {
|
||||
|
|
|
|||
|
|
@ -324,11 +324,11 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
<Card className="w-[520px] max-w-full p-6">
|
||||
<div className="mb-5 text-center">
|
||||
<img
|
||||
src="/brand/hero.png"
|
||||
alt="Tactical Media Broadcast Hub — arrows from a circle, warm red on black"
|
||||
width={1024}
|
||||
height={1024}
|
||||
className="mx-auto mb-3 h-20 w-20 rounded-lg object-cover"
|
||||
src="/brand/t-hub.svg"
|
||||
alt="T-HUB: three tents wired to a switch with an antenna"
|
||||
width={400}
|
||||
height={400}
|
||||
className="mx-auto mb-3 h-20 w-20"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
|
||||
<p className="mt-1 text-2xs text-text-faint">
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import type { Metadata, Viewport } from "next";
|
|||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tactical Media Broadcast Hub",
|
||||
description: "Tactical Media Broadcast Hub — post, schedule and amplify across every network you refuse to be owned by.",
|
||||
title: "T-HUB",
|
||||
description: "T-HUB — post, schedule and amplify across every network you refuse to be owned by. Based on Tactical Media Broadcast Hub.",
|
||||
icons: { icon: "/brand/favicon.svg" },
|
||||
};
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ export const viewport: Viewport = {
|
|||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
interactiveWidget: "resizes-content",
|
||||
themeColor: "#FF1610",
|
||||
themeColor: "#ffffff",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactNode {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function LoginPage(): React.ReactElement {
|
|||
<Card className="w-[420px] max-w-full p-6">
|
||||
<div className="mb-5 text-center">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="mt-1 text-2xs text-text-faint">Tactical Media Broadcast Hub</p>
|
||||
<p className="mt-1 text-2xs text-text-faint">T-HUB</p>
|
||||
</div>
|
||||
<form onSubmit={(e) => void submit(e)}>
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ function ManualLanding(): React.ReactElement {
|
|||
<header className="sticky top-0 z-10 border-b border-border-subtle bg-surface">
|
||||
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
|
||||
<Logo size={26} withText={false} />
|
||||
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
|
||||
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">T-HUB</span>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label={t({ es: "Idioma", en: "Language" })}>
|
||||
{(["es", "en"] as const).map((code) => (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -119,10 +119,10 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
|
|||
|
||||
if (phase === "checking") {
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-[#060403] px-6">
|
||||
<main className="flex min-h-dvh items-center justify-center bg-white px-6">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loading size={32} label="Opening the box…" />
|
||||
<span className="text-2xs text-[#a4a09e]">Opening the box…</span>
|
||||
<span className="text-2xs text-[#555]">Opening the box…</span>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
@ -130,12 +130,12 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
|
|||
|
||||
if (phase === "unreachable") {
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
|
||||
<main className="flex min-h-dvh items-center justify-center bg-white p-6">
|
||||
<DeadCard
|
||||
tone="warn"
|
||||
title="Can't reach the server"
|
||||
body="Your link is still fine — the server isn't answering. Try again in a moment."
|
||||
action={<button type="button" onClick={() => setAttempt((n) => n + 1)} className="mt-6 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#e3130e]">Retry</button>}
|
||||
action={<button type="button" onClick={() => setAttempt((n) => n + 1)} className="mt-6 rounded-md bg-[#00E5FF] px-3 py-1.5 text-xs font-medium text-black hover:bg-[#5cefff]">Retry</button>}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
|
@ -143,7 +143,7 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
|
|||
|
||||
if (phase === "gone" || box === null) {
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
|
||||
<main className="flex min-h-dvh items-center justify-center bg-white p-6">
|
||||
<DeadCard
|
||||
tone="danger"
|
||||
title="This box isn't open"
|
||||
|
|
@ -154,7 +154,7 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-dvh bg-[#060403] px-4 py-10 sm:px-6">
|
||||
<main className="min-h-dvh bg-white px-4 py-10 sm:px-6">
|
||||
<SubmitForm key={token} token={token} box={box} />
|
||||
</main>
|
||||
);
|
||||
|
|
@ -162,16 +162,16 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
|
|||
|
||||
function DeadCard({ tone, title, body, action }: { tone: "warn" | "danger"; title: string; body: string; action?: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 text-center">
|
||||
<div className="w-full max-w-md rounded-lg border border-[#d4d4d4] bg-[#f2f2f2] p-6 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full text-lg ${
|
||||
tone === "warn" ? "bg-[#FF1610]/15 text-[#FF1610]" : "bg-[#FF1610]/15 text-[#FF1610]"
|
||||
tone === "warn" ? "bg-[#00E5FF]/15 text-[#00798a]" : "bg-[#00E5FF]/15 text-[#00798a]"
|
||||
}`}
|
||||
>
|
||||
{tone === "warn" ? "!" : "✕"}
|
||||
</div>
|
||||
<h1 className="text-base font-semibold text-[#f5efed]">{title}</h1>
|
||||
<p className="mt-2 text-xs leading-relaxed text-[#a4a09e]">{body}</p>
|
||||
<h1 className="text-base font-semibold text-black">{title}</h1>
|
||||
<p className="mt-2 text-xs leading-relaxed text-[#555]">{body}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -282,13 +282,13 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
|
|||
|
||||
if (phase === "sent" && done !== null) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-8 text-center">
|
||||
<LogoMark size={44} className="text-[#FF1610]" title="" />
|
||||
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Received</h1>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-[#c7c3c1]">
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 rounded-lg border border-[#d4d4d4] bg-[#f2f2f2] p-8 text-center">
|
||||
<LogoMark size={44} className="text-black" title="" />
|
||||
<h1 className="text-xl font-semibold tracking-tight text-black">Received</h1>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-[#333]">
|
||||
Received — the collective will review it. Nothing is published until they approve it.
|
||||
</p>
|
||||
<p className="max-w-sm text-2xs leading-relaxed text-[#a4a09e]">
|
||||
<p className="max-w-sm text-2xs leading-relaxed text-[#555]">
|
||||
Your footage stays under {box.licenseDefault}: it may be shared and remixed with attribution.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -301,13 +301,13 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
|
|||
return (
|
||||
<form
|
||||
onSubmit={(event) => void submit(event)}
|
||||
className="mx-auto flex w-full max-w-xl flex-col gap-6 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 sm:p-8"
|
||||
className="mx-auto flex w-full max-w-xl flex-col gap-6 rounded-lg border border-[#d4d4d4] bg-[#f2f2f2] p-6 sm:p-8"
|
||||
>
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
<LogoMark size={36} className="text-[#FF1610]" title="Tactical Media Broadcast Hub" />
|
||||
<LogoMark size={36} className="text-black" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Send footage to the collective</h1>
|
||||
<p className="mt-1 text-xs leading-relaxed text-[#a4a09e]">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-black">Send footage to the collective</h1>
|
||||
<p className="mt-1 text-xs leading-relaxed text-[#555]">
|
||||
No account needed. mp4 or webm video up to 200 MiB, jpeg or png photo up to 50 MiB.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -339,33 +339,33 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
|
|||
}}
|
||||
className={`flex min-h-[180px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors duration-150 ${
|
||||
uploading
|
||||
? "cursor-not-allowed border-[#3a312e] opacity-60"
|
||||
: "cursor-pointer border-[#3a312e] hover:border-[#FF1610] focus-visible:border-[#FF1610]"
|
||||
? "cursor-not-allowed border-black opacity-60"
|
||||
: "cursor-pointer border-black hover:border-[#00E5FF] focus-visible:border-[#00E5FF]"
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs text-[#c7c3c1]">
|
||||
<span className="inline-flex items-center gap-2 text-xs text-[#333]">
|
||||
<Loading size={14} label="Uploading" />
|
||||
{progress < 100 ? `Uploading ${progress}%` : "Upload sent — the collective's server is taking it in…"}{" "}
|
||||
{file !== null && `${file.name} (${formatBytes(file.size)})`}
|
||||
</span>
|
||||
<progress className="h-2 w-full accent-[#FF1610]" aria-label="Upload progress" value={progress} max={100} />
|
||||
<progress className="h-2 w-full accent-[#00E5FF]" aria-label="Upload progress" value={progress} max={100} />
|
||||
</div>
|
||||
) : file !== null && kind !== null ? (
|
||||
<>
|
||||
<span className="text-sm font-medium text-[#f5efed]">{file.name}</span>
|
||||
<span className="text-2xs text-[#a4a09e]">
|
||||
<span className="text-sm font-medium text-black">{file.name}</span>
|
||||
<span className="text-2xs text-[#555]">
|
||||
{kind === "video" ? "video" : "photo"} · {formatBytes(file.size)} · click or drop to choose another
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-[#c7c3c1]">Drop your video or photo here</span>
|
||||
<span className="text-2xs leading-relaxed text-[#a4a09e]">
|
||||
<span className="text-sm text-[#333]">Drop your video or photo here</span>
|
||||
<span className="text-2xs leading-relaxed text-[#555]">
|
||||
or browse your device, or paste a file. Nothing is sent until you press Send.
|
||||
</span>
|
||||
<span className="mt-2 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white">Browse files</span>
|
||||
<span className="mt-2 rounded-md bg-[#00E5FF] px-3 py-1.5 text-xs font-medium text-black">Browse files</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
|
|
@ -381,24 +381,24 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
|
|||
/>
|
||||
</div>
|
||||
|
||||
{fileError !== null && <p role="alert" className="text-xs text-[#ff6f6b]">{fileError}</p>}
|
||||
{error !== null && <p role="alert" className="rounded-md border border-[#FF1610]/30 bg-[#FF1610]/10 px-3 py-2 text-xs text-[#ff9b97]">{error}</p>}
|
||||
{fileError !== null && <p role="alert" className="text-xs text-[#c4001a]">{fileError}</p>}
|
||||
{error !== null && <p role="alert" className="rounded-md border border-[#00E5FF]/30 bg-[#00E5FF]/10 px-3 py-2 text-xs text-[#c4001a]">{error}</p>}
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-[#3a312e] bg-[#060403] p-3">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-black bg-white p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={licence}
|
||||
onChange={(event) => setLicence(event.target.checked)}
|
||||
disabled={uploading}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-[#FF1610]"
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-[#00E5FF]"
|
||||
/>
|
||||
<span className="text-xs leading-relaxed text-[#c7c3c1]">
|
||||
{SUBMISSION_LICENSE_TEXT} <span className="text-[#FF1610]">(required)</span>
|
||||
<span className="text-xs leading-relaxed text-[#333]">
|
||||
{SUBMISSION_LICENSE_TEXT} <span className="text-[#00798a]">(required)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block text-2xs text-[#a4a09e]">
|
||||
<label className="block text-2xs text-[#555]">
|
||||
Credit name (optional, max {SUBMISSION_CREDIT_MAX})
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -407,12 +407,12 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
|
|||
onChange={(event) => setCreditName(event.target.value)}
|
||||
disabled={uploading}
|
||||
placeholder="How your name should appear in the credit"
|
||||
className="mt-1 w-full rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
|
||||
className="mt-1 w-full rounded-md border border-black bg-white px-3 py-2 text-sm text-black placeholder:text-[#888] focus:border-[#00E5FF] focus:outline-none"
|
||||
/>
|
||||
{creditTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CREDIT_MAX} characters.</span>}
|
||||
{creditTooLong && <span className="mt-1 block text-[#c4001a]">Up to {SUBMISSION_CREDIT_MAX} characters.</span>}
|
||||
</label>
|
||||
|
||||
<label className="block text-2xs text-[#a4a09e]">
|
||||
<label className="block text-2xs text-[#555]">
|
||||
Contact (optional, max {SUBMISSION_CONTACT_MAX})
|
||||
<textarea
|
||||
value={contact}
|
||||
|
|
@ -421,20 +421,20 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
|
|||
disabled={uploading}
|
||||
rows={2}
|
||||
placeholder="How licensors can reach you — shown only to admins"
|
||||
className="mt-1 w-full resize-none rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
|
||||
className="mt-1 w-full resize-none rounded-md border border-black bg-white px-3 py-2 text-sm text-black placeholder:text-[#888] focus:border-[#00E5FF] focus:outline-none"
|
||||
/>
|
||||
{contactTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CONTACT_MAX} characters.</span>}
|
||||
{contactTooLong && <span className="mt-1 block text-[#c4001a]">Up to {SUBMISSION_CONTACT_MAX} characters.</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready || uploading}
|
||||
className="w-full rounded-md bg-[#FF1610] px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#e3130e] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="w-full rounded-md bg-[#00E5FF] px-4 py-2.5 text-sm font-semibold text-black transition-colors duration-150 hover:bg-[#5cefff] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{uploading ? `Sending… ${progress}%` : "Send to the collective"}
|
||||
</button>
|
||||
<p className="text-center text-2xs leading-relaxed text-[#a4a09e]">
|
||||
<p className="text-center text-2xs leading-relaxed text-[#555]">
|
||||
The collective reviews every submission before anything is used. Your contact details stay with their admins.
|
||||
</p>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,62 @@
|
|||
import type { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* The Tactical Media Broadcast Hub mark, inline.
|
||||
*
|
||||
* Source of truth: `public/brand/tactical-media-broadcast-hub.svg` — the arrows-from-circle
|
||||
* mark, tilted −17.5° counterclockwise so the top arrow points upper-left
|
||||
* (nothing static; everything points outward). The strokes use `currentColor`
|
||||
* so the mark always rides the workspace accent, which in the warm palette
|
||||
* lands within a hair of the brand red (#FF1610 ≈ oklch(0.63 0.24 29) vs the
|
||||
* token's oklch(0.62 0.22 30)). Geometry is copied 1:1 from the asset on its
|
||||
* 1700×1700 grid, minus the black plate and the screen-print distress paths
|
||||
* (those only read on pure black at full size).
|
||||
* The T-HUB mark, inline: three dome tents in a circle, each with a cable
|
||||
* out of its door into a switch with an antenna. Drawn in the hand-inked
|
||||
* style of the acampada26s.net mark, ink in `currentColor` over a neon
|
||||
* offset copy. Source: docs/brand/t-hub.svg (same 400×400 geometry).
|
||||
*/
|
||||
|
||||
const TENTS: ReadonlyArray<readonly [number, number]> = [[105, 150], [295, 150], [200, 340]];
|
||||
|
||||
function Tent({ x, y }: { x: number; y: number }): ReactElement {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" strokeWidth="7" />
|
||||
<path fill="none" d="M-64 1 Q0 -3 64 1" strokeWidth="6" />
|
||||
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" strokeWidth="4.5" />
|
||||
<path fill="none" d="M44 0 Q30 -58 -1 -72" strokeWidth="4.5" />
|
||||
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" strokeWidth="4" />
|
||||
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" strokeWidth="2.6" />
|
||||
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" strokeWidth="2.6" />
|
||||
<circle cx="-64" cy="1" r="5.5" stroke="none" />
|
||||
<circle cx="64" cy="1" r="5.5" stroke="none" />
|
||||
<circle cx="-1" cy="-73" r="5.5" stroke="none" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Mark({ paper }: { paper: string }): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<path fill="none" d="M105 150 Q112 206 168 214" strokeWidth="5" />
|
||||
<path fill="none" d="M295 150 Q288 206 232 214" strokeWidth="5" />
|
||||
<path fill="none" d="M200 318 Q197 280 200 236" strokeWidth="5" />
|
||||
<path fill="none" d="M200 200 Q198 160 200 118" strokeWidth="6" />
|
||||
<path fill="none" d="M184 98 Q200 82 216 98" strokeWidth="5" />
|
||||
<path fill="none" d="M171 88 Q200 58 229 88" strokeWidth="5" />
|
||||
<circle cx="200" cy="112" r="7" stroke="none" />
|
||||
<path d="M168 201 L233 200 L234 235 L167 236 Z" strokeWidth="6" fill={paper} />
|
||||
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none" />
|
||||
{TENTS.map(([x, y]) => <Tent key={`${x}-${y}`} x={x} y={y} />)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoMark({
|
||||
size = 32,
|
||||
className = "text-accent",
|
||||
title = "Tactical Media Broadcast Hub",
|
||||
className = "text-text",
|
||||
title = "T-HUB",
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1700 1700"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={className}
|
||||
>
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="850" cy="850" r="350" strokeWidth="74" />
|
||||
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 850 350" strokeWidth="88" />
|
||||
<path d="M 345 850 L 1355 850" strokeWidth="88" />
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<polygon points="850,240 925,385 775,385" />
|
||||
<polygon points="235,850 380,775 380,925" />
|
||||
<polygon points="1465,850 1320,925 1320,775" />
|
||||
<polygon points="420,1370 410.51,1207.03 547.52,1268.08" />
|
||||
<polygon points="1280,1370 1152.48,1268.08 1289.49,1207.03" />
|
||||
</g>
|
||||
<svg width={size} height={size} viewBox="0 0 400 400" role="img" aria-label={title} className={className}>
|
||||
<g strokeLinecap="round" strokeLinejoin="round">
|
||||
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff"><Mark paper="#fff" /></g>
|
||||
<g stroke="currentColor" fill="currentColor"><Mark paper="#fff" /></g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -112,10 +123,14 @@ export function Logo({ size = 32, withText = true }: { size?: number; withText?:
|
|||
<span className={`inline-flex ${stacked ? "flex-col items-center gap-3" : "items-center gap-2.5"}`}>
|
||||
<LogoMark size={size} />
|
||||
<span
|
||||
className={`font-semibold tracking-tight text-text ${stacked ? "text-2xl" : ""}`}
|
||||
style={stacked ? undefined : { fontSize: `${Math.max(13, Math.round(size * 0.45))}px` }}
|
||||
className={`uppercase tracking-wide text-text ${stacked ? "text-4xl" : ""}`}
|
||||
style={{
|
||||
fontFamily: "var(--font-title)",
|
||||
textShadow: "0.04em 0.04em 0 var(--color-neon)",
|
||||
...(stacked ? {} : { fontSize: `${Math.max(16, Math.round(size * 0.6))}px` }),
|
||||
}}
|
||||
>
|
||||
Tactical Media Broadcast Hub
|
||||
T-HUB
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function CommandPalette(): React.ReactElement {
|
|||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 border-b border-border-subtle px-4">
|
||||
<LogoMark size={16} title="Tactical Media Broadcast Hub" />
|
||||
<LogoMark size={16} />
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Type a command or search…"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const NAV: { href: string; label: string; icon: string; external?: boolean }[] =
|
|||
{ href: "/files", label: "Files", icon: "files" },
|
||||
{ href: "/archive", label: "Archive", icon: "files" },
|
||||
{ href: "/inbox", label: "Inbox", icon: "inbox" },
|
||||
{ href: "/outreach", label: "Outreach", icon: "team" },
|
||||
{ href: "/approvals", label: "Approvals", icon: "approvals" },
|
||||
{ href: "/video", label: "Video", icon: "video" },
|
||||
{ href: "/analytics", label: "Analytics", icon: "analytics" },
|
||||
|
|
@ -54,7 +55,7 @@ export function Sidebar(): React.ReactElement {
|
|||
className="mt-0.5 h-[30px] w-[30px] shrink-0 rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<LogoMark size={30} title="Tactical Media Broadcast Hub" />
|
||||
<LogoMark size={30} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold leading-tight tracking-tight" title={workspaceName}>
|
||||
|
|
@ -66,7 +67,7 @@ export function Sidebar(): React.ReactElement {
|
|||
</div>
|
||||
) : (
|
||||
<div className="truncate text-2xs text-text-faint" title={workspaceName}>
|
||||
Tactical Media Broadcast Hub
|
||||
T-HUB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
30
apps/web/src/lib/outreach-types.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { fillTemplate, parseImport, sentToday, type OutreachContact } from "./outreach-types";
|
||||
|
||||
test("parseImport reads quoted fields, skips header, blanks and comments", () => {
|
||||
const { rows, errors } = parseImport([
|
||||
"name,platform,handle,profileUrl,category,language",
|
||||
"# afines",
|
||||
"",
|
||||
'"Stop the War, UK",Web,@STWuk,https://example.org/stw,paz,en',
|
||||
].join("\n"));
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(rows, [{ name: "Stop the War, UK", platform: "web", handle: "@STWuk", profileUrl: "https://example.org/stw", category: "paz", language: "en" }]);
|
||||
});
|
||||
|
||||
test("parseImport reports missing columns and non-https links by line", () => {
|
||||
const { rows, errors } = parseImport("A,web,@a\nB,web,@b,http://example.org/b");
|
||||
assert.deepEqual(rows, []);
|
||||
assert.deepEqual(errors, ["line 1: needs name, platform, handle and profileUrl", "line 2: profileUrl must start with https://"]);
|
||||
});
|
||||
|
||||
test("fillTemplate replaces every placeholder", () => {
|
||||
assert.equal(fillTemplate("Hola {name} ({handle}), {name}", { name: "DAL", handle: "@dal" }), "Hola DAL (@dal), DAL");
|
||||
});
|
||||
|
||||
test("sentToday counts first messages sent on the same local day", () => {
|
||||
const now = new Date(2026, 9, 15, 18);
|
||||
const at = (d: Date | null) => ({ sentAt: d === null ? null : d.toISOString() }) as OutreachContact;
|
||||
assert.equal(sentToday([at(new Date(2026, 9, 15, 9)), at(new Date(2026, 9, 14, 23)), at(null)], now), 1);
|
||||
});
|
||||
93
apps/web/src/lib/outreach-types.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/** Mirrors apps/api/src/outreach/outreach.service.ts (OutreachContact). */
|
||||
|
||||
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
|
||||
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
|
||||
|
||||
export interface OutreachContact {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
handle: string;
|
||||
profileUrl: string;
|
||||
category: string | null;
|
||||
language: string | null;
|
||||
message: string;
|
||||
notes: string;
|
||||
status: OutreachStatus;
|
||||
sentAt: string | null;
|
||||
repliedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OutreachImportRow {
|
||||
name: string;
|
||||
platform: string;
|
||||
handle: string;
|
||||
profileUrl: string;
|
||||
category?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/** First messages per day above which platforms start flagging an account. */
|
||||
export const OUTREACH_DAILY_SOFT_CAP = 20;
|
||||
|
||||
export const IMPORT_COLUMNS = ["name", "platform", "handle", "profileUrl", "category", "language"] as const;
|
||||
|
||||
/**
|
||||
* Parse pasted CSV (one contact per line, IMPORT_COLUMNS order, optional
|
||||
* header). Double quotes wrap fields that contain commas. Blank lines and
|
||||
* lines starting with # are skipped. Returns rows plus per-line errors.
|
||||
*/
|
||||
export function parseImport(text: string): { rows: OutreachImportRow[]; errors: string[] } {
|
||||
const rows: OutreachImportRow[] = [];
|
||||
const errors: string[] = [];
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) return;
|
||||
const cells = splitCsvLine(trimmed);
|
||||
if (index === 0 && cells[0]?.toLowerCase() === "name") return;
|
||||
const [name, platform, handle, profileUrl, category, language] = cells;
|
||||
if (!name || !platform || !handle || !profileUrl) {
|
||||
errors.push(`line ${index + 1}: needs name, platform, handle and profileUrl`);
|
||||
return;
|
||||
}
|
||||
if (!profileUrl.startsWith("https://")) {
|
||||
errors.push(`line ${index + 1}: profileUrl must start with https://`);
|
||||
return;
|
||||
}
|
||||
const row: OutreachImportRow = { name, platform: platform.toLowerCase(), handle, profileUrl };
|
||||
if (category) row.category = category;
|
||||
if (language) row.language = language;
|
||||
rows.push(row);
|
||||
});
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
function splitCsvLine(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const char = line[i];
|
||||
if (quoted) {
|
||||
if (char === '"' && line[i + 1] === '"') { current += '"'; i += 1; }
|
||||
else if (char === '"') quoted = false;
|
||||
else current += char;
|
||||
} else if (char === '"') quoted = true;
|
||||
else if (char === ",") { cells.push(current.trim()); current = ""; }
|
||||
else current += char;
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** Fill a template: {name} and {handle} are replaced per contact. */
|
||||
export function fillTemplate(template: string, contact: Pick<OutreachContact, "name" | "handle">): string {
|
||||
return template.replaceAll("{name}", contact.name).replaceAll("{handle}", contact.handle);
|
||||
}
|
||||
|
||||
export function sentToday(contacts: OutreachContact[], now = new Date()): number {
|
||||
const day = now.toDateString();
|
||||
return contacts.filter((contact) => contact.sentAt !== null && new Date(contact.sentAt).toDateString() === day).length;
|
||||
}
|
||||
205
docs/T-HUB-VALIDACION.txt
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
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/<id> con sesión del colectivo B -> 404.
|
||||
|
||||
|
||||
3b. PUBLICAR CON IMÁGENES (scheduler)
|
||||
-------------------------------------
|
||||
Problema
|
||||
Desde 71db0a0/a6843e9 un borrador aprobado puede llevar mediaAssetIds y
|
||||
altTexts, pero el registro del scheduler
|
||||
(packages/connector-runtime/src/registry.ts) creaba los conectores sin
|
||||
loadAsset/openAsset: Mastodon y Telegram devolvían rejected_permanent
|
||||
INVALID_POST y el post no salía. Los altTexts no llegaban al conector.
|
||||
|
||||
Qué cambia
|
||||
- packages/connector-runtime/src/media.ts: loadAsset lee media_assets
|
||||
dentro del colectivo de la ejecución (app.workspace_id, RLS forzado);
|
||||
openAsset descarga el objeto, abre el marco SPENC1 con S3_KEK y
|
||||
comprueba el hash del contenido. Solo lectura. Los bytes van
|
||||
worker -> red; nunca pasan por la API.
|
||||
- preparePostForDispatch convierte altTexts[i] en
|
||||
providerMetadata.altText[mediaAssetIds[i]] (lo que lee Mastodon).
|
||||
- Instagram registrado también en el scheduler: rechaza con error
|
||||
explícito en vez de "proveedor desconocido". Sigue sin publicar fotos
|
||||
(necesita URL pública; decisión pendiente).
|
||||
|
||||
Tests
|
||||
cd packages/connector-runtime && npx vitest run esperado: 33 pass
|
||||
cd apps/scheduler && npx vitest run esperado: 96 pass
|
||||
|
||||
Prueba manual (entorno de pruebas)
|
||||
1. Compose: post con 2 imágenes y texto alternativo a Mastodon.
|
||||
2. Aprobar y programar a 1 minuto.
|
||||
3. El post sale con las 2 imágenes y su descripción en la instancia.
|
||||
4. Con una imagen de otro colectivo forzada en mediaAssetIds -> falla
|
||||
(MEDIA_ASSET_NOT_FOUND), no se publica nada.
|
||||
|
||||
|
||||
3c. PRODUCTOR DE VÍDEO
|
||||
----------------------
|
||||
- Quitado MEDIA_DEBUG_STDERR=1 del compose de producción (escribía la
|
||||
salida de ffmpeg sin censurar en /tmp).
|
||||
- El logo superpuesto solo acepta imágenes del propio colectivo
|
||||
(adjuntos, recursos de grupo o la marca). Antes bastaba una clave con
|
||||
formato válido.
|
||||
cd apps/api && npx vitest run src/video esperado: 38 pass
|
||||
|
||||
|
||||
4. ESTILO Y LOGO
|
||||
----------------
|
||||
- apps/web/src/app/globals.css: paleta de acampada26s.net. Fondo
|
||||
blanco, texto y bordes negros, botones negros con sombra neón
|
||||
desplazada, foco y elemento activo en neón #00e5ff. Títulos h1/h2 en
|
||||
Anton en mayúsculas con la sombra neón escalonada de la web.
|
||||
- Iconos de navegación en negro; iconos de redes con trazo neón.
|
||||
- Logo: tres tiendas iglú cableadas a un switch con antena, en el
|
||||
estilo a tinta del logo de la acampada (docs/brand/t-hub.svg;
|
||||
t-hub-lockup.svg/png con el nombre en Anton convertido a trazos).
|
||||
Componente LogoMark sustituido; nombre visible "T-HUB".
|
||||
- Fuente Anton en apps/web/public/fonts (licencia SIL OFL incluida).
|
||||
- Página pública de buzón (/submit/[token]) en claro.
|
||||
Revisado con capturas (Chromium sin interfaz, modo offline con datos de
|
||||
ejemplo): Streams, Compose, Outreach, Channels, Approvals, Onboarding,
|
||||
/submit en escritorio y móvil. Falta revisar Control Room y Calendar con
|
||||
datos reales.
|
||||
|
||||
|
||||
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.
|
||||
BIN
docs/brand/t-hub-lockup.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
41
docs/brand/t-hub-lockup.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/brand/t-hub-tiendas-blanco.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
10
docs/brand/t-hub-tiendas-blanco.svg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/brand/t-hub-tiendas-matrix-sinlluvia.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
10
docs/brand/t-hub-tiendas-matrix-sinlluvia.svg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/brand/t-hub-tiendas-matrix.png
Normal file
|
After Width: | Height: | Size: 377 KiB |
10
docs/brand/t-hub-tiendas-matrix.svg
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/brand/t-hub-tiendas.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
10
docs/brand/t-hub-tiendas.svg
Normal file
|
After Width: | Height: | Size: 120 KiB |
44
docs/brand/t-hub.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 400" width="400" height="400" role="img" aria-labelledby="t">
|
||||
<title id="t">T-HUB</title>
|
||||
<defs>
|
||||
<!-- Dome tent in the hand-inked style of the acampada26s.net mark:
|
||||
curved poles with ball ends, centre seam, hatched panel, door.
|
||||
Base midpoint at 0,0. -->
|
||||
<g id="tienda" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" stroke-width="7"/>
|
||||
<path fill="none" d="M-64 1 Q0 -3 64 1" stroke-width="6"/>
|
||||
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" stroke-width="4.5"/>
|
||||
<path fill="none" d="M44 0 Q30 -58 -1 -72" stroke-width="4.5"/>
|
||||
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" stroke-width="4"/>
|
||||
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" stroke-width="2.6"/>
|
||||
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" stroke-width="2.6"/>
|
||||
<circle cx="-64" cy="1" r="5.5" stroke="none"/>
|
||||
<circle cx="64" cy="1" r="5.5" stroke="none"/>
|
||||
<circle cx="-1" cy="-73" r="5.5" stroke="none"/>
|
||||
</g>
|
||||
<g id="marca" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- cables: out of each tent door to the switch -->
|
||||
<path fill="none" d="M105 150 Q112 206 168 214" stroke-width="5"/>
|
||||
<path fill="none" d="M295 150 Q288 206 232 214" stroke-width="5"/>
|
||||
<path fill="none" d="M200 318 Q197 280 200 236" stroke-width="5"/>
|
||||
<!-- antenna -->
|
||||
<path fill="none" d="M200 200 Q198 160 200 118" stroke-width="6"/>
|
||||
<path fill="none" d="M184 98 Q200 82 216 98" stroke-width="5"/>
|
||||
<path fill="none" d="M171 88 Q200 58 229 88" stroke-width="5"/>
|
||||
<circle cx="200" cy="112" r="7" stroke="none"/>
|
||||
<!-- switch -->
|
||||
<path d="M168 201 L233 200 L234 235 L167 236 Z" stroke-width="6" fill="#fff"/>
|
||||
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none"/>
|
||||
<use xlink:href="#tienda" transform="translate(105,150)"/>
|
||||
<use xlink:href="#tienda" transform="translate(295,150)"/>
|
||||
<use xlink:href="#tienda" transform="translate(200,340)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<!-- neon offset underneath, ink on top, as on acampada26s.net -->
|
||||
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff">
|
||||
<use xlink:href="#marca"/>
|
||||
</g>
|
||||
<g stroke="#000" fill="#000">
|
||||
<use xlink:href="#marca"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
# Connecting platform accounts
|
||||
|
||||
The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, and Telegram. Other providers are not offered as connectable until a connector implements the same authorization surface.
|
||||
The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, Telegram, and Instagram. Other providers are not offered as connectable until a connector implements the same authorization surface.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ The API resolves authorization behavior through connector methods and manifests.
|
|||
| `MASTODON_CLIENT_ID`, `MASTODON_CLIENT_SECRET` | Optional pre-registered Mastodon application credentials, used only for `MASTODON_INSTANCE_URL`. |
|
||||
| `PEERTUBE_INSTANCE_URL` | Instance origin to which the configured PeerTube bridge client belongs. |
|
||||
| `PEERTUBE_CLIENT_ID`, `PEERTUBE_CLIENT_SECRET` | Optional credentials for that instance. Otherwise the connector discovers the local client through `/api/v1/oauth-clients/local`. |
|
||||
| `INSTAGRAM_CLIENT_ID`, `INSTAGRAM_CLIENT_SECRET` | The Hub's Facebook app (App ID / App Secret from developers.facebook.com → App settings → Basic). One pair per deployment; Instagram is offered as connectable only when both are set. |
|
||||
| `PEERTUBE_AUTHORIZE_PATH`, `PEERTUBE_TOKEN_PATH` | Required to enable a PeerTube redirect bridge. Both must be paths beginning with `/`, without a second leading slash, on the selected instance origin. |
|
||||
|
||||
Use HTTPS and the same browser site for the web and API (for example `app.example.org` and `api.example.org`), or proxy the API under the web origin. The callback binding cookie is HttpOnly, SameSite=Lax, and Secure in production. For development, consistently use `localhost` or consistently use `127.0.0.1` for both web and API; mixing the two breaks browser cookie delivery.
|
||||
|
|
@ -30,6 +31,14 @@ Stock PeerTube documents local OAuth client discovery followed by a **password g
|
|||
|
||||
An instance administrator can enable a compatible OAuth bridge, then configure the two paths above. Its authorize endpoint must preserve `state`, register/validate the redirect URI, support `response_type=code` and S256 PKCE, and redirect back with a one-use code. Its token endpoint must validate `client_id`, `client_secret`, `redirect_uri`, `code`, and `code_verifier`, returning **actual PeerTube API** `access_token` and `refresh_token` values. Identity, refresh, and revoke then use PeerTube's native `/api/v1/users/me`, `/api/v1/users/token`, and `/api/v1/users/revoke-token` endpoints. These settings do not add an authorization-code grant to a stock instance, and an OIDC identity token alone is insufficient. The mock integration test proves this adapter contract; no third-party bridge is included in this change.
|
||||
|
||||
### Instagram
|
||||
|
||||
Instagram connects through Facebook Login against the Hub's own Facebook app. The App ID and App Secret identify the Hub, not a collective: they are read only from `INSTAGRAM_CLIENT_ID` / `INSTAGRAM_CLIENT_SECRET`, never entered on the connect form and never written to the vault or the pending store. In the Facebook app, add the product *Facebook Login for Business* and register the exact `CONNECTIONS_CALLBACK_URL` (default `${API_URL}/connections/callback`) as a Valid OAuth Redirect URI.
|
||||
|
||||
Each person connects their own account through Meta's consent dialog; the Hub keeps the resulting long-lived token encrypted per connection. Requirements per account: an Instagram **Business or Creator** account linked to a Facebook Page, and — while the app is in Development mode — a Tester role on the app. Grant exactly one Instagram account in the dialog: when several are granted the connect fails instead of guessing which one to publish as. Public use needs Meta App Review for `instagram_basic`, `instagram_content_publish` and `pages_show_list`.
|
||||
|
||||
Limitations: Instagram has no text-only posts and no binary upload — Meta downloads each JPEG from a public https URL. Media storage is client-side encrypted, so S3 presigned URLs cannot serve it; publishing images requires a short-lived plaintext media endpoint that is not part of this change. Until then publishing to Instagram fails with an explicit error. Stories are not publishable through the API; Meta caps API publishing at 25 posts per rolling 24 h per account.
|
||||
|
||||
### Telegram
|
||||
|
||||
Create a bot in BotFather and enter its bot token. The connector validates it with `getMe`, then stores the token through the same encrypted vault envelope as OAuth credentials. Optionally supply a chat/channel ID (`@channel` or a numeric chat ID); add the bot to the target and grant any necessary posting rights. Identity health confirms token validity, not permission to publish to every chat. Reconnect accepts a replacement bot token for the same bot and rotates the credential row in place. Telegram's Bot API cannot revoke its own bot token: Remove clears local authorization; revoke/rotate the token through BotFather when needed.
|
||||
|
|
|
|||
|
|
@ -176,7 +176,6 @@ services:
|
|||
<<: *appenv
|
||||
MEDIA_WORKER_LANE: local
|
||||
MEDIA_SCRATCH_DIR: /var/lib/media-worker/produce
|
||||
MEDIA_DEBUG_STDERR: "1"
|
||||
volumes: [mediaworkerstate:/var/lib/media-worker]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import { AuthError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import {
|
||||
AuthorizationRedirectRequired,
|
||||
|
|
@ -19,7 +19,6 @@ interface StubCall {
|
|||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
const API = "https://graph.facebook.com/v21.0";
|
||||
const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth";
|
||||
const REDIRECT = "https://app.example.com/oauth/instagram/callback";
|
||||
const IG_USER_ID = "17841400000000001";
|
||||
|
|
@ -169,11 +168,12 @@ interface Harness {
|
|||
credentials: InMemoryCredentials;
|
||||
}
|
||||
|
||||
/** The Hub's Facebook app lives in the API environment, never in a connection. */
|
||||
const APP_ENV = { INSTAGRAM_CLIENT_ID: "stub-client-id", INSTAGRAM_CLIENT_SECRET: "stub-client-secret" };
|
||||
|
||||
async function createHarness(seedToken = true): Promise<Harness> {
|
||||
const stub = { calls: [], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
await credentials.put(connection, "client_id", "stub-client-id");
|
||||
await credentials.put(connection, "client_secret", "stub-client-secret");
|
||||
await credentials.put(connection, "ig_user_id", IG_USER_ID);
|
||||
if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN);
|
||||
const connector = createInstagramConnector({
|
||||
|
|
@ -181,7 +181,7 @@ async function createHarness(seedToken = true): Promise<Harness> {
|
|||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
credentials,
|
||||
env: {},
|
||||
env: APP_ENV,
|
||||
});
|
||||
return { stub, connector, credentials };
|
||||
}
|
||||
|
|
@ -190,8 +190,6 @@ const leg1: InstagramAuthorizationRequest = {
|
|||
redirectUri: REDIRECT,
|
||||
workspaceId: "ws-1",
|
||||
state: "csrf-1",
|
||||
// App credentials arrive as connection fields on the connect form.
|
||||
fields: { clientId: "stub-client-id", clientSecret: "stub-client-secret" },
|
||||
};
|
||||
|
||||
const post = (text: string, mediaAssetIds: string[] = [], providerMetadata: Record<string, unknown> = {}): NormalizedPost => ({
|
||||
|
|
@ -250,9 +248,9 @@ describe("instagram connector: authorize + identity", () => {
|
|||
expect(dialog.searchParams.get("scope")).toBe("instagram_basic,instagram_content_publish,pages_show_list");
|
||||
expect(dialog.searchParams.get("state")).toBe("csrf-1");
|
||||
expect(redirect.connectionId).toBe("instagram:ws-1");
|
||||
// App credentials persist for leg 2.
|
||||
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).toBe("stub-client-id");
|
||||
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).toBe("stub-client-secret");
|
||||
// App credentials stay in the environment: nothing app-level reaches the vault.
|
||||
await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).rejects.toThrow();
|
||||
await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).rejects.toThrow();
|
||||
expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
@ -299,7 +297,7 @@ describe("instagram connector: authorize + identity", () => {
|
|||
});
|
||||
|
||||
it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => {
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(href);
|
||||
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
|
||||
|
|
@ -312,20 +310,56 @@ describe("instagram connector: authorize + identity", () => {
|
|||
}
|
||||
return graphError("no route", 100, 404);
|
||||
};
|
||||
const credentials = new InMemoryCredentials();
|
||||
await credentials.put(connection, "client_id", "stub-client-id");
|
||||
await credentials.put(connection, "client_secret", "stub-client-secret");
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl,
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => undefined,
|
||||
credentials,
|
||||
env: {},
|
||||
credentials: new InMemoryCredentials(),
|
||||
env: APP_ENV,
|
||||
});
|
||||
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
||||
await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/);
|
||||
});
|
||||
|
||||
it("refuses to pick one when several Instagram accounts were granted", async () => {
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(href);
|
||||
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
|
||||
if (url.pathname === "/oauth/access_token") {
|
||||
return json({ access_token: url.searchParams.get("code") ? SHORT_TOKEN : LONG_TOKEN, token_type: "bearer" });
|
||||
}
|
||||
if (url.pathname === "/me") return json({ id: FB_USER_ID });
|
||||
if (url.pathname === "/me/accounts") {
|
||||
return json({
|
||||
data: [
|
||||
{ id: "page-a", instagram_business_account: { id: "17841400000000001", username: "acampada_a" } },
|
||||
{ id: "page-b", instagram_business_account: { id: "17841400000000002", username: "acampada_b" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
return graphError("no route", 100, 404);
|
||||
};
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl,
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => undefined,
|
||||
credentials: new InMemoryCredentials(),
|
||||
env: APP_ENV,
|
||||
});
|
||||
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
||||
await expect(connector.authorize(request)).rejects.toThrow(/2 Instagram professional accounts were granted/);
|
||||
});
|
||||
|
||||
it("is not connectable until the Hub's Facebook app is configured", async () => {
|
||||
const connector = createInstagramConnector({ credentials: new InMemoryCredentials(), env: {} });
|
||||
expect(connector.manifest().oauth?.connectEnabled).toBe(false);
|
||||
expect(connector.manifest().connectionFields.map((field) => field.name)).toEqual(["igUserId"]);
|
||||
await expect(connector.authorize(leg1)).rejects.toThrow(/INSTAGRAM_CLIENT_ID/);
|
||||
const configured = createInstagramConnector({ credentials: new InMemoryCredentials(), env: APP_ENV });
|
||||
expect(configured.manifest().oauth?.connectEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => {
|
||||
const { stub, connector } = await createHarness();
|
||||
const identity = await connector.getIdentity(connection);
|
||||
|
|
@ -374,8 +408,6 @@ describe("instagram connector: publish", () => {
|
|||
it("uploads assets through loadAsset and captions the container", async () => {
|
||||
const stub = { calls: [] as StubCall[], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
await credentials.put(connection, "client_id", "stub-client-id");
|
||||
await credentials.put(connection, "client_secret", "stub-client-secret");
|
||||
await credentials.put(connection, "ig_user_id", IG_USER_ID);
|
||||
await credentials.put(connection, "access_token", LONG_TOKEN);
|
||||
const connector = createInstagramConnector({
|
||||
|
|
@ -415,8 +447,6 @@ describe("instagram connector: publish", () => {
|
|||
const stub = { calls: [] as StubCall[], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
for (const [key, value] of [
|
||||
["client_id", "stub-client-id"],
|
||||
["client_secret", "stub-client-secret"],
|
||||
["ig_user_id", IG_USER_ID],
|
||||
["access_token", LONG_TOKEN],
|
||||
] as const) {
|
||||
|
|
@ -458,8 +488,6 @@ describe("instagram connector: publish", () => {
|
|||
const stub = { calls: [] as StubCall[], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
for (const [key, value] of [
|
||||
["client_id", "stub-client-id"],
|
||||
["client_secret", "stub-client-secret"],
|
||||
["ig_user_id", IG_USER_ID],
|
||||
["access_token", LONG_TOKEN],
|
||||
] as const) {
|
||||
|
|
@ -499,7 +527,6 @@ describe("instagram connector: publish", () => {
|
|||
|
||||
it("rejects non-JPEG assets", async () => {
|
||||
const { connector } = await createHarness();
|
||||
(connector as unknown as { options?: unknown });
|
||||
await expect(
|
||||
connector.publish?.(connection, post("png", ["asset-png"], {
|
||||
media: [],
|
||||
|
|
@ -538,8 +565,6 @@ describe("instagram connector: deletePublication", () => {
|
|||
const stub404Calls: StubCall[] = [];
|
||||
const credentials = new InMemoryCredentials();
|
||||
for (const [key, value] of [
|
||||
["client_id", "stub-client-id"],
|
||||
["client_secret", "stub-client-secret"],
|
||||
["ig_user_id", IG_USER_ID],
|
||||
["access_token", LONG_TOKEN],
|
||||
] as const) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
* immediately exchanges it (grant_type=fb_exchange_token) for a long-lived
|
||||
* (~60 day) token, then resolves the Instagram professional account through
|
||||
* GET /me + GET /me/accounts (instagram_business_account). Stored credential
|
||||
* keys: access_token, ig_user_id, client_id, client_secret.
|
||||
* keys: access_token, ig_user_id. The Facebook app credentials identify the
|
||||
* Hub, not a collective: they come only from INSTAGRAM_CLIENT_ID /
|
||||
* INSTAGRAM_CLIENT_SECRET in the API environment and never enter the vault.
|
||||
*
|
||||
* Honest capability notes (verified against the Graph API):
|
||||
* - Instagram has no text-only posts. publishText is declared only because
|
||||
|
|
@ -100,8 +102,6 @@ const CAPABILITIES: ConnectorCapabilities = {
|
|||
const SECRET = {
|
||||
accessToken: "access_token",
|
||||
igUserId: "ig_user_id",
|
||||
clientId: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
} as const;
|
||||
|
||||
export interface InstagramConnectorOptions {
|
||||
|
|
@ -115,7 +115,7 @@ export interface InstagramConnectorOptions {
|
|||
credentials?: CredentialAccessor;
|
||||
/** Graph API version path segment. Default v21.0. */
|
||||
apiVersion?: string;
|
||||
/** Facebook app credentials fallback when not supplied per connection. */
|
||||
/** INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET: the Hub's Facebook app. */
|
||||
env?: Record<string, string | undefined>;
|
||||
/** Resolve a media asset id to its metadata (needed by publish with media). */
|
||||
loadAsset?: (assetId: string) => Promise<MediaAsset>;
|
||||
|
|
@ -270,18 +270,6 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
|
|||
displayName: "Instagram",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "clientId",
|
||||
label: "Facebook app ID (or INSTAGRAM_CLIENT_ID env)",
|
||||
required: false,
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
name: "clientSecret",
|
||||
label: "Facebook app secret (or INSTAGRAM_CLIENT_SECRET env)",
|
||||
required: false,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "igUserId",
|
||||
label: "Instagram professional account id (optional — resolved at connect)",
|
||||
|
|
@ -296,14 +284,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
|
|||
authorizeUrlTemplate: `${dialogBase}/dialog/oauth`,
|
||||
scopes: SCOPES,
|
||||
pkce: false,
|
||||
connectEnabled: Boolean(str(env.INSTAGRAM_CLIENT_ID) && str(env.INSTAGRAM_CLIENT_SECRET)),
|
||||
connectNote: "Instagram linking needs the Hub's Facebook app: set INSTAGRAM_CLIENT_ID and INSTAGRAM_CLIENT_SECRET on the API.",
|
||||
},
|
||||
docsUrl: "https://developers.facebook.com/docs/instagram-platform/content-publishing",
|
||||
};
|
||||
|
||||
function igUserIdOf(connection: ConnectorConnection): string {
|
||||
return str(connection.fields.igUserId) ?? `vault:${connection.id}`;
|
||||
}
|
||||
|
||||
/** Vault first; the connection field is the dev/test fallback. */
|
||||
async function tokenOf(connection: ConnectorConnection): Promise<string> {
|
||||
try {
|
||||
|
|
@ -333,24 +319,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
|
|||
}
|
||||
}
|
||||
|
||||
/** Facebook app credentials: vault first, then connection fields, then env. */
|
||||
void secretOrUndefined;
|
||||
async function clientCredentials(
|
||||
connection: ConnectorConnection,
|
||||
): Promise<{ clientId: string; clientSecret: string }> {
|
||||
const clientId =
|
||||
(await secretOrUndefined(connection, SECRET.clientId)) ??
|
||||
str(connection.fields.clientId) ??
|
||||
str(env.INSTAGRAM_CLIENT_ID);
|
||||
const clientSecret =
|
||||
(await secretOrUndefined(connection, SECRET.clientSecret)) ??
|
||||
str(connection.fields.clientSecret) ??
|
||||
str(env.INSTAGRAM_CLIENT_SECRET);
|
||||
/** The Hub's Facebook app credentials — server environment only, never per connection. */
|
||||
function clientCredentials(): { clientId: string; clientSecret: string } {
|
||||
const clientId = str(env.INSTAGRAM_CLIENT_ID);
|
||||
const clientSecret = str(env.INSTAGRAM_CLIENT_SECRET);
|
||||
if (clientId === undefined || clientSecret === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: no Facebook app credentials — set fields.clientId/clientSecret or INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`,
|
||||
PROVIDER,
|
||||
);
|
||||
throw new ConnectorError(`${PROVIDER}: no Facebook app credentials — set INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, PROVIDER);
|
||||
}
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
|
@ -408,16 +382,27 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
|
|||
|
||||
/**
|
||||
* GET /me validates the token (it is the Facebook user under Facebook
|
||||
* Login), then GET /me/accounts resolves the first Page carrying an
|
||||
* Login), then GET /me/accounts resolves the Page carrying an
|
||||
* instagram_business_account — the IG professional account to publish as.
|
||||
* Exactly one must be granted: picking one of several would publish as an
|
||||
* account nobody chose, so the person re-links selecting a single one in
|
||||
* Meta's dialog.
|
||||
*/
|
||||
async function fetchIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" });
|
||||
const { data } = await graph<GraphList<FacebookPage>>(connection, "GET", "/me/accounts", {
|
||||
fields: PAGE_FIELDS,
|
||||
});
|
||||
const linked = data.find((page) => page.instagram_business_account !== undefined)
|
||||
?.instagram_business_account;
|
||||
const granted = data
|
||||
.map((page) => page.instagram_business_account)
|
||||
.filter((account): account is NonNullable<FacebookPage["instagram_business_account"]> => account !== undefined);
|
||||
if (granted.length > 1) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: ${granted.length} Instagram professional accounts were granted — select exactly one in the Facebook dialog`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const linked = granted[0];
|
||||
if (linked === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: no Instagram professional account is linked to any Page of Facebook user ${me.id} — convert the account to business/creator and link a Page`,
|
||||
|
|
@ -516,7 +501,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
|
|||
provider: PROVIDER,
|
||||
channelId: error.connectionId,
|
||||
credentialId: { kind: "credential-ref", id: error.connectionId },
|
||||
fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {},
|
||||
fields: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
return { authorizationUrl: error.authorizationUrl, connection };
|
||||
|
|
@ -531,7 +516,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
|
|||
},
|
||||
|
||||
/**
|
||||
* Leg 1 (no code): validate app credentials, store them in the vault and
|
||||
* Leg 1 (no code): read the Hub's app credentials from the environment and
|
||||
* throw AuthorizationRedirectRequired with the Facebook dialog URL.
|
||||
* Leg 2 (providerMetadata.code): code → short-lived token → long-lived
|
||||
* token (fb_exchange_token) → resolve the IG professional account via
|
||||
|
|
@ -542,24 +527,17 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
|
|||
const code = str(meta.code);
|
||||
const connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`;
|
||||
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
|
||||
const provided: Record<string, string> = {};
|
||||
const fieldClientId = str(input.fields?.clientId);
|
||||
const fieldClientSecret = str(input.fields?.clientSecret);
|
||||
if (fieldClientId !== undefined) provided.clientId = fieldClientId;
|
||||
if (fieldClientSecret !== undefined) provided.clientSecret = fieldClientSecret;
|
||||
const connection: ConnectorConnection = {
|
||||
id: connectionId,
|
||||
workspaceId: input.workspaceId,
|
||||
provider: PROVIDER,
|
||||
channelId: str(meta.channelId) ?? connectionId,
|
||||
credentialId,
|
||||
fields: { ...provided },
|
||||
fields: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const client = await clientCredentials({ ...connection, fields: provided });
|
||||
await credentials.put(connection, SECRET.clientId, client.clientId);
|
||||
await credentials.put(connection, SECRET.clientSecret, client.clientSecret);
|
||||
const client = clientCredentials();
|
||||
|
||||
if (code === undefined) {
|
||||
const url = new URL(`${dialogBase}/dialog/oauth`);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
"@tactical-media-broadcast-hub/core": "workspace:*",
|
||||
"pg": "^8.23.1",
|
||||
"@tactical-media-broadcast-hub/connector-mastodon": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*"
|
||||
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/connector-instagram": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.1146.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
|
|
|
|||
|
|
@ -39,3 +39,10 @@ export function publishingContext(connection: ConnectorConnection): PublishingEx
|
|||
return context;
|
||||
}
|
||||
export function executionSignal(): AbortSignal | undefined { return executionStorage.getStore()?.signal; }
|
||||
/** The running execution's context; media access has no connection to fence on, only the tenant. */
|
||||
export function currentPublishingContext(): PublishingExecutionContext {
|
||||
const context = executionStorage.getStore();
|
||||
if (!context) throw new Error("EXECUTION_CONTEXT_MISSING");
|
||||
context.signal?.throwIfAborted();
|
||||
return context;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,22 @@ export interface DispatchPreparation {
|
|||
* drop author-supplied execution keys, inject the attempt's idempotency key
|
||||
* and strip scheduledAt unless provider scheduling is explicitly allowed.
|
||||
*/
|
||||
/**
|
||||
* The approved snapshot carries alt texts positionally (altTexts[i] describes
|
||||
* mediaAssetIds[i]); connectors read them as providerMetadata.altText keyed by
|
||||
* asset id. Pipeline-owned, so it replaces any author-set map.
|
||||
*/
|
||||
function altTextByAsset(post: NormalizedPost): { altText?: Record<string, string> } {
|
||||
const altTexts = (post as NormalizedPost & { altTexts?: unknown }).altTexts;
|
||||
if (!Array.isArray(altTexts) || altTexts.length !== post.mediaAssetIds.length) return {};
|
||||
const map: Record<string, string> = {};
|
||||
post.mediaAssetIds.forEach((id, index) => {
|
||||
const text = altTexts[index];
|
||||
if (typeof text === "string" && text.trim() !== "") map[id] = text;
|
||||
});
|
||||
return Object.keys(map).length === 0 ? {} : { altText: map };
|
||||
}
|
||||
|
||||
export function preparePostForDispatch(post: NormalizedPost, prep: DispatchPreparation): NormalizedPost {
|
||||
for (const key of FORBIDDEN_POST_METADATA_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(post.providerMetadata, key)) throw new ExecutionMetadataError(key);
|
||||
|
|
@ -45,7 +61,7 @@ export function preparePostForDispatch(post: NormalizedPost, prep: DispatchPrepa
|
|||
const prepared: NormalizedPost = {
|
||||
...stripped,
|
||||
mediaAssetIds: [...post.mediaAssetIds],
|
||||
providerMetadata: { ...stripped.providerMetadata, idempotencyKey: prep.idempotencyKey },
|
||||
providerMetadata: { ...stripped.providerMetadata, ...altTextByAsset(post), idempotencyKey: prep.idempotencyKey },
|
||||
};
|
||||
if (prep.allowProviderScheduling !== true) delete prepared.scheduledAt;
|
||||
return prepared;
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ export * from "./vault.js";
|
|||
export * from "./provider-fetch.js";
|
||||
export * from "./credentials.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./media.js";
|
||||
|
|
|
|||
86
packages/connector-runtime/src/media.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import type { Pool } from "pg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MediaAsset, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import { runWithPublishingContext } from "./execution-context.js";
|
||||
import { preparePostForDispatch } from "./idempotency.js";
|
||||
import { createPublishingMediaAccess, openSealedFrame } from "./media.js";
|
||||
|
||||
const KEK = randomBytes(32);
|
||||
const WS = "0d9a1b40-6f0f-4b1e-8f0e-3f3c9e6b4a10";
|
||||
const context = { workspaceId: WS, connectionId: "conn-1", credentialId: "cred-1", credentialGeneration: "1" };
|
||||
|
||||
/** The API's frame: magic | iv | AES-GCM(kek, cek) | AES-GCM(cek, body). */
|
||||
function seal(plaintext: Buffer, kek = KEK): Buffer {
|
||||
const cek = randomBytes(32);
|
||||
const iv = randomBytes(12);
|
||||
const wrap = createCipheriv("aes-256-gcm", kek, iv);
|
||||
const sealedCek = Buffer.concat([wrap.update(cek), wrap.final(), wrap.getAuthTag()]);
|
||||
const cipher = createCipheriv("aes-256-gcm", cek, iv);
|
||||
const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
||||
return Buffer.concat([Buffer.from("SPENC1"), iv, sealedCek, body]);
|
||||
}
|
||||
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]);
|
||||
const KEY = `sha256/${createHash("sha256").update(JPEG).digest("hex")}.jpg`;
|
||||
const asset: MediaAsset = { id: "11111111-2222-4333-8444-555555555555", workspaceId: WS, storageKey: KEY, mimeType: "image/jpeg", sizeBytes: JPEG.length, createdAt: new Date() };
|
||||
|
||||
function fakePool(rows: unknown[]): { pool: Pool; queries: Array<{ sql: string; values?: unknown[] }> } {
|
||||
const queries: Array<{ sql: string; values?: unknown[] }> = [];
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => { queries.push({ sql, values }); return { rows: sql.startsWith("SELECT id") ? rows : [] }; },
|
||||
release: () => undefined,
|
||||
};
|
||||
return { pool: { connect: async () => client } as unknown as Pool, queries };
|
||||
}
|
||||
|
||||
describe("openSealedFrame", () => {
|
||||
it("opens the API frame and rejects tampering or a wrong key", () => {
|
||||
const sealed = seal(JPEG);
|
||||
expect(openSealedFrame(sealed, KEK).equals(JPEG)).toBe(true);
|
||||
const flipped = Buffer.from(sealed); flipped[flipped.length - 20]! ^= 1;
|
||||
expect(() => openSealedFrame(flipped, KEK)).toThrow();
|
||||
expect(() => openSealedFrame(sealed, randomBytes(32))).toThrow();
|
||||
expect(() => openSealedFrame(Buffer.from("plain"), KEK)).toThrow(/MEDIA_FRAME_INVALID/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPublishingMediaAccess", () => {
|
||||
it("loads the asset inside the execution's tenant", async () => {
|
||||
const { pool, queries } = fakePool([{ id: asset.id, workspace_id: WS, storage_key: KEY, mime: "image/jpeg", size_bytes: "8", created_at: asset.createdAt }]);
|
||||
const media = createPublishingMediaAccess(pool, { read: async () => seal(JPEG) }, KEK);
|
||||
const loaded = await runWithPublishingContext(context, () => media.loadAsset(asset.id));
|
||||
expect(loaded).toMatchObject({ id: asset.id, workspaceId: WS, storageKey: KEY, mimeType: "image/jpeg", sizeBytes: 8 });
|
||||
expect(queries[1]).toEqual({ sql: "SELECT set_config('app.workspace_id', $1, true)", values: [WS] });
|
||||
expect(queries[2]?.sql).toContain("workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid");
|
||||
});
|
||||
|
||||
it("refuses outside a publishing execution and for unknown ids", async () => {
|
||||
const { pool } = fakePool([]);
|
||||
const media = createPublishingMediaAccess(pool, { read: async () => seal(JPEG) }, KEK);
|
||||
await expect(media.loadAsset(asset.id)).rejects.toThrow(/EXECUTION_CONTEXT_MISSING/);
|
||||
await expect(runWithPublishingContext(context, () => media.loadAsset(asset.id))).rejects.toThrow(/MEDIA_ASSET_NOT_FOUND/);
|
||||
});
|
||||
|
||||
it("opens bytes, checks the content address and the tenant", async () => {
|
||||
const media = createPublishingMediaAccess(fakePool([]).pool, { read: async () => seal(JPEG) }, KEK);
|
||||
const opened = await runWithPublishingContext(context, () => media.openAsset(asset));
|
||||
expect(Buffer.from(opened.bytes).equals(JPEG)).toBe(true);
|
||||
expect(opened.filename).toBe(`${asset.id}.jpg`);
|
||||
await expect(runWithPublishingContext(context, () => media.openAsset({ ...asset, workspaceId: "other" }))).rejects.toThrow(/MEDIA_TENANT_FENCE/);
|
||||
const swapped = createPublishingMediaAccess(fakePool([]).pool, { read: async () => seal(Buffer.from("other bytes")) }, KEK);
|
||||
await expect(runWithPublishingContext(context, () => swapped.openAsset(asset))).rejects.toThrow(/MEDIA_INTEGRITY_FAILURE/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("alt text at dispatch", () => {
|
||||
it("maps positional altTexts onto providerMetadata.altText by asset id", () => {
|
||||
const post: NormalizedPost & { altTexts?: Array<string | null> } = { text: "x", mediaAssetIds: ["a", "b", "c"], altTexts: ["first", null, " "], visibility: "public", providerMetadata: { altText: { a: "author-set", b: "author-set" } } };
|
||||
const prepared = preparePostForDispatch(post, { idempotencyKey: "k" });
|
||||
expect(prepared.providerMetadata.altText).toEqual({ a: "first" });
|
||||
expect(prepared.providerMetadata.idempotencyKey).toBe("k");
|
||||
const withoutAltTexts: NormalizedPost = { text: "x", mediaAssetIds: ["a"], visibility: "public", providerMetadata: {} };
|
||||
const none = preparePostForDispatch(withoutAltTexts, { idempotencyKey: "k" });
|
||||
expect(none.providerMetadata.altText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
137
packages/connector-runtime/src/media.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Publishing-side media access: the `loadAsset` / `openAsset` pair the
|
||||
* connectors need to attach an approved post's media.
|
||||
*
|
||||
* - loadAsset reads the `media_assets` row inside the current execution's
|
||||
* tenant (app.workspace_id set from the publishing context, RLS forced),
|
||||
* so a job can only ever resolve its own workspace's assets.
|
||||
* - openAsset downloads the sealed object and opens the SPENC1 frame with the
|
||||
* media KEK, checking the content address. Read-only: publishing never
|
||||
* writes to storage.
|
||||
*
|
||||
* Bytes travel worker → provider; they never pass through the API process.
|
||||
*/
|
||||
import { createDecipheriv, createHash } from "node:crypto";
|
||||
import type { Pool } from "pg";
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import type { MediaAsset } from "@tactical-media-broadcast-hub/core";
|
||||
import { currentPublishingContext } from "./execution-context.js";
|
||||
|
||||
const FRAME_MAGIC = Buffer.from("SPENC1");
|
||||
/** Same per-asset caps the API enforces at upload (media.service.ts). */
|
||||
const MAX_ASSET_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export interface SealedObjectReader {
|
||||
/** Returns the stored (sealed) object body. */
|
||||
read(key: string): Promise<Buffer>;
|
||||
}
|
||||
|
||||
export interface PublishingMediaAccess {
|
||||
loadAsset(assetId: string): Promise<MediaAsset>;
|
||||
openAsset(media: MediaAsset): Promise<{ bytes: Uint8Array; filename: string }>;
|
||||
}
|
||||
|
||||
function aesGcmOpen(key: Buffer, iv: Buffer, sealed: Buffer): Buffer {
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(sealed.subarray(sealed.length - 16));
|
||||
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
|
||||
}
|
||||
|
||||
/** Open [magic | iv(12) | wrapped CEK(48) | body+tag], the API's storage frame. */
|
||||
export function openSealedFrame(sealed: Buffer, kek: Buffer): Buffer {
|
||||
if (!sealed.subarray(0, FRAME_MAGIC.length).equals(FRAME_MAGIC)) throw new Error("MEDIA_FRAME_INVALID");
|
||||
const iv = sealed.subarray(6, 18);
|
||||
const cek = aesGcmOpen(kek, iv, sealed.subarray(18, 66));
|
||||
return aesGcmOpen(cek, iv, sealed.subarray(66));
|
||||
}
|
||||
|
||||
export function mediaKekFromEnv(env: Record<string, string | undefined> = process.env): Buffer {
|
||||
const raw = env.S3_KEK;
|
||||
if (raw !== undefined && raw !== "") {
|
||||
const kek = Buffer.from(raw, "base64");
|
||||
if (kek.length !== 32) throw new Error("S3_KEK must be 32 bytes base64");
|
||||
return kek;
|
||||
}
|
||||
if (env.NODE_ENV === "production") throw new Error("S3_KEK is required in production (media encryption is mandatory)");
|
||||
// Dev mirror of the API's fallback so a local scheduler reads local uploads.
|
||||
return createHash("sha256").update(`dev-media-kek:${env.HOSTNAME ?? "local"}`).digest();
|
||||
}
|
||||
|
||||
export function s3ObjectReaderFromEnv(env: Record<string, string | undefined> = process.env): SealedObjectReader {
|
||||
const bucket = env.S3_BUCKET ?? "tactical-media-broadcast-hub-media";
|
||||
const client = new S3Client({
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
region: env.S3_REGION ?? "us-east-1",
|
||||
credentials: env.S3_ACCESS_KEY !== undefined && env.S3_SECRET_KEY !== undefined
|
||||
? { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY }
|
||||
: undefined,
|
||||
forcePathStyle: env.S3_FORCE_PATH_STYLE !== "false",
|
||||
});
|
||||
return {
|
||||
async read(key) {
|
||||
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||
if (response.Body === undefined) throw new Error("MEDIA_OBJECT_EMPTY");
|
||||
return Buffer.from(await response.Body.transformToByteArray());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const EXTENSIONS: Record<string, string> = {
|
||||
"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "video/mp4": "mp4", "video/webm": "webm",
|
||||
};
|
||||
|
||||
interface MediaRow {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
storage_key: string;
|
||||
mime: string;
|
||||
size_bytes: string | number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Caller owns pool lifecycle (same contract as the credential accessor). */
|
||||
export function createPublishingMediaAccess(pool: Pool, reader: SealedObjectReader, kek: Buffer): PublishingMediaAccess {
|
||||
async function loadAsset(assetId: string): Promise<MediaAsset> {
|
||||
const context = currentPublishingContext();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [context.workspaceId]);
|
||||
const result = await client.query<MediaRow>(
|
||||
`SELECT id, workspace_id, storage_key, mime, size_bytes, created_at FROM media_assets
|
||||
WHERE id = $1 AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid`,
|
||||
[assetId],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
const row = result.rows[0];
|
||||
if (row === undefined) throw new Error("MEDIA_ASSET_NOT_FOUND");
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
storageKey: row.storage_key,
|
||||
mimeType: row.mime,
|
||||
sizeBytes: Number(row.size_bytes),
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function openAsset(media: MediaAsset): Promise<{ bytes: Uint8Array; filename: string }> {
|
||||
const context = currentPublishingContext();
|
||||
if (media.workspaceId !== context.workspaceId) throw new Error("MEDIA_TENANT_FENCE");
|
||||
if (media.sizeBytes > MAX_ASSET_BYTES) throw new Error("MEDIA_ASSET_TOO_LARGE");
|
||||
const plaintext = openSealedFrame(await reader.read(media.storageKey), kek);
|
||||
const expected = /^sha256\/([0-9a-f]{64})\./.exec(media.storageKey)?.[1];
|
||||
if (expected === undefined || createHash("sha256").update(plaintext).digest("hex") !== expected) {
|
||||
throw new Error("MEDIA_INTEGRITY_FAILURE");
|
||||
}
|
||||
return { bytes: plaintext, filename: `${media.id}.${EXTENSIONS[media.mimeType] ?? "bin"}` };
|
||||
}
|
||||
|
||||
return { loadAsset, openAsset };
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000;
|
|||
export const PUBLISHING_POLICIES: Readonly<Record<string, PublishingPolicy>> = {
|
||||
mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS },
|
||||
telegram: { idempotency: "none" },
|
||||
// Graph media_publish takes no idempotency key: an ambiguous send is never replayed.
|
||||
instagram: { idempotency: "none" },
|
||||
};
|
||||
|
||||
/** Unknown providers get the safe default: never replay an ambiguous send. */
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
|||
import { runWithPublishingContext } from "./execution-context.js";
|
||||
const connection: ConnectorConnection = { id: "connection", workspaceId: "workspace", provider: "mastodon", channelId: "channel", fields: {}, createdAt: new Date(), credentialId: { kind: "credential-ref", id: "connection" } };
|
||||
describe("production publishing registry", () => {
|
||||
it("registers real Mastodon and Telegram connectors and rejects duplicates", () => {
|
||||
it("registers real Mastodon, Telegram and Instagram connectors and rejects duplicates", () => {
|
||||
const pool = {} as Pool;
|
||||
const registry = createPublishingRegistry(pool);
|
||||
expect(registry.list().map((c) => c.manifest().id).sort()).toEqual(["mastodon", "telegram"]);
|
||||
expect(registry.list().map((c) => c.manifest().id).sort()).toEqual(["instagram", "mastodon", "telegram"]);
|
||||
expect(registry.get("mastodon").publish).toBeTypeOf("function");
|
||||
expect(() => registry.register(registry.get("mastodon"))).toThrow(/already registered/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
import type { Pool } from "pg";
|
||||
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
|
||||
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
||||
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
||||
import { createPublishingCredentialAccessor } from "./credentials.js";
|
||||
import { providerFetch } from "./provider-fetch.js";
|
||||
import { executionSignal } from "./execution-context.js";
|
||||
import { createPublishingMediaAccess, mediaKekFromEnv, s3ObjectReaderFromEnv, type PublishingMediaAccess } from "./media.js";
|
||||
/** Production composition registers real connectors, once per worker process. */
|
||||
export function createPublishingRegistry(pool: Pool): ConnectorRegistry {
|
||||
export function createPublishingRegistry(pool: Pool, media?: PublishingMediaAccess): ConnectorRegistry {
|
||||
const registry = new ConnectorRegistry();
|
||||
const fetchImpl: typeof fetch = (input, init) => providerFetch(input, { ...init, signal: executionSignal() ?? init?.signal });
|
||||
const options = { credentials: createPublishingCredentialAccessor(pool), fetchImpl, env: process.env };
|
||||
const assets = media ?? createPublishingMediaAccess(pool, s3ObjectReaderFromEnv(), mediaKekFromEnv());
|
||||
const options = { credentials: createPublishingCredentialAccessor(pool), fetchImpl, env: process.env, loadAsset: assets.loadAsset, openAsset: assets.openAsset };
|
||||
registry.register(createMastodonConnector(options));
|
||||
registry.register(createTelegramConnector(options));
|
||||
// Connects and refuses text-only posts; image publishing needs a public media URL (not provided yet).
|
||||
registry.register(createInstagramConnector({ credentials: options.credentials, fetchImpl, env: process.env, loadAsset: assets.loadAsset }));
|
||||
return registry;
|
||||
}
|
||||
export { verifiedDedupeOrigin } from "./policy.js";
|
||||
|
|
|
|||
9
pnpm-lock.yaml
generated
|
|
@ -50,6 +50,9 @@ importers:
|
|||
'@opentelemetry/sdk-node':
|
||||
specifier: ^0.222.0
|
||||
version: 0.222.0(@opentelemetry/api@1.9.1)
|
||||
'@tactical-media-broadcast-hub/connector-instagram':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/connector-instagram
|
||||
'@tactical-media-broadcast-hub/connector-mastodon':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/connector-mastodon
|
||||
|
|
@ -499,6 +502,12 @@ importers:
|
|||
|
||||
packages/connector-runtime:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1146.0
|
||||
'@tactical-media-broadcast-hub/connector-instagram':
|
||||
specifier: workspace:*
|
||||
version: link:../connector-instagram
|
||||
'@tactical-media-broadcast-hub/connector-mastodon':
|
||||
specifier: workspace:*
|
||||
version: link:../connector-mastodon
|
||||
|
|
|
|||