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:
Ezequiel C. 2026-09-02 21:18:25 +02:00
parent bb754cdd8c
commit 187379de4e
106 changed files with 21391 additions and 286 deletions

3
.gitignore vendored
View file

@ -13,6 +13,9 @@ build/
# local installer state # local installer state
.homelab/ .homelab/
# AgentTeams transient state
.agent-teams/
# OS noise # OS noise
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View file

@ -3,7 +3,7 @@
A common, reusable repository for running services on a personal homelab. A common, reusable repository for running services on a personal homelab.
Every service lives in its own folder under [`services/`](services/) and is described by a Every service lives in its own folder under [`services/`](services/) and is described by a
`metadata.json` file (plus any extra files the service needs, such as a `Caddyfile`). The `metadata.json` file (plus any extra files the service needs, such as a config file). The
[`installer/`](installer/) directory contains a Bun + TypeScript [`installer/`](installer/) directory contains a Bun + TypeScript
terminal UI (TUI) that reads the catalog straight from GitHub (used as the CDN) and installs or terminal UI (TUI) that reads the catalog straight from GitHub (used as the CDN) and installs or
updates services with Docker. updates services with Docker.
@ -12,16 +12,21 @@ updates services with Docker.
homelab/ homelab/
├── services/ # one folder per service ├── services/ # one folder per service
│ ├── catalog.json # generated index used by the installer (CDN listing) │ ├── catalog.json # generated index used by the installer (CDN listing)
│ ├── allprox/ # Caddy reverse proxy + portal │ ├── nginx-proxy-manager/ # reverse proxy dashboard (NPM)
│ │ ├── metadata.json │ │ └── metadata.json
│ │ ├── Caddyfile # proxy rules (domains → local IPs)
│ │ └── portal/index.html
│ ├── authelia/ # SSO / IdP (login portal, OIDC, forward-auth) │ ├── authelia/ # SSO / IdP (login portal, OIDC, forward-auth)
│ │ ├── metadata.json │ │ ├── metadata.json
│ │ ├── configuration.yml │ │ ├── configuration.yml
│ │ └── users_database.yml │ │ └── users_database.yml
│ ├── lldap/ # lightweight LDAP user store │ ├── lldap/ # lightweight LDAP user store
│ │ └── metadata.json │ │ └── metadata.json
│ ├── multistreaming/ # self-built: OBS ingest -> multi-platform re-streaming
│ │ ├── metadata.json
│ │ ├── Dockerfile
│ │ ├── src/
│ │ └── web/
│ ├── portainer/ # Docker + network manager (VLANs, static IPs)
│ │ └── metadata.json
│ └── ... │ └── ...
├── installer/ # Bun + TypeScript TUI ├── installer/ # Bun + TypeScript TUI
│ ├── src/ │ ├── src/
@ -32,23 +37,53 @@ homelab/
## Identity stack (SSO) ## Identity stack (SSO)
Three services work together for single sign-on: Every web service logs in through the same IdP ([`authelia`](services/authelia)), which authenticates
users against [`lldap`](services/lldap) and sits behind [`nginx-proxy-manager`](services/nginx-proxy-manager):
``` ```
browser ──▶ allprox (Caddy) ──forward_auth──▶ authelia ──LDAP──▶ lldap browser ──▶ nginx-proxy-manager ──▶ authelia ──LDAP──▶ lldap
``` ```
- [`allprox`](services/allprox) — reverse proxy; protected routes use Caddy `forward_auth` to Authelia. Two integration modes, depending on what each app supports:
- [`authelia`](services/authelia) — the SSO server (login portal, OIDC, 2FA) that authenticates users against…
- [`lldap`](services/lldap) — the lightweight LDAP directory (web UI at `http://<host>:17170`).
They talk to each other over a shared external Docker network named `homelab`, which the installer 1. **OIDC (true SSO)** — the app delegates login to Authelia. [`portainer`](services/portainer) uses
creates automatically. Install `lldap` first, then `authelia`, then `allprox`: this (client `portainer` is pre-registered in Authelia).
2. **Forward-auth (proxy gate)** — the app has no OIDC, so NPM asks Authelia to authorize each
request via an `auth_request` block before forwarding. Used by `multistreaming` and lldap's web UI.
| Service | SSO mode |
| --- | --- |
| Portainer | OIDC (native) |
| multistreaming panel | forward-auth (no OIDC) |
| lldap web UI | forward-auth (no OIDC) |
| Nginx Proxy Manager | its own login (no OIDC; keep port 81 restricted) |
Authelia also exposes OIDC discovery at `https://auth.example.com/.well-known/openid-configuration`
for any future OIDC-capable app. Install order: `lldap`, `authelia`, `nginx-proxy-manager`:
```bash ```bash
bun run src/index.ts install lldap authelia allprox bun run src/index.ts install lldap authelia nginx-proxy-manager
``` ```
## Network & DMZ topology
[`portainer`](services/portainer) is the Docker/network manager: from its dashboard you can create
**macvlan/ipvlan networks** (each mapped to a host VLAN via its parent interface) and assign each
container a **static IP**. A typical setup keeps only the reverse proxy in the DMZ and everything
else on separate VLANs:
```
internet ──▶ DMZ VLAN (e.g. 10.0.10.0/24)
└── nginx-proxy-manager (the only public entrypoint)
├─▶ services VLAN (10.0.20.0/24) ── multistreaming, …
└─▶ identity VLAN (10.0.30.0/24) ── authelia, lldap
```
To make a VLAN usable as a macvlan parent, the tagged sub-interface must first exist on the host
(e.g. `eth0.10`, configured in `/etc/network/interfaces` or netplan — outside Docker's scope).
Then create the network in Portainer and attach services to it with the IPs you want.
## Quick start ## Quick start
```bash ```bash
@ -74,11 +109,11 @@ The same app works as a plain CLI:
```bash ```bash
bun run src/index.ts list bun run src/index.ts list
bun run src/index.ts info allprox bun run src/index.ts info nginx-proxy-manager
bun run src/index.ts install allprox bun run src/index.ts install nginx-proxy-manager
bun run src/index.ts update # update everything installed bun run src/index.ts update # update everything installed
bun run src/index.ts update allprox # update one service bun run src/index.ts update nginx-proxy-manager # update one service
bun run src/index.ts uninstall allprox bun run src/index.ts uninstall nginx-proxy-manager
bun run src/index.ts status bun run src/index.ts status
``` ```
@ -120,8 +155,8 @@ bun run catalog
1. Create `services/<id>/metadata.json` (the folder name **must** equal the `id`). 1. Create `services/<id>/metadata.json` (the folder name **must** equal the `id`).
2. Fill in the [metadata schema](docs/metadata-schema.json) — see [`services/README.md`](services/README.md) 2. Fill in the [metadata schema](docs/metadata-schema.json) — see [`services/README.md`](services/README.md)
for a walkthrough of every field. Add any extra files the service needs (a `Caddyfile`, a for a walkthrough of every field. Add any extra files the service needs (a config file, a
`Dockerfile`, a static `portal/`, …) alongside `metadata.json`; the installer copies them into `Dockerfile`, …) alongside `metadata.json`; the installer copies them into
the deploy directory, so reference them with relative bind mounts in `compose.volumes`. the deploy directory, so reference them with relative bind mounts in `compose.volumes`.
3. Regenerate the catalog: `cd installer && bun run catalog`. 3. Regenerate the catalog: `cd installer && bun run catalog`.
4. Commit and push. The installer will now offer the new service. 4. Commit and push. The installer will now offer the new service.
@ -132,22 +167,21 @@ bun run catalog
```json ```json
{ {
"id": "allprox", "id": "nginx-proxy-manager",
"name": "allprox", "name": "Nginx Proxy Manager",
"description": "Caddy reverse proxy with an SSO-ready portal", "description": "Reverse proxy with a web dashboard",
"version": "1.0.0", "version": "1.0.0",
"category": "network", "category": "network",
"compose": { "compose": {
"image": "caddy:2-alpine", "image": "jc21/nginx-proxy-manager:2.14.0",
"container_name": "allprox", "container_name": "nginx-proxy-manager",
"restart": "unless-stopped", "restart": "unless-stopped",
"ports": ["80:80", "443:443"], "ports": ["80:80", "443:443", "81:81"],
"volumes": ["./Caddyfile:/etc/caddy/Caddyfile:ro", "allprox_data:/data"], "volumes": ["npm_data:/data", "npm_letsencrypt:/etc/letsencrypt"]
"environment": ["PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}"]
}, },
"volumes": { "allprox_data": {} }, "volumes": { "npm_data": {}, "npm_letsencrypt": {} },
"env": [ "env": [
{ "name": "PORTAL_AUTH_HASH", "label": "Portal admin password hash", "secret": true } { "name": "TZ", "label": "Timezone", "default": "UTC" }
] ]
} }
``` ```
@ -157,8 +191,9 @@ bun run catalog
- `volumes` / `networks` are optional top-level named volumes/networks to declare. - `volumes` / `networks` are optional top-level named volumes/networks to declare.
- `env` declares variables the installer should resolve for you. Use `${NAME}` in `compose` to - `env` declares variables the installer should resolve for you. Use `${NAME}` in `compose` to
reference them — the installer writes the resolved values to a `.env` file next to the compose file. reference them — the installer writes the resolved values to a `.env` file next to the compose file.
- Any other files in the service folder (e.g. `Caddyfile`, `portal/index.html`) are copied into the - Any other files in the service folder (e.g. `configuration.yml`, `users_database.yml`) are copied
deploy directory, so you can mount them with relative paths (`./Caddyfile:...`) in `compose.volumes`. into the deploy directory, so you can mount them with relative paths (`./configuration.yml:...`) in
`compose.volumes`.
See [`docs/metadata-schema.json`](docs/metadata-schema.json) for the complete, machine-readable schema. See [`docs/metadata-schema.json`](docs/metadata-schema.json) for the complete, machine-readable schema.

View file

@ -26,7 +26,7 @@
}, },
"compose": { "compose": {
"type": "object", "type": "object",
"description": "A Docker Compose service definition (image, ports, volumes, environment, ...)." "description": "A Docker Compose service definition. Must set either `image` (pull a prebuilt image) or `build` (build from a Dockerfile, e.g. { \"context\": \".\", \"dockerfile\": \"Dockerfile\" }), plus ports, volumes, environment, etc."
}, },
"volumes": { "volumes": {
"type": "object", "type": "object",
@ -55,6 +55,20 @@
} }
} }
}, },
"rsaKeys": {
"type": "array",
"description": "RSA private keys the installer generates into files on first install (used for OIDC signing keys that cannot be injected via env).",
"items": {
"type": "object",
"required": ["name", "path"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"path": { "type": "string" },
"bits": { "type": "number" }
}
}
},
"category": { "type": "string" }, "category": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } }, "tags": { "type": "array", "items": { "type": "string" } },
"icon": { "type": "string", "format": "uri" }, "icon": { "type": "string", "format": "uri" },

View file

@ -1,10 +1,11 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import type { ActionResult, InstalledService, ServiceMetadata } from "./types.ts"; import type { ActionResult, InstalledService, ServiceMetadata } from "./types.ts";
import type { Config } from "./config.ts"; import type { Config } from "./config.ts";
import { fetchMetadata, fetchServiceFiles, listServices, type ServiceFile } from "./github.ts"; import { fetchMetadata, fetchServiceFiles, listServices, type ServiceFile } from "./github.ts";
import { generateComposeFile } from "./compose.ts"; import { generateComposeFile, usesBuild } from "./compose.ts";
import { collectEnvVars, envFileContent, readEnvFile } from "./env.ts"; import { collectEnvVars, envFileContent, readEnvFile } from "./env.ts";
import { generateRsaPrivateKey } from "./keys.ts";
import { isDockerAvailable, runCommand, runDockerCompose, type RunResult } from "./docker.ts"; import { isDockerAvailable, runCommand, runDockerCompose, type RunResult } from "./docker.ts";
import { loadState, markInstalled, removeInstalled } from "./state.ts"; import { loadState, markInstalled, removeInstalled } from "./state.ts";
import { serviceDir } from "./paths.ts"; import { serviceDir } from "./paths.ts";
@ -68,10 +69,11 @@ export async function installService(
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8"); writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8"); writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars); materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
materializeRsaKeys(dir, meta);
await requireDocker(); await requireDocker();
await ensureExternalNetworks(meta, cfg); await ensureExternalNetworks(meta, cfg);
const r = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose); const r = await bringUp(dir, meta, cfg);
if (r.code !== 0) { if (r.code !== 0) {
printDockerFailure(r); printDockerFailure(r);
throw new Error(`docker compose up failed for ${meta.id}`); throw new Error(`docker compose up failed for ${meta.id}`);
@ -101,15 +103,11 @@ export async function updateService(
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8"); writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8"); writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars); materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
materializeRsaKeys(dir, meta);
await requireDocker(); await requireDocker();
await ensureExternalNetworks(meta, cfg); await ensureExternalNetworks(meta, cfg);
const pull = await runDockerCompose(dir, ["pull"], !cfg.verbose); const up = await bringUp(dir, meta, cfg, /* update */ true);
if (pull.code !== 0) {
printDockerFailure(pull);
throw new Error(`docker compose pull failed for ${meta.id}`);
}
const up = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose);
if (up.code !== 0) { if (up.code !== 0) {
printDockerFailure(up); printDockerFailure(up);
throw new Error(`docker compose up failed for ${meta.id}`); throw new Error(`docker compose up failed for ${meta.id}`);
@ -212,6 +210,51 @@ function materializeFiles(dir: string, files: ServiceFile[], vars: Record<string
} }
} }
/**
* Generate any declared RSA private keys (see RsaKeySpec) into the service dir.
* Keys are only created if absent, so re-running an update never rotates the
* OIDC signing key and invalidates existing sessions.
*/
function materializeRsaKeys(dir: string, meta: ServiceMetadata): void {
for (const spec of meta.rsaKeys ?? []) {
const target = join(dir, spec.path);
if (existsSync(target)) continue;
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, generateRsaPrivateKey(spec.bits ?? 2048), { encoding: "utf8", mode: 0o600 });
}
}
/**
* Bring a service up. For Dockerfile-based services this builds the image
* first (and re-pulls base images on update); for image-based services it
* pulls the tag (on update) before starting the containers.
*/
async function bringUp(
dir: string,
meta: ServiceMetadata,
cfg: Config,
update = false,
): Promise<RunResult> {
const capture = !cfg.verbose;
if (usesBuild(meta)) {
const buildArgs = update ? ["build", "--pull"] : ["build"];
const build = await runDockerCompose(dir, buildArgs, capture);
if (build.code !== 0) {
printDockerFailure(build);
throw new Error(`docker compose build failed for ${meta.id}`);
}
} else if (update) {
const pull = await runDockerCompose(dir, ["pull"], capture);
if (pull.code !== 0) {
printDockerFailure(pull);
throw new Error(`docker compose pull failed for ${meta.id}`);
}
}
return runDockerCompose(dir, ["up", "-d"], capture);
}
async function ensureExternalNetworks(meta: ServiceMetadata, cfg: Config): Promise<void> { async function ensureExternalNetworks(meta: ServiceMetadata, cfg: Config): Promise<void> {
if (cfg.dryRun) return; if (cfg.dryRun) return;
const nets = meta.networks ?? {}; const nets = meta.networks ?? {};

View file

@ -1,6 +1,11 @@
import { stringify } from "yaml"; import { stringify } from "yaml";
import type { ServiceMetadata } from "./types.ts"; import type { ServiceMetadata } from "./types.ts";
/** True when the service builds its image from a Dockerfile rather than pulling one. */
export function usesBuild(meta: ServiceMetadata): boolean {
return Boolean(meta.compose && (meta.compose as Record<string, unknown>).build);
}
/** Build the docker-compose.yml content for a service from its metadata. */ /** Build the docker-compose.yml content for a service from its metadata. */
export function generateComposeFile(meta: ServiceMetadata): string { export function generateComposeFile(meta: ServiceMetadata): string {
const doc: Record<string, unknown> = {}; const doc: Record<string, unknown> = {};

View file

@ -160,6 +160,13 @@ function walk(base: string, rel: string, out: ServiceFile[]): void {
return; return;
} }
for (const e of entries) { for (const e of entries) {
// Never copy dependency dirs, build output, or VCS metadata into the deploy/build context.
if (
e.isDirectory() &&
(e.name === "node_modules" || e.name === "dist" || e.name === ".git")
) {
continue
}
const relPath = rel ? `${rel}/${e.name}` : e.name; const relPath = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) { if (e.isDirectory()) {
walk(base, relPath, out); walk(base, relPath, out);

11
installer/src/keys.ts Normal file
View file

@ -0,0 +1,11 @@
import { generateKeyPairSync } from "node:crypto";
/** Generate an RSA private key in PKCS#8 PEM (used by Authelia to sign OIDC tokens). */
export function generateRsaPrivateKey(bits = 2048): string {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: bits,
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
return privateKey;
}

View file

@ -43,6 +43,11 @@ export function validateMetadata(meta: ServiceMetadata, fallbackId: string): Val
} }
if (!meta.compose || typeof meta.compose !== "object" || Array.isArray(meta.compose)) { if (!meta.compose || typeof meta.compose !== "object" || Array.isArray(meta.compose)) {
issues.push({ path: "compose", message: "required object (docker-compose service spec)" }); issues.push({ path: "compose", message: "required object (docker-compose service spec)" });
} else if (!meta.compose.image && !meta.compose.build) {
issues.push({
path: "compose",
message: "must define `image` (pull an image) or `build` (build from a Dockerfile)",
});
} }
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
issues.push({ path: "id", message: "must be kebab-case and match the folder name" }); issues.push({ path: "id", message: "must be kebab-case and match the folder name" });

View file

@ -9,6 +9,13 @@ export interface EnvSpec {
generate?: boolean; generate?: boolean;
} }
/** An RSA private key the installer generates into a file on first install (used for Authelia OIDC signing). */
export interface RsaKeySpec {
name: string;
path: string;
bits?: number;
}
export interface ServiceMetadata { export interface ServiceMetadata {
id: string; id: string;
name: string; name: string;
@ -25,6 +32,7 @@ export interface ServiceMetadata {
volumes?: Record<string, unknown>; volumes?: Record<string, unknown>;
networks?: Record<string, unknown>; networks?: Record<string, unknown>;
env?: EnvSpec[]; env?: EnvSpec[];
rsaKeys?: RsaKeySpec[];
dependsOn?: string[]; dependsOn?: string[];
notes?: string; notes?: string;
} }

View file

@ -2,7 +2,7 @@
One folder per service. Each folder must contain a `metadata.json` describing the service 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 (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. into the deploy directory.
## Folder conventions ## Folder conventions
@ -20,25 +20,27 @@ into the deploy directory.
## Extra files ## Extra files
A service folder can contain files beyond `metadata.json` — for example the `allprox` service ships A service folder can contain files beyond `metadata.json` — for example the `authelia` service ships
a `Caddyfile` and a `portal/index.html`. On install/update the installer copies the whole folder a `configuration.yml` and a `users_database.yml`, while the `multistreaming` service ships a `Dockerfile`,
(except `metadata.json`) into `~/.homelab/services/<id>/`, preserving subdirectories. `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: Reference those files from `compose.volumes` with **relative** paths:
```json ```json
"volumes": [ "volumes": [
"./Caddyfile:/etc/caddy/Caddyfile:ro", "./configuration.yml:/config/configuration.yml:ro",
"./portal:/srv/portal:ro" "./users_database.yml:/config/users_database.yml:ro"
] ]
``` ```
Because the generated `docker-compose.yml` lives in the same directory, `docker compose` resolves 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 ## 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: Docker network. Declare it at the top level of `metadata.json` and attach the service to it:
```json ```json
@ -77,8 +79,9 @@ A machine-readable JSON Schema is available at [`docs/metadata-schema.json`](../
### `compose` (the service definition) ### `compose` (the service definition)
This object is the value you would normally put under a service key in `docker-compose.yml`. For This object is the value you would normally put under a service key in `docker-compose.yml`. It must
example: set **either** `image` (pull a prebuilt image) **or** `build` (build from a Dockerfile shipped in the
service folder). Pull example:
```json ```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 ```json
services: {
myapp: "build": { "context": ".", "dockerfile": "Dockerfile" },
image: ghcr.io/example/myapp:1.0.0 "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 The installer turns either into a `docker-compose.yml` service. On install it runs
an update is available. `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) ### `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 Docker Compose automatically reads the `.env` written next to the generated compose file, so
`${NAME}` references resolve at `docker compose up` time. `${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.

View file

@ -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}
}

View file

@ -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."
}

View file

@ -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>

View file

@ -1,8 +1,14 @@
# Authelia configuration — https://www.authelia.com/configuration/prologue/introduction/ # Authelia configuration — https://www.authelia.com/configuration/prologue/introduction/
# #
# Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD) are # Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD,
# resolved from the service's .env by the installer and substituted into this file # OIDC_HMAC_SECRET, OIDC_PORTAINER_SECRET) are resolved from the service's .env
# on install/update, so they are not committed here. # 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 theme: dark
@ -41,8 +47,11 @@ access_control:
rules: rules:
- domain: 'auth.example.com' - domain: 'auth.example.com'
policy: bypass policy: bypass
- domain: 'portal.example.com' # Everything else on your domain is SSO-protected (one factor by default).
policy: one_factor # 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' - domain: '*.example.com'
policy: one_factor policy: one_factor
@ -55,7 +64,7 @@ session:
cookies: cookies:
- domain: 'example.com' - domain: 'example.com'
authelia_url: 'https://auth.example.com' authelia_url: 'https://auth.example.com'
default_redirection_url: 'https://portal.example.com' default_redirection_url: 'https://auth.example.com'
regulation: regulation:
max_retries: 3 max_retries: 3
@ -69,3 +78,54 @@ storage:
notifier: notifier:
filesystem: filesystem:
filename: '/config/notification.txt' 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'

View file

@ -1,23 +1,27 @@
{ {
"id": "authelia", "id": "authelia",
"name": "Authelia", "name": "Authelia",
"description": "Open-source authentication and authorization server providing SSO and 2FA for the homelab.", "description": "Open-source authentication and authorization server providing SSO, 2FA, forward-auth, and an OIDC provider for the homelab.",
"version": "1.0.0", "version": "3.0.0",
"category": "identity", "category": "identity",
"tags": ["sso", "authentication", "2fa", "oidc", "forward-auth"], "tags": ["sso", "authentication", "2fa", "oidc", "forward-auth", "idp"],
"author": "Authelia", "author": "Authelia",
"license": "Apache-2.0", "license": "Apache-2.0",
"homepage": "https://www.authelia.com", "homepage": "https://www.authelia.com",
"documentation": "https://www.authelia.com/configuration/prologue/introduction/", "documentation": "https://www.authelia.com/configuration/prologue/introduction/",
"compose": { "compose": {
"image": "authelia/authelia:latest", "image": "authelia/authelia:4.39.14",
"container_name": "authelia", "container_name": "authelia",
"restart": "unless-stopped", "restart": "unless-stopped",
"ports": ["9091:9091"], "ports": ["9091:9091"],
"volumes": [ "volumes": [
"authelia_config:/config", "authelia_config:/config",
"./configuration.yml:/config/configuration.yml:ro", "./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"] "networks": ["homelab"]
}, },
@ -50,8 +54,26 @@
"default": "changeme-admin", "default": "changeme-admin",
"required": false, "required": false,
"secret": true "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"], "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."
} }

View file

@ -1,20 +1,30 @@
{ {
"generatedAt": "2026-09-01T16:20:56.550Z", "generatedAt": "2026-09-02T16:59:46.903Z",
"services": [ "services": [
{
"id": "allprox",
"version": "1.0.0",
"path": "services/allprox/metadata.json"
},
{ {
"id": "authelia", "id": "authelia",
"version": "1.0.0", "version": "3.0.0",
"path": "services/authelia/metadata.json" "path": "services/authelia/metadata.json"
}, },
{ {
"id": "lldap", "id": "lldap",
"version": "1.0.0", "version": "1.0.0",
"path": "services/lldap/metadata.json" "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"
} }
] ]
} }

View file

@ -62,5 +62,5 @@
"generate": true "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."
} }

View 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

View 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"]

View 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` (24 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.

View 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 | Lowmoderate |
| 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).

View 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 23) 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.

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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 };

View 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 23 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,
};

View 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,
};
};

View 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 };

View 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 };

View 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 };

View 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();

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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);
});

View 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);
});

View 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 });
});

View 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
View 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?

View file

@ -0,0 +1,7 @@
node_modules/
coverage/
.pnpm-store/
pnpm-lock.yaml
package-lock.json
pnpm-lock.yaml
yarn.lock

View 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"]
}

View 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"
```

View 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": {}
}

View 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,
},
},
])

View 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>

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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

View 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

View 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

View 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>
)
}

View 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>
)
}

View 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&apos;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>
)
}

View 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 &quot;{activeVault.name}&quot;.
</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 24"
if (layout.slots.length < 1 || layout.slots.length > 6) return "Grid layout needs 16 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 1256 characters"
if (!Number.isInteger(o.x) || o.x < 0 || o.x >= CANVAS_W) return "Text x must be 01919"
if (!Number.isInteger(o.y) || o.y < 0 || o.y >= CANVAS_H) return "Text y must be 01079"
if (o.fontSize < 8 || o.fontSize > 144) return "Font size must be 8144"
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 161920"
if (o.height < 16 || o.height > CANVAS_H) return "Image height must be 161080"
if (o.opacity != null && (o.opacity < 0 || o.opacity > 1)) return "Image opacity must be 01"
}
}
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>
)
}

View 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>
)
}

View 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
}

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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,
}

View file

@ -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 }

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View file

@ -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 }

View 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 }

View 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 }

View 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
}

View 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;
}
}

View 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 }

View 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 }
}

View 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>

View 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))
}

View 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,
}

View 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>,
)

Some files were not shown because too many files have changed in this diff Show more