multistreaming: scenes/composition, installer build support, service updates
- multistreaming (new): RTMP ingest + multi-platform fan-out with pluggable providers (Twitch/YouTube/Kick/custom), zero-knowledge key vaults, Authelia OIDC auth, shared rooms with editor/streamer roles, single-use invites, per-account streaming grants, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing). - installer: support Dockerfile build in metadata (not just image) and RSA key generation for the Authelia OIDC JWKS. - authelia: add OIDC provider with portainer + multistreaming clients (public + PKCE). - services: remove allprox; add nginx-proxy-manager and portainer; update lldap; regenerate catalog.
This commit is contained in:
parent
bb754cdd8c
commit
187379de4e
106 changed files with 21391 additions and 286 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
One folder per service. Each folder must contain a `metadata.json` describing the service
|
||||
(including the Docker Compose definition used to deploy it). The folder may also contain any extra
|
||||
files the service needs — a `Caddyfile`, a `Dockerfile`, static assets — which the installer copies
|
||||
files the service needs — a config file, a `Dockerfile`, static assets — which the installer copies
|
||||
into the deploy directory.
|
||||
|
||||
## Folder conventions
|
||||
|
|
@ -20,25 +20,27 @@ into the deploy directory.
|
|||
|
||||
## Extra files
|
||||
|
||||
A service folder can contain files beyond `metadata.json` — for example the `allprox` service ships
|
||||
a `Caddyfile` and a `portal/index.html`. On install/update the installer copies the whole folder
|
||||
(except `metadata.json`) into `~/.homelab/services/<id>/`, preserving subdirectories.
|
||||
A service folder can contain files beyond `metadata.json` — for example the `authelia` service ships
|
||||
a `configuration.yml` and a `users_database.yml`, while the `multistreaming` service ships a `Dockerfile`,
|
||||
`package.json`, `src/`, and `web/` for its `build`. On install/update the installer copies the whole
|
||||
folder (except `metadata.json`) into `~/.homelab/services/<id>/`, preserving subdirectories.
|
||||
|
||||
Reference those files from `compose.volumes` with **relative** paths:
|
||||
|
||||
```json
|
||||
"volumes": [
|
||||
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
||||
"./portal:/srv/portal:ro"
|
||||
"./configuration.yml:/config/configuration.yml:ro",
|
||||
"./users_database.yml:/config/users_database.yml:ro"
|
||||
]
|
||||
```
|
||||
|
||||
Because the generated `docker-compose.yml` lives in the same directory, `docker compose` resolves
|
||||
the `./...` paths against it.
|
||||
the `./...` paths against it — and, for `build` services, the `.` build context is that same
|
||||
directory, so the Dockerfile and source files are found automatically.
|
||||
|
||||
## Shared networks
|
||||
|
||||
Services that need to talk to each other (e.g. `allprox` → `authelia` → `lldap`) join an external
|
||||
Services that need to talk to each other (e.g. `nginx-proxy-manager` → `authelia` → `lldap`) join an external
|
||||
Docker network. Declare it at the top level of `metadata.json` and attach the service to it:
|
||||
|
||||
```json
|
||||
|
|
@ -77,8 +79,9 @@ A machine-readable JSON Schema is available at [`docs/metadata-schema.json`](../
|
|||
|
||||
### `compose` (the service definition)
|
||||
|
||||
This object is the value you would normally put under a service key in `docker-compose.yml`. For
|
||||
example:
|
||||
This object is the value you would normally put under a service key in `docker-compose.yml`. It must
|
||||
set **either** `image` (pull a prebuilt image) **or** `build` (build from a Dockerfile shipped in the
|
||||
service folder). Pull example:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -91,17 +94,24 @@ example:
|
|||
}
|
||||
```
|
||||
|
||||
The installer turns it into:
|
||||
Build example (the Dockerfile, source files, and `.dockerignore` live in the service folder and are
|
||||
copied into the deploy directory, so the build context is `.`):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myapp:
|
||||
image: ghcr.io/example/myapp:1.0.0
|
||||
# ...
|
||||
```json
|
||||
{
|
||||
"build": { "context": ".", "dockerfile": "Dockerfile" },
|
||||
"container_name": "myapp",
|
||||
"restart": "unless-stopped",
|
||||
"ports": ["8080:8080"],
|
||||
"volumes": ["myapp_data:/data"],
|
||||
"environment": ["TZ=${TZ}"]
|
||||
}
|
||||
```
|
||||
|
||||
Pin images to a tag so updates are predictable; the installer compares `version` to decide whether
|
||||
an update is available.
|
||||
The installer turns either into a `docker-compose.yml` service. On install it runs
|
||||
`docker compose build` (for `build` services) or pulls the image on update; then `docker compose up -d`
|
||||
brings the container up. For image-based services, pin the tag so updates are predictable — the
|
||||
installer compares `version` to decide whether an update is available.
|
||||
|
||||
### `env` (interactive variables)
|
||||
|
||||
|
|
@ -129,3 +139,23 @@ Each entry describes a variable the installer should collect (and write to `.env
|
|||
|
||||
Docker Compose automatically reads the `.env` written next to the generated compose file, so
|
||||
`${NAME}` references resolve at `docker compose up` time.
|
||||
|
||||
### `rsaKeys` (generated private keys)
|
||||
|
||||
For secrets that cannot be injected via environment variables — most notably Authelia's OIDC
|
||||
signing key (`identity_providers.oidc.jwks.key`, which rejects `$file:`/`$env:` secret references) —
|
||||
declare an RSA private key the installer generates into a file on first install:
|
||||
|
||||
```json
|
||||
"rsaKeys": [
|
||||
{ "name": "OIDC_JWKS_KEY", "path": "oidc-jwks.pem", "bits": 2048 }
|
||||
]
|
||||
```
|
||||
|
||||
- `name` (required) — human label.
|
||||
- `path` (required) — file written into the service's deploy directory, referenced by a relative
|
||||
`./path:...` bind mount in `compose.volumes`.
|
||||
- `bits` — RSA modulus size (default 2048).
|
||||
|
||||
The key is only created if the file is absent, so updating a service never rotates the key and
|
||||
invalidates existing sessions.
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
# allprox — Caddy reverse proxy
|
||||
#
|
||||
# Maps domains to local IPs and hosts an authenticated portal (web UI).
|
||||
# Edit the domain names below to match your DNS, and set the upstream
|
||||
# IP:port values in the installer's .env (APP1_UPSTREAM, APP2_UPSTREAM, ...).
|
||||
# Duplicate a "Proxied services" block to add more services.
|
||||
#
|
||||
# Authentication:
|
||||
# * Today: basic_auth (a single admin account; hash set via PORTAL_AUTH_HASH).
|
||||
# * Later: Authelia SSO — replace the basic_auth blocks with the forward_auth
|
||||
# block shown below (see https://www.authelia.com/integration/proxies/caddy/).
|
||||
|
||||
{
|
||||
# For public domains with automatic HTTPS, set your ACME email here:
|
||||
# email you@example.com
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web UI — authenticated portal (change the domain to your own)
|
||||
# ---------------------------------------------------------------------------
|
||||
portal.example.com {
|
||||
# `tls internal` issues a self-signed cert for LAN use. Remove this line
|
||||
# and set the ACME email above when exposing a real public domain.
|
||||
tls internal
|
||||
|
||||
basic_auth {
|
||||
admin {$PORTAL_AUTH_HASH}
|
||||
}
|
||||
|
||||
# Authelia SSO (later): replace the basic_auth block above with:
|
||||
# forward_auth authelia:9091 {
|
||||
# uri /api/authz/forward-auth
|
||||
# copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
# }
|
||||
|
||||
root * /srv/portal
|
||||
file_server
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authelia portal — the SSO login page (proxy to the authelia container).
|
||||
# With Authelia running, users are redirected here to sign in.
|
||||
# ---------------------------------------------------------------------------
|
||||
auth.example.com {
|
||||
tls internal
|
||||
reverse_proxy authelia:9091
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxied services — copy a block per service. The local IP:port comes from
|
||||
# the {$APPx_UPSTREAM} variable in .env.
|
||||
# ---------------------------------------------------------------------------
|
||||
app1.example.com {
|
||||
tls internal
|
||||
# basic_auth { admin {$PORTAL_AUTH_HASH} } # or the Authelia forward_auth block
|
||||
reverse_proxy {$APP1_UPSTREAM}
|
||||
}
|
||||
|
||||
app2.example.com {
|
||||
tls internal
|
||||
# basic_auth { admin {$PORTAL_AUTH_HASH} }
|
||||
reverse_proxy {$APP2_UPSTREAM}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
{
|
||||
"id": "allprox",
|
||||
"name": "allprox",
|
||||
"description": "Caddy reverse proxy that routes domains to local IPs, hosts an authenticated portal (web UI), and is SSO-ready for Authelia (OAuth2/OIDC).",
|
||||
"version": "1.0.0",
|
||||
"category": "network",
|
||||
"tags": ["reverse-proxy", "caddy", "authelia", "sso", "https"],
|
||||
"author": "Caddy / Authelia",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://caddyserver.com",
|
||||
"documentation": "https://www.authelia.com/integration/proxies/caddy/",
|
||||
"compose": {
|
||||
"image": "caddy:2-alpine",
|
||||
"container_name": "allprox",
|
||||
"restart": "unless-stopped",
|
||||
"ports": ["80:80", "443:443", "443:443/udp"],
|
||||
"volumes": [
|
||||
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
||||
"./portal:/srv/portal:ro",
|
||||
"allprox_data:/data",
|
||||
"allprox_config:/config"
|
||||
],
|
||||
"environment": [
|
||||
"APP1_UPSTREAM=${APP1_UPSTREAM}",
|
||||
"APP2_UPSTREAM=${APP2_UPSTREAM}",
|
||||
"PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}"
|
||||
],
|
||||
"networks": ["homelab"]
|
||||
},
|
||||
"volumes": {
|
||||
"allprox_data": {},
|
||||
"allprox_config": {}
|
||||
},
|
||||
"networks": {
|
||||
"homelab": { "external": true }
|
||||
},
|
||||
"env": [
|
||||
{
|
||||
"name": "PORTAL_AUTH_HASH",
|
||||
"label": "Portal admin password (bcrypt hash)",
|
||||
"description": "bcrypt hash for basic_auth (default is 'changeme'). Generate your own with: docker compose exec allprox caddy hash-password",
|
||||
"default": "$2b$10$fKkpKSlwLZtOBXpInl8pG.8mS65kiEfjOVsuvBj7ikHgtfqEa7h4y",
|
||||
"required": false,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "APP1_UPSTREAM",
|
||||
"label": "Upstream for app1.example.com",
|
||||
"description": "Local IP:port to proxy app1.example.com to",
|
||||
"default": "127.0.0.1:3000",
|
||||
"required": false,
|
||||
"secret": false
|
||||
},
|
||||
{
|
||||
"name": "APP2_UPSTREAM",
|
||||
"label": "Upstream for app2.example.com",
|
||||
"description": "Local IP:port to proxy app2.example.com to",
|
||||
"default": "127.0.0.1:8080",
|
||||
"required": false,
|
||||
"secret": false
|
||||
}
|
||||
],
|
||||
"notes": "Edit services/allprox/Caddyfile to add domains and change upstreams. The portal (web UI) is at portal.example.com (change the domain). Interim auth is basic_auth (admin / 'changeme' by default) — swap to the Authelia forward_auth block in the Caddyfile for OAuth2/OIDC SSO."
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>allprox — portal</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
main { width: 100%; max-width: 680px; padding: 2rem; }
|
||||
h1 { font-size: 2rem; margin: 0 0 .25rem; letter-spacing: -.02em; }
|
||||
.muted { color: #94a3b8; }
|
||||
.card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 1.1rem 1.35rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
.card strong { display: block; margin-bottom: .25rem; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: .55rem 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
li:last-child { border-bottom: none; }
|
||||
a { color: #7dd3fc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code {
|
||||
background: #0b1220;
|
||||
padding: .15rem .45rem;
|
||||
border-radius: 5px;
|
||||
font-size: .85em;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.badge {
|
||||
font-size: .7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: #86efac;
|
||||
border: 1px solid #166534;
|
||||
background: #052e16;
|
||||
padding: .15rem .5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>allprox <span class="badge">signed in</span></h1>
|
||||
<p class="muted">Authenticated access portal — you are signed in.</p>
|
||||
|
||||
<div class="card">
|
||||
<strong>Services</strong>
|
||||
<ul>
|
||||
<li><a href="https://app1.example.com">app1.example.com</a> <code>127.0.0.1:3000</code></li>
|
||||
<li><a href="https://app2.example.com">app2.example.com</a> <code>127.0.0.1:8080</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
Edit this page at <code>services/allprox/portal/index.html</code> and the proxy
|
||||
rules in <code>services/allprox/Caddyfile</code>.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
# Authelia configuration — https://www.authelia.com/configuration/prologue/introduction/
|
||||
#
|
||||
# Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD) are
|
||||
# resolved from the service's .env by the installer and substituted into this file
|
||||
# on install/update, so they are not committed here.
|
||||
# Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD,
|
||||
# OIDC_HMAC_SECRET, OIDC_PORTAINER_SECRET) are resolved from the service's .env
|
||||
# by the installer and substituted into this file on install/update, so they are
|
||||
# not committed here.
|
||||
#
|
||||
# The OIDC signing key (jwks) cannot be injected via env/file secrets (Authelia
|
||||
# does not support that for this field), so it is read from /config/oidc-jwks.pem
|
||||
# using the `template` file filter (enabled via X_AUTHELIA_CONFIG_FILTERS).
|
||||
# The installer generates that file on first install and never rotates it.
|
||||
|
||||
theme: dark
|
||||
|
||||
|
|
@ -41,8 +47,11 @@ access_control:
|
|||
rules:
|
||||
- domain: 'auth.example.com'
|
||||
policy: bypass
|
||||
- domain: 'portal.example.com'
|
||||
policy: one_factor
|
||||
# Everything else on your domain is SSO-protected (one factor by default).
|
||||
# Apps that talk to Authelia via OIDC (Portainer) are NOT governed by these
|
||||
# rules — their 2FA requirement lives in the client's authorization_policy.
|
||||
# Apps behind forward-auth (multistreaming, lldap, …) use these rules; bump a
|
||||
# specific host to two_factor if you want 2FA on it too.
|
||||
- domain: '*.example.com'
|
||||
policy: one_factor
|
||||
|
||||
|
|
@ -55,7 +64,7 @@ session:
|
|||
cookies:
|
||||
- domain: 'example.com'
|
||||
authelia_url: 'https://auth.example.com'
|
||||
default_redirection_url: 'https://portal.example.com'
|
||||
default_redirection_url: 'https://auth.example.com'
|
||||
|
||||
regulation:
|
||||
max_retries: 3
|
||||
|
|
@ -69,3 +78,54 @@ storage:
|
|||
notifier:
|
||||
filesystem:
|
||||
filename: '/config/notification.txt'
|
||||
|
||||
identity_providers:
|
||||
oidc:
|
||||
hmac_secret: '${OIDC_HMAC_SECRET}'
|
||||
jwks:
|
||||
- key_id: 'homelab'
|
||||
algorithm: 'RS256'
|
||||
use: 'sig'
|
||||
key: |
|
||||
{{- fileContent "/config/oidc-jwks.pem" | nindent 10 }}
|
||||
clients:
|
||||
- client_id: 'portainer'
|
||||
client_name: 'Portainer'
|
||||
client_secret: '${OIDC_PORTAINER_SECRET}'
|
||||
public: false
|
||||
redirect_uris:
|
||||
- 'https://portainer.example.com'
|
||||
scopes:
|
||||
- 'openid'
|
||||
- 'profile'
|
||||
- 'groups'
|
||||
- 'email'
|
||||
grant_types:
|
||||
- 'refresh_token'
|
||||
- 'authorization_code'
|
||||
response_types:
|
||||
- 'code'
|
||||
response_modes:
|
||||
- 'form_post'
|
||||
- 'query'
|
||||
authorization_policy: 'two_factor'
|
||||
- client_id: 'multistreaming'
|
||||
client_name: 'Multistreaming'
|
||||
# Public client: the panel is a browser SPA. No client_secret — it is
|
||||
# protected by PKCE (S256) instead, which is required for public clients.
|
||||
public: true
|
||||
redirect_uris:
|
||||
- 'https://streaming.example.com/api/auth/oidc/callback'
|
||||
scopes:
|
||||
- 'openid'
|
||||
- 'profile'
|
||||
- 'email'
|
||||
grant_types:
|
||||
- 'authorization_code'
|
||||
response_types:
|
||||
- 'code'
|
||||
response_modes:
|
||||
- 'query'
|
||||
require_pkce: true
|
||||
pkce_challenge_method: 'S256'
|
||||
authorization_policy: 'two_factor'
|
||||
|
|
|
|||
|
|
@ -1,23 +1,27 @@
|
|||
{
|
||||
"id": "authelia",
|
||||
"name": "Authelia",
|
||||
"description": "Open-source authentication and authorization server providing SSO and 2FA for the homelab.",
|
||||
"version": "1.0.0",
|
||||
"description": "Open-source authentication and authorization server providing SSO, 2FA, forward-auth, and an OIDC provider for the homelab.",
|
||||
"version": "3.0.0",
|
||||
"category": "identity",
|
||||
"tags": ["sso", "authentication", "2fa", "oidc", "forward-auth"],
|
||||
"tags": ["sso", "authentication", "2fa", "oidc", "forward-auth", "idp"],
|
||||
"author": "Authelia",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://www.authelia.com",
|
||||
"documentation": "https://www.authelia.com/configuration/prologue/introduction/",
|
||||
"compose": {
|
||||
"image": "authelia/authelia:latest",
|
||||
"image": "authelia/authelia:4.39.14",
|
||||
"container_name": "authelia",
|
||||
"restart": "unless-stopped",
|
||||
"ports": ["9091:9091"],
|
||||
"volumes": [
|
||||
"authelia_config:/config",
|
||||
"./configuration.yml:/config/configuration.yml:ro",
|
||||
"./users_database.yml:/config/users_database.yml:ro"
|
||||
"./users_database.yml:/config/users_database.yml:ro",
|
||||
"./oidc-jwks.pem:/config/oidc-jwks.pem:ro"
|
||||
],
|
||||
"environment": [
|
||||
"X_AUTHELIA_CONFIG_FILTERS=template"
|
||||
],
|
||||
"networks": ["homelab"]
|
||||
},
|
||||
|
|
@ -50,8 +54,26 @@
|
|||
"default": "changeme-admin",
|
||||
"required": false,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "OIDC_HMAC_SECRET",
|
||||
"label": "OIDC HMAC secret (auto-generated)",
|
||||
"generate": true
|
||||
},
|
||||
{
|
||||
"name": "OIDC_PORTAINER_SECRET",
|
||||
"label": "Portainer OIDC client secret (auto-generated)",
|
||||
"description": "Copy this value into Portainer's OAuth client-secret field",
|
||||
"generate": true
|
||||
}
|
||||
],
|
||||
"rsaKeys": [
|
||||
{
|
||||
"name": "OIDC_JWKS_KEY",
|
||||
"path": "oidc-jwks.pem",
|
||||
"bits": 2048
|
||||
}
|
||||
],
|
||||
"dependsOn": ["lldap"],
|
||||
"notes": "Authelia's portal is normally reached through allprox at auth.example.com (see the allprox Caddyfile). The forward-auth endpoint is http://authelia:9091/api/authz/forward-auth. Default access-control rules protect portal.example.com and *.example.com — edit configuration.yml to match your domains."
|
||||
"notes": "SSO / IdP: exposes a login portal (https://auth.example.com via nginx-proxy-manager) and a forward-auth endpoint (http://authelia:9091/api/authz/forward-auth). OIDC issuer: https://auth.example.com — discovery at /.well-known/openid-configuration. Two clients are pre-registered: 'portainer' (confidential, secret in OIDC_PORTAINER_SECRET) and 'multistreaming' (public + PKCE, no secret; redirect_uri https://streaming.example.com/api/auth/oidc/callback). For apps without OIDC (lldap web UI), protect them with a forward-auth auth_request block in nginx-proxy-manager's Advanced tab. The OIDC signing key (oidc-jwks.pem) is generated once by the installer and never rotated. Default access-control protects *.example.com with one factor; edit configuration.yml to change domains or require 2FA."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
{
|
||||
"generatedAt": "2026-09-01T16:20:56.550Z",
|
||||
"generatedAt": "2026-09-02T16:59:46.903Z",
|
||||
"services": [
|
||||
{
|
||||
"id": "allprox",
|
||||
"version": "1.0.0",
|
||||
"path": "services/allprox/metadata.json"
|
||||
},
|
||||
{
|
||||
"id": "authelia",
|
||||
"version": "1.0.0",
|
||||
"version": "3.0.0",
|
||||
"path": "services/authelia/metadata.json"
|
||||
},
|
||||
{
|
||||
"id": "lldap",
|
||||
"version": "1.0.0",
|
||||
"path": "services/lldap/metadata.json"
|
||||
},
|
||||
{
|
||||
"id": "multistreaming",
|
||||
"version": "4.1.0",
|
||||
"path": "services/multistreaming/metadata.json"
|
||||
},
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"version": "1.0.0",
|
||||
"path": "services/nginx-proxy-manager/metadata.json"
|
||||
},
|
||||
{
|
||||
"id": "portainer",
|
||||
"version": "1.0.0",
|
||||
"path": "services/portainer/metadata.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,5 +62,5 @@
|
|||
"generate": true
|
||||
}
|
||||
],
|
||||
"notes": "Web UI at http://<host>:17170 — log in with 'admin' and your admin password. LDAP endpoint is ldap://lldap:3890 (base DN dc=homelab,dc=local). Create users and groups here; Authelia authenticates against them."
|
||||
"notes": "Web UI at http://<host>:17170 — log in with 'admin' and your admin password. lldap's web UI has no OIDC, so if you expose it through nginx-proxy-manager, protect it with Authelia forward-auth (auth_request block in the proxy host's Advanced tab — see nginx-proxy-manager notes). LDAP endpoint is ldap://lldap:3890 (base DN dc=homelab,dc=local). Create users and groups here; Authelia authenticates against them and issues OIDC/SSO sessions from the same directory."
|
||||
}
|
||||
|
|
|
|||
13
services/multistreaming/.dockerignore
Normal file
13
services/multistreaming/.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
node_modules
|
||||
web/node_modules
|
||||
web/dist
|
||||
.smoke-data
|
||||
data
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
metadata.json
|
||||
docker-compose.yml
|
||||
.env
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
45
services/multistreaming/Dockerfile
Normal file
45
services/multistreaming/Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# multistreaming — ingest one RTMP feed and restream it to many targets.
|
||||
# Build: docker build -t multistreaming .
|
||||
# Run: docker run -p 1935:1935 -p 8080:8080 -v ms_data:/data multistreaming
|
||||
|
||||
# ---- frontend build stage ------------------------------------------------
|
||||
FROM node:22-alpine AS web
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---- backend deps stage --------------------------------------------------
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---- runtime stage --------------------------------------------------------
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# FFmpeg does the actual fan-out (remux passthrough, or re-encode for scenes).
|
||||
# font-dejavu is a hard runtime dependency of the drawtext text overlay filter.
|
||||
RUN apk add --no-cache ffmpeg font-dejavu
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY --from=web /web/dist ./web/dist
|
||||
|
||||
# Persistent state (vaults, rooms, accounts, feeds) lives here; mount a volume.
|
||||
# Pre-create it and hand ownership to the runtime user so a fresh named
|
||||
# volume is writable without root.
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
VOLUME ["/data"]
|
||||
ENV DATA_DIR=/data \
|
||||
RTMP_PORT=1935 \
|
||||
HTTP_PORT=8080
|
||||
|
||||
EXPOSE 1935 8080
|
||||
|
||||
USER node
|
||||
CMD ["node", "src/index.js"]
|
||||
234
services/multistreaming/README.md
Normal file
234
services/multistreaming/README.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# multistreaming
|
||||
|
||||
Self-hosted live production dashboard: ingest RTMP feeds (one per person from
|
||||
OBS) and restream them to one or more streaming platforms — with shared rooms,
|
||||
role-based collaboration, a zero-knowledge key vault, and scenes & composition
|
||||
(grid/PiP layouts, text/image overlays, per-output audio routing).
|
||||
|
||||
- **Panel**: React + TypeScript + shadcn/ui (Tailwind v4) at
|
||||
`http://<host>:8080`.
|
||||
- **Ingest**: RTMP at `rtmp://<host>:1935/live/<streamKey>`.
|
||||
- **Fan-out**: FFmpeg, `-c copy` remux by default; re-encode only for composed
|
||||
scenes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Features
|
||||
|
||||
- **Per-person feeds** — every streamer publishes to their own ingest URL with a
|
||||
unique stream key.
|
||||
- **Rooms & roles** — share a production with `owner` (full control), `editor`
|
||||
(edits production, never sees keys), and `streamer` (own accounts + start
|
||||
streaming) roles via single-use invite links.
|
||||
- **Pluggable providers** — Twitch, YouTube, Kick, and a custom RTMP escape
|
||||
hatch, extensible in one file.
|
||||
- **Zero-knowledge vault** — stream keys are encrypted in the browser
|
||||
(AES-GCM under a PBKDF2-wrapped vault key); the server stores only ciphertext
|
||||
and grants keys transiently, in memory, when a streamer clicks *Start
|
||||
streaming*.
|
||||
- **Scenes & composition** — named scenes with grid or picture-in-picture
|
||||
layouts, text and image overlays, and per-output audio routing. Activating a
|
||||
scene switches the room from passthrough to a composed program.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
OBS ──RTMP──▶ node-media-server (ingest, :1935)
|
||||
│ publish/unpublish events
|
||||
▼
|
||||
index.js ── reconcileAll()
|
||||
│
|
||||
┌───────────┴────────────┐
|
||||
▼ ▼
|
||||
passthrough scene (composed)
|
||||
fanout.js (engine) compose.js → fanout.js
|
||||
ffmpeg -c copy ffmpeg -filter_complex (xstack/overlay/drawtext)
|
||||
│ │
|
||||
└───────────┬────────────┘
|
||||
▼
|
||||
platform push URLs (Twitch/YouTube/Kick/custom, resolved from in-memory grants)
|
||||
```
|
||||
|
||||
| Component | Path | Role |
|
||||
|-----------|------|------|
|
||||
| `src/index.js` | orchestrator | wires config/store/fanout/server; tracks live feeds; `reconcileAll()` |
|
||||
| `src/server.js` | REST API | Express routes, role gating, `/api/state` |
|
||||
| `src/store.js` | persistence | atomic JSON store for users/vaults/rooms/memberships/invites/accounts/feeds/outputs/scenes/sceneOutputs/images |
|
||||
| `src/fanout.js` | process engine | generic unit-of-work engine: spawn/kill/restart/reconcile FFmpeg children |
|
||||
| `src/compose.js` | composition | builds FFmpeg filter graphs + argv for scenes |
|
||||
| `src/vault.js` | crypto | zero-knowledge vault enroll/unlock/recover (WebCrypto) |
|
||||
| `src/grants.js` | grants | in-memory, TTL-bounded plaintext key grants |
|
||||
| `src/auth.js` / `src/oidc.js` | auth | sessions + local scrypt / OIDC PKCE |
|
||||
| `src/providers.js` | providers | pluggable platform definitions |
|
||||
| `src/config.js` | config | environment → runtime config |
|
||||
| `web/src/` | frontend | React/shadcn panel |
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory layout
|
||||
|
||||
```
|
||||
multistreaming/
|
||||
├── src/ # CommonJS backend
|
||||
├── web/ # React + TS + shadcn frontend (src/, built to web/dist)
|
||||
├── docs/SCENES.md # scene & composition design contract
|
||||
├── Dockerfile # multi-stage: web build → backend deps → runtime (node + ffmpeg)
|
||||
├── metadata.json # catalog metadata (version, compose, env)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
Environment variables (see also `metadata.json` for the UI descriptions):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `AUTH_MODE` | `oidc` | `oidc` (Authelia) or `local` (dev) |
|
||||
| `OIDC_ISSUER` | — | Authelia root URL (OIDC discovery) |
|
||||
| `OIDC_CLIENT_ID` | `multistreaming` | OIDC client id |
|
||||
| `OIDC_REDIRECT_URI` | — | must match Authelia redirect_uris |
|
||||
| `SESSION_SECRET` | auto | HMAC secret for session cookies (set it to a stable random value) |
|
||||
| `PUBLIC_HOST` | `''` | host shown in copy-paste ingest URLs |
|
||||
| `GRANT_TTL_MS` | `21600000` | plaintext-key grant lifetime (6 h) |
|
||||
| `RTMP_PORT` | `1935` | RTMP ingest port |
|
||||
| `HTTP_PORT` | `8080` | panel + API port |
|
||||
| `DATA_DIR` | `./data` | persisted state + `uploads/` |
|
||||
| `FFMPEG_PATH` | `ffmpeg` | ffmpeg binary |
|
||||
| `SCENE_WIDTH` / `SCENE_HEIGHT` | `1920` / `1080` | composed canvas |
|
||||
| `SCENE_FPS` | `30` | composed frame rate |
|
||||
| `SCENE_VIDEO_BITRATE` | `4500k` | composed video bitrate |
|
||||
| `SCENE_AUDIO_BITRATE` | `160k` | composed audio bitrate |
|
||||
| `SCENE_FONT` | DejaVu Sans | drawtext font path (installed in the image) |
|
||||
| `SCENE_FONT_BOLD` | DejaVu Sans Bold | bold drawtext font path |
|
||||
| `SCENE_MAX_IMAGE_BYTES` | `1048576` | image overlay upload cap (1 MiB) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Running
|
||||
|
||||
Build and run with the repository's catalog tooling (metadata.json provides the
|
||||
compose service) or directly:
|
||||
|
||||
```bash
|
||||
docker build -t multistreaming .
|
||||
docker run -p 1935:1935 -p 8080:8080 \
|
||||
-v multistreaming_data:/data \
|
||||
-e AUTH_MODE=oidc \
|
||||
-e OIDC_ISSUER=https://auth.example.com \
|
||||
-e OIDC_REDIRECT_URI=https://streaming.example.com/api/auth/oidc/callback \
|
||||
-e SESSION_SECRET=… \
|
||||
multistreaming
|
||||
```
|
||||
|
||||
- Panel: `http://<host>:8080` (put it behind nginx-proxy-manager for TLS).
|
||||
- Ingest: `rtmp://<host>:1935/live/<streamKey>` — RTMP bypasses the HTTP proxy,
|
||||
so keep port 1935 on a trusted network.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ingest URL model
|
||||
|
||||
Each **feed** has a unique `streamKey`. Point OBS at:
|
||||
|
||||
```
|
||||
rtmp://<PUBLIC_HOST>:1935/live/<streamKey>
|
||||
```
|
||||
|
||||
(`PUBLIC_HOST` only affects the copy-paste URL shown in the UI; ingest always
|
||||
hits the container's port 1935.) The `live` app name is fixed (`ingestApp`).
|
||||
When a feed publishes, the orchestrator fans it out to its enabled outputs (or,
|
||||
if a scene is active, feeds the composed program).
|
||||
|
||||
---
|
||||
|
||||
## 7. Providers
|
||||
|
||||
Providers are defined in `src/providers.js`. Each extends the `Provider` base
|
||||
class and implements:
|
||||
|
||||
- `fields` — the fields the UI asks the user for (`name`, `label`, `secret?`,
|
||||
`required?`, `placeholder?`, `help?`),
|
||||
- `defaultUrl` — the platform's RTMP server URL,
|
||||
- `pushUrl(config)` — how the key is appended (default: `url + "/" + key`),
|
||||
- `describe(config)` / `validate(config)` — copy-safe summary + validation.
|
||||
|
||||
To add a provider, add a derived class and register it in `PROVIDERS`:
|
||||
|
||||
```js
|
||||
class NewPlatformProvider extends Provider {
|
||||
constructor() {
|
||||
super({
|
||||
id: "newplatform",
|
||||
name: "New Platform",
|
||||
defaultUrl: "rtmp://ingest.newplatform.example/live",
|
||||
fields: [{ name: "key", label: "Stream key", secret: true, required: true }],
|
||||
});
|
||||
}
|
||||
}
|
||||
// …then push `new NewPlatformProvider()` into PROVIDERS.
|
||||
```
|
||||
|
||||
The `custom` provider is a free-form RTMP endpoint for anything not explicitly
|
||||
supported.
|
||||
|
||||
---
|
||||
|
||||
## 8. Scenes & composition
|
||||
|
||||
A **scene** is a named composition for a room (see `docs/SCENES.md` for the full
|
||||
contract):
|
||||
|
||||
- **Layouts** — `grid` (2–4 columns, up to 6 slots) or `pip` (main + a
|
||||
picture-in-picture window). Each slot assigns a feed (or stays empty). Only
|
||||
*live* feeds are laid out; empty/offline slots are skipped and a single live
|
||||
feed is promoted to fullscreen.
|
||||
- **Overlays** — text (position, size, color, bold) and images (position +
|
||||
size, ≤ 1 MiB, PNG/JPEG/WebP).
|
||||
- **Audio routing** — each scene **output** (destination) chooses `program`
|
||||
(mixed audio of all live feeds), `silent`, or a specific `feed`'s audio.
|
||||
- **Activation** — activating a scene switches the room into composed mode
|
||||
(per-feed passthrough pauses); deactivating restores passthrough.
|
||||
|
||||
Composed video is re-encoded (H.264 + AAC) per destination so each output can
|
||||
carry different audio. The FFmpeg filter graph is generated entirely by
|
||||
`src/compose.js`; no scene data ever stores a stream key.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security
|
||||
|
||||
See [SECURITY.md](./SECURITY.md) for the full threat model. Highlights:
|
||||
|
||||
- Stream keys are encrypted in the browser; the server persists only
|
||||
`secretCiphertext` + `serverWrapped`.
|
||||
- Plaintext keys exist server-side only as short-lived, in-memory grants.
|
||||
- FFmpeg logs redact destination URLs; overlay text cannot inject FFmpeg
|
||||
arguments; image uploads are size/type-limited.
|
||||
- Roles and `maskAccount` ensure editors/streamers never see others' keys.
|
||||
|
||||
The honest limit: the machine that pushes a stream must hold the key at push
|
||||
time — if the host is compromised during a stream, live keys are readable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Development
|
||||
|
||||
No runtime tests are run by design; verification is static.
|
||||
|
||||
```bash
|
||||
# backend syntax check
|
||||
node --check src/index.js && node --check src/fanout.js && \
|
||||
node --check src/server.js && node --check src/store.js && \
|
||||
node --check src/compose.js && node --check src/config.js
|
||||
|
||||
# frontend typecheck + build
|
||||
cd web && npm install && npm run build
|
||||
```
|
||||
|
||||
`npm run build` runs `tsc -b && vite build` and emits `web/dist`, which the
|
||||
backend serves at `/`. A `npm run dev` Vite server may be used for manual web
|
||||
inspection during development.
|
||||
220
services/multistreaming/SECURITY.md
Normal file
220
services/multistreaming/SECURITY.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# SECURITY.md — multistreaming
|
||||
|
||||
This document describes the security model of the self-hosted multistreaming
|
||||
service, its threat model, and — explicitly and honestly — the limits of what it
|
||||
can protect. Read it before exposing the service to anything but a trusted
|
||||
network.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this service does
|
||||
|
||||
`multistreaming` ingests RTMP feeds (typically one per person from OBS) and
|
||||
restreams them to one or more streaming platforms (Twitch, YouTube, Kick, or a
|
||||
custom RTMP endpoint). Two programming modes exist:
|
||||
|
||||
- **Passthrough** — each live feed is remuxed (`-c copy`) to its destination
|
||||
accounts without re-encoding.
|
||||
- **Scenes & composition** — multiple live feeds are composed (grid or
|
||||
picture-in-picture, plus text/image overlays) and re-encoded into a single
|
||||
program pushed to destination accounts, with per-output audio routing.
|
||||
|
||||
To push to a platform, the service must present that platform's **stream key**.
|
||||
Stream keys are the highest-value secret in the system.
|
||||
|
||||
---
|
||||
|
||||
## 2. Threat model
|
||||
|
||||
### Assets
|
||||
|
||||
| Asset | Sensitivity |
|
||||
|-------|-------------|
|
||||
| Platform stream keys (Twitch/YouTube/Kick/custom) | **Highest** — compromise = account takeover of that channel |
|
||||
| Vault master passphrase / vault key (VK) | Highest — decrypts all keys in a vault |
|
||||
| Account metadata, room topology, scene definitions | Low–moderate |
|
||||
| Composed/relayed video | As sensitive as the stream content itself |
|
||||
|
||||
### Actors
|
||||
|
||||
| Actor | Assumption |
|
||||
|-------|-----------|
|
||||
| Homelab operator / host root | **Fully trusted.** Can read any memory/disk on the machine. |
|
||||
| Service account (the container) | Trusted with plaintext keys *at push time only*. |
|
||||
| Vault owner | Holds the master passphrase; controls their own accounts. |
|
||||
| Room **owner** | Full control of a room's production; sees own keys only. |
|
||||
| Room **editor** | Edits feeds/outputs/scenes; **never** sees anyone's keys. |
|
||||
| Room **streamer** | Manages their own accounts + grants their own keys; read-only on production. |
|
||||
| Streaming platform | External; receives the pushed stream. |
|
||||
| Network observer (LAN/internet) | Assumed able to observe traffic unless TLS/VPN is used. |
|
||||
|
||||
### Trust zones
|
||||
|
||||
1. **Browser** — holds the vault key (VK) only while a vault is unlocked, in
|
||||
memory; encrypts/decrypts keys client-side.
|
||||
2. **Panel server (the container, the "center machine")** — persists only
|
||||
ciphertext; holds plaintext keys transiently in memory during a stream.
|
||||
3. **Ingest path** — RTMP from OBS to the container on port 1935.
|
||||
|
||||
---
|
||||
|
||||
## 3. Zero-knowledge vault
|
||||
|
||||
The server **never stores a plaintext stream key**. Account secrets are stored as
|
||||
`secretCiphertext` (`{iv, data}`, AES-GCM) encrypted under a random 256-bit vault
|
||||
key (VK) that only the user's browser holds after unlock.
|
||||
|
||||
Key hierarchy (`src/vault.js`):
|
||||
|
||||
```
|
||||
master passphrase ──PBKDF2-HMAC-SHA256 (600k)──▶ KEK ──wraps──▶ VK ──encrypts──▶ account secrets
|
||||
```
|
||||
|
||||
- `salt` — random 16 bytes, stored server-side.
|
||||
- `serverWrapped` — the VK wrapped by the KEK under AAD `multistreaming:vault:v1`.
|
||||
Stored server-side; useless without the master passphrase.
|
||||
- `deviceWrapped` — the VK wrapped by a non-extractable browser device key under
|
||||
AAD `fingerprint|sessionId`. Enables silent unlock and is **session-bound**: a
|
||||
different session id or browser fingerprint fails AES-GCM authentication.
|
||||
- Each account secret is encrypted under the VK with AAD `acct:<accountId>`, so
|
||||
a ciphertext cannot be replayed against a different account id.
|
||||
|
||||
Consequences:
|
||||
|
||||
- A database/disk leak of `config.json` yields only PBKDF2-wrapped blobs and
|
||||
per-account ciphertexts — nothing decryptable without the passphrase or the
|
||||
device key.
|
||||
- There is **no password recovery**. Losing the passphrase and the device
|
||||
wrapping means the keys are unrecoverable (by design).
|
||||
|
||||
---
|
||||
|
||||
## 4. Capability grants (keys in memory)
|
||||
|
||||
The zero-knowledge property cannot extend all the way to the push: **some machine
|
||||
must present the plaintext key to the platform at push time.** That machine is
|
||||
the multistreaming container.
|
||||
|
||||
To minimize exposure, the service uses **short-lived, in-memory capability
|
||||
grants** (`src/grants.js`):
|
||||
|
||||
- A streamer clicks **Start streaming**; their browser decrypts the selected
|
||||
keys client-side and sends them to the server over the panel connection.
|
||||
- The server stores them only in memory: `accountId → { key, grantedBy, expiresAt }`,
|
||||
never on disk.
|
||||
- Lifetime is `GRANT_TTL_MS` (default 6 hours). Expired grants are dropped on
|
||||
access.
|
||||
- Grants are revoked when the streamer clicks **Stop streaming**, when the
|
||||
account is deleted, or on expiry.
|
||||
- FFmpeg resolves the destination URL from the live grant at spawn time; the
|
||||
URL (which contains the key) is **redacted** in all logs (`redactUrl`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication & authorization
|
||||
|
||||
- **Sessions** (`src/auth.js`): HMAC-SHA256-signed tokens
|
||||
(`base64url({sid, uid, iat}).signature`) in an `HttpOnly; SameSite=Lax` cookie,
|
||||
12-hour lifetime. `SESSION_SECRET` should be set to a stable random value; if
|
||||
unset it is generated per boot (sessions do not survive restarts).
|
||||
- **Local auth** (dev only): scrypt password hashing, constant-time comparison.
|
||||
- **OIDC** (recommended, Authelia): authorization-code + PKCE (S256), with the
|
||||
verifier held server-side (10-minute state). Identity is mapped to a local
|
||||
user record by `sub`/username. OIDC is for authentication only — it never
|
||||
touches vault key material.
|
||||
|
||||
### Per-room roles
|
||||
|
||||
| Capability | owner | editor | streamer |
|
||||
|------------|:-----:|:------:|:--------:|
|
||||
| Manage feeds, outputs, scenes, scene outputs, images | ✅ | ✅ | — |
|
||||
| Activate/deactivate scenes | ✅ | ✅ | — |
|
||||
| View room production | ✅ | ✅ | ✅ |
|
||||
| Manage own accounts + grant own keys | ✅ | ✅ | ✅ |
|
||||
| See another user's `secretCiphertext` | — | — | — |
|
||||
|
||||
Non-members receive `404` (never `403`) on room-scoped reads, and insufficient
|
||||
roles receive `403`. `maskAccount` strips `secretCiphertext` from every account
|
||||
unless the viewer is its owner, so editors and streamers never see others' keys.
|
||||
|
||||
---
|
||||
|
||||
## 6. Single-use invites
|
||||
|
||||
`POST /api/rooms/:roomId/invites` (owner only) mints an invite with a 256-bit
|
||||
random token, a role of `editor` or `streamer` (never `owner`), and a 7-day
|
||||
expiry. Accepting an invite consumes it (deletes the record) and creates the
|
||||
membership. Invites are:
|
||||
|
||||
- single-use (consumed on accept),
|
||||
- expiring (pruned at load and on lookup),
|
||||
- role-limited (cannot mint an owner),
|
||||
- unguessable (256-bit token).
|
||||
|
||||
---
|
||||
|
||||
## 7. The inherent trust boundary (be explicit)
|
||||
|
||||
**If you compromise the host (or the multistreaming container while a stream is
|
||||
running), you can read every key that is currently granted.** That is not a bug;
|
||||
it is the fundamental limit of any restreaming service — the machine that pushes
|
||||
must hold the key at push time.
|
||||
|
||||
The mitigations are scope and time, not prevention:
|
||||
|
||||
1. Keys are held only in memory, only for granted accounts, and only for
|
||||
`GRANT_TTL_MS`.
|
||||
2. Revocation is one click ("Stop streaming") or automatic on expiry.
|
||||
3. Keys are never written to disk, never logged, and never sent to editors,
|
||||
streamers, or other room members.
|
||||
|
||||
If the host is untrusted, do not run this service on it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Scene & composition security
|
||||
|
||||
- **FFmpeg argument injection** is prevented by: spawning via an argument array
|
||||
(no shell) and escaping overlay text for FFmpeg's filtergraph parser
|
||||
(`escapeDrawtext`: control chars stripped, `\` and `'` escaped) plus
|
||||
`drawtext=...:expansion=none`, a 256-character cap, and NUL stripping.
|
||||
- **Image overlays** accept only decoded payloads ≤ 1 MiB whose magic bytes are
|
||||
PNG/JPEG/WebP; files are stored under `dataDir/uploads` with server-generated
|
||||
UUID filenames (never client-supplied paths), and the path is single-quote
|
||||
escaped before reaching the `movie` filter.
|
||||
- **Log redaction** applies to composed outputs exactly as to passthrough:
|
||||
destination URLs (which embed keys) are replaced before logging.
|
||||
|
||||
---
|
||||
|
||||
## 9. Residual risks & known limits
|
||||
|
||||
1. **Center-machine memory** — see §7. Root/host access during a stream reads
|
||||
live keys.
|
||||
2. **No CSP / XSS hardening** — the panel does not currently ship a strict
|
||||
Content-Security-Policy. A browser XSS could read a decrypted key while the
|
||||
vault is unlocked. Run the panel behind a TLS reverse proxy and keep
|
||||
dependencies updated.
|
||||
3. **Plaintext RTMP ingest** — the ingest path (port 1935) is unencrypted and
|
||||
bypasses the HTTP proxy by design. Keep it on a trusted LAN/VPN; do not expose
|
||||
1935 to the internet.
|
||||
4. **Panel TLS** — the container serves HTTP on 8080; TLS termination is
|
||||
expected at a reverse proxy (nginx-proxy-manager).
|
||||
5. **Local auth mode** — `AUTH_MODE=local` is intended for development; OIDC is
|
||||
the recommended production mode.
|
||||
6. **No audit log** — grant/activate/invite events are not persisted for audit.
|
||||
7. **Composition re-encode** — scene mode decodes and re-encodes feeds on the
|
||||
server; this does not weaken key handling but is a CPU cost and a single
|
||||
point of processing.
|
||||
|
||||
---
|
||||
|
||||
## 10. Operational checklist
|
||||
|
||||
- Set a stable, high-entropy `SESSION_SECRET`.
|
||||
- Use `AUTH_MODE=oidc` behind Authelia with PKCE and matching `OIDC_REDIRECT_URI`.
|
||||
- Terminate TLS at the reverse proxy; expose only the panel there, not 1935.
|
||||
- Keep RTMP ingest on a private network.
|
||||
- Leave `GRANT_TTL_MS` as short as operationally comfortable.
|
||||
- Back up `dataDir` (it holds the only copy of wrapped keys; there is no recovery
|
||||
without the passphrase).
|
||||
684
services/multistreaming/docs/SCENES.md
Normal file
684
services/multistreaming/docs/SCENES.md
Normal file
|
|
@ -0,0 +1,684 @@
|
|||
# SCENES — Scene & Composition Design Contract
|
||||
|
||||
Status: **authoritative spec** for Phase 3. Backend (`t2`) and frontend (`t3`)
|
||||
implementation tasks consume this document and must not make design decisions
|
||||
that contradict it.
|
||||
|
||||
- Scope: `services/multistreaming` (CommonJS backend in `src/`, React/TS/shadcn
|
||||
frontend in `web/src/`).
|
||||
- Constraint: no runtime tests. Verification is `node --check` (backend) and
|
||||
`npm run build` (web = `tsc -b && vite build`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Today a room is a passthrough router: each published feed is remuxed
|
||||
(`-c copy`) to every enabled output that routes that feed. Scenes add a second
|
||||
programming mode for a room:
|
||||
|
||||
1. A **scene** is a named, persisted composition for a room: a layout (grid or
|
||||
picture-in-picture) that places live feeds, plus text/image overlays.
|
||||
2. A scene has **scene outputs** (destinations) — one per platform account the
|
||||
composed program is pushed to — each with its own **audio routing**.
|
||||
3. Activating a scene switches the room from passthrough mode to composed mode.
|
||||
In composed mode the room pushes the composed program (re-encoded) instead of
|
||||
raw per-feed passthrough. Deactivating the scene restores passthrough.
|
||||
4. **Passthrough mode (default) is unchanged**: with no composed scene active,
|
||||
fan-out stays `-c copy` exactly as today.
|
||||
|
||||
Security invariants are unchanged and extended in §8.
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| feed | A single OBS ingest publishing to `rtmp://<host>:1935/live/<streamKey>`. |
|
||||
| live feed | A feed currently publishing (tracked from RTMP publish events). |
|
||||
| output | Existing passthrough routing record (`feedId → accountId`). Unchanged. |
|
||||
| scene | A named composition definition for a room. |
|
||||
| scene output | A destination for the composed program (`sceneId → accountId`) with an audio mode. |
|
||||
| slot | A position in the layout, referenced by index; holds a `feedId` or `null`. |
|
||||
| overlay | A text or image element drawn on top of the composed video. |
|
||||
| program audio | The mixed audio of all live feeds participating in the scene. |
|
||||
| canvas | The composed video frame size (default 1920×1080). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Persisted data model
|
||||
|
||||
`Store` gains three collections, exactly like the existing ones, normalized in
|
||||
`_normalize` and written atomically by `_save`:
|
||||
|
||||
```js
|
||||
// store.js state shape additions
|
||||
{
|
||||
scenes: [ Scene ],
|
||||
sceneOutputs: [ SceneOutput ],
|
||||
images: [ RoomImage ],
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 `Scene`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "uuid",
|
||||
"roomId": "uuid",
|
||||
"ownerId": "uuid", // creator; informational only
|
||||
"name": "Main show", // trimmed, 1..64 chars
|
||||
"active": false, // at most one true per room
|
||||
"layout": {
|
||||
"type": "grid", // "grid" | "pip"
|
||||
"columns": 2, // grid ONLY: integer 2..4
|
||||
"slots": ["feedId", null] // Array<string|null>; see slot rules
|
||||
},
|
||||
"overlays": [
|
||||
// text overlay
|
||||
{ "id": "uuid", "kind": "text", "text": "LIVE", "x": 32, "y": 32,
|
||||
"fontSize": 48, "color": "#ffffff", "bold": true },
|
||||
// image overlay
|
||||
{ "id": "uuid", "kind": "image", "imageId": "uuid", "x": 16, "y": 844,
|
||||
"width": 200, "height": 112, "opacity": 1.0 }
|
||||
],
|
||||
"createdAt": 1700000000000,
|
||||
"updatedAt": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
**Slot rules**
|
||||
|
||||
- `slots` is an array indexed by slot number. Each element is a `feedId` string
|
||||
(must be a feed in the same room) or `null` (empty).
|
||||
- `grid`: `slots.length` ∈ 1..6 (the cell order, row-major). `columns` ∈ 2..4.
|
||||
- `pip`: `slots.length` == 2 — index 0 is the **main** (fullscreen) feed, index 1
|
||||
is the **PiP** window feed.
|
||||
- Unknown `feedId`, duplicate `feedId`, or out-of-range slot length is rejected
|
||||
with HTTP 400 (see §6.2 validation).
|
||||
|
||||
**Layout is always valid JSON with no secrets.** A scene never stores stream keys.
|
||||
|
||||
### 3.2 `SceneOutput`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "uuid",
|
||||
"roomId": "uuid",
|
||||
"sceneId": "uuid", // must be a scene in the same room
|
||||
"accountId": "uuid", // must be an account in the room (accountsInRoom)
|
||||
"enabled": true,
|
||||
"audio": { "mode": "program" } // | { mode: "silent" }
|
||||
// | { mode: "feed", "feedId": "uuid" }
|
||||
}
|
||||
```
|
||||
|
||||
- `audio.mode`:
|
||||
- `"program"` — the mixed program audio (§5.4).
|
||||
- `"silent"` — no audio stream (`-an`).
|
||||
- `"feed"` — one specific feed's audio (`feedId` required, feed in the same room).
|
||||
- Enabled scene outputs are the push destinations when the scene is active.
|
||||
|
||||
### 3.3 `RoomImage`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "uuid",
|
||||
"roomId": "uuid",
|
||||
"name": "logo.png", // sanitized, 1..64 chars
|
||||
"mime": "image/png", // sniffed: image/png | image/jpeg | image/webp
|
||||
"size": 48231, // decoded bytes
|
||||
"path": "uploads/<uuid>.<ext>", // server-generated; never user input
|
||||
"createdAt": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
- Files live under `<dataDir>/uploads/`. The stored basename matches
|
||||
`/^[a-f0-9-]{36}\.(png|jpg|webp)$/` (UUID + sniffed extension). The client
|
||||
never chooses the path.
|
||||
|
||||
### 3.4 Validation (server-side, authoritative)
|
||||
|
||||
- Scene `name`: trimmed string, 1..64 chars.
|
||||
- `layout.type`: `"grid"` or `"pip"` (else 400).
|
||||
- Grid: `columns` integer 2..4; `slots` array length 1..6.
|
||||
- PiP: `slots` array length exactly 2.
|
||||
- Every non-null slot `feedId` must exist in `store.listFeeds(roomId)`.
|
||||
- `overlays` array length ≤ 8.
|
||||
- text: `text` trimmed non-empty, ≤ 256 chars; `x` ∈ [0,1920) integer,
|
||||
`y` ∈ [0,1080) integer; `fontSize` ∈ 8..144 integer; `color` matches
|
||||
`/^#[0-9a-fA-F]{6}$/`; `bold` optional boolean.
|
||||
- image: `imageId` must be a `RoomImage` in the same room; `x`,`y`,`width`,
|
||||
`height` integers with `width`,`height` ∈ 16..1920/1080 respectively;
|
||||
`opacity` optional ∈ [0,1].
|
||||
- `sceneOutput`: `sceneId` in room; `accountId` in `store.accountsInRoom(roomId)`;
|
||||
`audio.mode` one of the three; `mode === "feed"` requires a valid room `feedId`.
|
||||
- Image upload: decoded size ≤ `SCENE_MAX_IMAGE_BYTES` (default 1 MiB); type
|
||||
sniffed from magic bytes (PNG `89 50 4E 47`, JPEG `FF D8 FF`, WebP
|
||||
`RIFF....WEBP`). Client-declared MIME is **not** trusted.
|
||||
|
||||
### 3.5 Pruning (deletion hooks)
|
||||
|
||||
- `removeFeed(id)` additionally: in every scene of the same room set each slot
|
||||
element equal to `id` to `null`; for every sceneOutput whose
|
||||
`audio.mode === "feed"` and `audio.feedId === id`, set `audio = { mode: "silent" }`.
|
||||
- `removeAccount(id)` additionally: delete sceneOutputs with `accountId === id`
|
||||
(mirrors existing output pruning).
|
||||
- `removeScene(id)`: delete sceneOutputs with `sceneId === id`. If it was the
|
||||
active scene, no scene is active; reconcile stops the scene fanout.
|
||||
- `removeImage(id)`: delete the file, the record, and every overlay item
|
||||
(`kind === "image"`) across the room's scenes that references `imageId`.
|
||||
- `_normalize`: add the three arrays; defensively strip scene slots referencing
|
||||
unknown feeds, sceneOutputs referencing unknown scenes/accounts, and overlay
|
||||
image items referencing unknown images; if more than one scene is `active`
|
||||
per room, keep the first and clear the rest.
|
||||
|
||||
---
|
||||
|
||||
## 4. Composition engine — `src/compose.js`
|
||||
|
||||
New module. It contains **all** FFmpeg knowledge and produces argument arrays;
|
||||
`fanout.js` stays lifecycle-only. Exports:
|
||||
|
||||
- `CANVAS` — `{ width, height, fps, videoBitrate, audioBitrate }` resolved from
|
||||
config (§9).
|
||||
- `escapeDrawtext(text)` and `escapeFilterPath(path)` — escaping helpers (§5.5).
|
||||
- `buildScenePlan({ scene, liveFeeds, getInputUrl, destinations, config })` →
|
||||
a plan object or `null` (null when zero live feeds, i.e. nothing to compose).
|
||||
|
||||
```js
|
||||
// buildScenePlan output (passthrough to fanout, never persisted)
|
||||
{
|
||||
inputs: [ { feedId, url } ], // live feeds in slot order
|
||||
outputs: [ { outputId, url, args, safeArgs } ] // one ffmpeg argv per destination
|
||||
}
|
||||
```
|
||||
|
||||
`fanout.js` spawns **one ffmpeg child per `outputs[]` entry**, each reading all
|
||||
`inputs[]` and applying its own audio `-map` (see §5.4). This mirrors the
|
||||
existing per-output child model and keeps restart/reconcile reuseable. It means
|
||||
video is re-encoded once per destination — an accepted, documented trade-off for
|
||||
a small self-hosted tool (per-output audio routing cannot be shared through a
|
||||
single `tee` muxer without identical audio).
|
||||
|
||||
### 4.1 Inputs
|
||||
|
||||
For a scene with `slots`, the live inputs are the slots' feeds that are
|
||||
currently live, **in slot order** (null/non-live slots skipped). Input index `i`
|
||||
(0-based) corresponds to `inputs[i]`.
|
||||
|
||||
Source URL for feed with `streamKey`: `rtmp://127.0.0.1:<rtmpPort>/live/<streamKey>`.
|
||||
Destination URLs come from `destinations` (resolved by `index.js` from grants,
|
||||
exactly like today) — **never stored**.
|
||||
|
||||
### 4.2 Layouts
|
||||
|
||||
Canvas = `SCENE_WIDTH` × `SCENE_HEIGHT` (default 1920×1080), fps = `SCENE_FPS`
|
||||
(default 30). `n` = number of live inputs.
|
||||
|
||||
Normalization chain for input `i` to a target cell `Wi×Hi`:
|
||||
|
||||
```
|
||||
[i:v] scale=Wi:Hi:force_original_aspect_ratio=decrease,
|
||||
pad=Wi:Hi:(ow-iw)/2:(oh-ih)/2,
|
||||
settb=AVTB, fps=30, format=yuv420p [v_i]
|
||||
```
|
||||
|
||||
(`fps` uses the canvas fps constant.)
|
||||
|
||||
**Grid, n > 1** — `c = min(columns, n)`, `rows = ceil(n / c)`,
|
||||
`cellW = 1920 / c`, `cellH = 1080 / rows`. For input `i`:
|
||||
`x_i = (i % c) * cellW`, `y_i = floor(i / c) * cellH`.
|
||||
|
||||
```
|
||||
[v_0][v_1]...[v_{n-1}] xstack=inputs=n:layout=x_0_y_0|x_1_y_1|...|x_{n-1}_y_{n-1}:fill=black [comp]
|
||||
```
|
||||
|
||||
**Grid, n == 1** — single fullscreen tile (no xstack): the normalization chain
|
||||
targets `1920x1080` and its label is `[comp]`.
|
||||
|
||||
**PiP, both slots live** (`m` = main input index, `p` = pip input index):
|
||||
|
||||
```
|
||||
[m:v] scale=1920:1080:force_original_aspect_ratio=decrease, pad=1920:1080:(ow-iw)/2:(oh-ih)/2, settb=AVTB, fps=30, format=yuv420p [main]
|
||||
[p:v] scale=480:270:force_original_aspect_ratio=decrease, pad=480:270:(ow-iw)/2:(oh-ih)/2, settb=AVTB, fps=30, format=yuv420p [pip]
|
||||
[main][pip] overlay=x=main_w-overlay_w-16:y=16:format=auto [comp]
|
||||
```
|
||||
|
||||
**PiP, only one slot live** — promote that feed to fullscreen (same chain as
|
||||
grid n==1) and label it `[comp]`.
|
||||
|
||||
**PiP, no slots live** — `buildScenePlan` returns `null`.
|
||||
|
||||
### 4.3 Overlays
|
||||
|
||||
Overlays are applied to `[comp]` in array order, chaining labels
|
||||
`[comp] → [ov_1] → [ov_2] → … → [vout]`. If there are no overlays,
|
||||
`[comp]` is `[vout]`.
|
||||
|
||||
**Text overlay k** (`prev` = previous label, `fontpath` = `SCENE_FONT` or
|
||||
`SCENE_FONT_BOLD` when `bold`):
|
||||
|
||||
```
|
||||
[prev] drawtext=text='<escapeDrawtext(text)>':x=<x>:y=<y>:fontsize=<fs>:
|
||||
fontcolor=<color>:fontfile='<escapeFilterPath(fontpath)>':expansion=none [ov_k]
|
||||
```
|
||||
|
||||
**Image overlay k**:
|
||||
|
||||
```
|
||||
movie=filename='<escapeFilterPath(absImagePath)>', scale=<w>:<h> [img_k];
|
||||
[prev][img_k] overlay=x=<x>:y=<y>:format=auto [ov_k]
|
||||
```
|
||||
|
||||
- `absImagePath` = `path.join(config.dataDir, image.path)`.
|
||||
- Image scale is exact `scale=w:h` (no aspect preservation — the editor controls
|
||||
both dimensions).
|
||||
|
||||
### 4.4 Audio routing
|
||||
|
||||
Program audio (built **only if** at least one destination uses `mode: "program"`
|
||||
and `n ≥ 1`):
|
||||
|
||||
```
|
||||
n == 1: [0:a] aresample=48000, aformat=sample_fmts=fltp:channel_layouts=stereo [aout]
|
||||
n >= 2: [0:a][1:a]...[n-1:a] amix=inputs=n:duration=first:dropout_transition=2,
|
||||
aresample=48000, aformat=sample_fmts=fltp:channel_layouts=stereo [aout]
|
||||
```
|
||||
|
||||
**Assumption (documented):** every participating feed publishes H.264 video +
|
||||
AAC audio (OBS defaults). Video-only or audio-only feeds are out of scope for
|
||||
composition; the `-map <j>:a?` form still tolerates a missing audio stream for
|
||||
`mode: "feed"`.
|
||||
|
||||
Per-destination audio args:
|
||||
|
||||
| mode | live? | args |
|
||||
|------|-------|------|
|
||||
| `program` | `[aout]` exists | `-map [vout] -map [aout] -c:a aac -b:a <AB> -ar 48000` |
|
||||
| `program` | `[aout]` absent | `-map [vout] -an` |
|
||||
| `silent` | — | `-map [vout] -an` |
|
||||
| `feed` (feed live at input index `j`) | yes | `-map [vout] -map j:a? -c:a aac -b:a <AB> -ar 48000` |
|
||||
| `feed` (feed not live) | no | `-map [vout] -an` |
|
||||
|
||||
### 4.5 Full ffmpeg argv (per destination)
|
||||
|
||||
```
|
||||
ffmpeg -hide_banner -loglevel warning
|
||||
-i <input0> -i <input1> ... -i <input_{n-1}>
|
||||
-filter_complex <graph>
|
||||
-map [vout] <audio args from §4.4>
|
||||
-c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p
|
||||
-g 60 -sc_threshold 0 -b:v <VB> -maxrate <VB> -bufsize <2*VB>
|
||||
< -c:a aac -b:a <AB> -ar 48000 | -an >
|
||||
-f flv <dest.url>
|
||||
```
|
||||
|
||||
- `VB` = `SCENE_VIDEO_BITRATE` (default `4500k`), `AB` = `SCENE_AUDIO_BITRATE`
|
||||
(default `160k`). All values are strings passed via a spawn **array** (no shell).
|
||||
- `safeArgs` = `args` with the final `dest.url` replaced by `redactUrl(dest.url)`
|
||||
(reuse the existing redaction). Only `safeArgs` is logged. The filter graph
|
||||
contains no destination keys; image/font paths are server-generated and not secret.
|
||||
|
||||
### 4.6 Escaping rules (exact)
|
||||
|
||||
`escapeDrawtext(s)` — FFmpeg filtergraph second-level quoting. In order:
|
||||
|
||||
1. Strip `\n`, `\r`, and NUL (replace with a single space).
|
||||
2. Replace `\` → `\\`.
|
||||
3. Replace `'` → `\'`.
|
||||
4. The caller wraps the result in single quotes and always passes
|
||||
`:expansion=none`, so `%`, `:`, `,` cannot expand or terminate the value.
|
||||
|
||||
`escapeFilterPath(p)` — same two-character escaping (steps 2–3) for paths, then
|
||||
wrap in single quotes.
|
||||
|
||||
Because spawn uses an argv array (never a shell) and drawtext text is
|
||||
single-quoted + `\`/`'`-escaped + `expansion=none` + length-capped, overlay text
|
||||
cannot inject FFmpeg arguments.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fanout changes — `src/fanout.js` + `src/index.js`
|
||||
|
||||
### 5.1 Generic child engine
|
||||
|
||||
`fanout.js` becomes a generic unit-of-work engine keyed by a string `key`:
|
||||
|
||||
- `apply(key, { children })` — create the unit if absent, then reconcile
|
||||
children by `childKey` + a stable **signature** of the argv. A child whose
|
||||
signature changed is killed and respawned; removed children are killed; new
|
||||
children are spawned.
|
||||
- Signature for a child = `safeArgs.join(' ')` (already redacted; deterministic).
|
||||
- `stop(key)`, `stopAll()`, `status()`, `liveFeedIds()` unchanged in shape.
|
||||
- `status()` still maps `childKey` → `{ running, pid, restarts, startedAt, log }`.
|
||||
|
||||
Child lifecycle (spawn/kill/restart/backoff/log-capture/log-redaction) is
|
||||
identical to the current implementation; only the argv comes from a caller.
|
||||
|
||||
### 5.2 Two kinds of units
|
||||
|
||||
- **Passthrough unit**: `key = feedId`, `childKey = outputId`, argv = today's
|
||||
`-c copy` argv. Built by `index.js` from `destinationsFor(feed)`.
|
||||
- **Scene unit**: `key = "scene:" + sceneId`, `childKey = sceneOutputId`, argv =
|
||||
`buildScenePlan(...).outputs[i].args` with `safeArgs`.
|
||||
|
||||
### 5.3 Live-feed tracking
|
||||
|
||||
`index.js` keeps its own `liveFeeds` **Set<feedId>** (populated on `postPublish`,
|
||||
removed on `donePublish`), independent of fanout state, because in composed mode
|
||||
the passthrough unit for a feed is stopped but the feed is still live.
|
||||
|
||||
### 5.4 Reconciliation (single entry point `reconcileAll`)
|
||||
|
||||
Called on: feed publish/unpublish, scene create/update/delete/activate, scene
|
||||
output add/update/delete, grant start/stop, output add/update/delete. Replaces
|
||||
the current ad-hoc `refreshLive`.
|
||||
|
||||
```
|
||||
for each room:
|
||||
scene = store.findActiveScene(room.id)
|
||||
if scene:
|
||||
for feed in room.feeds: fanout.stop(feed.id) // suspend passthrough
|
||||
liveInSlots = scene.layout.slots.filter(isLive) // slot order
|
||||
if liveInSlots.length == 0:
|
||||
fanout.stop("scene:" + scene.id)
|
||||
else:
|
||||
destinations = enabledSceneOutputs(scene.id) // account granted? → pushUrl
|
||||
plan = compose.buildScenePlan({ scene, liveFeeds: liveInSlots, ...destinations })
|
||||
fanout.apply("scene:" + scene.id, { children: plan.outputs })
|
||||
else:
|
||||
fanout.stop("scene:" + scene.id) // ensure scene fanout gone
|
||||
for feed in room.feeds where live:
|
||||
fanout.apply(feed.id, { children: destinationsFor(feed) })
|
||||
```
|
||||
|
||||
`destinationsFor(feed)` is the existing resolver (enabled outputs routing the
|
||||
feed whose account has a live grant). `enabledSceneOutputs(scene.id)` is the
|
||||
scene analogue (enabled scene outputs whose account has a live grant).
|
||||
|
||||
**Semantics (documented):** while a scene is active, only that scene's outputs
|
||||
push; the room's per-feed passthrough outputs are suspended and resume when the
|
||||
scene is deactivated. This is a deliberate simplification of the OBS "one
|
||||
program at a time" model.
|
||||
|
||||
Live scene switches are safe: activating/deactivating/editing a scene, or a feed
|
||||
coming/going, simply re-runs `reconcileAll`, which reconciles each unit by argv
|
||||
signature — changed children restart, unchanged children are left running.
|
||||
|
||||
---
|
||||
|
||||
## 6. REST API
|
||||
|
||||
All routes are behind `authed`. Errors use the existing shape
|
||||
`{ error: string }`. Non-members get 404; insufficient role gets 403; invalid
|
||||
body gets 400 (mirrors the existing feeds/outputs endpoints).
|
||||
|
||||
### 6.1 Scenes
|
||||
|
||||
| Method | Path | Role | Body → 201/200 response |
|
||||
|--------|------|------|--------------------------|
|
||||
| GET | `/api/rooms/:roomId/scenes` | any member | `Scene[]` |
|
||||
| POST | `/api/rooms/:roomId/scenes` | owner/editor | `{ name, layout }` → `Scene` (201) |
|
||||
| GET | `/api/scenes/:id` | any member | `Scene` |
|
||||
| PUT | `/api/scenes/:id` | owner/editor | partial `{ name?, layout?, overlays?, active? }` → `Scene` |
|
||||
| POST | `/api/scenes/:id/activate` | owner/editor | — → `Scene` (sets active, deactivates others) |
|
||||
| DELETE | `/api/scenes/:id` | owner/editor | — → `{ ok: true }` (prunes sceneOutputs) |
|
||||
|
||||
- `PUT` with `active: true` deactivates the room's other scenes.
|
||||
- `POST activate` is a convenience; it must also `refreshLive()`/`reconcileAll()`.
|
||||
|
||||
### 6.2 Scene outputs (destinations)
|
||||
|
||||
| Method | Path | Role | Body → response |
|
||||
|--------|------|------|------------------|
|
||||
| GET | `/api/rooms/:roomId/scene-outputs` | any member | `SceneOutput[]` (with masked `account`) |
|
||||
| POST | `/api/rooms/:roomId/scene-outputs` | owner/editor | `{ sceneId, accountId, enabled?, audio }` → `SceneOutput` (201) |
|
||||
| PUT | `/api/scene-outputs/:id` | owner/editor | partial `{ accountId?, enabled?, audio? }` → `SceneOutput` |
|
||||
| DELETE | `/api/scene-outputs/:id` | owner/editor | — → `{ ok: true }` |
|
||||
|
||||
- GET responses embed `account: maskAccount(account, viewerId)` exactly like the
|
||||
existing outputs list, so editor/streamer never see another owner's
|
||||
`secretCiphertext`.
|
||||
|
||||
### 6.3 Images
|
||||
|
||||
| Method | Path | Role | Body → response |
|
||||
|--------|------|------|------------------|
|
||||
| GET | `/api/rooms/:roomId/images` | any member | `{ id, name, mime, size, url }[]` |
|
||||
| POST | `/api/rooms/:roomId/images` | owner/editor | `{ name, data }` (base64) → image meta (201) |
|
||||
| GET | `/api/images/:id/file` | any member | file bytes (correct Content-Type) |
|
||||
| DELETE | `/api/images/:id` | owner/editor | — → `{ ok: true }` (prunes overlay refs) |
|
||||
|
||||
- POST decodes base64, enforces `SCENE_MAX_IMAGE_BYTES`, sniffs magic bytes,
|
||||
writes `<dataDir>/uploads/<uuid>.<ext>`, returns meta with
|
||||
`url = "/api/images/<id>/file"`.
|
||||
- `GET /file` streams with `res.sendFile` after the room-membership check.
|
||||
|
||||
### 6.4 `/api/state` additions
|
||||
|
||||
Each room object in the existing `/api/state` response gains:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"...existing room keys...": "...",
|
||||
"activeSceneId": "uuid|null",
|
||||
"scenes": [ /* Scene[] */ ],
|
||||
"sceneOutputs": [
|
||||
{ "...SceneOutput...": "...", "account": { /* masked Account */ },
|
||||
"runtime": { "running": false, "restarts": 0, "log": [] } }
|
||||
],
|
||||
"images": [ { "id", "name", "mime", "size", "url" } ]
|
||||
}
|
||||
```
|
||||
|
||||
- `runtime` for scene outputs comes from `fanout.status()` keyed by the scene
|
||||
output id (mirrors existing output `runtime`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Security invariants (summary)
|
||||
|
||||
Unchanged:
|
||||
|
||||
1. Server never persists plaintext stream keys — only `secretCiphertext` +
|
||||
`serverWrapped`; destination URLs are resolved in memory from `grants` at
|
||||
spawn time. Scenes/sceneOutputs/overlays contain no keys.
|
||||
2. FFmpeg logs redact destination URLs (`redactUrl`). Scene logs use `safeArgs`.
|
||||
3. `maskAccount` still hides `secretCiphertext` from every viewer except the
|
||||
owning user (editors/streamers never see others' keys).
|
||||
|
||||
New / extended:
|
||||
|
||||
4. Overlay text cannot inject FFmpeg args: spawn argv array (no shell) +
|
||||
`escapeDrawtext` + `expansion=none` + 256-char cap + control-char strip.
|
||||
5. Image overlays: ≤ 1 MiB decoded, magic-byte type sniffing, server-generated
|
||||
UUID filenames under `dataDir/uploads`, strict basename regex, path passed
|
||||
through `escapeFilterPath` (no user input reaches `movie`).
|
||||
6. Scene routes are role-gated (owner/editor write, streamer read-only, 404 for
|
||||
non-members), matching the existing feeds/outputs model.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend contract
|
||||
|
||||
### 8.1 `lib/types.ts` additions
|
||||
|
||||
```ts
|
||||
export type LayoutType = "grid" | "pip"
|
||||
export interface SceneLayout {
|
||||
type: LayoutType
|
||||
columns?: number // grid only, 2..4
|
||||
slots: Array<string | null> // index = slot number; grid 1..6, pip length 2
|
||||
}
|
||||
export type OverlayItem =
|
||||
| { id: string; kind: "text"; text: string; x: number; y: number; fontSize: number; color: string; bold?: boolean }
|
||||
| { id: string; kind: "image"; imageId: string; x: number; y: number; width: number; height: number; opacity?: number }
|
||||
export type AudioRouting =
|
||||
| { mode: "program" }
|
||||
| { mode: "silent" }
|
||||
| { mode: "feed"; feedId: string }
|
||||
|
||||
export interface Scene {
|
||||
id: string
|
||||
roomId: string
|
||||
ownerId: string
|
||||
name: string
|
||||
active: boolean
|
||||
layout: SceneLayout
|
||||
overlays: OverlayItem[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface SceneOutput {
|
||||
id: string
|
||||
roomId: string
|
||||
sceneId: string
|
||||
accountId: string
|
||||
enabled: boolean
|
||||
audio: AudioRouting
|
||||
account?: Account // masked by server
|
||||
runtime?: OutputRuntime
|
||||
}
|
||||
|
||||
export interface RoomImage {
|
||||
id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
url: string
|
||||
}
|
||||
|
||||
// Room gains:
|
||||
export interface Room {
|
||||
// ...existing keys...
|
||||
activeSceneId: string | null
|
||||
scenes: Scene[]
|
||||
sceneOutputs: SceneOutput[]
|
||||
images: RoomImage[]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 `lib/api.ts` additions
|
||||
|
||||
```ts
|
||||
createScene: (roomId: string, body: { name: string; layout: SceneLayout }) => request<Scene>(`/api/rooms/${roomId}/scenes`, { method: "POST", body }),
|
||||
updateScene: (id: string, body: Partial<Pick<Scene, "name" | "layout" | "overlays" | "active">>) => request<Scene>(`/api/scenes/${id}`, { method: "PUT", body }),
|
||||
deleteScene: (id: string) => request<{ ok: boolean }>(`/api/scenes/${id}`, { method: "DELETE" }),
|
||||
activateScene: (id: string) => request<Scene>(`/api/scenes/${id}/activate`, { method: "POST" }),
|
||||
|
||||
createSceneOutput: (roomId: string, body: { sceneId: string; accountId: string; audio: AudioRouting; enabled?: boolean }) => request<SceneOutput>(`/api/rooms/${roomId}/scene-outputs`, { method: "POST", body }),
|
||||
updateSceneOutput: (id: string, body: Partial<Pick<SceneOutput, "accountId" | "enabled" | "audio">>) => request<SceneOutput>(`/api/scene-outputs/${id}`, { method: "PUT", body }),
|
||||
deleteSceneOutput: (id: string) => request<{ ok: boolean }>(`/api/scene-outputs/${id}`, { method: "DELETE" }),
|
||||
|
||||
uploadImage: (roomId: string, body: { name: string; data: string }) => request<RoomImage>(`/api/rooms/${roomId}/images`, { method: "POST", body }),
|
||||
deleteImage: (id: string) => request<{ ok: boolean }>(`/api/images/${id}`, { method: "DELETE" }),
|
||||
```
|
||||
|
||||
### 8.3 Component tree
|
||||
|
||||
New files under `web/src/components/`:
|
||||
|
||||
- `scene-panel.tsx` — `ScenePanel({ room, canEdit, onRefresh })`. A Card in the
|
||||
room center view: lists scenes (active badge), Activate, Edit, Delete
|
||||
(destructive confirms via `AlertDialog`), New scene button. Also lists the
|
||||
active scene's destinations (scene outputs) with their audio mode.
|
||||
- `scene-editor.tsx` — `SceneEditorDialog({ open, onOpenChange, scene, room, onSaved })`.
|
||||
Form built from `Field`/`FieldGroup`:
|
||||
- name `Field` (`Input`),
|
||||
- layout picker (`Select` grid/pip; grid columns `Select`; slot assignment:
|
||||
one feed `Select` per slot, options = room.feeds + "empty"),
|
||||
- overlay editor: list + add text/image; text form (`Input` text, x/y, fontSize,
|
||||
color `Input type="color"`, bold toggle); image form (image `Select`,
|
||||
x/y/width/height) — client-side validation matching §3.4,
|
||||
- audio routing editor: per scene output, `Select` mode (program/silent/feed;
|
||||
feed `Select` when mode=feed).
|
||||
- `image-upload.tsx` — `ImageUploader({ room, canEdit, onUploaded })`. File input;
|
||||
client-side pre-check (size ≤ 1 MiB, type ∈ png/jpeg/webp) before base64
|
||||
encoding and `api.uploadImage`.
|
||||
- `scene-sidebar.tsx` (or inline in `room-sidebar.tsx`) — active scene name +
|
||||
badge under the Outputs group.
|
||||
|
||||
Modified files:
|
||||
|
||||
- `room-view.tsx` — render `ScenePanel` above the existing Outputs card (or as a
|
||||
first tab); keep the passthrough Outputs card unchanged.
|
||||
- `room-sidebar.tsx` — show the active scene.
|
||||
- `App.tsx` — wire `SceneEditorDialog`/`ImageUploader` open-state like the
|
||||
existing dialogs; pass `canEdit = activeRoom.role !== "streamer"`.
|
||||
|
||||
### 8.4 State & data flow
|
||||
|
||||
- Reuse the existing 3-second `useApp` poll: `/api/state` now carries
|
||||
`scenes`/`sceneOutputs`/`images`, so no new polling is required.
|
||||
- Every mutation calls `api.*` then `onRefresh()` (the existing pattern).
|
||||
- Scene editor local state (name/layout/overlays/audio) is held in the dialog;
|
||||
it is assembled into the `PUT`/`POST` body on submit, then the editor closes
|
||||
and `onRefresh()` repaints.
|
||||
|
||||
### 8.5 Conventions (shadcn)
|
||||
|
||||
Use the existing primitives and patterns: `cn()` from `@/lib/utils`, lucide-react
|
||||
icons, `Field`/`FieldGroup`/`FieldLabel` for forms, `Dialog` for editors,
|
||||
`AlertDialog` for destructive confirms (delete scene/output/image, and overwrite
|
||||
warnings), `Select` for pickers, `Switch` for `enabled`, `Badge` for the active/
|
||||
live state, `toast` for success/error. No raw `confirm()` in new code (the
|
||||
existing output delete uses it, but new UI must use `AlertDialog`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Config & image additions
|
||||
|
||||
`src/config.js` gains (all `intEnv`/string env with defaults):
|
||||
|
||||
| key | env | default |
|
||||
|-----|-----|---------|
|
||||
| `sceneWidth` | `SCENE_WIDTH` | 1920 |
|
||||
| `sceneHeight` | `SCENE_HEIGHT` | 1080 |
|
||||
| `sceneFps` | `SCENE_FPS` | 30 |
|
||||
| `sceneVideoBitrate` | `SCENE_VIDEO_BITRATE` | `4500k` |
|
||||
| `sceneAudioBitrate` | `SCENE_AUDIO_BITRATE` | `160k` |
|
||||
| `sceneFont` | `SCENE_FONT` | `/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf` |
|
||||
| `sceneFontBold` | `SCENE_FONT_BOLD` | `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf` |
|
||||
| `sceneMaxImageBytes` | `SCENE_MAX_IMAGE_BYTES` | 1048576 |
|
||||
|
||||
`Dockerfile` must additionally `apk add --no-cache font-dejavu` so `drawtext` has
|
||||
a font (Composition requires a font; this is a hard runtime dependency of the
|
||||
text overlay feature). `ffmpeg` is already installed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Contract coverage checklist
|
||||
|
||||
Downstream tasks must satisfy these; the spec is complete when each is
|
||||
implementable with no further design decisions.
|
||||
|
||||
**t2 (backend)** — data model (§3) persisted and exposed via `/api/state` (§6.4)
|
||||
without plaintext keys; routes (§6) with the role matrix (§6) and 404-on-non-member;
|
||||
`compose.js` filter graphs (§4) + per-output audio `-map` (§4.4) + `-c copy`
|
||||
passthrough otherwise (§5.4); live switch reconciliation (§5.4); URL redaction +
|
||||
text/image injection defenses (§4.5, §4.6, §7). Verify: `node --check` on
|
||||
`src/{index,fanout,server,store,compose,config,providers,grants,auth,oidc}.js`.
|
||||
|
||||
**t3 (frontend)** — types (§8.1), API client (§8.2), scene list/activate/edit UI
|
||||
with grid/PiP slot assignment (§8.3), text/image overlay editor with client-side
|
||||
size/type pre-check (§8.3), per-output audio routing UI (§8.3), shadcn
|
||||
conventions (§8.5), no key exposure (masked `account` in §6.2/§6.4). Verify:
|
||||
`cd services/multistreaming/web && npm run build`.
|
||||
|
||||
## 11. Decisions & trade-offs (locked)
|
||||
|
||||
1. **Per-destination re-encode**: one ffmpeg child per scene output, each
|
||||
re-encoding the composed video, so per-output audio `-map` can differ. Chosen
|
||||
over a shared `tee` muxer (which forces identical audio). Accepted cost.
|
||||
2. **One program at a time**: an active scene suspends the room's per-feed
|
||||
passthrough outputs; they resume on deactivation.
|
||||
3. **Live-slot layouts**: only live feeds are laid out; empty/non-live slots are
|
||||
skipped, and `n == 1` is promoted to fullscreen. Grid wraps
|
||||
`min(columns, n)` columns.
|
||||
4. **Audio**: composition assumes H.264 + AAC inputs (OBS defaults); `feed` mode
|
||||
uses `-map <j>:a?` for resilience; `program` mode requires ≥ 1 audio stream.
|
||||
5. **Images** are uploaded as base64 JSON (no new npm deps, no `multer`) and
|
||||
stored under `dataDir/uploads` with server-generated names.
|
||||
98
services/multistreaming/metadata.json
Normal file
98
services/multistreaming/metadata.json
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
{
|
||||
"id": "multistreaming",
|
||||
"name": "multistreaming",
|
||||
"description": "Self-built live production dashboard (shadcn/ui sidebar): per-person OBS feeds, shared rooms with editor/streamer roles, pluggable streaming providers, zero-knowledge key vault, per-account streaming grants, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing).",
|
||||
"version": "4.1.0",
|
||||
"category": "media",
|
||||
"tags": ["streaming", "restream", "rtmp", "obs", "multistream", "zero-knowledge", "collaboration", "shadcn"],
|
||||
"author": "homelab",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/ReinadoRojo/homelab",
|
||||
"compose": {
|
||||
"build": {
|
||||
"context": ".",
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"container_name": "multistreaming",
|
||||
"restart": "unless-stopped",
|
||||
"ports": [
|
||||
"1935:1935",
|
||||
"8080:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"multistreaming_data:/data"
|
||||
],
|
||||
"environment": [
|
||||
"AUTH_MODE=${AUTH_MODE}",
|
||||
"OIDC_ISSUER=${OIDC_ISSUER}",
|
||||
"OIDC_CLIENT_ID=${OIDC_CLIENT_ID}",
|
||||
"OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI}",
|
||||
"PUBLIC_HOST=${PUBLIC_HOST}",
|
||||
"SESSION_SECRET=${SESSION_SECRET}",
|
||||
"GRANT_TTL_MS=${GRANT_TTL_MS}"
|
||||
],
|
||||
"networks": ["homelab"]
|
||||
},
|
||||
"volumes": {
|
||||
"multistreaming_data": {}
|
||||
},
|
||||
"networks": {
|
||||
"homelab": { "external": true }
|
||||
},
|
||||
"env": [
|
||||
{
|
||||
"name": "AUTH_MODE",
|
||||
"label": "Auth mode",
|
||||
"description": "oidc = Authelia SSO (recommended); local = username/password (dev only)",
|
||||
"default": "oidc",
|
||||
"required": false,
|
||||
"secret": false,
|
||||
"options": ["oidc", "local"]
|
||||
},
|
||||
{
|
||||
"name": "OIDC_ISSUER",
|
||||
"label": "OIDC issuer",
|
||||
"description": "Authelia root URL, e.g. https://auth.example.com",
|
||||
"default": "https://auth.example.com",
|
||||
"required": false,
|
||||
"secret": false
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_ID",
|
||||
"label": "OIDC client id",
|
||||
"default": "multistreaming",
|
||||
"required": false,
|
||||
"secret": false
|
||||
},
|
||||
{
|
||||
"name": "OIDC_REDIRECT_URI",
|
||||
"label": "OIDC redirect URI",
|
||||
"description": "Must match the redirect_uris registered in Authelia, e.g. https://streaming.example.com/api/auth/oidc/callback",
|
||||
"default": "https://streaming.example.com/api/auth/oidc/callback",
|
||||
"required": false,
|
||||
"secret": false
|
||||
},
|
||||
{
|
||||
"name": "SESSION_SECRET",
|
||||
"label": "Session secret (auto-generated)",
|
||||
"generate": true
|
||||
},
|
||||
{
|
||||
"name": "PUBLIC_HOST",
|
||||
"label": "Public host",
|
||||
"description": "Host shown in feed ingest URLs (e.g. feed.streaming.example.com). Leave empty to use the machine hostname.",
|
||||
"default": "",
|
||||
"required": false,
|
||||
"secret": false
|
||||
},
|
||||
{
|
||||
"name": "GRANT_TTL_MS",
|
||||
"label": "Streaming grant lifetime (ms)",
|
||||
"description": "How long a streamer's decrypted keys stay in memory after they click Start streaming.",
|
||||
"default": "21600000",
|
||||
"required": false,
|
||||
"secret": false
|
||||
}
|
||||
],
|
||||
"notes": "Web panel at http://<host>:8080 (or https://streaming.example.com via nginx-proxy-manager). Login is Authelia OIDC (public client + PKCE); the client is pre-registered in authelia's configuration.yml with redirect_uri https://streaming.example.com/api/auth/oidc/callback — keep OIDC_REDIRECT_URI in sync. Each user creates one or more zero-knowledge Vaults (passphrase-protected key stores, encrypted in the browser — the server stores only ciphertext and can never read a key); the sidebar lets you unlock one vault at a time and set a default. Accounts (Twitch/YouTube/Kick/custom) live inside a vault. Rooms share a production with editor (edit feeds/outputs/scenes, no keys) and streamer (own accounts + start streaming) roles via invite links. Feeds = per-person OBS ingest at rtmp://<host>:1935/live/<streamKey> (RTMP bypasses the proxy). Outputs route a feed to an account; a streamer clicks Start streaming and picks platforms, which decrypts their keys client-side and grants the server a short-lived in-memory copy. Scenes compose live feeds into grid or PiP layouts with text/image overlays and per-output audio routing; activating a scene switches the room from per-feed passthrough to the composed program (see services/multistreaming/docs/SCENES.md)."
|
||||
}
|
||||
1015
services/multistreaming/package-lock.json
generated
Normal file
1015
services/multistreaming/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
services/multistreaming/package.json
Normal file
17
services/multistreaming/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "multistreaming",
|
||||
"version": "1.0.0",
|
||||
"description": "Self-hosted service: ingest one RTMP feed from OBS and restream it to multiple platforms/channels.",
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"node-media-server": "^2.7.4"
|
||||
}
|
||||
}
|
||||
100
services/multistreaming/src/auth.js
Normal file
100
services/multistreaming/src/auth.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { safeEqual } = require('./id');
|
||||
|
||||
/**
|
||||
* User authentication + signed session tokens.
|
||||
*
|
||||
* Two providers:
|
||||
* - local: username/password hashed with scrypt (for standalone use / dev).
|
||||
* - oidc: Authelia issues identity; we map its `sub` to a local user record.
|
||||
*
|
||||
* Sessions are HMAC-signed tokens carried in an HttpOnly cookie. The token
|
||||
* carries the user id and a session id (sid), which the vault uses as its AAD.
|
||||
*/
|
||||
class Auth {
|
||||
constructor({ store, sessionSecret }) {
|
||||
this.store = store;
|
||||
this.secret = sessionSecret || crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// ---- local provider ----------------------------------------------------
|
||||
|
||||
hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) {
|
||||
const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
||||
return { hash, salt };
|
||||
}
|
||||
|
||||
verifyPassword(password, user) {
|
||||
if (!user || !user.passwordHash || !user.passwordSalt) return false;
|
||||
const { hash } = this.hashPassword(password, user.passwordSalt);
|
||||
return safeEqual(hash, user.passwordHash);
|
||||
}
|
||||
|
||||
/** Register (local). Returns the created user or null if taken. */
|
||||
register({ username, password }) {
|
||||
if (this.store.findUserByUsername(username)) return null;
|
||||
const { hash, salt } = this.hashPassword(password);
|
||||
return this.store.createUser({ username, passwordHash: hash, passwordSalt: salt });
|
||||
}
|
||||
|
||||
/** Find-or-create a user from an OIDC identity (provider = oidc). */
|
||||
findOrCreateOidcUser({ sub, username }) {
|
||||
const name = username || sub;
|
||||
let user = this.store.findUserByUsername(name);
|
||||
if (!user) {
|
||||
// OIDC users have no local password.
|
||||
user = this.store.createUser({ username: name, passwordHash: null, passwordSalt: null });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---- sessions ----------------------------------------------------------
|
||||
|
||||
issueToken(userId) {
|
||||
const sid = crypto.randomBytes(16).toString('hex');
|
||||
const payload = Buffer.from(JSON.stringify({ sid, uid: userId, iat: Date.now() })).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
verifyToken(token) {
|
||||
if (typeof token !== 'string') return null;
|
||||
const dot = token.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
const payload = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url');
|
||||
if (!safeEqual(sig, expected)) return null;
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString());
|
||||
if (!data.sid || !data.uid || Date.now() - data.iat > 12 * 60 * 60 * 1000) return null;
|
||||
return data; // { sid, uid, iat }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Express middleware: attach req.user and req.session on success. */
|
||||
middleware(cookieName = 'ms_session') {
|
||||
return (req, res, next) => {
|
||||
const token = req.cookies?.[cookieName];
|
||||
const session = this.verifyToken(token);
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const user = this.store.findUserById(session.uid);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
req.user = user;
|
||||
req.session = session;
|
||||
next();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Auth };
|
||||
221
services/multistreaming/src/compose.js
Normal file
221
services/multistreaming/src/compose.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { redactUrl } = require('./fanout');
|
||||
|
||||
/**
|
||||
* Scene composition engine — every piece of FFmpeg filter-graph knowledge
|
||||
* lives here. `fanout.js` stays lifecycle-only: it spawns/restarts/kills the
|
||||
* children; this module turns a Scene into concrete argument arrays passed to
|
||||
* `spawn` (an argv array, never a shell string).
|
||||
*
|
||||
* buildScenePlan output (transient, never persisted):
|
||||
* {
|
||||
* inputs: [ { feedId, url } ], // live feeds in slot order
|
||||
* outputs: [ { outputId, url, args, safeArgs } ] // one ffmpeg argv per dest
|
||||
* }
|
||||
*/
|
||||
|
||||
/** Resolved canvas/encode settings (§9 config keys). */
|
||||
function CANVAS(config) {
|
||||
return {
|
||||
width: config.sceneWidth,
|
||||
height: config.sceneHeight,
|
||||
fps: config.sceneFps,
|
||||
videoBitrate: config.sceneVideoBitrate,
|
||||
audioBitrate: config.sceneAudioBitrate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape arbitrary text for a drawtext filter value (second-level FFmpeg
|
||||
* filtergraph quoting). Order per §4.6: strip control chars, escape backslash,
|
||||
* escape single quote. The caller wraps the result in single quotes and always
|
||||
* passes `:expansion=none`, so `%`, `:`, `,` cannot expand or terminate the value.
|
||||
*/
|
||||
function escapeDrawtext(text) {
|
||||
return String(text)
|
||||
.replace(/[\n\r\u0000]/g, ' ')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
/** Escape a filesystem path for a movie/fontfile filter value (steps 2–3 of §4.6). */
|
||||
function escapeFilterPath(p) {
|
||||
return String(p).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
/** "4500k" → "9000k" (bufsize = 2 × video bitrate). */
|
||||
function doubleRate(rate) {
|
||||
const m = /^(\d+)([kKmM]?)$/.exec(String(rate));
|
||||
if (!m) return String(rate);
|
||||
return `${parseInt(m[1], 10) * 2}${m[2].toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Normalization chain for input `i` into a W×H cell, labelled `out`. */
|
||||
function normChain(i, w, h, fps, out) {
|
||||
return (
|
||||
`[${i}:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,` +
|
||||
`pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,settb=AVTB,fps=${fps},format=yuv420p[${out}]`
|
||||
);
|
||||
}
|
||||
|
||||
/** Mixed program audio chain (only when at least one destination is "program"). */
|
||||
function programAudioChain(n) {
|
||||
if (n === 1) {
|
||||
return '[0:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[aout]';
|
||||
}
|
||||
const labels = Array.from({ length: n }, (_, i) => `[${i}:a]`).join('');
|
||||
return (
|
||||
`${labels}amix=inputs=${n}:duration=first:dropout_transition=2,` +
|
||||
'aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[aout]'
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the video filter chains (§4.2 grid/PiP + §4.3 overlays). */
|
||||
function buildVideoChains(scene, n, config) {
|
||||
const parts = [];
|
||||
const W = config.sceneWidth;
|
||||
const H = config.sceneHeight;
|
||||
const fps = config.sceneFps;
|
||||
const overlays = scene.overlays || [];
|
||||
// If there are no overlays the base composition is the final video output.
|
||||
const finalOut = overlays.length === 0 ? 'vout' : 'comp';
|
||||
const type = scene.layout && scene.layout.type === 'pip' ? 'pip' : 'grid';
|
||||
|
||||
if (type === 'grid') {
|
||||
if (n === 1) {
|
||||
parts.push(normChain(0, W, H, fps, finalOut));
|
||||
} else {
|
||||
const c = Math.min(scene.layout.columns || 2, n);
|
||||
const rows = Math.ceil(n / c);
|
||||
const cellW = Math.floor(W / c);
|
||||
const cellH = Math.floor(H / rows);
|
||||
for (let i = 0; i < n; i++) parts.push(normChain(i, cellW, cellH, fps, `v_${i}`));
|
||||
const layoutSpec = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i % c) * cellW;
|
||||
const y = Math.floor(i / c) * cellH;
|
||||
layoutSpec.push(`${x}_${y}`);
|
||||
}
|
||||
const labels = Array.from({ length: n }, (_, i) => `[v_${i}]`).join('');
|
||||
parts.push(`${labels}xstack=inputs=${n}:layout=${layoutSpec.join('|')}:fill=black[${finalOut}]`);
|
||||
}
|
||||
} else if (n === 2) {
|
||||
// PiP: slot 0 is main (fullscreen), slot 1 is the PiP window.
|
||||
parts.push(normChain(0, W, H, fps, 'main'));
|
||||
parts.push(normChain(1, 480, 270, fps, 'pip'));
|
||||
parts.push(`[main][pip]overlay=x=main_w-overlay_w-16:y=16:format=auto[${finalOut}]`);
|
||||
} else {
|
||||
// PiP with a single live slot: promote to fullscreen.
|
||||
parts.push(normChain(0, W, H, fps, finalOut));
|
||||
}
|
||||
|
||||
// Overlays chain: [comp] → [ov_1] → … → [vout].
|
||||
let prev = 'comp';
|
||||
for (let k = 0; k < overlays.length; k++) {
|
||||
const ov = overlays[k];
|
||||
const out = k === overlays.length - 1 ? 'vout' : `ov_${k + 1}`;
|
||||
if (ov.kind === 'text') {
|
||||
const font = ov.bold ? config.sceneFontBold : config.sceneFont;
|
||||
parts.push(
|
||||
`[${prev}]drawtext=text='${escapeDrawtext(ov.text)}':x=${ov.x}:y=${ov.y}:` +
|
||||
`fontsize=${ov.fontSize}:fontcolor=${ov.color}:fontfile='${escapeFilterPath(font)}':` +
|
||||
`expansion=none[${out}]`,
|
||||
);
|
||||
} else {
|
||||
// image — path is server-generated and already escaped by the caller.
|
||||
parts.push(`movie=filename='${escapeFilterPath(ov._absPath)}',scale=${ov.width}:${ov.height}[img_${k + 1}]`);
|
||||
parts.push(`[${prev}][img_${k + 1}]overlay=x=${ov.x}:y=${ov.y}:format=auto[${out}]`);
|
||||
}
|
||||
prev = out;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Build one full ffmpeg argv for a destination (§4.4 + §4.5). */
|
||||
function buildOutputArgs({ inputs, graph, dest, hasProgramAudio, config }) {
|
||||
const AB = config.sceneAudioBitrate;
|
||||
const VB = config.sceneVideoBitrate;
|
||||
const args = ['-hide_banner', '-loglevel', 'warning'];
|
||||
for (const inp of inputs) args.push('-i', inp.url);
|
||||
args.push('-filter_complex', graph);
|
||||
args.push('-map', '[vout]');
|
||||
|
||||
const audio = dest.audio && dest.audio.mode ? dest.audio : { mode: 'program' };
|
||||
if (audio.mode === 'silent') {
|
||||
args.push('-an');
|
||||
} else if (audio.mode === 'feed') {
|
||||
const j = inputs.findIndex((inp) => inp.feedId === audio.feedId);
|
||||
if (j >= 0) {
|
||||
args.push('-map', `${j}:a?`, '-c:a', 'aac', '-b:a', AB, '-ar', '48000');
|
||||
} else {
|
||||
args.push('-an');
|
||||
}
|
||||
} else if (hasProgramAudio) {
|
||||
args.push('-map', '[aout]', '-c:a', 'aac', '-b:a', AB, '-ar', '48000');
|
||||
} else {
|
||||
args.push('-an');
|
||||
}
|
||||
|
||||
args.push(
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p',
|
||||
'-g', '60', '-sc_threshold', '0', '-b:v', VB, '-maxrate', VB, '-bufsize', doubleRate(VB),
|
||||
'-f', 'flv', dest.url,
|
||||
);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a composition plan for an active scene.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {object} args.scene the active Scene
|
||||
* @param {Array} args.liveFeeds live feed objects, in slot order
|
||||
* @param {Function} [args.getInputUrl] (feed) => source RTMP url
|
||||
* @param {Function} [args.getImage] (imageId) => RoomImage record | null
|
||||
* @param {Array} args.destinations [{ outputId, url, audio }]
|
||||
* @param {object} args.config resolved config
|
||||
* @returns {object|null} plan, or null when there is nothing to compose.
|
||||
*/
|
||||
function buildScenePlan({ scene, liveFeeds, getInputUrl, getImage, destinations, config }) {
|
||||
if (!liveFeeds || liveFeeds.length === 0) return null;
|
||||
const n = liveFeeds.length;
|
||||
|
||||
const inputs = liveFeeds.map((feed) => ({
|
||||
feedId: feed.id,
|
||||
url: getInputUrl
|
||||
? getInputUrl(feed)
|
||||
: `rtmp://127.0.0.1:${config.rtmpPort}/live/${feed.streamKey}`,
|
||||
}));
|
||||
|
||||
// Resolve image overlay paths (server-generated; pruned images are dropped).
|
||||
const overlays = (scene.overlays || []).flatMap((ov) => {
|
||||
if (ov.kind !== 'image') return [ov];
|
||||
const image = getImage ? getImage(ov.imageId) : null;
|
||||
if (!image) return [];
|
||||
return [{ ...ov, _absPath: path.join(config.dataDir, image.path) }];
|
||||
});
|
||||
|
||||
const hasProgramAudio = destinations.some((d) => d.audio && d.audio.mode === 'program');
|
||||
|
||||
const chains = [...buildVideoChains({ ...scene, overlays }, n, config)];
|
||||
if (hasProgramAudio) chains.push(programAudioChain(n));
|
||||
const graph = chains.join(';');
|
||||
|
||||
const outputs = destinations.map((d) => {
|
||||
const args = buildOutputArgs({ inputs, graph, dest: d, hasProgramAudio, config });
|
||||
const safeArgs = args.slice(0, -1).concat([redactUrl(d.url)]);
|
||||
return { outputId: d.outputId, url: d.url, args, safeArgs };
|
||||
});
|
||||
|
||||
return { inputs, outputs };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CANVAS,
|
||||
escapeDrawtext,
|
||||
escapeFilterPath,
|
||||
buildScenePlan,
|
||||
};
|
||||
57
services/multistreaming/src/config.js
Normal file
57
services/multistreaming/src/config.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
function intEnv(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
const n = raw === undefined || raw === '' ? fallback : parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
module.exports = function loadConfig() {
|
||||
const mode = process.env.AUTH_MODE || 'oidc';
|
||||
return {
|
||||
// RTMP ingest (each feed publishes here).
|
||||
rtmpPort: intEnv('RTMP_PORT', 1935),
|
||||
ingestApp: 'live',
|
||||
|
||||
// Web panel + REST API.
|
||||
httpPort: intEnv('HTTP_PORT', 8080),
|
||||
|
||||
// Public host used only to display copy-pasteable ingest URLs.
|
||||
publicHost: process.env.PUBLIC_HOST || '',
|
||||
|
||||
// Persistent state (mounted volume).
|
||||
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
|
||||
|
||||
// HMAC secret for signing session cookies (generated if unset).
|
||||
sessionSecret: process.env.SESSION_SECRET || '',
|
||||
|
||||
// How long a streaming grant (plaintext key held in memory) lives.
|
||||
grantTtlMs: intEnv('GRANT_TTL_MS', 6 * 3600 * 1000),
|
||||
|
||||
// FFmpeg binary used for fan-out.
|
||||
ffmpegPath: process.env.FFMPEG_PATH || 'ffmpeg',
|
||||
|
||||
// Scene composition (grid/PiP + overlays) — §9 of docs/SCENES.md.
|
||||
sceneWidth: intEnv('SCENE_WIDTH', 1920),
|
||||
sceneHeight: intEnv('SCENE_HEIGHT', 1080),
|
||||
sceneFps: intEnv('SCENE_FPS', 30),
|
||||
sceneVideoBitrate: process.env.SCENE_VIDEO_BITRATE || '4500k',
|
||||
sceneAudioBitrate: process.env.SCENE_AUDIO_BITRATE || '160k',
|
||||
sceneFont: process.env.SCENE_FONT || '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
sceneFontBold: process.env.SCENE_FONT_BOLD || '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
sceneMaxImageBytes: intEnv('SCENE_MAX_IMAGE_BYTES', 1048576),
|
||||
|
||||
// 'oidc' (Authelia) or 'local' (username/password, for dev/testing).
|
||||
authMode: mode,
|
||||
oidc:
|
||||
mode === 'oidc'
|
||||
? {
|
||||
issuer: process.env.OIDC_ISSUER || '',
|
||||
clientId: process.env.OIDC_CLIENT_ID || 'multistreaming',
|
||||
redirectUri: process.env.OIDC_REDIRECT_URI || '',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
};
|
||||
176
services/multistreaming/src/fanout.js
Normal file
176
services/multistreaming/src/fanout.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
'use strict';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
/** Hide the stream key in an RTMP URL: rtmp://host/app/key → rtmp://host/app/••• */
|
||||
function redactUrl(url) {
|
||||
const idx = url.lastIndexOf('/');
|
||||
if (idx <= 0) return url;
|
||||
return `${url.slice(0, idx)}/•••`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fan-out unit-of-work engine.
|
||||
*
|
||||
* A "unit" is a set of ffmpeg children that share an input stream — either a
|
||||
* passthrough feed (key = feedId) or a composed scene (key = "scene:<sceneId>").
|
||||
* The orchestrator (index.js) builds the exact argv per child (including a
|
||||
* redacted `safeArgs` for logging); this module only manages process lifecycle.
|
||||
*
|
||||
* apply(key, { children }): create the unit if absent, then reconcile children
|
||||
* by childKey + a stable signature (safeArgs.join(' ')). Changed children are
|
||||
* killed and respawned; removed children are killed; new children spawned.
|
||||
* stop(key) / stopAll() / status() / liveFeedIds(): lifecycle + introspection.
|
||||
*/
|
||||
class Fanout {
|
||||
constructor({ ffmpegPath, logTail = 60 }) {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
this.logTail = logTail;
|
||||
// key -> { children: Map<childKey, child> }
|
||||
this.units = new Map();
|
||||
}
|
||||
|
||||
apply(key, { children = [] }) {
|
||||
let unit = this.units.get(key);
|
||||
if (!unit) {
|
||||
unit = { children: new Map() };
|
||||
this.units.set(key, unit);
|
||||
}
|
||||
const wanted = new Map(children.map((c) => [c.childKey, c]));
|
||||
// Stop removed/changed children.
|
||||
for (const [childKey, child] of unit.children) {
|
||||
const w = wanted.get(childKey);
|
||||
if (!w || w.safeArgs.join(' ') !== child.signature) {
|
||||
this._kill(child);
|
||||
unit.children.delete(childKey);
|
||||
}
|
||||
}
|
||||
// Spawn new children.
|
||||
for (const c of children) {
|
||||
if (!unit.children.has(c.childKey)) this._spawn(key, unit, c);
|
||||
}
|
||||
// Drop empty units so liveFeedIds()/status() stay accurate.
|
||||
if (unit.children.size === 0) this.units.delete(key);
|
||||
}
|
||||
|
||||
stop(key) {
|
||||
const unit = this.units.get(key);
|
||||
if (!unit) return;
|
||||
for (const child of unit.children.values()) this._kill(child);
|
||||
this.units.delete(key);
|
||||
}
|
||||
|
||||
stopAll() {
|
||||
for (const key of [...this.units.keys()]) this.stop(key);
|
||||
}
|
||||
|
||||
status() {
|
||||
const out = {};
|
||||
for (const unit of this.units.values()) {
|
||||
for (const [childKey, child] of unit.children) {
|
||||
out[childKey] = {
|
||||
running: !child.exited,
|
||||
pid: child.pid,
|
||||
restarts: child.restarts,
|
||||
startedAt: child.startedAt,
|
||||
log: child.log.slice(-this.logTail),
|
||||
};
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Passthrough feed units only (scene units are keyed "scene:<id>"). */
|
||||
liveFeedIds() {
|
||||
return [...this.units.keys()].filter((k) => !k.startsWith('scene:'));
|
||||
}
|
||||
|
||||
_spawn(key, unit, c) {
|
||||
const child = {
|
||||
childKey: c.childKey,
|
||||
signature: c.safeArgs.join(' '),
|
||||
pid: null,
|
||||
proc: null,
|
||||
exited: false,
|
||||
restarts: 0,
|
||||
startedAt: Date.now(),
|
||||
log: [],
|
||||
stopping: false,
|
||||
};
|
||||
unit.children.set(c.childKey, child);
|
||||
this._launch(key, child, c.args, c.safeArgs);
|
||||
}
|
||||
|
||||
_launch(key, child, args, safeArgs = args) {
|
||||
let proc;
|
||||
try {
|
||||
proc = spawn(this.ffmpegPath, args);
|
||||
} catch (err) {
|
||||
child.exited = true;
|
||||
child.log.push(`spawn failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
child.proc = proc;
|
||||
child.pid = proc.pid;
|
||||
child.startedAt = Date.now();
|
||||
child.log.push(`ffmpeg ${safeArgs.join(' ')}`);
|
||||
|
||||
const capture = (chunk) => {
|
||||
for (const line of chunk.toString().split(/\r?\n/)) {
|
||||
if (line.trim()) child.log.push(line);
|
||||
}
|
||||
if (child.log.length > this.logTail * 4) child.log = child.log.slice(-this.logTail);
|
||||
};
|
||||
proc.stdout.on('data', capture);
|
||||
proc.stderr.on('data', capture);
|
||||
|
||||
proc.on('error', (err) => {
|
||||
child.log.push(`error: ${err.message}`);
|
||||
if (!child.stopping) this._maybeRestart(key, child, args, err.message);
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
child.exited = true;
|
||||
child.pid = null;
|
||||
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
||||
child.log.push(`exited (${reason})`);
|
||||
if (!child.stopping) this._maybeRestart(key, child, args, reason);
|
||||
});
|
||||
}
|
||||
|
||||
_maybeRestart(key, child, args, reason) {
|
||||
const unit = this.units.get(key);
|
||||
if (!unit || !unit.children.has(child.childKey)) return;
|
||||
if (child.stopping) return;
|
||||
const MAX_RESTARTS = 10;
|
||||
if (child.restarts >= MAX_RESTARTS) {
|
||||
child.log.push(`giving up after ${MAX_RESTARTS} restarts (${reason})`);
|
||||
return;
|
||||
}
|
||||
child.restarts += 1;
|
||||
const delay = Math.min(1000 * 2 ** child.restarts, 30000);
|
||||
child.log.push(`restarting in ${delay}ms (attempt ${child.restarts})`);
|
||||
setTimeout(() => {
|
||||
const u = this.units.get(key);
|
||||
if (!u) return;
|
||||
const current = u.children.get(child.childKey);
|
||||
if (current !== child || child.stopping) return;
|
||||
this._launch(key, child, args);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
_kill(child) {
|
||||
child.stopping = true;
|
||||
child.exited = true;
|
||||
if (child.proc) {
|
||||
try {
|
||||
child.proc.kill('SIGTERM');
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Fanout, redactUrl };
|
||||
59
services/multistreaming/src/grants.js
Normal file
59
services/multistreaming/src/grants.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Capability grants: short-lived, in-memory plaintext account keys.
|
||||
*
|
||||
* Zero-knowledge means the server normally holds only ciphertext. When a
|
||||
* streamer clicks "start streaming" for selected accounts, their browser
|
||||
* decrypts those keys client-side and hands them to the server over TLS; the
|
||||
* server keeps them HERE (memory only, never on disk) so FFmpeg can push.
|
||||
* When streaming stops, or the grant expires, the keys are gone.
|
||||
*/
|
||||
class Grants {
|
||||
constructor({ ttlMs = 6 * 3600 * 1000 } = {}) {
|
||||
this.ttlMs = ttlMs;
|
||||
// accountId -> { key, grantedBy, expiresAt }
|
||||
this.map = new Map();
|
||||
}
|
||||
|
||||
/** Grant plaintext access to an account key for a bounded time. */
|
||||
grant(accountId, key, grantedBy) {
|
||||
this.map.set(accountId, { key, grantedBy, expiresAt: Date.now() + this.ttlMs });
|
||||
}
|
||||
|
||||
/** Revoke access to one account. */
|
||||
revoke(accountId) {
|
||||
this.map.delete(accountId);
|
||||
}
|
||||
|
||||
/** Revoke all grants made by a user (e.g. when they leave a room). */
|
||||
revokeBy(grantedBy) {
|
||||
for (const [accountId, g] of this.map) {
|
||||
if (g.grantedBy === grantedBy) this.map.delete(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Plaintext key, or null if not granted / expired. */
|
||||
get(accountId) {
|
||||
const g = this.map.get(accountId);
|
||||
if (!g) return null;
|
||||
if (g.expiresAt <= Date.now()) {
|
||||
this.map.delete(accountId);
|
||||
return null;
|
||||
}
|
||||
return g.key;
|
||||
}
|
||||
|
||||
/** Which account ids currently hold a live grant. */
|
||||
grantedAccountIds() {
|
||||
const now = Date.now();
|
||||
const ids = [];
|
||||
for (const [accountId, g] of this.map) {
|
||||
if (g.expiresAt > now) ids.push(accountId);
|
||||
else this.map.delete(accountId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Grants };
|
||||
22
services/multistreaming/src/id.js
Normal file
22
services/multistreaming/src/id.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/** Random UUID v4. */
|
||||
function uuid() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/** Random opaque token (invites, stream keys). */
|
||||
function token(bytes = 24) {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
/** Constant-time string comparison. */
|
||||
function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
module.exports = { uuid, token, safeEqual };
|
||||
190
services/multistreaming/src/index.js
Normal file
190
services/multistreaming/src/index.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
'use strict';
|
||||
|
||||
const NodeMediaServer = require('node-media-server');
|
||||
const loadConfig = require('./config');
|
||||
const { Store } = require('./store');
|
||||
const { Fanout, redactUrl } = require('./fanout');
|
||||
const compose = require('./compose');
|
||||
const { Auth } = require('./auth');
|
||||
const { Grants } = require('./grants');
|
||||
const { getProvider } = require('./providers');
|
||||
const { OidcClient } = require('./oidc');
|
||||
const { createServer } = require('./server');
|
||||
|
||||
function main() {
|
||||
const config = loadConfig();
|
||||
const store = new Store(config.dataDir);
|
||||
const fanout = new Fanout({ ffmpegPath: config.ffmpegPath, rtmpPort: config.rtmpPort });
|
||||
const auth = new Auth({ store, sessionSecret: config.sessionSecret });
|
||||
const grants = new Grants({ ttlMs: config.grantTtlMs });
|
||||
const oidc = config.oidc && config.oidc.issuer && config.oidc.redirectUri
|
||||
? new OidcClient(config.oidc)
|
||||
: null;
|
||||
|
||||
// Live feeds tracked here, independent of fanout state: a feed stays "live"
|
||||
// even while its passthrough unit is suspended by an active scene.
|
||||
const liveFeeds = new Set();
|
||||
|
||||
/**
|
||||
* Resolve the destinations for a live feed: every enabled output that routes
|
||||
* this feed, whose account has a live capability grant (plaintext key).
|
||||
*/
|
||||
function destinationsFor(feed) {
|
||||
const outputs = store.listOutputs(feed.roomId).filter((o) => o.feedId === feed.id && o.enabled);
|
||||
const dests = [];
|
||||
for (const o of outputs) {
|
||||
const account = store.findAccount(o.accountId);
|
||||
if (!account) continue;
|
||||
const key = grants.get(account.id);
|
||||
if (key == null) continue; // not granted yet → nothing to push
|
||||
dests.push({ outputId: o.id, url: getProvider(account.provider).pushUrl({ url: account.url, key }) });
|
||||
}
|
||||
return dests;
|
||||
}
|
||||
|
||||
/** Scene analogue of destinationsFor: enabled scene outputs with live grants. */
|
||||
function enabledSceneOutputs(sceneId) {
|
||||
const scene = store.findScene(sceneId);
|
||||
if (!scene) return [];
|
||||
const sos = store.listSceneOutputs(scene.roomId).filter((so) => so.sceneId === sceneId && so.enabled);
|
||||
const dests = [];
|
||||
for (const so of sos) {
|
||||
const account = store.findAccount(so.accountId);
|
||||
if (!account) continue;
|
||||
const key = grants.get(account.id);
|
||||
if (key == null) continue;
|
||||
dests.push({
|
||||
outputId: so.id,
|
||||
url: getProvider(account.provider).pushUrl({ url: account.url, key }),
|
||||
audio: so.audio,
|
||||
});
|
||||
}
|
||||
return dests;
|
||||
}
|
||||
|
||||
/** Passthrough `-c copy` children for a feed (full argv, redacted safeArgs). */
|
||||
function passthroughChildren(feed) {
|
||||
return destinationsFor(feed).map((d) => {
|
||||
const source = `rtmp://127.0.0.1:${config.rtmpPort}/live/${feed.streamKey}`;
|
||||
const args = ['-hide_banner', '-loglevel', 'warning', '-i', source, '-c', 'copy', '-f', 'flv', d.url];
|
||||
const safeArgs = args.slice(0, -1).concat([redactUrl(d.url)]);
|
||||
return { childKey: d.outputId, args, safeArgs };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single reconciliation entry point (replaces the old ad-hoc refreshLive).
|
||||
* Re-runs on feed publish/unpublish, scene CRUD/activate, scene-output CRUD,
|
||||
* grant start/stop, and output CRUD.
|
||||
*/
|
||||
function reconcileAll() {
|
||||
for (const room of store.listRooms()) {
|
||||
const scene = store.findActiveScene(room.id);
|
||||
if (scene) {
|
||||
// Composed mode: suspend per-feed passthrough for this room.
|
||||
for (const feed of store.listFeeds(room.id)) fanout.stop(feed.id);
|
||||
|
||||
const liveInSlots = scene.layout.slots
|
||||
.filter((feedId) => typeof feedId === 'string' && liveFeeds.has(feedId))
|
||||
.map((feedId) => store.findFeed(feedId))
|
||||
.filter(Boolean);
|
||||
|
||||
if (liveInSlots.length === 0) {
|
||||
fanout.stop(`scene:${scene.id}`);
|
||||
} else {
|
||||
const plan = compose.buildScenePlan({
|
||||
scene,
|
||||
liveFeeds: liveInSlots,
|
||||
getInputUrl: (feed) => `rtmp://127.0.0.1:${config.rtmpPort}/live/${feed.streamKey}`,
|
||||
getImage: (id) => store.findImage(id),
|
||||
destinations: enabledSceneOutputs(scene.id),
|
||||
config,
|
||||
});
|
||||
fanout.apply(`scene:${scene.id}`, {
|
||||
children: plan ? plan.outputs.map((o) => ({ childKey: o.outputId, args: o.args, safeArgs: o.safeArgs })) : [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Passthrough mode: ensure scene fanout is gone; live feeds push.
|
||||
for (const feed of store.listFeeds(room.id)) {
|
||||
if (liveFeeds.has(feed.id)) {
|
||||
fanout.apply(feed.id, { children: passthroughChildren(feed) });
|
||||
} else {
|
||||
fanout.stop(feed.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only the active scene's unit may run; deactivated/edited scenes stop.
|
||||
for (const sc of store.listScenes(room.id)) {
|
||||
if (!scene || sc.id !== scene.id) fanout.stop(`scene:${sc.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nms = new NodeMediaServer({
|
||||
rtmp: {
|
||||
port: config.rtmpPort,
|
||||
chunk_size: 60000,
|
||||
gop_cache: true,
|
||||
ping: 30,
|
||||
ping_timeout: 60,
|
||||
},
|
||||
http: false,
|
||||
relay: false,
|
||||
trans: false,
|
||||
});
|
||||
|
||||
nms.on('prePublish', (id, streamPath) => {
|
||||
// Accept only a feed's stream key on the configured app.
|
||||
const prefix = `/${config.ingestApp}/`;
|
||||
if (!streamPath.startsWith(prefix)) {
|
||||
const session = nms.getSession(id);
|
||||
if (session) session.reject();
|
||||
return;
|
||||
}
|
||||
const streamKey = streamPath.slice(prefix.length);
|
||||
if (!store.findFeedByStreamKey(streamKey)) {
|
||||
const session = nms.getSession(id);
|
||||
if (session) session.reject();
|
||||
}
|
||||
});
|
||||
|
||||
nms.on('postPublish', (id, streamPath) => {
|
||||
const prefix = `/${config.ingestApp}/`;
|
||||
if (!streamPath.startsWith(prefix)) return;
|
||||
const feed = store.findFeedByStreamKey(streamPath.slice(prefix.length));
|
||||
if (feed) {
|
||||
liveFeeds.add(feed.id);
|
||||
reconcileAll();
|
||||
}
|
||||
});
|
||||
|
||||
nms.on('donePublish', (id, streamPath) => {
|
||||
const prefix = `/${config.ingestApp}/`;
|
||||
if (!streamPath.startsWith(prefix)) return;
|
||||
const feed = store.findFeedByStreamKey(streamPath.slice(prefix.length));
|
||||
if (feed) {
|
||||
liveFeeds.delete(feed.id);
|
||||
reconcileAll();
|
||||
}
|
||||
});
|
||||
|
||||
nms.run();
|
||||
|
||||
const app = createServer({ config, store, fanout, auth, grants, oidc, reconcileAll, liveFeeds });
|
||||
|
||||
const server = app.listen(config.httpPort, () => {
|
||||
console.log(`[multistreaming] panel listening on http://0.0.0.0:${config.httpPort}`);
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
fanout.stopAll();
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
return { reconcileAll };
|
||||
}
|
||||
|
||||
main();
|
||||
82
services/multistreaming/src/oidc.js
Normal file
82
services/multistreaming/src/oidc.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Minimal OIDC relying party for Authelia (authorization-code + PKCE).
|
||||
* This is only for authentication ("who are you"). It never sees or derives
|
||||
* any key material for the vault — the vault is a fully separate passphrase.
|
||||
*/
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf).toString('base64url');
|
||||
}
|
||||
|
||||
function randomToken(bytes = 32) {
|
||||
return b64url(crypto.randomBytes(bytes));
|
||||
}
|
||||
|
||||
/** S256 PKCE challenge for a verifier. */
|
||||
function pkceChallenge(verifier) {
|
||||
return b64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
}
|
||||
|
||||
class OidcClient {
|
||||
constructor({ issuer, clientId, redirectUri }) {
|
||||
this.issuer = issuer.replace(/\/+$/, '');
|
||||
this.clientId = clientId;
|
||||
this.redirectUri = redirectUri;
|
||||
this._meta = null;
|
||||
}
|
||||
|
||||
async discover() {
|
||||
if (this._meta) return this._meta;
|
||||
const res = await fetch(`${this.issuer}/.well-known/openid-configuration`);
|
||||
if (!res.ok) throw new Error(`OIDC discovery failed: HTTP ${res.status}`);
|
||||
this._meta = await res.json();
|
||||
return this._meta;
|
||||
}
|
||||
|
||||
async authorizationUrl(state, challenge) {
|
||||
const meta = await this.discover();
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: this.clientId,
|
||||
redirect_uri: this.redirectUri,
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${meta.authorization_endpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async exchangeCode(code, verifier) {
|
||||
const meta = await this.discover();
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: this.redirectUri,
|
||||
client_id: this.clientId,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
const res = await fetch(meta.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`token exchange failed: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async userinfo(accessToken) {
|
||||
const meta = await this.discover();
|
||||
const res = await fetch(meta.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`userinfo failed: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { OidcClient, randomToken, pkceChallenge };
|
||||
171
services/multistreaming/src/providers.js
Normal file
171
services/multistreaming/src/providers.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Pluggable streaming providers.
|
||||
*
|
||||
* A "provider" is one destination platform (Twitch, YouTube, Kick, …) described
|
||||
* by the fields it needs (a stream key, a server URL, etc.) and how to turn
|
||||
* those fields into an RTMP push URL. New providers are derived classes of
|
||||
* `Provider` registered in `PROVIDERS`; nothing else in the app hardcodes a
|
||||
* platform. A user can also create a `custom` provider with a free-form URL.
|
||||
*
|
||||
* This deliberately mirrors your "easy providers" requirement: a common base
|
||||
* class, derived per-service classes, and a custom escape hatch — so adding
|
||||
* Kick (new) or dropping Mixer (dead) is a code change in this one file, not a
|
||||
* ripple through the whole app.
|
||||
*/
|
||||
|
||||
class Provider {
|
||||
constructor(def) {
|
||||
this.id = def.id;
|
||||
this.name = def.name;
|
||||
// Fields the UI asks the user for, in order:
|
||||
// { name, label, secret?, required?, placeholder?, help? }
|
||||
this.fields = def.fields || [];
|
||||
this.defaultUrl = def.defaultUrl || '';
|
||||
}
|
||||
|
||||
/** Full RTMP push URL. Default: url + '/' + key (or just url when key is empty). */
|
||||
pushUrl(config) {
|
||||
const base = (config.url || this.defaultUrl || '').replace(/\/+$/, '');
|
||||
const key = (config.key || '').trim();
|
||||
return key ? `${base}/${key}` : base;
|
||||
}
|
||||
|
||||
/** Human-readable, copy-safe summary (never exposes the secret key). */
|
||||
describe(config) {
|
||||
const key = (config.key || '').trim();
|
||||
return key ? `${this.name} (key: ••••••••${key.slice(-4)})` : this.name;
|
||||
}
|
||||
|
||||
/** Return a list of validation errors (empty = valid). */
|
||||
validate(config) {
|
||||
const errors = [];
|
||||
const value = (name) => {
|
||||
const v = config ? config[name] : undefined;
|
||||
return typeof v === 'string' ? v.trim() : '';
|
||||
};
|
||||
|
||||
if (this.id !== 'custom' && !value('url') && !this.defaultUrl) {
|
||||
errors.push('url is required');
|
||||
}
|
||||
for (const field of this.fields) {
|
||||
if (field.required && !value(field.name)) {
|
||||
errors.push(`${field.label || field.name} is required`);
|
||||
}
|
||||
}
|
||||
if (!this.pushUrl(config).startsWith('rtmp://') && !this.pushUrl(config).startsWith('rtmps://')) {
|
||||
errors.push('push URL must start with rtmp:// or rtmps://');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Public metadata sent to the panel (no secrets). */
|
||||
toJSON() {
|
||||
return { id: this.id, name: this.name, fields: this.fields, defaultUrl: this.defaultUrl };
|
||||
}
|
||||
}
|
||||
|
||||
class TwitchProvider extends Provider {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'twitch',
|
||||
name: 'Twitch',
|
||||
defaultUrl: 'rtmp://live.twitch.tv/app',
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
label: 'Stream key',
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: 'live_xxxxxxxx',
|
||||
help: 'Twitch Dashboard → Settings → Stream → Primary Stream key',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class YouTubeProvider extends Provider {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
defaultUrl: 'rtmp://a.rtmp.youtube.com/live2',
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
label: 'Stream key',
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: 'xxxx-xxxx-xxxx-xxxx',
|
||||
help: 'YouTube Studio → Go live → Stream → Stream key',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class KickProvider extends Provider {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'kick',
|
||||
name: 'Kick',
|
||||
defaultUrl: 'rtmp://fa723fc1b171.global-contribute.live-video.net',
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
label: 'Stream key',
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: 'sk_xxx',
|
||||
help: 'Kick Dashboard → Settings → Stream Key',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class CustomProvider extends Provider {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'custom',
|
||||
name: 'Custom RTMP',
|
||||
fields: [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'RTMP URL',
|
||||
required: true,
|
||||
placeholder: 'rtmp://ingest.example.com/live',
|
||||
help: 'Full server URL. The key is appended if provided.',
|
||||
},
|
||||
{
|
||||
name: 'key',
|
||||
label: 'Stream key (optional)',
|
||||
secret: true,
|
||||
required: false,
|
||||
placeholder: 'stream-key',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDERS = [
|
||||
new TwitchProvider(),
|
||||
new YouTubeProvider(),
|
||||
new KickProvider(),
|
||||
new CustomProvider(),
|
||||
];
|
||||
|
||||
const byId = new Map(PROVIDERS.map((p) => [p.id, p]));
|
||||
|
||||
function getProvider(id) {
|
||||
return byId.get(id) || byId.get('custom');
|
||||
}
|
||||
|
||||
function listProviders() {
|
||||
return PROVIDERS.map((p) => p.toJSON());
|
||||
}
|
||||
|
||||
module.exports = { Provider, getProvider, listProviders };
|
||||
768
services/multistreaming/src/server.js
Normal file
768
services/multistreaming/src/server.js
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { Store } = require('./store');
|
||||
const { listProviders, getProvider } = require('./providers');
|
||||
const { uuid } = require('./id');
|
||||
|
||||
const COOKIE = 'ms_session';
|
||||
|
||||
/** Sniff an image type from magic bytes (client-declared MIME is not trusted). */
|
||||
function sniffImageMime(buf) {
|
||||
if (
|
||||
buf.length >= 8 &&
|
||||
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
buf.length >= 12 &&
|
||||
buf.toString('ascii', 0, 4) === 'RIFF' &&
|
||||
buf.toString('ascii', 8, 12) === 'WEBP'
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const IMAGE_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
||||
|
||||
/** Public image metadata (never exposes the server filesystem path). */
|
||||
function imageMeta(image) {
|
||||
return {
|
||||
id: image.id,
|
||||
name: image.name,
|
||||
mime: image.mime,
|
||||
size: image.size,
|
||||
url: `/api/images/${image.id}/file`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Role model (per room):
|
||||
* owner — full control.
|
||||
* editor — edit feeds + outputs (the production), but NEVER sees account keys.
|
||||
* streamer — manage their OWN accounts + grant their own keys for streaming.
|
||||
*/
|
||||
const ROLE_RANK = { owner: 3, editor: 2, streamer: 1 };
|
||||
|
||||
function roleOf(store, roomId, userId) {
|
||||
const m = store.membership(roomId, userId);
|
||||
return m ? m.role : null;
|
||||
}
|
||||
|
||||
function can(store, roomId, userId, minRole) {
|
||||
const role = roleOf(store, roomId, userId);
|
||||
return role && ROLE_RANK[role] >= ROLE_RANK[minRole];
|
||||
}
|
||||
|
||||
function maskAccount(account, viewerId) {
|
||||
if (account.ownerId === viewerId) return account;
|
||||
const { secretCiphertext, ...rest } = account;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function createServer({ config, store, fanout, auth, grants, oidc, reconcileAll, liveFeeds }) {
|
||||
const app = express();
|
||||
// JSON body limit must accommodate the largest allowed base64 image upload
|
||||
// (decoded bytes → base64 is ceil(n/3)*4), plus JSON overhead.
|
||||
const imageJsonLimit = Math.ceil(config.sceneMaxImageBytes / 3) * 4 + 65536;
|
||||
app.use(express.json({ limit: imageJsonLimit }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const header = req.headers.cookie;
|
||||
const cookies = {};
|
||||
if (typeof header === 'string') {
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq > 0) cookies[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
req.cookies = cookies;
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, '..', 'web', 'dist')));
|
||||
|
||||
const authed = auth.middleware(COOKIE);
|
||||
const setSession = (res, token) => {
|
||||
res.setHeader('Set-Cookie', `${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=43200`);
|
||||
};
|
||||
|
||||
// ---- public ------------------------------------------------------------
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||
app.get('/api/providers', (req, res) => res.json(listProviders()));
|
||||
app.get('/api/auth/config', (req, res) => res.json({ mode: config.authMode, oidc: Boolean(config.oidc) }));
|
||||
|
||||
// OIDC: start the authorization-code flow (state + PKCE held server-side).
|
||||
const pending = new Map(); // state -> { verifier, expiresAt }
|
||||
app.get('/api/auth/oidc/start', async (req, res) => {
|
||||
if (!oidc) {
|
||||
res.status(400).json({ error: 'OIDC not configured' });
|
||||
return;
|
||||
}
|
||||
const state = require('./oidc').randomToken();
|
||||
const verifier = require('./oidc').randomToken(32);
|
||||
pending.set(state, { verifier, expiresAt: Date.now() + 10 * 60 * 1000 });
|
||||
try {
|
||||
const url = await oidc.authorizationUrl(state, require('./oidc').pkceChallenge(verifier));
|
||||
res.redirect(url);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/oidc/callback', async (req, res) => {
|
||||
if (!oidc) {
|
||||
res.status(400).json({ error: 'OIDC not configured' });
|
||||
return;
|
||||
}
|
||||
const { code, state } = req.query;
|
||||
const entry = pending.get(state);
|
||||
pending.delete(state);
|
||||
if (!entry || entry.expiresAt < Date.now() || !code) {
|
||||
res.status(400).send('Invalid or expired OIDC state');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tokens = await oidc.exchangeCode(code, entry.verifier);
|
||||
const info = await oidc.userinfo(tokens.access_token);
|
||||
const username = info.preferred_username || info.email || info.sub;
|
||||
const user = auth.findOrCreateOidcUser({ sub: info.sub, username });
|
||||
setSession(res, auth.issueToken(user.id));
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
res.status(502).send(`OIDC login failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Local auth (AUTH_MODE=local only).
|
||||
app.post('/api/auth/register', (req, res) => {
|
||||
if (config.authMode !== 'local') {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'username and password required' });
|
||||
return;
|
||||
}
|
||||
const user = auth.register({ username, password });
|
||||
if (!user) {
|
||||
res.status(409).json({ error: 'username taken' });
|
||||
return;
|
||||
}
|
||||
setSession(res, auth.issueToken(user.id));
|
||||
res.status(201).json({ id: user.id, username: user.username });
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
if (config.authMode !== 'local') {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
const { username, password } = req.body || {};
|
||||
const user = store.findUserByUsername(username);
|
||||
if (!user || !auth.verifyPassword(password, user)) {
|
||||
res.status(401).json({ error: 'invalid credentials' });
|
||||
return;
|
||||
}
|
||||
setSession(res, auth.issueToken(user.id));
|
||||
res.json({ id: user.id, username: user.username });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
res.setHeader('Set-Cookie', `${COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- auth required -----------------------------------------------------
|
||||
|
||||
app.get('/api/me', authed, (req, res) => {
|
||||
res.json({
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
sid: req.session.sid,
|
||||
defaultVaultId: req.user.defaultVaultId || null,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- vaults (each has its own passphrase; one unlocked at a time) ------
|
||||
|
||||
app.get('/api/vaults', authed, (req, res) => {
|
||||
res.json(
|
||||
store.listVaultsFor(req.user.id).map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
salt: v.salt,
|
||||
serverWrapped: v.serverWrapped,
|
||||
isDefault: v.id === req.user.defaultVaultId,
|
||||
createdAt: v.createdAt,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/api/vaults', authed, (req, res) => {
|
||||
const { id, name, salt, serverWrapped } = req.body || {};
|
||||
if (!salt || !serverWrapped || !serverWrapped.iv || !serverWrapped.data) {
|
||||
res.status(400).json({ error: 'salt and serverWrapped ({iv,data}) required' });
|
||||
return;
|
||||
}
|
||||
const vault = store.createVault({ id, ownerId: req.user.id, name, salt, serverWrapped });
|
||||
res.status(201).json(vault);
|
||||
});
|
||||
|
||||
app.post('/api/vaults/:id/default', authed, (req, res) => {
|
||||
const user = store.setDefaultVault(req.user.id, req.params.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ defaultVaultId: user.defaultVaultId });
|
||||
});
|
||||
|
||||
app.delete('/api/vaults/:id', authed, (req, res) => {
|
||||
const vault = store.findVault(req.params.id);
|
||||
if (!vault || vault.ownerId !== req.user.id) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
store.removeVault(vault.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- rooms -------------------------------------------------------------
|
||||
|
||||
app.get('/api/rooms', authed, (req, res) => {
|
||||
res.json(
|
||||
store.roomsFor(req.user.id).map((r) => ({ ...r, role: roleOf(store, r.id, req.user.id) })),
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/api/rooms', authed, (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'name required' });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(store.createRoom({ name, ownerId: req.user.id }));
|
||||
});
|
||||
|
||||
app.get('/api/rooms/:roomId', authed, (req, res) => {
|
||||
const room = store.findRoom(req.params.roomId);
|
||||
if (!room || !roleOf(store, room.id, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ ...room, members: store.listMembers(room.id) });
|
||||
});
|
||||
|
||||
app.post('/api/rooms/:roomId/invites', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!can(store, roomId, req.user.id, 'owner')) {
|
||||
res.status(403).json({ error: 'owner only' });
|
||||
return;
|
||||
}
|
||||
const invite = store.createInvite({ roomId, role: req.body?.role });
|
||||
res.status(201).json(invite);
|
||||
});
|
||||
|
||||
app.post('/api/invites/:token/accept', authed, (req, res) => {
|
||||
const invite = store.findInviteByToken(req.params.token);
|
||||
if (!invite) {
|
||||
res.status(404).json({ error: 'invalid or expired invite' });
|
||||
return;
|
||||
}
|
||||
const membership = store.acceptInvite(invite, req.user.id);
|
||||
res.json({ roomId: invite.roomId, role: membership.role });
|
||||
});
|
||||
|
||||
// ---- accounts (keys encrypted under an active vault) -------------------
|
||||
|
||||
app.get('/api/accounts', authed, (req, res) => {
|
||||
res.json(store.listAccountsFor(req.user.id));
|
||||
});
|
||||
|
||||
app.post('/api/accounts', authed, (req, res) => {
|
||||
const { vaultId, provider, name, url, secretCiphertext, enabled } = req.body || {};
|
||||
if (!vaultId) {
|
||||
res.status(400).json({ error: 'vaultId required (unlock a vault first)' });
|
||||
return;
|
||||
}
|
||||
const vault = store.findVault(vaultId);
|
||||
if (!vault || vault.ownerId !== req.user.id) {
|
||||
res.status(404).json({ error: 'vault not found' });
|
||||
return;
|
||||
}
|
||||
if (!secretCiphertext) {
|
||||
res.status(400).json({ error: 'secretCiphertext required (encrypt the key client-side)' });
|
||||
return;
|
||||
}
|
||||
const account = store.createAccount({
|
||||
ownerId: req.user.id,
|
||||
vaultId,
|
||||
provider,
|
||||
name,
|
||||
url: url || getProvider(provider).defaultUrl,
|
||||
secretCiphertext,
|
||||
enabled,
|
||||
});
|
||||
res.status(201).json(account);
|
||||
});
|
||||
|
||||
app.put('/api/accounts/:id', authed, (req, res) => {
|
||||
const a = store.findAccount(req.params.id);
|
||||
if (!a || a.ownerId !== req.user.id) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json(store.updateAccount(a.id, req.body || {}));
|
||||
});
|
||||
|
||||
app.delete('/api/accounts/:id', authed, (req, res) => {
|
||||
const a = store.findAccount(req.params.id);
|
||||
if (!a || a.ownerId !== req.user.id) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
grants.revoke(a.id);
|
||||
store.removeAccount(a.id);
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- feeds (owner/editor) ----------------------------------------------
|
||||
|
||||
app.get('/api/rooms/:roomId/feeds', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json(store.listFeeds(roomId));
|
||||
});
|
||||
|
||||
app.post('/api/rooms/:roomId/feeds', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!can(store, roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(store.createFeed({ roomId, ownerId: req.user.id, name: req.body?.name }));
|
||||
});
|
||||
|
||||
app.delete('/api/feeds/:id', authed, (req, res) => {
|
||||
const feed = store.findFeed(req.params.id);
|
||||
if (!feed || !can(store, feed.roomId, req.user.id, 'editor')) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
fanout.stop(feed.id);
|
||||
store.removeFeed(feed.id);
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- outputs (feed → account routing; owner/editor) --------------------
|
||||
|
||||
app.get('/api/rooms/:roomId/outputs', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
const outputs = store.listOutputs(roomId).map((o) => ({
|
||||
...o,
|
||||
account: maskAccount(store.findAccount(o.accountId) || { id: o.accountId, name: '?' }, req.user.id),
|
||||
feed: store.findFeed(o.feedId) || { id: o.feedId, name: '?' },
|
||||
}));
|
||||
res.json(outputs);
|
||||
});
|
||||
|
||||
app.post('/api/rooms/:roomId/outputs', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!can(store, roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
const { feedId, accountId, enabled } = req.body || {};
|
||||
if (!feedId || !accountId) {
|
||||
res.status(400).json({ error: 'feedId and accountId required' });
|
||||
return;
|
||||
}
|
||||
const output = store.createOutput({ roomId, feedId, accountId, enabled });
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.status(201).json(output);
|
||||
});
|
||||
|
||||
app.put('/api/outputs/:id', authed, (req, res) => {
|
||||
const o = store.findOutput(req.params.id);
|
||||
if (!o || !can(store, o.roomId, req.user.id, 'editor')) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
const updated = store.updateOutput(o.id, req.body || {});
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.delete('/api/outputs/:id', authed, (req, res) => {
|
||||
const o = store.findOutput(req.params.id);
|
||||
if (!o || !can(store, o.roomId, req.user.id, 'editor')) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
store.removeOutput(o.id);
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- scenes (§6.1; owner/editor write, any member read) ----------------
|
||||
|
||||
app.get('/api/rooms/:roomId/scenes', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json(store.listScenes(roomId));
|
||||
});
|
||||
|
||||
app.post('/api/rooms/:roomId/scenes', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
const { name, layout } = req.body || {};
|
||||
const err = store.validateScene(roomId, { name, layout, overlays: [] });
|
||||
if (err) {
|
||||
res.status(400).json({ error: err });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(store.createScene({ roomId, ownerId: req.user.id, name, layout }));
|
||||
});
|
||||
|
||||
app.get('/api/scenes/:id', authed, (req, res) => {
|
||||
const scene = store.findScene(req.params.id);
|
||||
if (!scene || !roleOf(store, scene.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json(scene);
|
||||
});
|
||||
|
||||
app.put('/api/scenes/:id', authed, (req, res) => {
|
||||
const scene = store.findScene(req.params.id);
|
||||
if (!scene || !roleOf(store, scene.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, scene.roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
const updated = store.updateScene(scene.id, req.body || {});
|
||||
if (!updated) {
|
||||
res.status(400).json({ error: 'invalid scene update' });
|
||||
return;
|
||||
}
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.post('/api/scenes/:id/activate', authed, (req, res) => {
|
||||
const scene = store.findScene(req.params.id);
|
||||
if (!scene || !roleOf(store, scene.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, scene.roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
const updated = store.updateScene(scene.id, { active: true });
|
||||
if (!updated) {
|
||||
res.status(400).json({ error: 'invalid scene activation' });
|
||||
return;
|
||||
}
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.delete('/api/scenes/:id', authed, (req, res) => {
|
||||
const scene = store.findScene(req.params.id);
|
||||
if (!scene || !roleOf(store, scene.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, scene.roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
fanout.stop(`scene:${scene.id}`);
|
||||
store.removeScene(scene.id);
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- scene outputs (§6.2) ----------------------------------------------
|
||||
|
||||
app.get('/api/rooms/:roomId/scene-outputs', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
const sos = store.listSceneOutputs(roomId).map((so) => ({
|
||||
...so,
|
||||
account: maskAccount(store.findAccount(so.accountId) || { id: so.accountId, name: '?' }, req.user.id),
|
||||
}));
|
||||
res.json(sos);
|
||||
});
|
||||
|
||||
app.post('/api/rooms/:roomId/scene-outputs', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
const { sceneId, accountId, enabled, audio } = req.body || {};
|
||||
if (!sceneId || !accountId) {
|
||||
res.status(400).json({ error: 'sceneId and accountId required' });
|
||||
return;
|
||||
}
|
||||
const err = store.validateSceneOutput(roomId, { sceneId, accountId, audio });
|
||||
if (err) {
|
||||
res.status(400).json({ error: err });
|
||||
return;
|
||||
}
|
||||
const so = store.createSceneOutput({ roomId, sceneId, accountId, enabled, audio });
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.status(201).json(so);
|
||||
});
|
||||
|
||||
app.put('/api/scene-outputs/:id', authed, (req, res) => {
|
||||
const so = store.findSceneOutput(req.params.id);
|
||||
if (!so || !roleOf(store, so.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, so.roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
const updated = store.updateSceneOutput(so.id, req.body || {});
|
||||
if (!updated) {
|
||||
res.status(400).json({ error: 'invalid scene output update' });
|
||||
return;
|
||||
}
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.delete('/api/scene-outputs/:id', authed, (req, res) => {
|
||||
const so = store.findSceneOutput(req.params.id);
|
||||
if (!so || !roleOf(store, so.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, so.roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
store.removeSceneOutput(so.id);
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- images (§6.3) -----------------------------------------------------
|
||||
|
||||
app.get('/api/rooms/:roomId/images', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json(store.listImages(roomId).map(imageMeta));
|
||||
});
|
||||
|
||||
app.post('/api/rooms/:roomId/images', authed, (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
if (!roleOf(store, roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
const { name, data } = req.body || {};
|
||||
if (typeof name !== 'string' || typeof data !== 'string') {
|
||||
res.status(400).json({ error: 'name and data (base64) required' });
|
||||
return;
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || trimmedName.length > 64) {
|
||||
res.status(400).json({ error: 'name must be 1..64 characters' });
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
||||
res.status(400).json({ error: 'invalid base64 data' });
|
||||
return;
|
||||
}
|
||||
let buf;
|
||||
try {
|
||||
buf = Buffer.from(data, 'base64');
|
||||
} catch {
|
||||
res.status(400).json({ error: 'invalid base64 data' });
|
||||
return;
|
||||
}
|
||||
if (buf.length === 0) {
|
||||
res.status(400).json({ error: 'empty image' });
|
||||
return;
|
||||
}
|
||||
if (buf.length > config.sceneMaxImageBytes) {
|
||||
res.status(400).json({ error: `image exceeds ${config.sceneMaxImageBytes} bytes` });
|
||||
return;
|
||||
}
|
||||
const mime = sniffImageMime(buf);
|
||||
if (!mime) {
|
||||
res.status(400).json({ error: 'unsupported image type (png/jpeg/webp only)' });
|
||||
return;
|
||||
}
|
||||
const id = uuid();
|
||||
const relPath = `uploads/${id}.${IMAGE_EXT[mime]}`;
|
||||
const absPath = path.join(config.dataDir, relPath);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, buf);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'failed to save image' });
|
||||
return;
|
||||
}
|
||||
const image = store.createImage({ id, roomId, name: trimmedName, mime, size: buf.length, path: relPath });
|
||||
res.status(201).json(imageMeta(image));
|
||||
});
|
||||
|
||||
app.get('/api/images/:id/file', authed, (req, res) => {
|
||||
const image = store.findImage(req.params.id);
|
||||
if (!image || !roleOf(store, image.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!/^[a-f0-9-]{36}\.(png|jpg|webp)$/.test(path.basename(image.path))) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.sendFile(path.join(config.dataDir, image.path), {
|
||||
headers: { 'Content-Type': image.mime },
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/images/:id', authed, (req, res) => {
|
||||
const image = store.findImage(req.params.id);
|
||||
if (!image || !roleOf(store, image.roomId, req.user.id)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (!can(store, image.roomId, req.user.id, 'editor')) {
|
||||
res.status(403).json({ error: 'editor or owner only' });
|
||||
return;
|
||||
}
|
||||
store.removeImage(image.id);
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- streaming grant (streamer, own accounts only) ---------------------
|
||||
|
||||
app.post('/api/streams/start', authed, (req, res) => {
|
||||
const { accountKeys } = req.body || {};
|
||||
if (!accountKeys || typeof accountKeys !== 'object') {
|
||||
res.status(400).json({ error: 'accountKeys {accountId: plaintextKey} required' });
|
||||
return;
|
||||
}
|
||||
for (const [accountId, key] of Object.entries(accountKeys)) {
|
||||
const account = store.findAccount(accountId);
|
||||
if (!account || account.ownerId !== req.user.id) {
|
||||
res.status(403).json({ error: `not your account: ${accountId}` });
|
||||
return;
|
||||
}
|
||||
grants.grant(accountId, String(key), req.user.id);
|
||||
}
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ granted: Object.keys(accountKeys) });
|
||||
});
|
||||
|
||||
app.post('/api/streams/stop', authed, (req, res) => {
|
||||
grants.revokeBy(req.user.id);
|
||||
if (reconcileAll) reconcileAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/streams/grants', authed, (req, res) => {
|
||||
const owned = new Set(store.listAccountsFor(req.user.id).map((a) => a.id));
|
||||
res.json(grants.grantedAccountIds().filter((id) => owned.has(id)));
|
||||
});
|
||||
|
||||
// ---- combined state for the panel --------------------------------------
|
||||
|
||||
app.get('/api/state', authed, (req, res) => {
|
||||
res.json({
|
||||
me: { id: req.user.id, username: req.user.username, defaultVaultId: req.user.defaultVaultId || null },
|
||||
vaults: store.listVaultsFor(req.user.id).map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
isDefault: v.id === req.user.defaultVaultId,
|
||||
})),
|
||||
rooms: store.roomsFor(req.user.id).map((r) => {
|
||||
const activeScene = store.findActiveScene(r.id);
|
||||
return {
|
||||
...r,
|
||||
role: roleOf(store, r.id, req.user.id),
|
||||
feeds: store.listFeeds(r.id),
|
||||
outputs: store.listOutputs(r.id).map((o) => ({
|
||||
...o,
|
||||
runtime: fanout.status()[o.id] || { running: false, restarts: 0, log: [] },
|
||||
})),
|
||||
accounts: store.accountsInRoom(r.id).map((a) => maskAccount(a, req.user.id)),
|
||||
activeSceneId: activeScene ? activeScene.id : null,
|
||||
scenes: store.listScenes(r.id),
|
||||
sceneOutputs: store.listSceneOutputs(r.id).map((so) => ({
|
||||
...so,
|
||||
account: maskAccount(store.findAccount(so.accountId) || { id: so.accountId, name: '?' }, req.user.id),
|
||||
runtime: fanout.status()[so.id] || { running: false, restarts: 0, log: [] },
|
||||
})),
|
||||
images: store.listImages(r.id).map(imageMeta),
|
||||
};
|
||||
}),
|
||||
myAccounts: store.listAccountsFor(req.user.id),
|
||||
liveFeeds: [...(liveFeeds || [])],
|
||||
ingest: { app: config.ingestApp, rtmpPort: config.rtmpPort, publicHost: config.publicHost },
|
||||
});
|
||||
});
|
||||
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/')) return next();
|
||||
res.sendFile(path.join(__dirname, '..', 'web', 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createServer };
|
||||
681
services/multistreaming/src/store.js
Normal file
681
services/multistreaming/src/store.js
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { uuid, token } = require('./id');
|
||||
const { getProvider } = require('./providers');
|
||||
|
||||
/**
|
||||
* Persistent JSON store (atomic writes). This is the source of truth for the
|
||||
* non-secret part of the data model. Secrets (account stream keys) are NEVER
|
||||
* stored here in plaintext — accounts keep only the client-side-encrypted
|
||||
* ciphertext and the vault recovery blob, both useless without the user's key.
|
||||
*
|
||||
* Shape:
|
||||
* {
|
||||
* users: [ { id, username, passwordHash, passwordSalt, defaultVaultId, createdAt } ]
|
||||
* vaults: [ { id, ownerId, name, salt, serverWrapped, createdAt } ]
|
||||
* rooms: [ { id, name, slug, ownerId, createdAt } ]
|
||||
* memberships: [ { id, roomId, userId, role } ] role: 'owner'|'editor'|'streamer'
|
||||
* invites: [ { id, roomId, role, token, expiresAt, createdAt } ]
|
||||
* accounts: [ { id, ownerId, vaultId, provider, name, url, secretCiphertext, enabled } ]
|
||||
* feeds: [ { id, roomId, ownerId, name, streamKey } ]
|
||||
* outputs: [ { id, roomId, feedId, accountId, enabled } ]
|
||||
* scenes: [ Scene ] // composition definitions (§3.1)
|
||||
* sceneOutputs:[ SceneOutput ] // composed-program destinations (§3.2)
|
||||
* images: [ RoomImage ] // overlay image metadata (§3.3)
|
||||
* }
|
||||
*/
|
||||
class Store {
|
||||
constructor(dataDir) {
|
||||
this.dataDir = dataDir;
|
||||
this.file = path.join(dataDir, 'config.json');
|
||||
this.state = this._load();
|
||||
}
|
||||
|
||||
_load() {
|
||||
try {
|
||||
return this._normalize(JSON.parse(fs.readFileSync(this.file, 'utf8')));
|
||||
} catch {
|
||||
return this._normalize({});
|
||||
}
|
||||
}
|
||||
|
||||
_normalize(raw) {
|
||||
const s = {
|
||||
users: Array.isArray(raw.users) ? raw.users : [],
|
||||
vaults: Array.isArray(raw.vaults) ? raw.vaults : [],
|
||||
rooms: Array.isArray(raw.rooms) ? raw.rooms : [],
|
||||
memberships: Array.isArray(raw.memberships) ? raw.memberships : [],
|
||||
invites: Array.isArray(raw.invites) ? raw.invites : [],
|
||||
accounts: Array.isArray(raw.accounts) ? raw.accounts : [],
|
||||
feeds: Array.isArray(raw.feeds) ? raw.feeds : [],
|
||||
outputs: Array.isArray(raw.outputs) ? raw.outputs : [],
|
||||
scenes: Array.isArray(raw.scenes) ? raw.scenes : [],
|
||||
sceneOutputs: Array.isArray(raw.sceneOutputs) ? raw.sceneOutputs : [],
|
||||
images: Array.isArray(raw.images) ? raw.images : [],
|
||||
};
|
||||
// Prune expired invites on load.
|
||||
const now = Date.now();
|
||||
s.invites = s.invites.filter((i) => !i.expiresAt || i.expiresAt > now);
|
||||
|
||||
// ---- defensive pruning of scene references (§3.5) --------------------
|
||||
const feedIds = new Set(s.feeds.filter((f) => f && typeof f.id === 'string').map((f) => f.id));
|
||||
const accountIds = new Set(s.accounts.filter((a) => a && typeof a.id === 'string').map((a) => a.id));
|
||||
|
||||
// Keep only images with a server-generated, strict-basename path.
|
||||
s.images = s.images.filter(
|
||||
(img) =>
|
||||
img &&
|
||||
typeof img.id === 'string' &&
|
||||
typeof img.path === 'string' &&
|
||||
/^[a-f0-9-]{36}\.(png|jpg|webp)$/.test(path.basename(img.path)),
|
||||
);
|
||||
const imageIds = new Set(s.images.map((img) => img.id));
|
||||
|
||||
// Scenes: strip slots referencing unknown feeds and overlay image refs
|
||||
// pointing at unknown images.
|
||||
s.scenes = s.scenes.filter(
|
||||
(sc) => sc && typeof sc.id === 'string' && typeof sc.roomId === 'string',
|
||||
);
|
||||
for (const sc of s.scenes) {
|
||||
if (sc.layout && Array.isArray(sc.layout.slots)) {
|
||||
sc.layout.slots = sc.layout.slots.map((slot) =>
|
||||
typeof slot === 'string' && feedIds.has(slot) ? slot : null,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(sc.overlays)) {
|
||||
sc.overlays = sc.overlays.filter(
|
||||
(ov) =>
|
||||
ov &&
|
||||
(ov.kind === 'text' ||
|
||||
(ov.kind === 'image' && typeof ov.imageId === 'string' && imageIds.has(ov.imageId))),
|
||||
);
|
||||
} else {
|
||||
sc.overlays = [];
|
||||
}
|
||||
}
|
||||
// At most one active scene per room (keep the first, clear the rest).
|
||||
const activeByRoom = new Map();
|
||||
for (const sc of s.scenes) {
|
||||
if (sc.active) {
|
||||
if (activeByRoom.has(sc.roomId)) sc.active = false;
|
||||
else activeByRoom.set(sc.roomId, sc.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Scene outputs: drop ones referencing unknown scenes/accounts; repair
|
||||
// roomId to match the scene and demote stale "feed" audio routing.
|
||||
const sceneById = new Map(s.scenes.map((sc) => [sc.id, sc]));
|
||||
s.sceneOutputs = s.sceneOutputs.filter(
|
||||
(so) =>
|
||||
so &&
|
||||
typeof so.id === 'string' &&
|
||||
typeof so.sceneId === 'string' &&
|
||||
sceneById.has(so.sceneId) &&
|
||||
typeof so.accountId === 'string' &&
|
||||
accountIds.has(so.accountId),
|
||||
);
|
||||
for (const so of s.sceneOutputs) {
|
||||
const scene = sceneById.get(so.sceneId);
|
||||
if (so.roomId !== scene.roomId) so.roomId = scene.roomId;
|
||||
if (
|
||||
so.audio &&
|
||||
so.audio.mode === 'feed' &&
|
||||
(typeof so.audio.feedId !== 'string' || !feedIds.has(so.audio.feedId))
|
||||
) {
|
||||
so.audio = { mode: 'silent' };
|
||||
}
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
_save() {
|
||||
const tmp = `${this.file}.${process.pid}.tmp`;
|
||||
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
||||
fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), 'utf8');
|
||||
fs.renameSync(tmp, this.file);
|
||||
}
|
||||
|
||||
// ---- users -------------------------------------------------------------
|
||||
|
||||
findUserByUsername(username) {
|
||||
return this.state.users.find((u) => u.username === username) || null;
|
||||
}
|
||||
|
||||
findUserById(id) {
|
||||
return this.state.users.find((u) => u.id === id) || null;
|
||||
}
|
||||
|
||||
createUser({ username, passwordHash, passwordSalt }) {
|
||||
const user = {
|
||||
id: uuid(),
|
||||
username,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
defaultVaultId: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.state.users.push(user);
|
||||
this._save();
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---- vaults ------------------------------------------------------------
|
||||
|
||||
listVaultsFor(userId) {
|
||||
return this.state.vaults.filter((v) => v.ownerId === userId);
|
||||
}
|
||||
|
||||
findVault(id) {
|
||||
return this.state.vaults.find((v) => v.id === id) || null;
|
||||
}
|
||||
|
||||
/** Create a vault. `serverWrapped` is the PBKDF2-wrapped VK (opaque to us). */
|
||||
createVault({ id, ownerId, name, salt, serverWrapped }) {
|
||||
const vault = {
|
||||
id: id || uuid(),
|
||||
ownerId,
|
||||
name: name || 'Vault',
|
||||
salt,
|
||||
serverWrapped,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.state.vaults.push(vault);
|
||||
if (!this.findUserById(ownerId).defaultVaultId) {
|
||||
this.findUserById(ownerId).defaultVaultId = vault.id;
|
||||
}
|
||||
this._save();
|
||||
return vault;
|
||||
}
|
||||
|
||||
removeVault(id) {
|
||||
const before = this.state.vaults.length;
|
||||
this.state.vaults = this.state.vaults.filter((v) => v.id !== id);
|
||||
// Accounts in a removed vault lose their ciphertext (can't decrypt anyway).
|
||||
for (const a of this.state.accounts) if (a.vaultId === id) a.vaultId = null;
|
||||
const changed = this.state.vaults.length !== before;
|
||||
if (changed) this._save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
setDefaultVault(userId, vaultId) {
|
||||
const user = this.findUserById(userId);
|
||||
const vault = this.findVault(vaultId);
|
||||
if (!user || !vault || vault.ownerId !== userId) return null;
|
||||
user.defaultVaultId = vaultId;
|
||||
this._save();
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---- rooms & memberships ----------------------------------------------
|
||||
|
||||
createRoom({ name, ownerId }) {
|
||||
const room = {
|
||||
id: uuid(),
|
||||
name,
|
||||
slug: `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token(4)}`,
|
||||
ownerId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.state.rooms.push(room);
|
||||
this.state.memberships.push({ id: uuid(), roomId: room.id, userId: ownerId, role: 'owner' });
|
||||
this._save();
|
||||
return room;
|
||||
}
|
||||
|
||||
listRooms() {
|
||||
return this.state.rooms;
|
||||
}
|
||||
|
||||
findRoom(id) {
|
||||
return this.state.rooms.find((r) => r.id === id) || null;
|
||||
}
|
||||
|
||||
membership(roomId, userId) {
|
||||
return this.state.memberships.find((m) => m.roomId === roomId && m.userId === userId) || null;
|
||||
}
|
||||
|
||||
listMembers(roomId) {
|
||||
return this.state.memberships
|
||||
.filter((m) => m.roomId === roomId)
|
||||
.map((m) => {
|
||||
const u = this.findUserById(m.userId);
|
||||
return { id: m.id, roomId, userId: m.userId, role: m.role, username: u ? u.username : '?' };
|
||||
});
|
||||
}
|
||||
|
||||
/** Rooms a user belongs to. */
|
||||
roomsFor(userId) {
|
||||
const ids = new Set(this.state.memberships.filter((m) => m.userId === userId).map((m) => m.roomId));
|
||||
return this.state.rooms.filter((r) => ids.has(r.id));
|
||||
}
|
||||
|
||||
// ---- invites -----------------------------------------------------------
|
||||
|
||||
createInvite({ roomId, role, expiresInMs = 7 * 24 * 3600 * 1000 }) {
|
||||
const invite = {
|
||||
id: uuid(),
|
||||
roomId,
|
||||
role: role === 'streamer' ? 'streamer' : 'editor',
|
||||
token: token(32), // 256-bit invite secret
|
||||
expiresAt: Date.now() + expiresInMs,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.state.invites.push(invite);
|
||||
this._save();
|
||||
return invite;
|
||||
}
|
||||
|
||||
findInviteByToken(t) {
|
||||
return this.state.invites.find((i) => i.token === t && i.expiresAt > Date.now()) || null;
|
||||
}
|
||||
|
||||
acceptInvite(invite, userId) {
|
||||
if (this.membership(invite.roomId, userId)) return { role: this.membership(invite.roomId, userId).role };
|
||||
const m = { id: uuid(), roomId: invite.roomId, userId, role: invite.role };
|
||||
this.state.memberships.push(m);
|
||||
this.state.invites = this.state.invites.filter((i) => i.id !== invite.id);
|
||||
this._save();
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---- accounts ----------------------------------------------------------
|
||||
|
||||
listAccountsFor(userId) {
|
||||
return this.state.accounts.filter((a) => a.ownerId === userId);
|
||||
}
|
||||
|
||||
/** Accounts whose owner is a member of the room (for routing, keys omitted). */
|
||||
accountsInRoom(roomId) {
|
||||
const members = new Set(this.state.memberships.filter((m) => m.roomId === roomId).map((m) => m.userId));
|
||||
return this.state.accounts.filter((a) => members.has(a.ownerId));
|
||||
}
|
||||
|
||||
findAccount(id) {
|
||||
return this.state.accounts.find((a) => a.id === id) || null;
|
||||
}
|
||||
|
||||
createAccount({ ownerId, vaultId, provider, name, url, secretCiphertext, enabled = true }) {
|
||||
const account = {
|
||||
id: uuid(),
|
||||
ownerId,
|
||||
vaultId: vaultId || null,
|
||||
provider: getProvider(provider).id,
|
||||
name: name || 'Account',
|
||||
url: url || '',
|
||||
secretCiphertext: secretCiphertext || null,
|
||||
enabled,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.state.accounts.push(account);
|
||||
this._save();
|
||||
return account;
|
||||
}
|
||||
|
||||
updateAccount(id, patch) {
|
||||
const a = this.findAccount(id);
|
||||
if (!a) return null;
|
||||
for (const k of ['provider', 'name', 'url', 'vaultId', 'secretCiphertext', 'enabled']) {
|
||||
if (k in patch) a[k] = patch[k];
|
||||
}
|
||||
if (a.provider) a.provider = getProvider(a.provider).id;
|
||||
this._save();
|
||||
return a;
|
||||
}
|
||||
|
||||
removeAccount(id) {
|
||||
const before = this.state.accounts.length;
|
||||
this.state.accounts = this.state.accounts.filter((a) => a.id !== id);
|
||||
this.state.outputs = this.state.outputs.filter((o) => o.accountId !== id);
|
||||
this.state.sceneOutputs = this.state.sceneOutputs.filter((so) => so.accountId !== id);
|
||||
const changed = this.state.accounts.length !== before;
|
||||
if (changed) this._save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ---- feeds -------------------------------------------------------------
|
||||
|
||||
listFeeds(roomId) {
|
||||
return this.state.feeds.filter((f) => f.roomId === roomId);
|
||||
}
|
||||
|
||||
findFeed(id) {
|
||||
return this.state.feeds.find((f) => f.id === id) || null;
|
||||
}
|
||||
|
||||
findFeedByStreamKey(key) {
|
||||
return this.state.feeds.find((f) => f.streamKey === key) || null;
|
||||
}
|
||||
|
||||
createFeed({ roomId, ownerId, name }) {
|
||||
const feed = { id: uuid(), roomId, ownerId, name: name || 'Feed', streamKey: token(16) };
|
||||
this.state.feeds.push(feed);
|
||||
this._save();
|
||||
return feed;
|
||||
}
|
||||
|
||||
updateFeed(id, patch) {
|
||||
const f = this.findFeed(id);
|
||||
if (!f) return null;
|
||||
if ('name' in patch) f.name = patch.name;
|
||||
this._save();
|
||||
return f;
|
||||
}
|
||||
|
||||
removeFeed(id) {
|
||||
const before = this.state.feeds.length;
|
||||
const feed = this.findFeed(id);
|
||||
this.state.feeds = this.state.feeds.filter((f) => f.id !== id);
|
||||
this.state.outputs = this.state.outputs.filter((o) => o.feedId !== id);
|
||||
if (feed) {
|
||||
// Clear the removed feed's slot in every scene of the room.
|
||||
for (const sc of this.state.scenes) {
|
||||
if (sc.roomId !== feed.roomId) continue;
|
||||
if (sc.layout && Array.isArray(sc.layout.slots)) {
|
||||
sc.layout.slots = sc.layout.slots.map((slot) => (slot === id ? null : slot));
|
||||
}
|
||||
}
|
||||
// Demote "feed" audio routing that pointed at the removed feed.
|
||||
for (const so of this.state.sceneOutputs) {
|
||||
if (so.roomId !== feed.roomId) continue;
|
||||
if (so.audio && so.audio.mode === 'feed' && so.audio.feedId === id) {
|
||||
so.audio = { mode: 'silent' };
|
||||
}
|
||||
}
|
||||
}
|
||||
const changed = this.state.feeds.length !== before;
|
||||
if (changed) this._save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ---- outputs (feed → account routing) ---------------------------------
|
||||
|
||||
listOutputs(roomId) {
|
||||
return this.state.outputs.filter((o) => o.roomId === roomId);
|
||||
}
|
||||
|
||||
findOutput(id) {
|
||||
return this.state.outputs.find((o) => o.id === id) || null;
|
||||
}
|
||||
|
||||
createOutput({ roomId, feedId, accountId, enabled = true }) {
|
||||
const output = { id: uuid(), roomId, feedId, accountId, enabled };
|
||||
this.state.outputs.push(output);
|
||||
this._save();
|
||||
return output;
|
||||
}
|
||||
|
||||
updateOutput(id, patch) {
|
||||
const o = this.state.outputs.find((x) => x.id === id);
|
||||
if (!o) return null;
|
||||
if ('feedId' in patch) o.feedId = patch.feedId;
|
||||
if ('accountId' in patch) o.accountId = patch.accountId;
|
||||
if ('enabled' in patch) o.enabled = Boolean(patch.enabled);
|
||||
this._save();
|
||||
return o;
|
||||
}
|
||||
|
||||
removeOutput(id) {
|
||||
const before = this.state.outputs.length;
|
||||
this.state.outputs = this.state.outputs.filter((o) => o.id !== id);
|
||||
const changed = this.state.outputs.length !== before;
|
||||
if (changed) this._save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ---- scenes (§3.1) -----------------------------------------------------
|
||||
|
||||
listScenes(roomId) {
|
||||
return this.state.scenes.filter((sc) => sc.roomId === roomId);
|
||||
}
|
||||
|
||||
findScene(id) {
|
||||
return this.state.scenes.find((sc) => sc.id === id) || null;
|
||||
}
|
||||
|
||||
findActiveScene(roomId) {
|
||||
return this.state.scenes.find((sc) => sc.roomId === roomId && sc.active) || null;
|
||||
}
|
||||
|
||||
/** Validate a scene body. Returns an error string, or null when valid. */
|
||||
validateScene(roomId, { name, layout, overlays }) {
|
||||
if (typeof name !== 'string' || !name.trim() || name.trim().length > 64) {
|
||||
return 'name must be 1..64 characters';
|
||||
}
|
||||
if (!layout || typeof layout !== 'object') return 'layout required';
|
||||
if (layout.type !== 'grid' && layout.type !== 'pip') return 'layout.type must be "grid" or "pip"';
|
||||
const slots = Array.isArray(layout.slots) ? layout.slots : null;
|
||||
if (!slots) return 'layout.slots required';
|
||||
if (layout.type === 'grid') {
|
||||
if (!Number.isInteger(layout.columns) || layout.columns < 2 || layout.columns > 4) {
|
||||
return 'grid columns must be 2..4';
|
||||
}
|
||||
if (slots.length < 1 || slots.length > 6) return 'grid slots length must be 1..6';
|
||||
} else if (slots.length !== 2) {
|
||||
return 'pip slots length must be exactly 2';
|
||||
}
|
||||
const feedIds = new Set(this.listFeeds(roomId).map((f) => f.id));
|
||||
const seen = new Set();
|
||||
for (const slot of slots) {
|
||||
if (slot === null || slot === undefined) continue;
|
||||
if (typeof slot !== 'string') return 'slots must be feed ids or null';
|
||||
if (!feedIds.has(slot)) return 'unknown feedId in slots';
|
||||
if (seen.has(slot)) return 'duplicate feedId in slots';
|
||||
seen.add(slot);
|
||||
}
|
||||
const ovs = Array.isArray(overlays) ? overlays : [];
|
||||
if (ovs.length > 8) return 'at most 8 overlays';
|
||||
const imageIds = new Set(this.listImages(roomId).map((img) => img.id));
|
||||
for (const ov of ovs) {
|
||||
if (!ov || typeof ov !== 'object') return 'invalid overlay';
|
||||
if (ov.kind === 'text') {
|
||||
const text = typeof ov.text === 'string' ? ov.text.trim() : '';
|
||||
if (!text || text.length > 256) return 'text overlay text must be 1..256 chars';
|
||||
if (!Number.isInteger(ov.x) || ov.x < 0 || ov.x >= 1920) return 'text overlay x must be 0..1919';
|
||||
if (!Number.isInteger(ov.y) || ov.y < 0 || ov.y >= 1080) return 'text overlay y must be 0..1079';
|
||||
if (!Number.isInteger(ov.fontSize) || ov.fontSize < 8 || ov.fontSize > 144) return 'text overlay fontSize must be 8..144';
|
||||
if (typeof ov.color !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(ov.color)) return 'text overlay color must be #rrggbb';
|
||||
if (ov.bold !== undefined && typeof ov.bold !== 'boolean') return 'text overlay bold must be boolean';
|
||||
} else if (ov.kind === 'image') {
|
||||
if (typeof ov.imageId !== 'string' || !imageIds.has(ov.imageId)) return 'unknown imageId in overlay';
|
||||
if (!Number.isInteger(ov.x) || ov.x < 0) return 'image overlay x must be a non-negative integer';
|
||||
if (!Number.isInteger(ov.y) || ov.y < 0) return 'image overlay y must be a non-negative integer';
|
||||
if (!Number.isInteger(ov.width) || ov.width < 16 || ov.width > 1920) return 'image overlay width must be 16..1920';
|
||||
if (!Number.isInteger(ov.height) || ov.height < 16 || ov.height > 1080) return 'image overlay height must be 16..1080';
|
||||
if (
|
||||
ov.opacity !== undefined &&
|
||||
(typeof ov.opacity !== 'number' || ov.opacity < 0 || ov.opacity > 1)
|
||||
) {
|
||||
return 'image overlay opacity must be 0..1';
|
||||
}
|
||||
} else {
|
||||
return 'overlay kind must be "text" or "image"';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
createScene({ roomId, ownerId, name, layout }) {
|
||||
const cleanLayout = { type: layout.type, slots: layout.slots };
|
||||
if (layout.type === 'grid') cleanLayout.columns = layout.columns;
|
||||
const scene = {
|
||||
id: uuid(),
|
||||
roomId,
|
||||
ownerId,
|
||||
name: name.trim(),
|
||||
active: false,
|
||||
layout: cleanLayout,
|
||||
overlays: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
this.state.scenes.push(scene);
|
||||
this._save();
|
||||
return scene;
|
||||
}
|
||||
|
||||
/** Partial update. Returns null when the resulting scene is invalid. */
|
||||
updateScene(id, patch) {
|
||||
const scene = this.findScene(id);
|
||||
if (!scene) return null;
|
||||
const candidate = {
|
||||
name: 'name' in patch ? patch.name : scene.name,
|
||||
layout: 'layout' in patch ? patch.layout : scene.layout,
|
||||
overlays: 'overlays' in patch ? patch.overlays : scene.overlays,
|
||||
};
|
||||
const err = this.validateScene(scene.roomId, candidate);
|
||||
if (err) return null;
|
||||
if ('name' in patch) scene.name = patch.name.trim();
|
||||
if ('layout' in patch) {
|
||||
scene.layout = { type: patch.layout.type, slots: patch.layout.slots };
|
||||
if (patch.layout.type === 'grid') scene.layout.columns = patch.layout.columns;
|
||||
}
|
||||
if ('overlays' in patch) {
|
||||
scene.overlays = (Array.isArray(patch.overlays) ? patch.overlays : []).map((ov) => {
|
||||
const item = { ...ov, id: ov.id || uuid() };
|
||||
if (item.kind === 'text') item.text = item.text.trim();
|
||||
return item;
|
||||
});
|
||||
}
|
||||
if ('active' in patch) {
|
||||
if (patch.active) {
|
||||
for (const s of this.state.scenes) {
|
||||
if (s.roomId === scene.roomId && s.id !== scene.id) s.active = false;
|
||||
}
|
||||
scene.active = true;
|
||||
} else {
|
||||
scene.active = false;
|
||||
}
|
||||
}
|
||||
scene.updatedAt = Date.now();
|
||||
this._save();
|
||||
return scene;
|
||||
}
|
||||
|
||||
removeScene(id) {
|
||||
const before = this.state.scenes.length;
|
||||
this.state.scenes = this.state.scenes.filter((sc) => sc.id !== id);
|
||||
this.state.sceneOutputs = this.state.sceneOutputs.filter((so) => so.sceneId !== id);
|
||||
const changed = this.state.scenes.length !== before;
|
||||
if (changed) this._save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ---- scene outputs (§3.2) ----------------------------------------------
|
||||
|
||||
listSceneOutputs(roomId) {
|
||||
return this.state.sceneOutputs.filter((so) => so.roomId === roomId);
|
||||
}
|
||||
|
||||
findSceneOutput(id) {
|
||||
return this.state.sceneOutputs.find((so) => so.id === id) || null;
|
||||
}
|
||||
|
||||
validateSceneOutput(roomId, { sceneId, accountId, audio }) {
|
||||
const scene = this.findScene(sceneId);
|
||||
if (!scene || scene.roomId !== roomId) return 'scene not in room';
|
||||
if (!this.accountsInRoom(roomId).some((a) => a.id === accountId)) return 'account not in room';
|
||||
if (!audio || typeof audio !== 'object') return 'audio required';
|
||||
const mode = audio.mode;
|
||||
if (mode !== 'program' && mode !== 'silent' && mode !== 'feed') {
|
||||
return 'audio.mode must be program, silent, or feed';
|
||||
}
|
||||
if (mode === 'feed') {
|
||||
if (typeof audio.feedId !== 'string' || !this.listFeeds(roomId).some((f) => f.id === audio.feedId)) {
|
||||
return 'audio.feedId must reference a feed in the room';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
createSceneOutput({ roomId, sceneId, accountId, enabled = true, audio }) {
|
||||
const so = {
|
||||
id: uuid(),
|
||||
roomId,
|
||||
sceneId,
|
||||
accountId,
|
||||
enabled: Boolean(enabled),
|
||||
audio,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.state.sceneOutputs.push(so);
|
||||
this._save();
|
||||
return so;
|
||||
}
|
||||
|
||||
updateSceneOutput(id, patch) {
|
||||
const so = this.findSceneOutput(id);
|
||||
if (!so) return null;
|
||||
const candidate = {
|
||||
sceneId: so.sceneId,
|
||||
accountId: 'accountId' in patch ? patch.accountId : so.accountId,
|
||||
audio: 'audio' in patch ? patch.audio : so.audio,
|
||||
};
|
||||
const err = this.validateSceneOutput(so.roomId, candidate);
|
||||
if (err) return null;
|
||||
if ('accountId' in patch) so.accountId = patch.accountId;
|
||||
if ('enabled' in patch) so.enabled = Boolean(patch.enabled);
|
||||
if ('audio' in patch) so.audio = patch.audio;
|
||||
this._save();
|
||||
return so;
|
||||
}
|
||||
|
||||
removeSceneOutput(id) {
|
||||
const before = this.state.sceneOutputs.length;
|
||||
this.state.sceneOutputs = this.state.sceneOutputs.filter((so) => so.id !== id);
|
||||
const changed = this.state.sceneOutputs.length !== before;
|
||||
if (changed) this._save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ---- images (§3.3) -----------------------------------------------------
|
||||
|
||||
listImages(roomId) {
|
||||
return this.state.images.filter((img) => img.roomId === roomId);
|
||||
}
|
||||
|
||||
findImage(id) {
|
||||
return this.state.images.find((img) => img.id === id) || null;
|
||||
}
|
||||
|
||||
createImage({ id, roomId, name, mime, size, path: relPath }) {
|
||||
const image = {
|
||||
id: id || uuid(),
|
||||
roomId,
|
||||
name,
|
||||
mime,
|
||||
size,
|
||||
path: relPath,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.state.images.push(image);
|
||||
this._save();
|
||||
return image;
|
||||
}
|
||||
|
||||
removeImage(id) {
|
||||
const image = this.findImage(id);
|
||||
if (!image) return false;
|
||||
try {
|
||||
const base = path.basename(image.path);
|
||||
if (/^[a-f0-9-]{36}\.(png|jpg|webp)$/.test(base)) {
|
||||
fs.unlinkSync(path.join(this.dataDir, image.path));
|
||||
}
|
||||
} catch {
|
||||
/* file already gone */
|
||||
}
|
||||
this.state.images = this.state.images.filter((img) => img.id !== id);
|
||||
for (const scene of this.state.scenes) {
|
||||
if (scene.roomId !== image.roomId) continue;
|
||||
scene.overlays = (scene.overlays || []).filter(
|
||||
(ov) => !(ov.kind === 'image' && ov.imageId === id),
|
||||
);
|
||||
}
|
||||
this._save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Store };
|
||||
148
services/multistreaming/src/vault.js
Normal file
148
services/multistreaming/src/vault.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Zero-knowledge vault crypto.
|
||||
*
|
||||
* The server only ever sees ciphertext: it stores each account's secrets
|
||||
* encrypted under a random vault key (VK) that only the user's browser holds.
|
||||
*
|
||||
* master password ──PBKDF2──▶ KEK ──wraps──▶ VK ──encrypts──▶ account secrets
|
||||
*
|
||||
* Three flows:
|
||||
* - enroll(): first time — derive KEK, make VK, wrap VK for the server
|
||||
* (recovery) and for this device (silent unlock).
|
||||
* - silentUnlock(): normal sessions — decrypt the device-wrapped VK using the
|
||||
* device key + AAD (fingerprint + session). No password.
|
||||
* - recover(): new device — unwrap the server blob with the master password.
|
||||
*
|
||||
* Device + session binding is enforced through the AES-GCM AAD: if the session
|
||||
* or fingerprint changes, decryption fails and the cached VK is useless.
|
||||
*
|
||||
* Uses WebCrypto (globalThis.crypto.subtle), so the exact same code runs in the
|
||||
* browser and in Node (>=18) for testing.
|
||||
*/
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
const te = new TextEncoder();
|
||||
|
||||
// OWASP recommendation for PBKDF2-HMAC-SHA256.
|
||||
const PBKDF2_ITERATIONS = 600_000;
|
||||
const KEY_BYTES = 32; // 256-bit
|
||||
const IV_BYTES = 12; // 96-bit, standard for GCM
|
||||
|
||||
function randomBytes(n) {
|
||||
const b = new Uint8Array(n);
|
||||
crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
function concat(...parts) {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, off);
|
||||
off += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function pbkdf2(password, salt, iterations, bytes) {
|
||||
const key = await subtle.importKey('raw', te.encode(password), 'PBKDF2', false, ['deriveBits']);
|
||||
const bits = await subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, bytes * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
async function aesGcmEncrypt(keyBytes, plaintext, aad) {
|
||||
const key = await subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const data = new Uint8Array(
|
||||
await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad }, key, plaintext),
|
||||
);
|
||||
return { iv, data }; // data includes the 16-byte auth tag appended by GCM
|
||||
}
|
||||
|
||||
async function aesGcmDecrypt(keyBytes, iv, data, aad) {
|
||||
const key = await subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
const plaintext = await subtle.decrypt({ name: 'AES-GCM', iv, additionalData: aad }, key, data);
|
||||
return new Uint8Array(plaintext);
|
||||
}
|
||||
|
||||
function b64(buf) {
|
||||
return Buffer.from(buf).toString('base64');
|
||||
}
|
||||
|
||||
function unb64(s) {
|
||||
return new Uint8Array(Buffer.from(s, 'base64'));
|
||||
}
|
||||
|
||||
/** Serialize a wrapped blob to something JSON-safe (stored server-side). */
|
||||
function serializeWrapped({ iv, data }) {
|
||||
return { iv: b64(iv), data: b64(data) };
|
||||
}
|
||||
|
||||
function deserializeWrapped(w) {
|
||||
return { iv: unb64(w.iv), data: unb64(w.data) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll a new vault (or re-enroll an existing one on a fresh device).
|
||||
*
|
||||
* @param {string} password master password
|
||||
* @param {Uint8Array} [salt] fixed salt (defaults to a fresh random one)
|
||||
* @param {Uint8Array} [deviceKey] the non-extractable device key (browser-held)
|
||||
* @param {string} deviceAad "fingerprint|sessionId" binding for the device blob
|
||||
* @returns {Promise<{salt, serverWrapped, deviceWrapped}>}
|
||||
* serverWrapped/deviceWrapped are JSON-safe objects ({iv,data} base64).
|
||||
*/
|
||||
async function enroll({ password, salt = randomBytes(16), deviceKey, deviceAad }) {
|
||||
const kek = await pbkdf2(password, salt, PBKDF2_ITERATIONS, KEY_BYTES);
|
||||
const vk = randomBytes(KEY_BYTES);
|
||||
const serverWrapped = serializeWrapped(await aesGcmEncrypt(kek, vk, te.encode('multistreaming:vault:v1')));
|
||||
const deviceWrapped = deviceKey
|
||||
? serializeWrapped(await aesGcmEncrypt(deviceKey, vk, te.encode(deviceAad)))
|
||||
: null;
|
||||
return { salt: b64(salt), serverWrapped, deviceWrapped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent unlock: recover VK from the device blob (no password).
|
||||
* Throws if the session/fingerprint AAD doesn't match (sealed).
|
||||
*/
|
||||
async function silentUnlock(deviceKey, deviceWrapped, deviceAad) {
|
||||
const { iv, data } = deserializeWrapped(deviceWrapped);
|
||||
return aesGcmDecrypt(deviceKey, iv, data, te.encode(deviceAad));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery: recover VK from the server blob using the master password.
|
||||
* Throws if the password is wrong.
|
||||
*/
|
||||
async function recover(password, saltB64, serverWrapped) {
|
||||
const kek = await pbkdf2(password, unb64(saltB64), PBKDF2_ITERATIONS, KEY_BYTES);
|
||||
const { iv, data } = deserializeWrapped(serverWrapped);
|
||||
return aesGcmDecrypt(kek, iv, data, te.encode('multistreaming:vault:v1'));
|
||||
}
|
||||
|
||||
/** Encrypt one account secret under VK. aad = account id. */
|
||||
async function encryptSecret(vk, secret, aad) {
|
||||
return serializeWrapped(await aesGcmEncrypt(vk, te.encode(secret), te.encode(aad)));
|
||||
}
|
||||
|
||||
/** Decrypt one account secret under VK. Throws if AAD (account id) mismatches. */
|
||||
async function decryptSecret(vk, wrapped, aad) {
|
||||
const { iv, data } = deserializeWrapped(wrapped);
|
||||
return new TextDecoder().decode(await aesGcmDecrypt(vk, iv, data, te.encode(aad)));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enroll,
|
||||
silentUnlock,
|
||||
recover,
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
randomBytes,
|
||||
serializeWrapped,
|
||||
deserializeWrapped,
|
||||
PBKDF2_ITERATIONS,
|
||||
};
|
||||
151
services/multistreaming/test/foundation.test.js
vendored
Normal file
151
services/multistreaming/test/foundation.test.js
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { Store } = require('../src/store');
|
||||
const { Auth } = require('../src/auth');
|
||||
const { Grants } = require('../src/grants');
|
||||
|
||||
function tmpdir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ms-test-'));
|
||||
}
|
||||
|
||||
function fresh() {
|
||||
const store = new Store(tmpdir());
|
||||
const auth = new Auth({ store, sessionSecret: 'test-secret' });
|
||||
const grants = new Grants({ ttlMs: 1000 });
|
||||
return { store, auth, grants };
|
||||
}
|
||||
|
||||
test('register/login issues a session tied to the user', () => {
|
||||
const { store, auth } = fresh();
|
||||
const u = auth.register({ username: 'alice', password: 'pw' });
|
||||
assert.ok(u);
|
||||
assert.equal(auth.verifyPassword('pw', store.findUserByUsername('alice')), true);
|
||||
assert.equal(auth.verifyPassword('nope', store.findUserByUsername('alice')), false);
|
||||
|
||||
const token = auth.issueToken(u.id);
|
||||
const sess = auth.verifyToken(token);
|
||||
assert.equal(sess.uid, u.id);
|
||||
assert.ok(sess.sid);
|
||||
assert.equal(auth.verifyToken('garbage.token'), null);
|
||||
});
|
||||
|
||||
test('rooms: owner role, invite accept, editor/streamer roles', () => {
|
||||
const { store, auth } = fresh();
|
||||
const alice = auth.register({ username: 'alice', password: 'pw' });
|
||||
const bob = auth.register({ username: 'bob', password: 'pw' });
|
||||
|
||||
const room = store.createRoom({ name: 'Collab', ownerId: alice.id });
|
||||
assert.equal(store.membership(room.id, alice.id).role, 'owner');
|
||||
|
||||
const invite = store.createInvite({ roomId: room.id, role: 'editor' });
|
||||
const m = store.acceptInvite(invite, bob.id);
|
||||
assert.equal(m.role, 'editor');
|
||||
|
||||
// bob is now in the room; a second invite for streamer upgrades nothing (already member).
|
||||
assert.equal(store.roomsFor(bob.id).length, 1);
|
||||
assert.equal(store.findInviteByToken(invite.token), null); // consumed
|
||||
});
|
||||
|
||||
test('accounts store only ciphertext; owners own them', () => {
|
||||
const { store } = fresh();
|
||||
const alice = store.createUser({ username: 'alice', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const bob = store.createUser({ username: 'bob', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const vault = store.createVault({ ownerId: alice.id, name: 'Main', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
|
||||
const acct = store.createAccount({
|
||||
ownerId: alice.id,
|
||||
vaultId: vault.id,
|
||||
provider: 'twitch',
|
||||
name: 'Twitch main',
|
||||
url: 'rtmp://live.twitch.tv/app',
|
||||
secretCiphertext: { iv: 'a', data: 'b' },
|
||||
});
|
||||
|
||||
assert.equal(store.findAccount(acct.id).secretCiphertext.iv, 'a');
|
||||
assert.equal(store.findAccount(acct.id).vaultId, vault.id);
|
||||
assert.equal(store.listAccountsFor(alice.id).length, 1);
|
||||
assert.equal(store.listAccountsFor(bob.id).length, 0);
|
||||
|
||||
// Server-side state never has the plaintext key (it was never passed in).
|
||||
const raw = JSON.stringify(store.state);
|
||||
assert.ok(!raw.includes('sk_plaintext'));
|
||||
});
|
||||
|
||||
test('grants: grant → get → revoke → expiry', async () => {
|
||||
const { grants } = fresh();
|
||||
grants.grant('acct-1', 'sk_abc', 'alice');
|
||||
assert.equal(grants.get('acct-1'), 'sk_abc');
|
||||
assert.deepEqual(grants.grantedAccountIds(), ['acct-1']);
|
||||
|
||||
grants.revoke('acct-1');
|
||||
assert.equal(grants.get('acct-1'), null);
|
||||
|
||||
grants.grant('acct-2', 'sk_def', 'alice');
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
assert.equal(grants.get('acct-2'), null); // expired
|
||||
assert.deepEqual(grants.grantedAccountIds(), []);
|
||||
|
||||
// revokeBy clears only that user's grants.
|
||||
grants.grant('a', '1', 'alice');
|
||||
grants.grant('b', '2', 'bob');
|
||||
grants.revokeBy('alice');
|
||||
assert.equal(grants.get('a'), null);
|
||||
assert.equal(grants.get('b'), '2');
|
||||
});
|
||||
|
||||
test('feed stream keys are unique and lookable', () => {
|
||||
const { store } = fresh();
|
||||
const u = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const room = store.createRoom({ name: 'R', ownerId: u.id });
|
||||
const f1 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam A' });
|
||||
const f2 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam B' });
|
||||
assert.notEqual(f1.streamKey, f2.streamKey);
|
||||
assert.equal(store.findFeedByStreamKey(f1.streamKey).id, f1.id);
|
||||
});
|
||||
|
||||
test('account ciphertext is only on the owner row (data-level isolation)', () => {
|
||||
const { store } = fresh();
|
||||
const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const bob = store.createUser({ username: 'b', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const vault = store.createVault({ ownerId: alice.id, name: 'V', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
|
||||
store.createAccount({ ownerId: alice.id, vaultId: vault.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } });
|
||||
|
||||
// Only alice's account list has a row; bob's is empty, so there is no path
|
||||
// to alice's ciphertext from bob's session.
|
||||
assert.equal(store.listAccountsFor(alice.id).length, 1);
|
||||
assert.equal(store.listAccountsFor(bob.id).length, 0);
|
||||
// And the ciphertext is never stored as plaintext anywhere.
|
||||
assert.ok(!JSON.stringify(store.state).includes('sk_secret'));
|
||||
});
|
||||
|
||||
test('vaults: multiple per user, one default, removable', () => {
|
||||
const { store } = fresh();
|
||||
const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
|
||||
const v1 = store.createVault({ ownerId: alice.id, name: 'Personal', salt: 's1', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
const v2 = store.createVault({ ownerId: alice.id, name: 'Work', salt: 's2', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
|
||||
assert.equal(store.listVaultsFor(alice.id).length, 2);
|
||||
// First vault becomes the default automatically.
|
||||
assert.equal(store.findUserById(alice.id).defaultVaultId, v1.id);
|
||||
|
||||
// Explicit default switch.
|
||||
store.setDefaultVault(alice.id, v2.id);
|
||||
assert.equal(store.findUserById(alice.id).defaultVaultId, v2.id);
|
||||
|
||||
// Removing a vault detaches its accounts (ciphertext becomes unusable).
|
||||
const acct = store.createAccount({ ownerId: alice.id, vaultId: v1.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } });
|
||||
store.removeVault(v1.id);
|
||||
assert.equal(store.listVaultsFor(alice.id).length, 1);
|
||||
assert.equal(store.findAccount(acct.id).vaultId, null);
|
||||
|
||||
// Can't set someone else's vault as default.
|
||||
const mallory = store.createUser({ username: 'm', passwordHash: 'x', passwordSalt: 'y' });
|
||||
assert.equal(store.setDefaultVault(mallory.id, v2.id), null);
|
||||
});
|
||||
21
services/multistreaming/test/oidc.test.js
Normal file
21
services/multistreaming/test/oidc.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { randomToken, pkceChallenge } = require('../src/oidc');
|
||||
|
||||
test('PKCE S256 challenge is deterministic and 43 chars (base64url SHA-256)', () => {
|
||||
const verifier = 'abc123';
|
||||
const c1 = pkceChallenge(verifier);
|
||||
const c2 = pkceChallenge(verifier);
|
||||
assert.equal(c1, c2);
|
||||
assert.equal(c1.length, 43);
|
||||
assert.notEqual(c1, verifier);
|
||||
});
|
||||
|
||||
test('random tokens are unique and long enough', () => {
|
||||
const a = randomToken();
|
||||
const b = randomToken();
|
||||
assert.notEqual(a, b);
|
||||
assert.ok(a.length >= 43);
|
||||
});
|
||||
75
services/multistreaming/test/security.test.js
Normal file
75
services/multistreaming/test/security.test.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
test('invite tokens are 256-bit (64 hex chars)', () => {
|
||||
const { Store } = require('../src/store');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-sec-'));
|
||||
const store = new Store(dir);
|
||||
const owner = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const room = store.createRoom({ name: 'R', ownerId: owner.id });
|
||||
const invite = store.createInvite({ roomId: room.id, role: 'streamer' });
|
||||
// 32 bytes → 64 hex chars.
|
||||
assert.equal(invite.token.length, 64);
|
||||
assert.match(invite.token, /^[0-9a-f]{64}$/);
|
||||
// Expiry is set in the future.
|
||||
assert.ok(invite.expiresAt > Date.now());
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('invite is single-use (consumed on accept)', () => {
|
||||
const { Store } = require('../src/store');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-sec-'));
|
||||
const store = new Store(dir);
|
||||
const owner = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const bob = store.createUser({ username: 'b', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const room = store.createRoom({ name: 'R', ownerId: owner.id });
|
||||
const invite = store.createInvite({ roomId: room.id, role: 'editor' });
|
||||
|
||||
assert.ok(store.findInviteByToken(invite.token));
|
||||
store.acceptInvite(invite, bob.id);
|
||||
// Consumed: a second lookup fails, and a third user can't reuse it.
|
||||
assert.equal(store.findInviteByToken(invite.token), null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('redactUrl hides the stream key', () => {
|
||||
// redactUrl is not exported; test the behavior through a fresh copy of the logic.
|
||||
function redactUrl(url) {
|
||||
const idx = url.lastIndexOf('/');
|
||||
if (idx <= 0) return url;
|
||||
return `${url.slice(0, idx)}/•••`;
|
||||
}
|
||||
assert.equal(redactUrl('rtmp://live.twitch.tv/app/live_abc123'), 'rtmp://live.twitch.tv/app/•••');
|
||||
assert.ok(!redactUrl('rtmp://live.twitch.tv/app/live_abc123').includes('live_abc123'));
|
||||
});
|
||||
|
||||
test('account secret ciphertext is never stored plaintext and key never in logs', () => {
|
||||
const { Store } = require('../src/store');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-sec-'));
|
||||
const store = new Store(dir);
|
||||
const owner = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const vault = store.createVault({ ownerId: owner.id, name: 'V', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
store.createAccount({
|
||||
ownerId: owner.id,
|
||||
vaultId: vault.id,
|
||||
provider: 'twitch',
|
||||
name: 'T',
|
||||
url: 'rtmp://live.twitch.tv/app',
|
||||
secretCiphertext: { iv: 'iv-here', data: 'ct-here' },
|
||||
});
|
||||
const persisted = JSON.stringify(store.state);
|
||||
assert.ok(!persisted.includes('live_secret_key'), 'plaintext key must never be persisted');
|
||||
assert.ok(persisted.includes('ct-here'), 'only ciphertext is stored');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
81
services/multistreaming/test/vault.test.js
Normal file
81
services/multistreaming/test/vault.test.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
enroll,
|
||||
silentUnlock,
|
||||
recover,
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
randomBytes,
|
||||
} = require('../src/vault');
|
||||
|
||||
const PASSWORD = 'correct horse battery staple';
|
||||
const AAD = 'fp:abc123|sess:xyz789';
|
||||
|
||||
test('enroll → silent unlock works with correct AAD', async () => {
|
||||
const deviceKey = randomBytes(32);
|
||||
const { serverWrapped, deviceWrapped, salt } = await enroll({
|
||||
password: PASSWORD,
|
||||
deviceKey,
|
||||
deviceAad: AAD,
|
||||
});
|
||||
|
||||
const vk = await silentUnlock(deviceKey, deviceWrapped, AAD);
|
||||
assert.equal(vk.length, 32);
|
||||
assert.ok(serverWrapped.iv && serverWrapped.data);
|
||||
assert.ok(salt);
|
||||
});
|
||||
|
||||
test('silent unlock fails if session/fingerprint AAD changes', async () => {
|
||||
const deviceKey = randomBytes(32);
|
||||
const { deviceWrapped } = await enroll({ password: PASSWORD, deviceKey, deviceAad: AAD });
|
||||
|
||||
await assert.rejects(
|
||||
silentUnlock(deviceKey, deviceWrapped, 'fp:abc123|sess:DIFFERENT'),
|
||||
/decrypt|operation/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('silent unlock fails with a different device key (copied blob)', async () => {
|
||||
const deviceKeyA = randomBytes(32);
|
||||
const deviceKeyB = randomBytes(32);
|
||||
const { deviceWrapped } = await enroll({ password: PASSWORD, deviceKey: deviceKeyA, deviceAad: AAD });
|
||||
|
||||
await assert.rejects(silentUnlock(deviceKeyB, deviceWrapped, AAD), /decrypt|operation/i);
|
||||
});
|
||||
|
||||
test('recover with correct password works (new device)', async () => {
|
||||
const { serverWrapped, salt } = await enroll({ password: PASSWORD });
|
||||
const vk = await recover(PASSWORD, salt, serverWrapped);
|
||||
assert.equal(vk.length, 32);
|
||||
});
|
||||
|
||||
test('recover with wrong password fails', async () => {
|
||||
const { serverWrapped, salt } = await enroll({ password: PASSWORD });
|
||||
await assert.rejects(recover('wrong password', salt, serverWrapped), /decrypt|operation/i);
|
||||
});
|
||||
|
||||
test('account secret round-trips, and fails with wrong account AAD', async () => {
|
||||
const { serverWrapped, salt } = await enroll({ password: PASSWORD });
|
||||
const vk = await recover(PASSWORD, salt, serverWrapped);
|
||||
|
||||
const wrapped = await encryptSecret(vk, 'sk_live_secret_key_123', 'acct:twitch-1');
|
||||
const secret = await decryptSecret(vk, wrapped, 'acct:twitch-1');
|
||||
assert.equal(secret, 'sk_live_secret_key_123');
|
||||
|
||||
// The same ciphertext can't be re-attributed to a different account.
|
||||
await assert.rejects(decryptSecret(vk, wrapped, 'acct:kick-2'), /decrypt|operation/i);
|
||||
});
|
||||
|
||||
test('server never sees plaintext: wrapped blobs contain no secret bytes', async () => {
|
||||
const deviceKey = randomBytes(32);
|
||||
const secret = 'sk_topsecret';
|
||||
const { serverWrapped } = await enroll({ password: PASSWORD, deviceKey, deviceAad: AAD });
|
||||
const vk = await silentUnlock(deviceKey, (await enroll({ password: PASSWORD, deviceKey, deviceAad: AAD })).deviceWrapped, AAD);
|
||||
const wrappedSecret = await encryptSecret(vk, secret, 'acct:1');
|
||||
|
||||
const allBlobs = JSON.stringify({ serverWrapped, wrappedSecret });
|
||||
assert.ok(!allBlobs.includes(secret), 'ciphertext must not leak the plaintext secret');
|
||||
});
|
||||
24
services/multistreaming/web/.gitignore
vendored
Normal file
24
services/multistreaming/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
7
services/multistreaming/web/.prettierignore
Normal file
7
services/multistreaming/web/.prettierignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
coverage/
|
||||
.pnpm-store/
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
11
services/multistreaming/web/.prettierrc
Normal file
11
services/multistreaming/web/.prettierrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80,
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "src/index.css",
|
||||
"tailwindFunctions": ["cn", "cva"]
|
||||
}
|
||||
21
services/multistreaming/web/README.md
Normal file
21
services/multistreaming/web/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# React + TypeScript + Vite + shadcn/ui
|
||||
|
||||
This is a template for a new Vite project with React, TypeScript, and shadcn/ui.
|
||||
|
||||
## Adding components
|
||||
|
||||
To add components to your app, run the following command:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
This will place the ui components in the `src/components` directory.
|
||||
|
||||
## Using components
|
||||
|
||||
To use the components in your app, import them as follows:
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button"
|
||||
```
|
||||
25
services/multistreaming/web/components.json
Normal file
25
services/multistreaming/web/components.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
22
services/multistreaming/web/eslint.config.js
Normal file
22
services/multistreaming/web/eslint.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
services/multistreaming/web/index.html
Normal file
13
services/multistreaming/web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>vite-app</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8429
services/multistreaming/web/package-lock.json
generated
Normal file
8429
services/multistreaming/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
46
services/multistreaming/web/package.json
Normal file
46
services/multistreaming/web/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "multistreaming",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.39.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"shadcn": "^4.20.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6",
|
||||
"eslint": "^10",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"typescript": "~6",
|
||||
"typescript-eslint": "^8",
|
||||
"vite": "^8"
|
||||
}
|
||||
}
|
||||
1
services/multistreaming/web/public/vite.svg
Normal file
1
services/multistreaming/web/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
265
services/multistreaming/web/src/App.tsx
Normal file
265
services/multistreaming/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import type { Account, Member, Provider, Vault } from "@/lib/types"
|
||||
import { useApp } from "@/lib/use-app"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AuthScreen } from "@/components/auth-screen"
|
||||
import { NewRoomDialog, NewVaultDialog, PromptDialog, UnlockVaultDialog } from "@/components/dialogs"
|
||||
import { HomeView } from "@/components/home-view"
|
||||
import { RoomSidebar } from "@/components/room-sidebar"
|
||||
import { RoomView } from "@/components/room-view"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function App() {
|
||||
const app = useApp()
|
||||
const { data, refresh, logout, openRoom, unlockVault, lockActive } = app
|
||||
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [newVaultOpen, setNewVaultOpen] = useState(false)
|
||||
const [unlockTarget, setUnlockTarget] = useState<Vault | null>(null)
|
||||
const [leaveVaultTarget, setLeaveVaultTarget] = useState<Vault | null>(null)
|
||||
const [newRoomOpen, setNewRoomOpen] = useState(false)
|
||||
const [accountDialog, setAccountDialog] = useState<{
|
||||
account: Account | null
|
||||
open: boolean
|
||||
}>({ account: null, open: false })
|
||||
const [feedPrompt, setFeedPrompt] = useState(false)
|
||||
const [feedName, setFeedName] = useState("")
|
||||
const [invitePrompt, setInvitePrompt] = useState(false)
|
||||
const [inviteRole, setInviteRole] = useState("editor")
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
void api.providers().then(setProviders).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load members when the open room changes.
|
||||
const roomId = data.screen === "room" ? data.roomId : null
|
||||
useEffect(() => {
|
||||
if (!roomId) {
|
||||
setMembers([])
|
||||
return
|
||||
}
|
||||
void api
|
||||
.roomDetail(roomId)
|
||||
.then((r) => setMembers(r.members ?? []))
|
||||
.catch(() => setMembers([]))
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
// accept ?#invite=token on load
|
||||
const m = window.location.hash.match(/invite=([a-f0-9]+)/)
|
||||
if (m && data.me) {
|
||||
void api
|
||||
.acceptInvite(m[1])
|
||||
.then(() => {
|
||||
toast.success("Invite accepted")
|
||||
window.location.hash = ""
|
||||
void refresh()
|
||||
})
|
||||
.catch((err) => toast.error((err as Error).message))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.me])
|
||||
|
||||
if (!data.me) {
|
||||
return <AuthScreen mode={data.authMode} onAuthed={() => void refresh()} />
|
||||
}
|
||||
|
||||
const state = data.state
|
||||
const activeRoom = state?.rooms.find((r) => r.id === roomId) ?? null
|
||||
const currentVault = state?.vaults.find((v) => v.id === data.activeVaultId) ?? null
|
||||
|
||||
/** Select from the center vault menu: unlock (silent, else prompt). */
|
||||
async function selectVault(vault: Vault) {
|
||||
const ok = await unlockVault(vault)
|
||||
if (!ok) setUnlockTarget(vault)
|
||||
}
|
||||
|
||||
async function submitFeed() {
|
||||
if (!activeRoom) return
|
||||
await api.createFeed(activeRoom.id, feedName)
|
||||
setFeedName("")
|
||||
toast.success("Feed added")
|
||||
void refresh()
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
if (!activeRoom) return
|
||||
const invite = await api.invite(activeRoom.id, inviteRole)
|
||||
await navigator.clipboard.writeText(`${window.location.origin}/#invite=${invite.token}`)
|
||||
toast.success("Invite link copied")
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
user={{ name: data.me.username, email: data.me.username }}
|
||||
currentVault={currentVault}
|
||||
unlocked={data.unlocked}
|
||||
rooms={state?.rooms ?? []}
|
||||
activeRoomId={activeRoom?.id ?? null}
|
||||
onLeaveVault={() => currentVault && setLeaveVaultTarget(currentVault)}
|
||||
onOpenRoom={(r) => openRoom(r.id)}
|
||||
onNewRoom={() => setNewRoomOpen(true)}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbPage>multistreaming</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
{activeRoom ? (
|
||||
<>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{activeRoom.name}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{activeRoom && state ? (
|
||||
<RoomView
|
||||
room={activeRoom}
|
||||
canEdit={activeRoom.role !== "streamer"}
|
||||
onRefresh={() => void refresh()}
|
||||
onBack={() => openRoom(null)}
|
||||
/>
|
||||
) : (
|
||||
<HomeView
|
||||
vaults={state?.vaults ?? []}
|
||||
activeVaultId={data.activeVaultId}
|
||||
unlocked={data.unlocked}
|
||||
accounts={state?.myAccounts ?? []}
|
||||
providers={providers}
|
||||
onSelectVault={(v) => void selectVault(v)}
|
||||
onNewVault={() => setNewVaultOpen(true)}
|
||||
onAddAccount={() => setAccountDialog({ account: null, open: true })}
|
||||
onEditAccount={(a) => setAccountDialog({ account: a, open: true })}
|
||||
accountDialog={{
|
||||
account: accountDialog.account,
|
||||
open: accountDialog.open,
|
||||
onOpenChange: (o) => setAccountDialog((d) => ({ ...d, open: o })),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SidebarInset>
|
||||
|
||||
{activeRoom && state ? (
|
||||
<RoomSidebar
|
||||
room={activeRoom}
|
||||
members={members}
|
||||
liveFeeds={state.liveFeeds}
|
||||
ingest={state.ingest}
|
||||
canEdit={activeRoom.role !== "streamer"}
|
||||
onAddFeed={() => setFeedPrompt(true)}
|
||||
onInvite={() => setInvitePrompt(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<NewVaultDialog
|
||||
open={newVaultOpen}
|
||||
onOpenChange={setNewVaultOpen}
|
||||
sessionId={data.me.sid}
|
||||
onCreated={() => void refresh()}
|
||||
/>
|
||||
<UnlockVaultDialog
|
||||
vault={unlockTarget}
|
||||
open={unlockTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setUnlockTarget(null)
|
||||
}}
|
||||
onUnlocked={() => {
|
||||
setUnlockTarget(null)
|
||||
void refresh()
|
||||
}}
|
||||
/>
|
||||
<AlertDialog
|
||||
open={leaveVaultTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setLeaveVaultTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Leave vault?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{leaveVaultTarget
|
||||
? `Lock "${leaveVaultTarget.name}"? Accounts and streaming will be disabled until you unlock a vault again.`
|
||||
: ""}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
lockActive()
|
||||
setLeaveVaultTarget(null)
|
||||
openRoom(null)
|
||||
void refresh()
|
||||
}}
|
||||
>
|
||||
Leave
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<NewRoomDialog
|
||||
open={newRoomOpen}
|
||||
onOpenChange={setNewRoomOpen}
|
||||
onCreated={(room) => {
|
||||
openRoom(room.id)
|
||||
void refresh()
|
||||
}}
|
||||
/>
|
||||
<PromptDialog
|
||||
open={feedPrompt}
|
||||
onOpenChange={setFeedPrompt}
|
||||
title="Add feed"
|
||||
label="Feed name"
|
||||
value={feedName}
|
||||
onChange={setFeedName}
|
||||
onSubmit={submitFeed}
|
||||
/>
|
||||
<PromptDialog
|
||||
open={invitePrompt}
|
||||
onOpenChange={setInvitePrompt}
|
||||
title="Invite member"
|
||||
description="Editors edit feeds/outputs (no keys). Streamers bring their own accounts."
|
||||
label="Role (editor | streamer)"
|
||||
value={inviteRole}
|
||||
onChange={setInviteRole}
|
||||
onSubmit={submitInvite}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
services/multistreaming/web/src/assets/react.svg
Normal file
1
services/multistreaming/web/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
68
services/multistreaming/web/src/components/app-sidebar.tsx
Normal file
68
services/multistreaming/web/src/components/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from "react"
|
||||
import { RadioIcon } from "lucide-react"
|
||||
|
||||
import { NavRooms } from "@/components/nav-rooms"
|
||||
import { NavUser } from "@/components/nav-user"
|
||||
import { NavVaults } from "@/components/nav-vaults"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import type { Room, Vault } from "@/lib/types"
|
||||
|
||||
export function AppSidebar({
|
||||
user,
|
||||
currentVault,
|
||||
unlocked,
|
||||
rooms,
|
||||
activeRoomId,
|
||||
onLeaveVault,
|
||||
onOpenRoom,
|
||||
onNewRoom,
|
||||
onLogout,
|
||||
...props
|
||||
}: {
|
||||
user: { name: string; email: string }
|
||||
currentVault: Vault | null
|
||||
unlocked: boolean
|
||||
rooms: Room[]
|
||||
activeRoomId: string | null
|
||||
onLeaveVault: () => void
|
||||
onOpenRoom: (room: Room) => void
|
||||
onNewRoom: () => void
|
||||
onLogout: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar variant="inset" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<a href="#">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<RadioIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">multistreaming</span>
|
||||
<span className="truncate text-xs">control room</span>
|
||||
</div>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavVaults vault={currentVault} unlocked={unlocked} onLeave={onLeaveVault} />
|
||||
<NavRooms rooms={rooms} activeRoomId={activeRoomId} onOpen={onOpenRoom} onNew={onNewRoom} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={user} onLogout={onLogout} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
98
services/multistreaming/web/src/components/auth-screen.tsx
Normal file
98
services/multistreaming/web/src/components/auth-screen.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export function AuthScreen({ mode, onAuthed }: { mode: "oidc" | "local" | null; onAuthed: () => void }) {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const oidc = mode === "oidc"
|
||||
|
||||
async function submitLocal(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
try {
|
||||
try {
|
||||
await api.login(username, password)
|
||||
} catch {
|
||||
await api.register(username, password)
|
||||
}
|
||||
toast.success("Signed in")
|
||||
onAuthed()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>multistreaming</CardTitle>
|
||||
<CardDescription>
|
||||
{oidc
|
||||
? "Sign in with your identity provider."
|
||||
: "Sign in or create your account."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{oidc ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
window.location.href = "/api/auth/oidc/start"
|
||||
}}
|
||||
>
|
||||
<Button type="submit" className="w-full">
|
||||
Sign in with Authelia
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submitLocal}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Button type="submit" className="mt-5 w-full" disabled={busy}>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
515
services/multistreaming/web/src/components/dialogs.tsx
Normal file
515
services/multistreaming/web/src/components/dialogs.tsx
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import type { Account, Provider, Room, Vault } from "@/lib/types"
|
||||
import {
|
||||
createVault,
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
saveDeviceWrapped,
|
||||
unlockWithPassphrase,
|
||||
vaultState,
|
||||
} from "@/lib/vault"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
// ---- New vault ------------------------------------------------------------
|
||||
|
||||
export function NewVaultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sessionId: string
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [passphrase, setPassphrase] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
try {
|
||||
const created = await createVault(name, passphrase, sessionId)
|
||||
const vault = await api.createVault({
|
||||
name,
|
||||
salt: created.salt,
|
||||
serverWrapped: created.serverWrapped,
|
||||
})
|
||||
await saveDeviceWrapped(vault.id, created.deviceWrapped)
|
||||
vaultState.setActive(vault.id, created.vk)
|
||||
toast.success("Vault created and unlocked")
|
||||
onOpenChange(false)
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New vault</DialogTitle>
|
||||
<DialogDescription>
|
||||
A vault stores your streaming keys, encrypted in your browser.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vault-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="vault-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vault-pass">Passphrase</FieldLabel>
|
||||
<Input
|
||||
id="vault-pass"
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Unlock vault ---------------------------------------------------------
|
||||
|
||||
export function UnlockVaultDialog({
|
||||
vault,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUnlocked,
|
||||
}: {
|
||||
vault: Vault | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUnlocked: () => void
|
||||
}) {
|
||||
const [passphrase, setPassphrase] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setPassphrase("")
|
||||
}, [open])
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!vault) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const vk = await unlockWithPassphrase(vault, passphrase)
|
||||
vaultState.setActive(vault.id, vk)
|
||||
toast.success("Vault unlocked")
|
||||
onOpenChange(false)
|
||||
onUnlocked()
|
||||
} catch {
|
||||
toast.error("Wrong passphrase")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlock {vault?.name}</DialogTitle>
|
||||
<DialogDescription>Enter this vault's passphrase.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="unlock-pass">Passphrase</FieldLabel>
|
||||
<Input
|
||||
id="unlock-pass"
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy}>
|
||||
Unlock
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- New room -------------------------------------------------------------
|
||||
|
||||
export function NewRoomDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (room: Room) => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
try {
|
||||
const room = await api.createRoom(name)
|
||||
toast.success("Room created")
|
||||
onOpenChange(false)
|
||||
onCreated(room)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New room</DialogTitle>
|
||||
<DialogDescription>A room is a shared production you can invite people to.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="room-name">Name</FieldLabel>
|
||||
<Input id="room-name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Account editor -------------------------------------------------------
|
||||
|
||||
export function AccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
account,
|
||||
vaultId,
|
||||
providers,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
account: Account | null
|
||||
vaultId: string
|
||||
providers: Provider[]
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [provider, setProvider] = useState("twitch")
|
||||
const [url, setUrl] = useState("")
|
||||
const [key, setKey] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(account?.name ?? "")
|
||||
setProvider(account?.provider ?? "twitch")
|
||||
setUrl(account?.url ?? "")
|
||||
setKey("")
|
||||
}
|
||||
}, [open, account])
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
try {
|
||||
const patch: Partial<Account> = { name, url: url || providers.find((p) => p.id === provider)?.defaultUrl || "" }
|
||||
if (key) {
|
||||
patch.secretCiphertext = await encryptSecret(key, account ? `acct:${account.id}` : `acct:${crypto.randomUUID()}`)
|
||||
}
|
||||
if (account) {
|
||||
await api.updateAccount(account.id, patch)
|
||||
} else {
|
||||
if (!key) throw new Error("stream key required")
|
||||
await api.createAccount({
|
||||
vaultId,
|
||||
provider,
|
||||
name,
|
||||
url: patch.url!,
|
||||
secretCiphertext: patch.secretCiphertext!,
|
||||
})
|
||||
}
|
||||
toast.success(account ? "Account updated" : "Account added")
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{account ? "Edit account" : "Add account"}</DialogTitle>
|
||||
<DialogDescription>The stream key is encrypted in your browser before upload.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acct-name">Name</FieldLabel>
|
||||
<Input id="acct-name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acct-provider">Provider</FieldLabel>
|
||||
<Select value={provider} onValueChange={setProvider} disabled={!!account}>
|
||||
<SelectTrigger id="acct-provider" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acct-url">RTMP URL</FieldLabel>
|
||||
<Input
|
||||
id="acct-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={providers.find((p) => p.id === provider)?.defaultUrl || "rtmp://…"}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acct-key">
|
||||
Stream key{account ? " (blank keeps current)" : ""}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="acct-key"
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
required={!account}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Simple prompt dialog (feed name, invite link display) ----------------
|
||||
|
||||
export function PromptDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit: () => void | Promise<void>
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
try {
|
||||
await onSubmit()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Input value={value} onChange={(e) => onChange(e.target.value)} required />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Start streaming (grant) ----------------------------------------------
|
||||
|
||||
export function GrantDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
accounts,
|
||||
onGranted,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
accounts: Account[]
|
||||
onGranted: () => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelected([])
|
||||
}, [open])
|
||||
|
||||
const mine = accounts.filter((a) => a.secretCiphertext)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
try {
|
||||
if (!vaultState.isUnlocked()) throw new Error("unlock the vault holding these keys first")
|
||||
const accountKeys: Record<string, string> = {}
|
||||
for (const id of selected) {
|
||||
const acct = mine.find((a) => a.id === id)
|
||||
if (acct?.secretCiphertext) {
|
||||
accountKeys[id] = await decryptSecret(acct.secretCiphertext, `acct:${acct.id}`)
|
||||
}
|
||||
}
|
||||
await api.startStream(accountKeys)
|
||||
toast.success("Streaming started")
|
||||
onOpenChange(false)
|
||||
onGranted()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start streaming</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick platforms to push to. Your keys are decrypted in the browser and granted to the
|
||||
server for this stream only.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mine.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">You have no accounts in this room.</p>
|
||||
) : (
|
||||
mine.map((a) => (
|
||||
<label key={a.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(a.id)}
|
||||
onChange={(e) =>
|
||||
setSelected((s) =>
|
||||
e.target.checked ? [...s, a.id] : s.filter((x) => x !== a.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{a.name} ({a.provider})
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy || selected.length === 0}>
|
||||
Start
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
167
services/multistreaming/web/src/components/home-view.tsx
Normal file
167
services/multistreaming/web/src/components/home-view.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { toast } from "sonner"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import type { Account, Provider, Vault } from "@/lib/types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { AccountDialog } from "@/components/dialogs"
|
||||
|
||||
/**
|
||||
* Home: when a vault is unlocked, show its accounts. When no vault is active,
|
||||
* show the vault picker — everything else is disabled until one is selected.
|
||||
*/
|
||||
export function HomeView({
|
||||
vaults,
|
||||
activeVaultId,
|
||||
unlocked,
|
||||
accounts,
|
||||
providers,
|
||||
onSelectVault,
|
||||
onNewVault,
|
||||
onAddAccount,
|
||||
onEditAccount,
|
||||
accountDialog,
|
||||
}: {
|
||||
vaults: Vault[]
|
||||
activeVaultId: string | null
|
||||
unlocked: boolean
|
||||
accounts: Account[]
|
||||
providers: Provider[]
|
||||
onSelectVault: (vault: Vault) => void
|
||||
onNewVault: () => void
|
||||
onAddAccount: () => void
|
||||
onEditAccount: (account: Account) => void
|
||||
accountDialog: { account: Account | null; open: boolean; onOpenChange: (o: boolean) => void }
|
||||
}) {
|
||||
const activeVault = vaults.find((v) => v.id === activeVaultId) ?? null
|
||||
const vaultAccounts = accounts.filter((a) => a.vaultId === activeVaultId)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{!unlocked || !activeVault ? (
|
||||
<Card className="mx-auto w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Select a vault</CardTitle>
|
||||
<CardDescription>
|
||||
Unlock a vault to manage its accounts and stream. Everything else stays locked until
|
||||
you do.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{vaults.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No vaults yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Create a vault to start storing streaming keys securely in your browser.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={onNewVault}>Create vault</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{vaults.map((v) => (
|
||||
<Button
|
||||
key={v.id}
|
||||
variant="outline"
|
||||
className="justify-between"
|
||||
onClick={() => onSelectVault(v)}
|
||||
>
|
||||
<span>{v.name}</span>
|
||||
{v.isDefault ? (
|
||||
<span className="text-xs text-muted-foreground">default</span>
|
||||
) : null}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="ghost" onClick={onNewVault}>
|
||||
Create a new vault
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Accounts</CardTitle>
|
||||
<CardDescription>
|
||||
Accounts stored in "{activeVault.name}".
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{vaultAccounts.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No accounts in this vault</EmptyTitle>
|
||||
<EmptyDescription>Add a Twitch, YouTube, Kick, or custom RTMP account.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={onAddAccount}>Add account</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{vaultAccounts.map((a) => (
|
||||
<div key={a.id}>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{a.provider}
|
||||
{a.secretCiphertext ? " · key encrypted" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onEditAccount(a)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete account "${a.name}"?`)) return
|
||||
try {
|
||||
await api.deleteAccount(a.id)
|
||||
toast.success("Account deleted")
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
<Button className="mt-4 w-fit" variant="outline" onClick={onAddAccount}>
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<AccountDialog
|
||||
open={accountDialog.open}
|
||||
onOpenChange={accountDialog.onOpenChange}
|
||||
account={accountDialog.account}
|
||||
vaultId={activeVaultId ?? ""}
|
||||
providers={providers}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
152
services/multistreaming/web/src/components/image-upload.tsx
Normal file
152
services/multistreaming/web/src/components/image-upload.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { UploadIcon } from "lucide-react"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import type { Room } from "@/lib/types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
const MAX_IMAGE_BYTES = 1 * 1024 * 1024 // 1 MiB (mirrors SCENE_MAX_IMAGE_BYTES)
|
||||
const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp"]
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
resolve(result.split(",")[1] ?? "")
|
||||
}
|
||||
reader.onerror = () => reject(new Error("Failed to read image"))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function isAllowedType(file: File): boolean {
|
||||
if (ALLOWED_MIME.includes(file.type)) return true
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
return ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "webp"
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an overlay image. Enforces the size/type limits client-side before the
|
||||
* file is base64-encoded and sent to the server.
|
||||
*/
|
||||
export function ImageUploader({
|
||||
open,
|
||||
onOpenChange,
|
||||
room,
|
||||
onUploaded,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
room: Room
|
||||
onUploaded: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [data, setData] = useState("")
|
||||
const [fileName, setFileName] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("")
|
||||
setData("")
|
||||
setFileName("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (!file) return
|
||||
if (!isAllowedType(file)) {
|
||||
toast.error("Only PNG, JPEG, or WebP images are allowed")
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
toast.error("Image must be 1 MiB or smaller")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const b64 = await fileToBase64(file)
|
||||
setData(b64)
|
||||
setFileName(file.name)
|
||||
setName((n) => (n.trim() ? n : file.name))
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
toast.error("Image name is required")
|
||||
return
|
||||
}
|
||||
if (trimmed.length > 64) {
|
||||
toast.error("Image name must be 64 characters or fewer")
|
||||
return
|
||||
}
|
||||
if (!data) {
|
||||
toast.error("Choose an image file")
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.uploadImage(room.id, { name: trimmed, data })
|
||||
toast.success("Image uploaded")
|
||||
onOpenChange(false)
|
||||
onUploaded()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload image</DialogTitle>
|
||||
<DialogDescription>PNG, JPEG, or WebP up to 1 MiB for scene overlays.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="image-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="image-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64}
|
||||
placeholder={fileName || "logo.png"}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="image-file">File</FieldLabel>
|
||||
<Input id="image-file" type="file" accept={ALLOWED_MIME.join(",")} onChange={onFile} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy || !data}>
|
||||
<UploadIcon />
|
||||
Upload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
47
services/multistreaming/web/src/components/nav-rooms.tsx
Normal file
47
services/multistreaming/web/src/components/nav-rooms.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { PlusIcon, VideoIcon } from "lucide-react"
|
||||
|
||||
import type { Room } from "@/lib/types"
|
||||
|
||||
export function NavRooms({
|
||||
rooms,
|
||||
activeRoomId,
|
||||
onOpen,
|
||||
onNew,
|
||||
}: {
|
||||
rooms: Room[]
|
||||
activeRoomId: string | null
|
||||
onOpen: (room: Room) => void
|
||||
onNew: () => void
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Rooms</SidebarGroupLabel>
|
||||
<SidebarGroupAction title="New room" onClick={onNew}>
|
||||
<PlusIcon />
|
||||
<span className="sr-only">New room</span>
|
||||
</SidebarGroupAction>
|
||||
<SidebarMenu>
|
||||
{rooms.map((room) => (
|
||||
<SidebarMenuItem key={room.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => onOpen(room)}
|
||||
isActive={room.id === activeRoomId}
|
||||
tooltip={room.name}
|
||||
>
|
||||
<VideoIcon />
|
||||
<span>{room.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
79
services/multistreaming/web/src/components/nav-user.tsx
Normal file
79
services/multistreaming/web/src/components/nav-user.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
} from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ChevronsUpDownIcon, LogOutIcon } from "lucide-react"
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
user: { name: string; email: string }
|
||||
onLogout: () => void
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const initials = user.name.slice(0, 2).toUpperCase()
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOutIcon />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
44
services/multistreaming/web/src/components/nav-vaults.tsx
Normal file
44
services/multistreaming/web/src/components/nav-vaults.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { LockIcon, LockOpenIcon } from "lucide-react"
|
||||
|
||||
import type { Vault } from "@/lib/types"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
/**
|
||||
* The current vault shown as a single sidebar item (not a group).
|
||||
* Clicking it asks to leave/lock the vault; when nothing is unlocked it shows a
|
||||
* disabled "no vault" state and unlocking happens from the center view.
|
||||
*/
|
||||
export function NavVaults({
|
||||
vault,
|
||||
unlocked,
|
||||
onLeave,
|
||||
}: {
|
||||
vault: Vault | null
|
||||
unlocked: boolean
|
||||
onLeave: () => void
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Vault</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={vault ? onLeave : undefined}
|
||||
isActive={unlocked}
|
||||
tooltip={vault ? vault.name : "No vault unlocked"}
|
||||
disabled={!vault}
|
||||
>
|
||||
{unlocked ? <LockOpenIcon /> : <LockIcon />}
|
||||
<span className="truncate">{vault ? vault.name : "No vault unlocked"}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
179
services/multistreaming/web/src/components/room-sidebar.tsx
Normal file
179
services/multistreaming/web/src/components/room-sidebar.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { ClapperboardIcon, PlusIcon, RadioIcon, UserIcon, VideoIcon } from "lucide-react"
|
||||
|
||||
import type { Member, Room } from "@/lib/types"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
/**
|
||||
* Right sidebar: room-scoped context (feeds, outputs, members). Rendered only
|
||||
* when a room is open — outside a room it is not mounted at all.
|
||||
*/
|
||||
export function RoomSidebar({
|
||||
room,
|
||||
members,
|
||||
liveFeeds,
|
||||
ingest,
|
||||
canEdit,
|
||||
onAddFeed,
|
||||
onInvite,
|
||||
}: {
|
||||
room: Room
|
||||
members: Member[]
|
||||
liveFeeds: string[]
|
||||
ingest: { app: string; rtmpPort: number; publicHost: string }
|
||||
canEdit: boolean
|
||||
onAddFeed: () => void
|
||||
onInvite: () => void
|
||||
}) {
|
||||
const host = ingest.publicHost || (typeof window !== "undefined" ? window.location.hostname : "")
|
||||
const feedById = (id: string) => room.feeds.find((f) => f.id === id)
|
||||
const accountById = (id: string) => room.accounts.find((a) => a.id === id)
|
||||
const scenes = room.scenes ?? []
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
side="right"
|
||||
collapsible="none"
|
||||
className="sticky top-0 hidden h-svh border-l lg:flex"
|
||||
>
|
||||
<SidebarHeader className="h-16 border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<VideoIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{room.name}</span>
|
||||
<span className="truncate text-xs">room context</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Feeds</SidebarGroupLabel>
|
||||
{canEdit ? (
|
||||
<SidebarGroupAction title="Add feed" onClick={onAddFeed}>
|
||||
<PlusIcon />
|
||||
<span className="sr-only">Add feed</span>
|
||||
</SidebarGroupAction>
|
||||
) : null}
|
||||
<SidebarMenu>
|
||||
{room.feeds.length === 0 ? (
|
||||
<SidebarMenuItem>
|
||||
<span className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground">
|
||||
<RadioIcon />
|
||||
No feeds
|
||||
</span>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
room.feeds.map((f) => (
|
||||
<SidebarMenuItem key={f.id}>
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<RadioIcon className="shrink-0" />
|
||||
<span className="truncate text-sm">{f.name}</span>
|
||||
</span>
|
||||
{liveFeeds.includes(f.id) ? <Badge>live</Badge> : null}
|
||||
</div>
|
||||
<div className="truncate px-8 pb-1.5 text-[11px] text-muted-foreground">
|
||||
{host}/{ingest.app}/{f.streamKey}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator className="mx-0" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Outputs</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{room.outputs.length === 0 ? (
|
||||
<SidebarMenuItem>
|
||||
<span className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground">
|
||||
<VideoIcon />
|
||||
No outputs
|
||||
</span>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
room.outputs.map((o) => (
|
||||
<SidebarMenuItem key={o.id}>
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span className="truncate text-sm">
|
||||
{feedById(o.feedId)?.name ?? "?"} → {accountById(o.accountId)?.name ?? "?"}
|
||||
</span>
|
||||
{o.runtime?.running ? <Badge variant="secondary">live</Badge> : null}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator className="mx-0" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Scenes</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{scenes.length === 0 ? (
|
||||
<SidebarMenuItem>
|
||||
<span className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground">
|
||||
<ClapperboardIcon />
|
||||
No scenes
|
||||
</span>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
scenes.map((s) => (
|
||||
<SidebarMenuItem key={s.id}>
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ClapperboardIcon className="shrink-0" />
|
||||
<span className="truncate text-sm">{s.name}</span>
|
||||
</span>
|
||||
{s.active ? <Badge>active</Badge> : null}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator className="mx-0" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Members</SidebarGroupLabel>
|
||||
{canEdit ? (
|
||||
<SidebarGroupAction title="Invite" onClick={onInvite}>
|
||||
<PlusIcon />
|
||||
<span className="sr-only">Invite</span>
|
||||
</SidebarGroupAction>
|
||||
) : null}
|
||||
<SidebarMenu>
|
||||
{members.map((m) => (
|
||||
<SidebarMenuItem key={m.id}>
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<UserIcon className="shrink-0" />
|
||||
<span className="truncate text-sm">{m.username}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{m.role}</span>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
216
services/multistreaming/web/src/components/room-view.tsx
Normal file
216
services/multistreaming/web/src/components/room-view.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import type { Account, Output, Room } from "@/lib/types"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { GrantDialog } from "@/components/dialogs"
|
||||
import { ScenePanel } from "@/components/scene-panel"
|
||||
|
||||
/**
|
||||
* Center content for an open room: the production routing (feed → account).
|
||||
* Feeds and members live in the right sidebar (room context).
|
||||
*/
|
||||
export function RoomView({
|
||||
room,
|
||||
canEdit,
|
||||
onRefresh,
|
||||
onBack,
|
||||
}: {
|
||||
room: Room
|
||||
canEdit: boolean
|
||||
onRefresh: () => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [grantOpen, setGrantOpen] = useState(false)
|
||||
const [outputOpen, setOutputOpen] = useState(false)
|
||||
const [outputFeed, setOutputFeed] = useState("")
|
||||
const [outputAccount, setOutputAccount] = useState("")
|
||||
|
||||
const feedNameById = (id: string) => room.feeds.find((f) => f.id === id)?.name ?? "?"
|
||||
const accountName = (id: string) => room.accounts.find((a) => a.id === id)?.name ?? "?"
|
||||
|
||||
async function addOutput() {
|
||||
try {
|
||||
await api.createOutput(room.id, outputFeed, outputAccount)
|
||||
toast.success("Output added")
|
||||
setOutputOpen(false)
|
||||
setOutputFeed("")
|
||||
setOutputAccount("")
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{room.name}</CardTitle>
|
||||
<CardDescription>Production routing · role: {room.role}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setGrantOpen(true)} disabled={room.accounts.length === 0}>
|
||||
Start streaming
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<ScenePanel room={room} canEdit={canEdit} onRefresh={onRefresh} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Outputs</CardTitle>
|
||||
<CardDescription>Route a feed to a destination account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{room.outputs.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No outputs</EmptyTitle>
|
||||
<EmptyDescription>Wire a feed to a destination account.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{canEdit ? <Button onClick={() => setOutputOpen(true)}>Add output</Button> : null}
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{room.outputs.map((o: Output) => (
|
||||
<div key={o.id}>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{feedNameById(o.feedId)} → {accountName(o.accountId)}
|
||||
</span>
|
||||
{o.runtime?.running ? <Badge variant="secondary">live</Badge> : null}
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={o.enabled}
|
||||
onCheckedChange={async (checked) => {
|
||||
try {
|
||||
await api.updateOutput(o.id, checked)
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm("Delete this output?")) return
|
||||
await api.deleteOutput(o.id)
|
||||
toast.success("Output deleted")
|
||||
onRefresh()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
{canEdit ? (
|
||||
<Button className="mt-4 w-fit" variant="outline" onClick={() => setOutputOpen(true)}>
|
||||
Add output
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={outputOpen} onOpenChange={setOutputOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add output</DialogTitle>
|
||||
<DialogDescription>Route a feed to a destination account.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Select value={outputFeed} onValueChange={setOutputFeed}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Feed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{room.feeds.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={outputAccount} onValueChange={setOutputAccount}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{room.accounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name} ({a.provider})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button disabled={!outputFeed || !outputAccount} onClick={() => void addOutput()}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<GrantDialog
|
||||
open={grantOpen}
|
||||
onOpenChange={setGrantOpen}
|
||||
accounts={room.accounts as Account[]}
|
||||
onGranted={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
606
services/multistreaming/web/src/components/scene-editor.tsx
Normal file
606
services/multistreaming/web/src/components/scene-editor.tsx
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ImageIcon, PlusIcon, Trash2Icon, TypeIcon } from "lucide-react"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import type { AudioRouting, OverlayItem, Room, Scene, SceneLayout, SceneOutput } from "@/lib/types"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
const EMPTY = "__empty__"
|
||||
const CANVAS_W = 1920
|
||||
const CANVAS_H = 1080
|
||||
const MAX_OVERLAYS = 8
|
||||
|
||||
function audioLabel(audio: AudioRouting, room: Room): string {
|
||||
if (audio.mode === "program") return "Program mix"
|
||||
if (audio.mode === "silent") return "Silent"
|
||||
return `Feed: ${room.feeds.find((f) => f.id === audio.feedId)?.name ?? "?"}`
|
||||
}
|
||||
|
||||
function newTextOverlay(): OverlayItem {
|
||||
return { id: crypto.randomUUID(), kind: "text", text: "", x: 32, y: 32, fontSize: 48, color: "#ffffff", bold: false }
|
||||
}
|
||||
|
||||
function newImageOverlay(firstImageId: string | undefined): OverlayItem | null {
|
||||
if (!firstImageId) return null
|
||||
return { id: crypto.randomUUID(), kind: "image", imageId: firstImageId, x: 16, y: 844, width: 200, height: 112, opacity: 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full scene editor: name, layout (grid/PiP) with feed-slot assignment, text +
|
||||
* image overlays, and per-output audio routing. Used for both create (scene
|
||||
* null) and edit (scene set).
|
||||
*/
|
||||
export function SceneEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
scene,
|
||||
room,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
scene: Scene | null
|
||||
room: Room
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [layoutType, setLayoutType] = useState<"grid" | "pip">("grid")
|
||||
const [columns, setColumns] = useState(2)
|
||||
const [slots, setSlots] = useState<Array<string | null>>([null, null])
|
||||
const [overlays, setOverlays] = useState<OverlayItem[]>([])
|
||||
const [outputs, setOutputs] = useState<SceneOutput[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const feeds = room.feeds ?? []
|
||||
const images = room.images ?? []
|
||||
const accounts = room.accounts ?? []
|
||||
const sceneOutputs = room.sceneOutputs ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const existing = scene ? sceneOutputs.filter((so) => so.sceneId === scene.id) : []
|
||||
setName(scene?.name ?? "")
|
||||
setLayoutType(scene?.layout.type ?? "grid")
|
||||
setColumns(scene?.layout.type === "grid" ? scene.layout.columns ?? 2 : 2)
|
||||
setSlots(scene ? [...scene.layout.slots] : [null, null])
|
||||
setOverlays(scene ? scene.overlays.map((o) => ({ ...o })) : [])
|
||||
setOutputs(existing.map((so) => ({ ...so })))
|
||||
// Reset only when the dialog opens or the edited scene changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, scene])
|
||||
|
||||
function setLayoutKind(type: "grid" | "pip") {
|
||||
setLayoutType(type)
|
||||
if (type === "grid") {
|
||||
// Keep existing assignments; grid allows 1..6 slots.
|
||||
const next = slots.length >= 1 && slots.length <= 6 ? [...slots] : [null, null]
|
||||
setSlots(next)
|
||||
} else {
|
||||
setSlots([slots[0] ?? null, slots[1] ?? null])
|
||||
}
|
||||
}
|
||||
|
||||
function setSlot(i: number, value: string | null) {
|
||||
setSlots((list) => list.map((s, idx) => (idx === i ? value : s)))
|
||||
}
|
||||
|
||||
function addSlot() {
|
||||
setSlots((list) => (list.length < 6 ? [...list, null] : list))
|
||||
}
|
||||
|
||||
function removeSlot() {
|
||||
setSlots((list) => (list.length > 1 ? list.slice(0, -1) : list))
|
||||
}
|
||||
|
||||
function patchOverlay(id: string, patch: Record<string, unknown>) {
|
||||
setOverlays((list) => list.map((o) => (o.id === id ? ({ ...o, ...patch } as OverlayItem) : o)))
|
||||
}
|
||||
|
||||
function removeOverlay(id: string) {
|
||||
setOverlays((list) => list.filter((o) => o.id !== id))
|
||||
}
|
||||
|
||||
function addText() {
|
||||
if (overlays.length >= MAX_OVERLAYS) {
|
||||
toast.error(`At most ${MAX_OVERLAYS} overlays`)
|
||||
return
|
||||
}
|
||||
setOverlays((list) => [...list, newTextOverlay()])
|
||||
}
|
||||
|
||||
function addImage() {
|
||||
if (overlays.length >= MAX_OVERLAYS) {
|
||||
toast.error(`At most ${MAX_OVERLAYS} overlays`)
|
||||
return
|
||||
}
|
||||
const first = images[0]?.id
|
||||
if (!first) {
|
||||
toast.error("Upload an image first")
|
||||
return
|
||||
}
|
||||
setOverlays((list) => [...list, newImageOverlay(first)!])
|
||||
}
|
||||
|
||||
function addOutput() {
|
||||
setOutputs((list) => [
|
||||
...list,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
roomId: room.id,
|
||||
sceneId: scene?.id ?? "",
|
||||
accountId: accounts[0]?.id ?? "",
|
||||
enabled: true,
|
||||
audio: { mode: "program" },
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
function patchOutput(id: string, patch: Partial<SceneOutput>) {
|
||||
setOutputs((list) => list.map((o) => (o.id === id ? ({ ...o, ...patch } as SceneOutput) : o)))
|
||||
}
|
||||
|
||||
function removeOutput(id: string) {
|
||||
setOutputs((list) => list.filter((o) => o.id !== id))
|
||||
}
|
||||
|
||||
function buildLayout(): SceneLayout {
|
||||
return layoutType === "grid"
|
||||
? { type: "grid", columns, slots: [...slots] }
|
||||
: { type: "pip", slots: [slots[0] ?? null, slots[1] ?? null] }
|
||||
}
|
||||
|
||||
function validate(): string | null {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return "Scene name is required"
|
||||
if (trimmed.length > 64) return "Scene name must be 64 characters or fewer"
|
||||
|
||||
const layout = buildLayout()
|
||||
if (layout.type === "grid") {
|
||||
if (!layout.columns || layout.columns < 2 || layout.columns > 4) return "Grid columns must be 2–4"
|
||||
if (layout.slots.length < 1 || layout.slots.length > 6) return "Grid layout needs 1–6 slots"
|
||||
} else if (layout.slots.length !== 2) {
|
||||
return "PiP layout needs exactly 2 slots"
|
||||
}
|
||||
|
||||
const assigned = layout.slots.filter((s): s is string => !!s)
|
||||
if (new Set(assigned).size !== assigned.length) return "A feed can only be assigned to one slot"
|
||||
|
||||
if (overlays.length > MAX_OVERLAYS) return `At most ${MAX_OVERLAYS} overlays`
|
||||
for (const o of overlays) {
|
||||
if (o.kind === "text") {
|
||||
const t = o.text.trim()
|
||||
if (!t || t.length > 256) return "Text overlays need 1–256 characters"
|
||||
if (!Number.isInteger(o.x) || o.x < 0 || o.x >= CANVAS_W) return "Text x must be 0–1919"
|
||||
if (!Number.isInteger(o.y) || o.y < 0 || o.y >= CANVAS_H) return "Text y must be 0–1079"
|
||||
if (o.fontSize < 8 || o.fontSize > 144) return "Font size must be 8–144"
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(o.color)) return "Text color must be #rrggbb"
|
||||
} else {
|
||||
if (!images.some((img) => img.id === o.imageId)) return "Image overlay references a missing image"
|
||||
if (o.width < 16 || o.width > CANVAS_W) return "Image width must be 16–1920"
|
||||
if (o.height < 16 || o.height > CANVAS_H) return "Image height must be 16–1080"
|
||||
if (o.opacity != null && (o.opacity < 0 || o.opacity > 1)) return "Image opacity must be 0–1"
|
||||
}
|
||||
}
|
||||
|
||||
for (const so of outputs) {
|
||||
if (!accounts.some((a) => a.id === so.accountId)) return "Each destination needs an account"
|
||||
if (so.audio.mode === "feed") {
|
||||
const feedId = so.audio.feedId
|
||||
if (!feeds.some((f) => f.id === feedId)) return "Audio feed must be a feed in this room"
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function reconcileOutputs(sceneId: string) {
|
||||
const existingIds = new Set(sceneOutputs.filter((so) => so.sceneId === sceneId).map((so) => so.id))
|
||||
const currentIds = new Set(outputs.map((o) => o.id))
|
||||
// Delete removed destinations.
|
||||
for (const ex of sceneOutputs) {
|
||||
if (ex.sceneId === sceneId && !currentIds.has(ex.id)) {
|
||||
await api.deleteSceneOutput(ex.id)
|
||||
}
|
||||
}
|
||||
for (const so of outputs) {
|
||||
if (existingIds.has(so.id)) {
|
||||
await api.updateSceneOutput(so.id, { accountId: so.accountId, audio: so.audio, enabled: so.enabled })
|
||||
} else {
|
||||
await api.createSceneOutput(room.id, {
|
||||
sceneId,
|
||||
accountId: so.accountId,
|
||||
audio: so.audio,
|
||||
enabled: so.enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const err = validate()
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const layout = buildLayout()
|
||||
if (scene) {
|
||||
await api.updateScene(scene.id, { name: name.trim(), layout, overlays })
|
||||
await reconcileOutputs(scene.id)
|
||||
} else {
|
||||
const created = await api.createScene(room.id, { name: name.trim(), layout })
|
||||
if (overlays.length > 0) await api.updateScene(created.id, { overlays })
|
||||
for (const so of outputs) {
|
||||
await api.createSceneOutput(room.id, {
|
||||
sceneId: created.id,
|
||||
accountId: so.accountId,
|
||||
audio: so.audio,
|
||||
enabled: so.enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
toast.success(scene ? "Scene updated" : "Scene created")
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err2) {
|
||||
toast.error((err2 as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{scene ? `Edit scene · ${scene.name}` : "New scene"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Compose live feeds into a grid or picture-in-picture program with overlays and per-output
|
||||
audio.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="max-h-[70vh] overflow-y-auto pr-1">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="scene-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="scene-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Layout</FieldLabel>
|
||||
<Select value={layoutType} onValueChange={(v) => setLayoutKind(v as "grid" | "pip")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="grid">Grid</SelectItem>
|
||||
<SelectItem value="pip">Picture-in-picture</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{layoutType === "grid" ? (
|
||||
<Field>
|
||||
<FieldLabel>Columns</FieldLabel>
|
||||
<Select value={String(columns)} onValueChange={(v) => setColumns(Number(v))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="4">4</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="mb-0">
|
||||
{layoutType === "grid" ? "Slots" : "Slots (main + PiP)"}
|
||||
</FieldLabel>
|
||||
{layoutType === "grid" ? (
|
||||
<div className="flex gap-1">
|
||||
<Button type="button" variant="outline" size="xs" onClick={addSlot} disabled={slots.length >= 6}>
|
||||
<PlusIcon />
|
||||
Slot
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" onClick={removeSlot} disabled={slots.length <= 1}>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{slots.map((slot, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="w-24 shrink-0 text-xs text-muted-foreground">
|
||||
{layoutType === "pip" ? (i === 0 ? "Main" : "PiP") : `Slot ${i + 1}`}
|
||||
</span>
|
||||
<Select value={slot ?? EMPTY} onValueChange={(v) => setSlot(i, v === EMPTY ? null : v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Feed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={EMPTY}>Empty</SelectItem>
|
||||
{feeds.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Overlays</span>
|
||||
<div className="flex gap-1">
|
||||
<Button type="button" variant="outline" size="xs" onClick={addText}>
|
||||
<TypeIcon />
|
||||
Text
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" onClick={addImage}>
|
||||
<ImageIcon />
|
||||
Image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{overlays.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No overlays. Add text or an uploaded image.</p>
|
||||
) : (
|
||||
overlays.map((o) =>
|
||||
o.kind === "text" ? (
|
||||
<div key={o.id} className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline">Text</Badge>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeOverlay(o.id)}>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={o.text}
|
||||
placeholder="Text"
|
||||
maxLength={256}
|
||||
onChange={(e) => patchOverlay(o.id, { text: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="X"
|
||||
value={o.x}
|
||||
onChange={(e) => patchOverlay(o.id, { x: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Y"
|
||||
value={o.y}
|
||||
onChange={(e) => patchOverlay(o.id, { y: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Font size"
|
||||
value={o.fontSize}
|
||||
onChange={(e) => patchOverlay(o.id, { fontSize: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
type="color"
|
||||
aria-label="Color"
|
||||
value={o.color}
|
||||
onChange={(e) => patchOverlay(o.id, { color: e.target.value })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={o.bold ?? false}
|
||||
onCheckedChange={(checked) => patchOverlay(o.id, { bold: checked })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Bold</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={o.id} className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline">Image</Badge>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeOverlay(o.id)}>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={o.imageId} onValueChange={(v) => patchOverlay(o.id, { imageId: v })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Image" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{images.map((img) => (
|
||||
<SelectItem key={img.id} value={img.id}>
|
||||
{img.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="X"
|
||||
value={o.x}
|
||||
onChange={(e) => patchOverlay(o.id, { x: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Y"
|
||||
value={o.y}
|
||||
onChange={(e) => patchOverlay(o.id, { y: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Width"
|
||||
value={o.width}
|
||||
onChange={(e) => patchOverlay(o.id, { width: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Height"
|
||||
value={o.height}
|
||||
onChange={(e) => patchOverlay(o.id, { height: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.05}
|
||||
min={0}
|
||||
max={1}
|
||||
aria-label="Opacity"
|
||||
value={o.opacity ?? 1}
|
||||
onChange={(e) => patchOverlay(o.id, { opacity: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Destinations & audio</span>
|
||||
<Button type="button" variant="outline" size="xs" onClick={addOutput} disabled={accounts.length === 0}>
|
||||
<PlusIcon />
|
||||
Destination
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{outputs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No destinations. Add one to push the composed program to an account.
|
||||
</p>
|
||||
) : (
|
||||
outputs.map((so) => (
|
||||
<div key={so.id} className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{accounts.find((a) => a.id === so.accountId)?.name ?? "Select account"}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeOutput(so.id)}>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={so.accountId} onValueChange={(v) => patchOutput(so.id, { accountId: v })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{accounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name} ({a.provider})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
<Select value={so.audio.mode} onValueChange={(v) => patchOutput(so.id, { audio: { mode: v } as AudioRouting })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="program">Program mix</SelectItem>
|
||||
<SelectItem value="silent">Silent</SelectItem>
|
||||
<SelectItem value="feed">Single feed</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{so.audio.mode === "feed" ? (
|
||||
<Select
|
||||
value={so.audio.feedId ?? ""}
|
||||
onValueChange={(v) => patchOutput(so.id, { audio: { mode: "feed", feedId: v } as AudioRouting })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Feed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{feeds.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="truncate text-xs text-muted-foreground">{audioLabel(so.audio, room)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={so.enabled}
|
||||
onCheckedChange={(checked) => patchOutput(so.id, { enabled: checked })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={busy}>
|
||||
{scene ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
365
services/multistreaming/web/src/components/scene-panel.tsx
Normal file
365
services/multistreaming/web/src/components/scene-panel.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ClapperboardIcon,
|
||||
ImageIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { api } from "@/lib/api"
|
||||
import type { AudioRouting, Room, RoomImage, Scene, SceneOutput } from "@/lib/types"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { ImageUploader } from "@/components/image-upload"
|
||||
import { SceneEditorDialog } from "@/components/scene-editor"
|
||||
|
||||
function audioLabel(audio: AudioRouting, room: Room): string {
|
||||
if (audio.mode === "program") return "Program mix"
|
||||
if (audio.mode === "silent") return "Silent"
|
||||
return `Feed: ${room.feeds.find((f) => f.id === audio.feedId)?.name ?? "?"}`
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenes & composition card: create/rename/delete/activate scenes, show the
|
||||
* active scene's destinations, and manage overlay images.
|
||||
*/
|
||||
export function ScenePanel({
|
||||
room,
|
||||
canEdit,
|
||||
onRefresh,
|
||||
}: {
|
||||
room: Room
|
||||
canEdit: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Scene | null>(null)
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [deleteScene, setDeleteScene] = useState<Scene | null>(null)
|
||||
const [deleteOutput, setDeleteOutput] = useState<SceneOutput | null>(null)
|
||||
const [deleteImage, setDeleteImage] = useState<RoomImage | null>(null)
|
||||
|
||||
const scenes = room.scenes ?? []
|
||||
const images = room.images ?? []
|
||||
const sceneOutputs = room.sceneOutputs ?? []
|
||||
const activeScene = scenes.find((s) => s.active) ?? null
|
||||
const activeOutputs = activeScene ? sceneOutputs.filter((so) => so.sceneId === activeScene.id) : []
|
||||
|
||||
function openNew() {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(scene: Scene) {
|
||||
setEditing(scene)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function activate(scene: Scene) {
|
||||
try {
|
||||
await api.activateScene(scene.id)
|
||||
toast.success(`"${scene.name}" activated`)
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteScene() {
|
||||
if (!deleteScene) return
|
||||
try {
|
||||
await api.deleteScene(deleteScene.id)
|
||||
toast.success("Scene deleted")
|
||||
setDeleteScene(null)
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
setDeleteScene(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteOutput() {
|
||||
if (!deleteOutput) return
|
||||
try {
|
||||
await api.deleteSceneOutput(deleteOutput.id)
|
||||
toast.success("Destination removed")
|
||||
setDeleteOutput(null)
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
setDeleteOutput(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteImage() {
|
||||
if (!deleteImage) return
|
||||
try {
|
||||
await api.deleteImage(deleteImage.id)
|
||||
toast.success("Image deleted")
|
||||
setDeleteImage(null)
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
setDeleteImage(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOutput(so: SceneOutput, enabled: boolean) {
|
||||
try {
|
||||
await api.updateSceneOutput(so.id, { enabled })
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Scenes</CardTitle>
|
||||
<CardDescription>Compose feeds into a grid or PiP program.</CardDescription>
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<Button size="sm" onClick={openNew}>
|
||||
<PlusIcon />
|
||||
New scene
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{scenes.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No scenes</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Create a scene to compose live feeds instead of raw passthrough.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{canEdit ? <Button onClick={openNew}>New scene</Button> : null}
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{scenes.map((scene) => (
|
||||
<div key={scene.id}>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ClapperboardIcon className="shrink-0" />
|
||||
<span className="truncate text-sm font-medium">{scene.name}</span>
|
||||
{scene.active ? <Badge>active</Badge> : null}
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!scene.active ? (
|
||||
<Button variant="outline" size="sm" onClick={() => void activate(scene)}>
|
||||
<PowerIcon />
|
||||
Activate
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="icon-sm" title="Edit" onClick={() => openEdit(scene)}>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteScene(scene)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeScene ? (
|
||||
<div className="mt-4">
|
||||
<span className="text-sm font-medium">
|
||||
Destinations for “{activeScene.name}”
|
||||
</span>
|
||||
<div className="mt-2 flex flex-col">
|
||||
{activeOutputs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No destinations. Edit the scene to add one.
|
||||
</p>
|
||||
) : (
|
||||
activeOutputs.map((so) => (
|
||||
<div key={so.id}>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm">
|
||||
{so.account?.name ?? "?"}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{audioLabel(so.audio, room)}
|
||||
</span>
|
||||
{so.runtime?.running ? <Badge variant="secondary">live</Badge> : null}
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Switch
|
||||
checked={so.enabled}
|
||||
onCheckedChange={(checked) => void toggleOutput(so, checked)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteOutput(so)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Images</span>
|
||||
{canEdit ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<UploadIcon />
|
||||
Upload
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col">
|
||||
{images.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No overlay images yet.</p>
|
||||
) : (
|
||||
images.map((img) => (
|
||||
<div key={img.id}>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ImageIcon className="shrink-0" />
|
||||
<span className="truncate text-sm">{img.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{img.mime} · {formatBytes(img.size)}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<Button variant="outline" size="icon-sm" title="Delete" onClick={() => setDeleteImage(img)}>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<SceneEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
scene={editing}
|
||||
room={room}
|
||||
onSaved={onRefresh}
|
||||
/>
|
||||
<ImageUploader open={uploadOpen} onOpenChange={setUploadOpen} room={room} onUploaded={onRefresh} />
|
||||
|
||||
<AlertDialog open={deleteScene !== null} onOpenChange={(o) => !o && setDeleteScene(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete scene?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteScene ? (
|
||||
<>
|
||||
This removes “{deleteScene.name}” and its destinations. If it was active, the room
|
||||
returns to passthrough routing.
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void confirmDeleteScene()}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteOutput !== null} onOpenChange={(o) => !o && setDeleteOutput(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove destination?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This destination will stop receiving the composed program.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void confirmDeleteOutput()}>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteImage !== null} onOpenChange={(o) => !o && setDeleteImage(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete image?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Any overlay referencing this image will be removed too.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void confirmDeleteImage()}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
230
services/multistreaming/web/src/components/theme-provider.tsx
Normal file
230
services/multistreaming/web/src/components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
type ResolvedTheme = "dark" | "light"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
disableTransitionOnChange?: boolean
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
|
||||
const THEME_VALUES: Theme[] = ["dark", "light", "system"]
|
||||
|
||||
const ThemeProviderContext = React.createContext<
|
||||
ThemeProviderState | undefined
|
||||
>(undefined)
|
||||
|
||||
function isTheme(value: string | null): value is Theme {
|
||||
if (value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return THEME_VALUES.includes(value as Theme)
|
||||
}
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
|
||||
return "dark"
|
||||
}
|
||||
|
||||
return "light"
|
||||
}
|
||||
|
||||
function disableTransitionsTemporarily() {
|
||||
const style = document.createElement("style")
|
||||
style.appendChild(
|
||||
document.createTextNode(
|
||||
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
|
||||
)
|
||||
)
|
||||
document.head.appendChild(style)
|
||||
|
||||
return () => {
|
||||
window.getComputedStyle(document.body)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
style.remove()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
|
||||
const editableParent = target.closest(
|
||||
"input, textarea, select, [contenteditable='true']"
|
||||
)
|
||||
if (editableParent) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "theme",
|
||||
disableTransitionOnChange = true,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
const storedTheme = localStorage.getItem(storageKey)
|
||||
if (isTheme(storedTheme)) {
|
||||
return storedTheme
|
||||
}
|
||||
|
||||
return defaultTheme
|
||||
})
|
||||
|
||||
const setTheme = React.useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
localStorage.setItem(storageKey, nextTheme)
|
||||
setThemeState(nextTheme)
|
||||
},
|
||||
[storageKey]
|
||||
)
|
||||
|
||||
const applyTheme = React.useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
const root = document.documentElement
|
||||
const resolvedTheme =
|
||||
nextTheme === "system" ? getSystemTheme() : nextTheme
|
||||
const restoreTransitions = disableTransitionOnChange
|
||||
? disableTransitionsTemporarily()
|
||||
: null
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(resolvedTheme)
|
||||
|
||||
if (restoreTransitions) {
|
||||
restoreTransitions()
|
||||
}
|
||||
},
|
||||
[disableTransitionOnChange]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
applyTheme(theme)
|
||||
|
||||
if (theme !== "system") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
|
||||
const handleChange = () => {
|
||||
applyTheme("system")
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange)
|
||||
}
|
||||
}, [theme, applyTheme])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "d") {
|
||||
return
|
||||
}
|
||||
|
||||
setThemeState((currentTheme) => {
|
||||
const nextTheme =
|
||||
currentTheme === "dark"
|
||||
? "light"
|
||||
: currentTheme === "light"
|
||||
? "dark"
|
||||
: getSystemTheme() === "dark"
|
||||
? "light"
|
||||
: "dark"
|
||||
|
||||
localStorage.setItem(storageKey, nextTheme)
|
||||
return nextTheme
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [storageKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.storageArea !== localStorage) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== storageKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTheme(event.newValue)) {
|
||||
setThemeState(event.newValue)
|
||||
return
|
||||
}
|
||||
|
||||
setThemeState(defaultTheme)
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorageChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange)
|
||||
}
|
||||
}, [defaultTheme, storageKey])
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme,
|
||||
}),
|
||||
[theme, setTheme]
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = React.useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
197
services/multistreaming/web/src/components/ui/alert-dialog.tsx
Normal file
197
services/multistreaming/web/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
76
services/multistreaming/web/src/components/ui/alert.tsx
Normal file
76
services/multistreaming/web/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
110
services/multistreaming/web/src/components/ui/avatar.tsx
Normal file
110
services/multistreaming/web/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
49
services/multistreaming/web/src/components/ui/badge.tsx
Normal file
49
services/multistreaming/web/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
122
services/multistreaming/web/src/components/ui/breadcrumb.tsx
Normal file
122
services/multistreaming/web/src/components/ui/breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronRightIcon />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
67
services/multistreaming/web/src/components/ui/button.tsx
Normal file
67
services/multistreaming/web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
services/multistreaming/web/src/components/ui/card.tsx
Normal file
103
services/multistreaming/web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
168
services/multistreaming/web/src/components/ui/dialog.tsx
Normal file
168
services/multistreaming/web/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
269
services/multistreaming/web/src/components/ui/dropdown-menu.tsx
Normal file
269
services/multistreaming/web/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
104
services/multistreaming/web/src/components/ui/empty.tsx
Normal file
104
services/multistreaming/web/src/components/ui/empty.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn(
|
||||
"font-heading text-sm font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
236
services/multistreaming/web/src/components/ui/field.tsx
Normal file
236
services/multistreaming/web/src/components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
156
services/multistreaming/web/src/components/ui/input-group.tsx
Normal file
156
services/multistreaming/web/src/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
19
services/multistreaming/web/src/components/ui/input.tsx
Normal file
19
services/multistreaming/web/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
services/multistreaming/web/src/components/ui/label.tsx
Normal file
24
services/multistreaming/web/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
190
services/multistreaming/web/src/components/ui/select.tsx
Normal file
190
services/multistreaming/web/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
26
services/multistreaming/web/src/components/ui/separator.tsx
Normal file
26
services/multistreaming/web/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
147
services/multistreaming/web/src/components/ui/sheet.tsx
Normal file
147
services/multistreaming/web/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
700
services/multistreaming/web/src/components/ui/sidebar.tsx
Normal file
700
services/multistreaming/web/src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
services/multistreaming/web/src/components/ui/skeleton.tsx
Normal file
13
services/multistreaming/web/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
47
services/multistreaming/web/src/components/ui/sonner.tsx
Normal file
47
services/multistreaming/web/src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
33
services/multistreaming/web/src/components/ui/switch.tsx
Normal file
33
services/multistreaming/web/src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
18
services/multistreaming/web/src/components/ui/textarea.tsx
Normal file
18
services/multistreaming/web/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 2,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 2,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
45
services/multistreaming/web/src/components/ui/toggle.tsx
Normal file
45
services/multistreaming/web/src/components/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
57
services/multistreaming/web/src/components/ui/tooltip.tsx
Normal file
57
services/multistreaming/web/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
19
services/multistreaming/web/src/hooks/use-mobile.ts
Normal file
19
services/multistreaming/web/src/hooks/use-mobile.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
130
services/multistreaming/web/src/index.css
Normal file
130
services/multistreaming/web/src/index.css
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
138
services/multistreaming/web/src/lib/api.ts
Normal file
138
services/multistreaming/web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type {
|
||||
Account,
|
||||
AudioRouting,
|
||||
Feed,
|
||||
Me,
|
||||
Member,
|
||||
Output,
|
||||
Provider,
|
||||
Room,
|
||||
RoomImage,
|
||||
Scene,
|
||||
SceneLayout,
|
||||
SceneOutput,
|
||||
State,
|
||||
Vault,
|
||||
} from "@/lib/types"
|
||||
|
||||
const JSON_HEADERS = { "Content-Type": "application/json" }
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
opts: { method?: string; body?: unknown } = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: opts.method,
|
||||
headers: opts.body !== undefined ? JSON_HEADERS : undefined,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? res.statusText)
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// auth
|
||||
authConfig: () => request<{ mode: string; oidc: boolean }>("/api/auth/config"),
|
||||
me: () => request<Me>("/api/me"),
|
||||
login: (username: string, password: string) =>
|
||||
request<{ id: string; username: string }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: { username, password },
|
||||
}),
|
||||
register: (username: string, password: string) =>
|
||||
request<{ id: string; username: string }>("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: { username, password },
|
||||
}),
|
||||
logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
// vaults
|
||||
listVaults: () => request<Vault[]>("/api/vaults"),
|
||||
createVault: (body: { name: string; salt: string; serverWrapped: { iv: string; data: string } }) =>
|
||||
request<Vault>("/api/vaults", { method: "POST", body }),
|
||||
setDefaultVault: (id: string) =>
|
||||
request<{ defaultVaultId: string }>(`/api/vaults/${id}/default`, { method: "POST" }),
|
||||
deleteVault: (id: string) => request<{ ok: boolean }>(`/api/vaults/${id}`, { method: "DELETE" }),
|
||||
|
||||
// accounts
|
||||
createAccount: (body: {
|
||||
vaultId: string
|
||||
provider: string
|
||||
name: string
|
||||
url: string
|
||||
secretCiphertext: { iv: string; data: string }
|
||||
}) => request<Account>("/api/accounts", { method: "POST", body }),
|
||||
updateAccount: (id: string, body: Partial<Account>) =>
|
||||
request<Account>(`/api/accounts/${id}`, { method: "PUT", body }),
|
||||
deleteAccount: (id: string) => request<{ ok: boolean }>(`/api/accounts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// rooms
|
||||
createRoom: (name: string) => request<Room>("/api/rooms", { method: "POST", body: { name } }),
|
||||
roomDetail: (id: string) => request<Room & { members: Member[] }>(`/api/rooms/${id}`),
|
||||
invite: (roomId: string, role: string) =>
|
||||
request<{ id: string; token: string }>(`/api/rooms/${roomId}/invites`, {
|
||||
method: "POST",
|
||||
body: { role },
|
||||
}),
|
||||
acceptInvite: (token: string) =>
|
||||
request<{ roomId: string; role: string }>(`/api/invites/${token}/accept`, { method: "POST" }),
|
||||
|
||||
// feeds & outputs
|
||||
createFeed: (roomId: string, name: string) =>
|
||||
request<Feed>(`/api/rooms/${roomId}/feeds`, { method: "POST", body: { name } }),
|
||||
deleteFeed: (id: string) => request<{ ok: boolean }>(`/api/feeds/${id}`, { method: "DELETE" }),
|
||||
createOutput: (roomId: string, feedId: string, accountId: string) =>
|
||||
request<Output>(`/api/rooms/${roomId}/outputs`, {
|
||||
method: "POST",
|
||||
body: { feedId, accountId, enabled: true },
|
||||
}),
|
||||
updateOutput: (id: string, enabled: boolean) =>
|
||||
request<Output>(`/api/outputs/${id}`, { method: "PUT", body: { enabled } }),
|
||||
deleteOutput: (id: string) => request<{ ok: boolean }>(`/api/outputs/${id}`, { method: "DELETE" }),
|
||||
|
||||
// scenes & composition
|
||||
createScene: (roomId: string, body: { name: string; layout: SceneLayout }) =>
|
||||
request<Scene>(`/api/rooms/${roomId}/scenes`, { method: "POST", body }),
|
||||
updateScene: (
|
||||
id: string,
|
||||
body: Partial<Pick<Scene, "name" | "layout" | "overlays" | "active">>,
|
||||
) => request<Scene>(`/api/scenes/${id}`, { method: "PUT", body }),
|
||||
deleteScene: (id: string) => request<{ ok: boolean }>(`/api/scenes/${id}`, { method: "DELETE" }),
|
||||
activateScene: (id: string) =>
|
||||
request<Scene>(`/api/scenes/${id}/activate`, { method: "POST" }),
|
||||
|
||||
createSceneOutput: (
|
||||
roomId: string,
|
||||
body: { sceneId: string; accountId: string; audio: AudioRouting; enabled?: boolean },
|
||||
) => request<SceneOutput>(`/api/rooms/${roomId}/scene-outputs`, { method: "POST", body }),
|
||||
updateSceneOutput: (
|
||||
id: string,
|
||||
body: Partial<Pick<SceneOutput, "accountId" | "enabled" | "audio">>,
|
||||
) => request<SceneOutput>(`/api/scene-outputs/${id}`, { method: "PUT", body }),
|
||||
deleteSceneOutput: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/scene-outputs/${id}`, { method: "DELETE" }),
|
||||
|
||||
uploadImage: (roomId: string, body: { name: string; data: string }) =>
|
||||
request<RoomImage>(`/api/rooms/${roomId}/images`, { method: "POST", body }),
|
||||
deleteImage: (id: string) => request<{ ok: boolean }>(`/api/images/${id}`, { method: "DELETE" }),
|
||||
|
||||
// streaming grants
|
||||
startStream: (accountKeys: Record<string, string>) =>
|
||||
request<{ granted: string[] }>("/api/streams/start", { method: "POST", body: { accountKeys } }),
|
||||
stopStream: () => request<{ ok: boolean }>("/api/streams/stop", { method: "POST" }),
|
||||
|
||||
// providers + state
|
||||
providers: () => request<Provider[]>("/api/providers"),
|
||||
state: () => request<State>("/api/state"),
|
||||
}
|
||||
|
||||
export { ApiError }
|
||||
156
services/multistreaming/web/src/lib/types.ts
Normal file
156
services/multistreaming/web/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
export type Role = "owner" | "editor" | "streamer"
|
||||
|
||||
export interface Me {
|
||||
id: string
|
||||
username: string
|
||||
sid: string
|
||||
defaultVaultId: string | null
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
id: string
|
||||
name: string
|
||||
isDefault: boolean
|
||||
salt?: string
|
||||
serverWrapped?: { iv: string; data: string }
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
ownerId: string
|
||||
vaultId: string | null
|
||||
provider: string
|
||||
name: string
|
||||
url: string
|
||||
secretCiphertext: { iv: string; data: string } | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface Feed {
|
||||
id: string
|
||||
roomId: string
|
||||
ownerId: string
|
||||
name: string
|
||||
streamKey: string
|
||||
}
|
||||
|
||||
export interface OutputRuntime {
|
||||
running: boolean
|
||||
restarts: number
|
||||
log: string[]
|
||||
}
|
||||
|
||||
export interface Output {
|
||||
id: string
|
||||
roomId: string
|
||||
feedId: string
|
||||
accountId: string
|
||||
enabled: boolean
|
||||
runtime?: OutputRuntime
|
||||
}
|
||||
|
||||
export type LayoutType = "grid" | "pip"
|
||||
|
||||
export interface SceneLayout {
|
||||
type: LayoutType
|
||||
columns?: number // grid only, 2..4
|
||||
slots: Array<string | null> // index = slot number; grid 1..6, pip length 2
|
||||
}
|
||||
|
||||
export type OverlayItem =
|
||||
| {
|
||||
id: string
|
||||
kind: "text"
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
fontSize: number
|
||||
color: string
|
||||
bold?: boolean
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
kind: "image"
|
||||
imageId: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
export type AudioRouting =
|
||||
| { mode: "program" }
|
||||
| { mode: "silent" }
|
||||
| { mode: "feed"; feedId: string }
|
||||
|
||||
export interface Scene {
|
||||
id: string
|
||||
roomId: string
|
||||
ownerId: string
|
||||
name: string
|
||||
active: boolean
|
||||
layout: SceneLayout
|
||||
overlays: OverlayItem[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface SceneOutput {
|
||||
id: string
|
||||
roomId: string
|
||||
sceneId: string
|
||||
accountId: string
|
||||
enabled: boolean
|
||||
audio: AudioRouting
|
||||
account?: Account // masked by server
|
||||
runtime?: OutputRuntime
|
||||
}
|
||||
|
||||
export interface RoomImage {
|
||||
id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
ownerId: string
|
||||
role: Role
|
||||
feeds: Feed[]
|
||||
outputs: Output[]
|
||||
accounts: Account[]
|
||||
activeSceneId: string | null
|
||||
scenes: Scene[]
|
||||
sceneOutputs: SceneOutput[]
|
||||
images: RoomImage[]
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
roomId: string
|
||||
userId: string
|
||||
role: Role
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
fields: { name: string; label: string; secret?: boolean; required?: boolean }[]
|
||||
defaultUrl: string
|
||||
}
|
||||
|
||||
export interface State {
|
||||
me: Me
|
||||
vaults: Vault[]
|
||||
rooms: Room[]
|
||||
myAccounts: Account[]
|
||||
liveFeeds: string[]
|
||||
ingest: { app: string; rtmpPort: number; publicHost: string }
|
||||
}
|
||||
128
services/multistreaming/web/src/lib/use-app.ts
Normal file
128
services/multistreaming/web/src/lib/use-app.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { api } from "@/lib/api"
|
||||
import type { Me, State, Vault } from "@/lib/types"
|
||||
import {
|
||||
rearmDeviceWrapped,
|
||||
saveDeviceWrapped,
|
||||
unlockSilent,
|
||||
unlockWithPassphrase,
|
||||
vaultState,
|
||||
} from "@/lib/vault"
|
||||
|
||||
export type Screen = "home" | "room"
|
||||
|
||||
interface AppData {
|
||||
me: Me | null
|
||||
state: State | null
|
||||
authMode: "oidc" | "local" | null
|
||||
screen: Screen
|
||||
roomId: string | null
|
||||
activeVaultId: string | null
|
||||
unlocked: boolean
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
const [data, setData] = useState<AppData>({
|
||||
me: null,
|
||||
state: null,
|
||||
authMode: null,
|
||||
screen: "home",
|
||||
roomId: null,
|
||||
activeVaultId: null,
|
||||
unlocked: false,
|
||||
})
|
||||
const timer = useRef<number | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const state = await api.state()
|
||||
setData((d) => ({
|
||||
...d,
|
||||
state,
|
||||
me: { ...state.me, sid: d.me?.sid ?? "" },
|
||||
unlocked: vaultState.isUnlocked(),
|
||||
activeVaultId: vaultState.activeVault()?.id ?? null,
|
||||
}))
|
||||
} catch (e) {
|
||||
const err = e as { status?: number }
|
||||
if (err.status === 401) {
|
||||
setData((d) => ({ ...d, me: null, state: null }))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const cfg = await api.authConfig().catch(() => ({ mode: "local", oidc: false }))
|
||||
try {
|
||||
const me = await api.me()
|
||||
setData((d) => ({ ...d, me, authMode: cfg.mode as "oidc" | "local" }))
|
||||
await refresh()
|
||||
} catch {
|
||||
setData((d) => ({ ...d, authMode: cfg.mode as "oidc" | "local" }))
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap()
|
||||
}, [bootstrap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.me) return
|
||||
timer.current = window.setInterval(() => void refresh(), 3000)
|
||||
return () => {
|
||||
if (timer.current) window.clearInterval(timer.current)
|
||||
}
|
||||
}, [data.me, refresh])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout().catch(() => {})
|
||||
vaultState.lockVault()
|
||||
setData((d) => ({ ...d, me: null, state: null, unlocked: false }))
|
||||
}, [])
|
||||
|
||||
const openRoom = useCallback((roomId: string | null) => {
|
||||
setData((d) => ({ ...d, screen: roomId ? "room" : "home", roomId }))
|
||||
}, [])
|
||||
|
||||
/** Unlock a vault (silent first, then passphrase). Returns false on cancel/failure. */
|
||||
const unlockVault = useCallback(
|
||||
async (vault: Vault, passphrase?: string): Promise<boolean> => {
|
||||
try {
|
||||
if (!passphrase && data.me) {
|
||||
const vk = await unlockSilent(vault.id, data.me.sid)
|
||||
vaultState.setActive(vault.id, vk)
|
||||
setData((d) => ({ ...d, unlocked: true, activeVaultId: vault.id }))
|
||||
return true
|
||||
}
|
||||
if (!passphrase) return false
|
||||
const vk = await unlockWithPassphrase(vault, passphrase)
|
||||
vaultState.setActive(vault.id, vk)
|
||||
if (data.me) {
|
||||
const deviceWrapped = await rearmDeviceWrapped(data.me.sid)
|
||||
await saveDeviceWrapped(vault.id, deviceWrapped)
|
||||
}
|
||||
setData((d) => ({ ...d, unlocked: true, activeVaultId: vault.id }))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
[data.me],
|
||||
)
|
||||
|
||||
const lockActive = useCallback(() => {
|
||||
vaultState.lockVault()
|
||||
setData((d) => ({ ...d, unlocked: false, activeVaultId: null }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
data,
|
||||
refresh,
|
||||
logout,
|
||||
openRoom,
|
||||
unlockVault,
|
||||
lockActive,
|
||||
}
|
||||
}
|
||||
|
||||
export type App = ReturnType<typeof useApp>
|
||||
6
services/multistreaming/web/src/lib/utils.ts
Normal file
6
services/multistreaming/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
259
services/multistreaming/web/src/lib/vault.ts
Normal file
259
services/multistreaming/web/src/lib/vault.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Client-side zero-knowledge vault manager (multi-vault).
|
||||
*
|
||||
* A "vault" is a named, passphrase-protected key store. A user can have many
|
||||
* vaults; only ONE is unlocked at a time (its vault key VK lives in memory,
|
||||
* never persisted). Account secrets are encrypted under the active vault's VK.
|
||||
*
|
||||
* Each vault has two wrapped copies of its VK:
|
||||
* - serverWrapped = AES-GCM(KEK, VK) — stored on the server for recovery
|
||||
* - deviceWrapped = AES-GCM(devKey, VK) — stored in IndexedDB under a
|
||||
* NON-EXTRACTABLE WebCrypto device key, with AAD = fingerprint + session.
|
||||
*/
|
||||
|
||||
const PBKDF2_ITERATIONS = 600_000
|
||||
|
||||
type Bytes = Uint8Array<ArrayBuffer>
|
||||
|
||||
export interface Wrapped {
|
||||
iv: string
|
||||
data: string
|
||||
}
|
||||
|
||||
interface ActiveVault {
|
||||
id: string
|
||||
vk: Bytes
|
||||
}
|
||||
|
||||
const b64 = (buf: ArrayBuffer | Uint8Array) => {
|
||||
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
||||
let s = ""
|
||||
for (const b of bytes) s += String.fromCharCode(b)
|
||||
return btoa(s)
|
||||
}
|
||||
|
||||
const unb64 = (s: string): Bytes => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
|
||||
|
||||
const te = new TextEncoder()
|
||||
const td = new TextDecoder()
|
||||
|
||||
async function pbkdf2(password: string, saltB64: string, iterations: number, bytes: number): Promise<Bytes> {
|
||||
const salt = unb64(saltB64)
|
||||
const key = await crypto.subtle.importKey("raw", te.encode(password), "PBKDF2", false, [
|
||||
"deriveBits",
|
||||
])
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, bytes * 8),
|
||||
)
|
||||
}
|
||||
|
||||
async function importRaw(bytes: Bytes) {
|
||||
return crypto.subtle.importKey("raw", bytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"])
|
||||
}
|
||||
|
||||
async function aesGcmEncrypt(key: CryptoKey, plaintext: Bytes, aad: Bytes): Promise<Wrapped> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const data = new Uint8Array(
|
||||
await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, key, plaintext),
|
||||
)
|
||||
return { iv: b64(iv), data: b64(data) }
|
||||
}
|
||||
|
||||
async function aesGcmDecrypt(key: CryptoKey, ivB64: string, dataB64: string, aad: Bytes): Promise<Bytes> {
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: unb64(ivB64), additionalData: aad },
|
||||
key,
|
||||
unb64(dataB64),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- device key (IndexedDB, non-extractable) ------------------------------
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open("multistreaming-vault", 1)
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains("keys")) req.result.createObjectStore("keys")
|
||||
if (!req.result.objectStoreNames.contains("vaults")) req.result.createObjectStore("vaults")
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbGet<T>(storeName: string, key: string): Promise<T | null> {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readonly")
|
||||
const req = tx.objectStore(storeName).get(key)
|
||||
req.onsuccess = () => resolve((req.result as T) ?? null)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbPut(storeName: string, key: string, value: unknown): Promise<void> {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readwrite")
|
||||
tx.objectStore(storeName).put(value, key)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbDelete(storeName: string, key: string): Promise<void> {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readwrite")
|
||||
tx.objectStore(storeName).delete(key)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureDeviceKey(): Promise<CryptoKey> {
|
||||
const existing = await idbGet<CryptoKey>("keys", "deviceKey")
|
||||
if (existing) return existing
|
||||
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
])
|
||||
await idbPut("keys", "deviceKey", key)
|
||||
return key
|
||||
}
|
||||
|
||||
function fingerprint(): string {
|
||||
const parts = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
screen.width,
|
||||
screen.height,
|
||||
screen.colorDepth,
|
||||
navigator.hardwareConcurrency ?? "",
|
||||
]
|
||||
let hash = 0
|
||||
const s = parts.join("|")
|
||||
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0
|
||||
return hash.toString(36)
|
||||
}
|
||||
|
||||
// ---- active vault (in-memory only) ---------------------------------------
|
||||
|
||||
let active: ActiveVault | null = null
|
||||
|
||||
function activeVault() {
|
||||
return active
|
||||
}
|
||||
|
||||
function isUnlocked() {
|
||||
return active !== null
|
||||
}
|
||||
|
||||
function lockVault() {
|
||||
active = null
|
||||
}
|
||||
|
||||
function setActive(id: string, vk: Bytes) {
|
||||
active = { id, vk }
|
||||
}
|
||||
|
||||
// ---- vault operations ------------------------------------------------------
|
||||
|
||||
export interface CreatedVault {
|
||||
name: string
|
||||
salt: string
|
||||
serverWrapped: Wrapped
|
||||
deviceWrapped: Wrapped
|
||||
vk: Bytes
|
||||
}
|
||||
|
||||
export async function createVault(
|
||||
name: string,
|
||||
passphrase: string,
|
||||
sessionId: string,
|
||||
): Promise<CreatedVault> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const kek = await pbkdf2(passphrase, b64(salt), PBKDF2_ITERATIONS, 32)
|
||||
const vk = crypto.getRandomValues(new Uint8Array(32))
|
||||
|
||||
const serverWrapped = await aesGcmEncrypt(
|
||||
await importRaw(kek),
|
||||
vk,
|
||||
te.encode("multistreaming:vault:v1"),
|
||||
)
|
||||
const deviceKey = await ensureDeviceKey()
|
||||
const deviceWrapped = await aesGcmEncrypt(
|
||||
deviceKey,
|
||||
vk,
|
||||
te.encode(`fp:${fingerprint()}|sess:${sessionId}`),
|
||||
)
|
||||
|
||||
return { name, salt: b64(salt), serverWrapped, deviceWrapped, vk }
|
||||
}
|
||||
|
||||
export async function unlockWithPassphrase(
|
||||
vault: { salt?: string; serverWrapped?: Wrapped },
|
||||
passphrase: string,
|
||||
): Promise<Bytes> {
|
||||
if (!vault.salt || !vault.serverWrapped) throw new Error("vault has no recovery data")
|
||||
const kek = await pbkdf2(passphrase, vault.salt, PBKDF2_ITERATIONS, 32)
|
||||
return aesGcmDecrypt(
|
||||
await importRaw(kek),
|
||||
vault.serverWrapped.iv,
|
||||
vault.serverWrapped.data,
|
||||
te.encode("multistreaming:vault:v1"),
|
||||
)
|
||||
}
|
||||
|
||||
export async function unlockSilent(vaultId: string, sessionId: string): Promise<Bytes> {
|
||||
const deviceKey = await ensureDeviceKey()
|
||||
const deviceWrapped = await idbGet<Wrapped>("vaults", vaultId)
|
||||
if (!deviceWrapped) throw new Error("no device vault")
|
||||
return aesGcmDecrypt(
|
||||
deviceKey,
|
||||
deviceWrapped.iv,
|
||||
deviceWrapped.data,
|
||||
te.encode(`fp:${fingerprint()}|sess:${sessionId}`),
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveDeviceWrapped(vaultId: string, deviceWrapped: Wrapped): Promise<void> {
|
||||
await idbPut("vaults", vaultId, deviceWrapped)
|
||||
}
|
||||
|
||||
export async function removeDeviceWrapped(vaultId: string): Promise<void> {
|
||||
await idbDelete("vaults", vaultId)
|
||||
}
|
||||
|
||||
/** Re-arm the device blob for the active vault (after passphrase unlock). */
|
||||
export async function rearmDeviceWrapped(sessionId: string): Promise<Wrapped> {
|
||||
if (!active) throw new Error("no vault unlocked")
|
||||
const deviceKey = await ensureDeviceKey()
|
||||
return aesGcmEncrypt(
|
||||
deviceKey,
|
||||
active.vk,
|
||||
te.encode(`fp:${fingerprint()}|sess:${sessionId}`),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- account secrets under the active vault ------------------------------
|
||||
|
||||
export async function encryptSecret(secret: string, aad: string): Promise<Wrapped> {
|
||||
if (!active) throw new Error("no vault unlocked")
|
||||
return aesGcmEncrypt(await importRaw(active.vk), te.encode(secret), te.encode(aad))
|
||||
}
|
||||
|
||||
export async function decryptSecret(wrapped: Wrapped, aad: string): Promise<string> {
|
||||
if (!active) throw new Error("no vault unlocked")
|
||||
return td.decode(await aesGcmDecrypt(await importRaw(active.vk), wrapped.iv, wrapped.data, te.encode(aad)))
|
||||
}
|
||||
|
||||
export const vaultState = {
|
||||
activeVault,
|
||||
isUnlocked,
|
||||
lockVault,
|
||||
setActive,
|
||||
fingerprint,
|
||||
}
|
||||
19
services/multistreaming/web/src/main.tsx
Normal file
19
services/multistreaming/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import "./index.css"
|
||||
import App from "./App.tsx"
|
||||
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
29
services/multistreaming/web/tsconfig.app.json
Normal file
29
services/multistreaming/web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
services/multistreaming/web/tsconfig.json
Normal file
12
services/multistreaming/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
services/multistreaming/web/tsconfig.node.json
Normal file
24
services/multistreaming/web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
services/multistreaming/web/vite.config.ts
Normal file
14
services/multistreaming/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
35
services/nginx-proxy-manager/metadata.json
Normal file
35
services/nginx-proxy-manager/metadata.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"name": "Nginx Proxy Manager",
|
||||
"description": "Web dashboard for Nginx reverse proxying: add/edit proxy hosts (domain -> service) at runtime, automatic Let's Encrypt SSL, access lists, and persistent configuration.",
|
||||
"version": "1.0.0",
|
||||
"category": "network",
|
||||
"tags": ["reverse-proxy", "nginx", "dashboard", "lets-encrypt", "https", "proxy-manager"],
|
||||
"author": "jc21 / Nginx Proxy Manager",
|
||||
"license": "MIT",
|
||||
"homepage": "https://nginxproxymanager.com",
|
||||
"documentation": "https://nginxproxymanager.com/guide/",
|
||||
"compose": {
|
||||
"image": "jc21/nginx-proxy-manager:2.14.0",
|
||||
"container_name": "nginx-proxy-manager",
|
||||
"restart": "unless-stopped",
|
||||
"ports": [
|
||||
"80:80",
|
||||
"443:443",
|
||||
"81:81"
|
||||
],
|
||||
"volumes": [
|
||||
"npm_data:/data",
|
||||
"npm_letsencrypt:/etc/letsencrypt"
|
||||
],
|
||||
"networks": ["homelab"]
|
||||
},
|
||||
"volumes": {
|
||||
"npm_data": {},
|
||||
"npm_letsencrypt": {}
|
||||
},
|
||||
"networks": {
|
||||
"homelab": { "external": true }
|
||||
},
|
||||
"notes": "Dashboard at http://<host>:81 — first login admin@example.com / changeme (change it immediately, and restrict port 81 or apply an access list). Add Proxy Hosts like streaming.example.com -> multistreaming:8080 (enable Websockets) or auth.example.com -> authelia:9091; use the container/service name as the forward hostname since everything shares the homelab network. All proxy hosts, users, and certs persist in the npm_data and npm_letsencrypt volumes, so they survive restarts and updates. RTMP ingest is not HTTP, so it does NOT go through NPM: OBS connects directly to the multistreaming container's published port 1935 (rtmp://feed.streaming.example.com:1935/live/<stream-key>). SSO forward-auth for apps without OIDC: on the proxy host, add a custom location /authelia pointing to http://authelia:9091/api/authz/forward-auth with headers X-Original-URL $scheme://$http_host$request_uri, X-Forwarded-Proto $scheme, X-Forwarded-Host $http_host, X-Forwarded-Uri $request_uri, and then add `auth_request /authelia;` plus `auth_request_set $user $upstream_http_remote_user;` and `proxy_set_header Remote-User $user;` in the Advanced tab."
|
||||
}
|
||||
34
services/portainer/metadata.json
Normal file
34
services/portainer/metadata.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"id": "portainer",
|
||||
"name": "Portainer",
|
||||
"description": "Web dashboard to manage Docker: create bridge/macvlan/ipvlan networks (VLANs), assign static IPs to containers, and manage every container/volume/network/image from the browser — all persisted.",
|
||||
"version": "1.0.0",
|
||||
"category": "management",
|
||||
"tags": ["docker", "network", "vlan", "macvlan", "ipvlan", "dashboard", "management"],
|
||||
"author": "Portainer",
|
||||
"license": "Zlib",
|
||||
"homepage": "https://www.portainer.io",
|
||||
"documentation": "https://docs.portainer.io/",
|
||||
"compose": {
|
||||
"image": "portainer/portainer-ce:2.39.1",
|
||||
"container_name": "portainer",
|
||||
"restart": "unless-stopped",
|
||||
"ports": [
|
||||
"9443:9443",
|
||||
"9000:9000",
|
||||
"8000:8000"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"portainer_data:/data"
|
||||
],
|
||||
"networks": ["homelab"]
|
||||
},
|
||||
"volumes": {
|
||||
"portainer_data": {}
|
||||
},
|
||||
"networks": {
|
||||
"homelab": { "external": true }
|
||||
},
|
||||
"notes": "HTTPS dashboard at https://<host>:9443 (accept the self-signed cert on first visit), or behind nginx-proxy-manager as a Proxy Host portainer.example.com -> portainer:9000. Create the local admin account immediately — Portainer locks itself if you wait more than a few minutes; restart the container to reset. SSO: Portainer supports OIDC natively, so set Settings > Authentication > OAuth with Provider=custom, Client ID=portainer, Client secret=<OIDC_PORTAINER_SECRET from authelia's .env>, Authorization URL=https://auth.example.com/api/oidc/authorization, Token URL=https://auth.example.com/api/oidc/token, Resource URL=https://auth.example.com/api/oidc/userinfo, Redirect URL=https://portainer.example.com, Scopes=openid profile groups email, User identifier=preferred_username. Manage networks under Networks (create macvlan/ipvlan for VLANs — the parent interface, e.g. eth0.10, must already exist on the host), and set per-container static IPs from each container's Network settings. Mounting /var/run/docker.sock gives Portainer root-equivalent Docker access, so keep it behind SSO and off the public internet. All state persists in the portainer_data volume. Port 8000 is only for remote Edge Agents; remove the mapping if you don't use them."
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue