1 Commits

Author SHA1 Message Date
7c737c1011 DUNGEONS-34: implement Webka local streaming app
Co-authored-by: multica-agent <github@multica.ai>
2026-09-06 16:23:09 +05:00
36 changed files with 21596 additions and 0 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ backend/target/
data/
.env
*.log
frontend/dist

6
Caddyfile.example Normal file
View File

@@ -0,0 +1,6 @@
# Copy to Caddyfile, then run: caddy run --config Caddyfile
# Trust this local CA on each LAN client before using browser capture.
webka.local {
tls internal
reverse_proxy 127.0.0.1:3001
}

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# Webka
A private camera and screen streaming app for your local network. Nuxt 3 / Vue 3 / TypeScript, Pinia, English/Russian/Spanish, and an Axum/Rust service with SQLite and browser-to-browser WebRTC.
## Run locally
Requirements: Node **22.12+**, npm, stable Rust, and a browser with WebRTC. Run these commands from the repository root:
```sh
npm --prefix frontend ci
npm --prefix frontend run build
WEBKA_BIND=127.0.0.1:3000 cargo run --manifest-path backend/Cargo.toml
```
Open **http://localhost:3000**. The first login page creates the administrator account. Read the one-time setup token from `data/setup-token` on the server device and enter it in the form. The administrator account cannot be deleted. Password resets end all of the account’s sessions, including your own if you reset the administrator password.
For development, run the Rust backend on its default `127.0.0.1:3001` and the frontend in another terminal:
```sh
npm --prefix frontend run dev
```
Use `http://localhost:3000` (not `127.0.0.1:3000`); the configured origin must match the browser URL exactly. The development proxy forwards REST and WebSocket traffic to Rust.
## Use on your LAN
A webpage **cannot launch a Rust server** on the device running its browser. Start the Rust service once on a computer on your LAN. A phone, tablet, or computer can then sign in as the administrator and act as the capturing host. This repository does not include a native iOS/Android service wrapper. Each service instance owns its account database.
Browser camera/screen APIs require a **secure context**: localhost on the service machine, or trusted HTTPS on a LAN device. LAN HTTP IP addresses cannot capture camera/screen. For LAN use, use the included `Caddyfile.example` with a trusted local certificate:
1. Point `webka.local` at the service computer, through mDNS (`WEBKA_MDNS=1`), your router, or a hosts entry. `.local` resolution and multicast support vary by network/device; a local DNS name or IP with a matching trusted certificate also works.
2. Configure Caddy with the example below, and install/trust Caddy’s local CA on **every** client device. Keep this CA private.
3. Start Webka with its exact public origin:
```sh
WEBKA_ORIGIN=https://webka.local WEBKA_MDNS=1 \
WEBKA_PUBLIC_PORT=443 WEBKA_BIND=127.0.0.1:3001 \
cargo run --release --manifest-path backend/Cargo.toml
```
4. Open `https://webka.local`, create the admin account, add viewer accounts in **Accounts**, enable preview, and choose **Go live**. Share the QR code/local address. Each viewer signs in with an account created on this service.
Caddy terminates HTTPS and proxies to the loopback Rust server. Cookies gain `Secure` automatically when `WEBKA_ORIGIN` starts with `https://`. WebSocket handshakes and all mutations validate the exact Origin. No CORS wildcard or trusted forwarded-IP headers are used. The login throttle sees the reverse proxy as one IP, so its ten-attempts-per-minute limit is shared behind that proxy.
Use a private LAN and firewall; do not expose the service through public port forwarding. The app uses **no cloud accounts, public STUN/TURN, telemetry, CDNs, or external fonts**. WebRTC connects peers directly with local ICE candidates. Client isolation, VLANs, and firewalls that block peer UDP can prevent streams; no cloud relay fallback is provided. The one active host sends a separate peer stream to each viewer (32 signaling connections total, including the host). Capacity depends on the host device and Wi-Fi.
## Configuration
| Variable | Default | Purpose |
|---|---|---|
| `WEBKA_BIND` | `127.0.0.1:3001` | Rust listener |
| `WEBKA_ORIGIN` | `http://localhost:3000` | Exact public origin; used for CSRF checks, QR and secure cookies |
| `WEBKA_DATA` | `data` | Local database and persistent secrets |
| `WEBKA_ASSETS` | `frontend/.output/public` | Generated frontend; paths are relative to the current directory |
| `WEBKA_MDNS` | off | Set `1` to advertise `_webka._tcp.local.` and `webka.local` |
| `WEBKA_PUBLIC_PORT` | `443` for HTTPS, otherwise `3000` | Advertised public port |
Back up `WEBKA_DATA` securely. On Unix the data directory is owner-only and secret files are created with mode 0600. On Windows configure equivalent directory ACLs. Session tokens are random, HMAC-signed, stored only as SHA-256 digests in SQLite, and sent in HttpOnly/SameSite=Strict cookies. Argon2id stores salted passwords. Sessions expire after 30 minutes without activity and always after 12 hours. Visible authenticated pages send a heartbeat; hidden pages do not. Revocation closes signaling and tells the host to close the affected peer. A recipient may still retain media already received.
## Features and implementation
- `/login`: protected first-run setup and local sign-in.
- `/host`: camera preview, front/back/screen selection, start/stop, uptime, bitrate presets, QR link and viewer disconnection.
- `/watch`: live video, connection/resolution indicator, fullscreen, logout and automatic reconnect with backoff.
- `/settings/accounts`: viewer creation/deletion, password reset, last login and active session revocation.
- `/settings/general`: light/dark glass, accent, locale and security information.
- Shared `GlassCard`, `GlassButton`, `GlassNavBar`, `GlassModal` and `LanguageSwitcher` with layered rim highlights, pointer-responsive specular light, rounded geometry, reduced-motion support, native keyboard focus and dialog focus containment.
- Content panels stay opaque. Glass is used for navigation, source/share controls and dialogs. CSS edge layers approximate refraction; they do not implement physically accurate optical lensing.
Quality presets control the host’s per-peer send bitrate; the viewer shows actual received resolution. Capture is video-only. Closing/navigating away from the host tab stops capture; a browser may also suspend hosting when the device locks or the tab is backgrounded. Screen share support varies, particularly on mobile. Back-camera discovery becomes reliable after granting camera permission. Physical-device tests are documented in `docs/MANUAL_TEST_PLAN.md`.
Nuxt 3 is retained to match the requested stack. Its upstream documentation now marks it end-of-life; plan a Nuxt 4 upgrade before broader distribution. Reference: https://nuxt.com/docs/3.x/getting-started/installation.
## Validation
```sh
cargo fmt --manifest-path backend/Cargo.toml --check
cargo test --manifest-path backend/Cargo.toml
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
npm --prefix frontend run typecheck
npm --prefix frontend run build
node frontend/scripts/check-locales.mjs
cargo build --manifest-path backend/Cargo.toml
python3 scripts/smoke.py
```
See `docs/API.md` for REST/signaling contracts and `docs/MANUAL_TEST_PLAN.md` for real-device checks. Design references: [Apple’s introduction](https://www.apple.com/uk/newsroom/2025/06/apple-introduces-a-delightful-and-elegant-new-software-design/) and [Meet Liquid Glass](https://developer.apple.com/videos/play/wwdc2025/219/). No Apple assets are included. Nothing is published by the build commands.

2471
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "webka"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
argon2 = "0.5"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
hmac = "0.12"
hex = "0.4"
mdns-sd = "0.13"
local-ip-address = "0.6"
tower-http = { version = "0.6", features = ["fs", "set-header"] }
futures-util = "0.3"
[dev-dependencies]
tokio-tungstenite = "0.29"
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
tempfile = "3"

767
backend/src/main.rs Normal file
View File

@@ -0,0 +1,767 @@
use argon2::{
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use axum::{
Json, Router,
extract::{
ConnectInfo, Path, Query, Request, State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
http::{HeaderMap, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{delete, get, post},
};
use futures_util::{SinkExt, StreamExt};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use sqlx::{Row, SqlitePool, sqlite::SqlitePoolOptions};
use std::{
collections::HashMap,
net::SocketAddr,
path::PathBuf,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use tokio::sync::{Mutex, mpsc};
use tower_http::services::{ServeDir, ServeFile};
use uuid::Uuid;
const IDLE: i64 = 30 * 60;
const ABSOLUTE: i64 = 12 * 60 * 60;
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn id() -> String {
Uuid::new_v4().to_string()
}
fn digest(s: &str) -> String {
hex::encode(Sha256::digest(s.as_bytes()))
}
type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug)]
struct ApiError(StatusCode, &'static str);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(json!({"error": self.1}))).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
eprintln!("database: {e}");
Self(StatusCode::INTERNAL_SERVER_ERROR, "server")
}
}
fn bad(code: &'static str) -> ApiError {
ApiError(StatusCode::BAD_REQUEST, code)
}
fn unauthorized() -> ApiError {
ApiError(StatusCode::UNAUTHORIZED, "unauthorized")
}
#[derive(Clone)]
struct App {
db: SqlitePool,
key: Arc<Vec<u8>>,
setup: Arc<String>,
origin: Arc<String>,
secure: bool,
peers: Arc<Mutex<Peers>>,
limits: Arc<Mutex<HashMap<String, (i64, u32)>>>,
hashes: Arc<tokio::sync::Semaphore>,
}
#[derive(Default)]
struct Peers {
clients: HashMap<String, Client>,
host: Option<String>,
since: Option<i64>,
}
struct Client {
session: String,
user: String,
name: String,
mode: String,
tx: mpsc::Sender<Message>,
shutdown: tokio::sync::watch::Sender<bool>,
}
#[derive(Clone)]
struct Auth {
session: String,
user: String,
name: String,
role: String,
}
impl Auth {
fn admin(&self) -> ApiResult<()> {
if self.role == "admin" {
Ok(())
} else {
Err(ApiError(StatusCode::FORBIDDEN, "forbidden"))
}
}
}
async fn database(url: &str) -> Result<SqlitePool, sqlx::Error> {
let db = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
url.parse::<sqlx::sqlite::SqliteConnectOptions>()?
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5)),
)
.await?;
sqlx::raw_sql("CREATE TABLE IF NOT EXISTS accounts(id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('admin','viewer')), last_login INTEGER);
CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, created INTEGER NOT NULL, last_seen INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
PRAGMA optimize;").execute(&db).await?;
Ok(db)
}
fn password_valid(p: &str) -> bool {
p.chars().count() >= 12
&& p.len() <= 256
&& p.chars().any(char::is_alphabetic)
&& p.chars().any(|c| c.is_ascii_digit())
}
fn credentials(c: &Credentials) -> ApiResult<()> {
if c.username.len() < 3
|| c.username.len() > 40
|| !c
.username
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
{
return Err(bad("username"));
}
if !password_valid(&c.password) {
return Err(bad("password"));
}
Ok(())
}
async fn hash_password(app: &App, password: String) -> ApiResult<String> {
let permit = app
.hashes
.clone()
.acquire_owned()
.await
.map_err(|_| bad("server"))?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
Argon2::default()
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
.map(|h| h.to_string())
.map_err(|_| bad("server"))
})
.await
.map_err(|_| bad("server"))?
}
async fn verify_password(app: &App, password: String, hash: String) -> bool {
let Ok(permit) = app.hashes.clone().acquire_owned().await else {
return false;
};
tokio::task::spawn_blocking(move || {
let _permit = permit;
PasswordHash::new(&hash)
.map(|h| {
Argon2::default()
.verify_password(password.as_bytes(), &h)
.is_ok()
})
.unwrap_or(false)
})
.await
.unwrap_or(false)
}
fn sign(key: &[u8], token: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(key).unwrap();
mac.update(token.as_bytes());
format!("{token}.{}", hex::encode(mac.finalize().into_bytes()))
}
fn cookie_token(headers: &HeaderMap, key: &[u8]) -> ApiResult<String> {
let raw = headers
.get(header::COOKIE)
.and_then(|h| h.to_str().ok())
.and_then(|s| {
s.split(';')
.find_map(|c| c.trim().strip_prefix("webka_session="))
})
.ok_or_else(unauthorized)?;
let (token, sig) = raw.split_once('.').ok_or_else(unauthorized)?;
let mut mac = Hmac::<Sha256>::new_from_slice(key).unwrap();
mac.update(token.as_bytes());
mac.verify_slice(&hex::decode(sig).map_err(|_| unauthorized())?)
.map_err(|_| unauthorized())?;
Ok(token.to_owned())
}
async fn session_by_id(app: &App, session: &str) -> ApiResult<Auth> {
let row = sqlx::query("SELECT s.id, a.id AS user_id, a.username, a.role FROM sessions s JOIN accounts a ON a.id=s.user_id WHERE s.id=? AND s.last_seen>? AND s.created>?").bind(session).bind(now()-IDLE).bind(now()-ABSOLUTE).fetch_optional(&app.db).await?.ok_or_else(unauthorized)?;
Ok(Auth {
session: row.get("id"),
user: row.get("user_id"),
name: row.get("username"),
role: row.get("role"),
})
}
async fn auth(app: &App, headers: &HeaderMap, touch: bool) -> ApiResult<Auth> {
let token = cookie_token(headers, &app.key)?;
let session: Option<String> = sqlx::query_scalar("SELECT id FROM sessions WHERE token_hash=?")
.bind(digest(&token))
.fetch_optional(&app.db)
.await?;
let a = session_by_id(app, &session.ok_or_else(unauthorized)?).await?;
if touch {
sqlx::query("UPDATE sessions SET last_seen=? WHERE id=?")
.bind(now())
.bind(&a.session)
.execute(&app.db)
.await?;
}
Ok(a)
}
async fn guard(State(app): State<App>, req: Request, next: Next) -> Response {
let mutation = !matches!(
*req.method(),
axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS
);
if (mutation || req.uri().path() == "/api/signal")
&& req
.headers()
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
!= Some(app.origin.as_str())
{
return ApiError(StatusCode::FORBIDDEN, "origin").into_response();
}
let mut res = next.run(req).await;
res.headers_mut()
.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
res.headers_mut()
.insert(header::X_CONTENT_TYPE_OPTIONS, "nosniff".parse().unwrap());
res.headers_mut()
.insert(header::REFERRER_POLICY, "no-referrer".parse().unwrap());
res.headers_mut().insert(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'".parse().unwrap());
res
}
async fn throttle(app: &App, key: String) -> ApiResult<()> {
let mut limits = app.limits.lock().await;
limits.retain(|_, (start, _)| now() - *start < 60);
if limits.len() >= 4096 && !limits.contains_key(&key) {
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "rateLimited"));
}
let entry = limits.entry(key).or_insert((now(), 0));
entry.1 += 1;
if entry.1 > 10 {
Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "rateLimited"))
} else {
Ok(())
}
}
#[derive(Deserialize)]
struct Credentials {
username: String,
password: String,
setup_token: Option<String>,
}
async fn bootstrap_status(State(app): State<App>) -> ApiResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM accounts")
.fetch_one(&app.db)
.await?;
Ok(Json(json!({"needs_setup": count == 0})))
}
async fn bootstrap(
State(app): State<App>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(c): Json<Credentials>,
) -> ApiResult<Response> {
throttle(&app, addr.ip().to_string()).await?;
if c.setup_token.as_deref().map(digest) != Some(digest(&app.setup)) {
return Err(ApiError(StatusCode::FORBIDDEN, "setupToken"));
}
credentials(&c)?;
let hash = hash_password(&app, c.password).await?;
let result = sqlx::query("INSERT INTO accounts(id,username,password,role) SELECT ?,?,?,'admin' WHERE NOT EXISTS (SELECT 1 FROM accounts)").bind(id()).bind(&c.username).bind(hash).execute(&app.db).await?;
if result.rows_affected() == 0 {
return Err(ApiError(StatusCode::CONFLICT, "alreadySetup"));
}
Ok(StatusCode::CREATED.into_response())
}
async fn login(
State(app): State<App>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(c): Json<Credentials>,
) -> ApiResult<Response> {
throttle(&app, addr.ip().to_string()).await?;
if c.password.len() > 256 || c.username.len() > 40 {
return Err(unauthorized());
}
let row = sqlx::query("SELECT id,password FROM accounts WHERE username=? COLLATE NOCASE")
.bind(&c.username)
.fetch_optional(&app.db)
.await?;
// Unknown users perform the same expensive Argon2 operation as existing users.
let dummy: String = sqlx::query_scalar("SELECT value FROM settings WHERE key='dummy_hash'")
.fetch_one(&app.db)
.await?;
let hash = row
.as_ref()
.map(|r| r.get::<String, _>("password"))
.unwrap_or(dummy);
if !verify_password(&app, c.password, hash.clone()).await || row.is_none() {
return Err(unauthorized());
}
let row = row.unwrap();
let uid: String = row.get("id");
let token = format!("{}{}", id(), id());
sqlx::query("DELETE FROM sessions WHERE last_seen<=? OR created<=?")
.bind(now() - IDLE)
.bind(now() - ABSOLUTE)
.execute(&app.db)
.await?;
// A reset/delete that won the race with password verification must invalidate this login.
let mut transaction = app.db.begin().await?;
let inserted = sqlx::query("INSERT INTO sessions(id,token_hash,user_id,created,last_seen) SELECT ?,?,id,?,? FROM accounts WHERE id=? AND password=?")
.bind(id()).bind(digest(&token)).bind(now()).bind(now()).bind(&uid).bind(&hash)
.execute(&mut *transaction).await?;
if inserted.rows_affected() == 0 {
return Err(unauthorized());
}
sqlx::query("UPDATE accounts SET last_login=? WHERE id=?")
.bind(now())
.bind(uid)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
let cookie = format!(
"webka_session={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ABSOLUTE}{}",
sign(&app.key, &token),
if app.secure { "; Secure" } else { "" }
);
Ok(([(header::SET_COOKIE, cookie)], Json(json!({"ok":true}))).into_response())
}
async fn me(State(app): State<App>, headers: HeaderMap) -> ApiResult<Json<Value>> {
let a = auth(&app, &headers, true).await?;
Ok(Json(
json!({"id":a.user,"username":a.name,"role":a.role,"session_id":a.session}),
))
}
async fn logout(State(app): State<App>, headers: HeaderMap) -> ApiResult<Response> {
let a = auth(&app, &headers, false).await?;
revoke(&app, &a.session).await?;
Ok((
[(
header::SET_COOKIE,
"webka_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
)],
StatusCode::NO_CONTENT,
)
.into_response())
}
async fn accounts(State(app): State<App>, headers: HeaderMap) -> ApiResult<Json<Value>> {
auth(&app, &headers, true).await?.admin()?;
let rows = sqlx::query("SELECT a.id,a.username,a.role,a.last_login,(SELECT COUNT(*) FROM sessions s WHERE s.user_id=a.id AND s.last_seen>? AND s.created>?) AS active_sessions FROM accounts a ORDER BY a.role,a.username").bind(now()-IDLE).bind(now()-ABSOLUTE).fetch_all(&app.db).await?;
Ok(Json(json!(rows.iter().map(|r| json!({"id":r.get::<String,_>("id"),"username":r.get::<String,_>("username"),"role":r.get::<String,_>("role"),"last_login":r.get::<Option<i64>,_>("last_login"),"active_sessions":r.get::<i64,_>("active_sessions")})).collect::<Vec<_>>())))
}
async fn create_account(
State(app): State<App>,
headers: HeaderMap,
Json(c): Json<Credentials>,
) -> ApiResult<StatusCode> {
auth(&app, &headers, true).await?.admin()?;
credentials(&c)?;
let hash = hash_password(&app, c.password).await?;
let result =
sqlx::query("INSERT INTO accounts(id,username,password,role) VALUES(?,?,?,'viewer')")
.bind(id())
.bind(c.username)
.bind(hash)
.execute(&app.db)
.await;
match result {
Ok(_) => Ok(StatusCode::CREATED),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ApiError(StatusCode::CONFLICT, "duplicate"))
}
Err(e) => Err(e.into()),
}
}
async fn delete_account(
State(app): State<App>,
headers: HeaderMap,
Path(uid): Path<String>,
) -> ApiResult<StatusCode> {
auth(&app, &headers, true).await?.admin()?;
let mut tx = app.db.begin().await?;
let sessions: Vec<String> = sqlx::query_scalar("SELECT id FROM sessions WHERE user_id=?")
.bind(&uid)
.fetch_all(&mut *tx)
.await?;
let result = sqlx::query("DELETE FROM accounts WHERE id=? AND role='viewer'")
.bind(&uid)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(bad("protectedAccount"));
}
tx.commit().await?;
for sid in sessions {
disconnect_session(&app, &sid).await;
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
struct PasswordChange {
password: String,
}
async fn reset_password(
State(app): State<App>,
headers: HeaderMap,
Path(uid): Path<String>,
Json(c): Json<PasswordChange>,
) -> ApiResult<StatusCode> {
auth(&app, &headers, true).await?.admin()?;
if !password_valid(&c.password) {
return Err(bad("password"));
}
let hash = hash_password(&app, c.password).await?;
let mut tx = app.db.begin().await?;
let result = sqlx::query("UPDATE accounts SET password=? WHERE id=?")
.bind(hash)
.bind(&uid)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(bad("notFound"));
}
let sessions: Vec<String> = sqlx::query_scalar("SELECT id FROM sessions WHERE user_id=?")
.bind(&uid)
.fetch_all(&mut *tx)
.await?;
sqlx::query("DELETE FROM sessions WHERE user_id=?")
.bind(&uid)
.execute(&mut *tx)
.await?;
tx.commit().await?;
for sid in sessions {
disconnect_session(&app, &sid).await;
}
Ok(StatusCode::NO_CONTENT)
}
async fn sessions(State(app): State<App>, headers: HeaderMap) -> ApiResult<Json<Value>> {
auth(&app, &headers, true).await?.admin()?;
let rows = sqlx::query("SELECT s.id,s.created,s.last_seen,a.username FROM sessions s JOIN accounts a ON a.id=s.user_id WHERE s.last_seen>? AND s.created>? ORDER BY s.last_seen DESC").bind(now()-IDLE).bind(now()-ABSOLUTE).fetch_all(&app.db).await?;
Ok(Json(json!(rows.iter().map(|r| json!({"id":r.get::<String,_>("id"),"username":r.get::<String,_>("username"),"created":r.get::<i64,_>("created"),"last_seen":r.get::<i64,_>("last_seen")})).collect::<Vec<_>>())))
}
async fn revoke(app: &App, sid: &str) -> ApiResult<()> {
sqlx::query("DELETE FROM sessions WHERE id=?")
.bind(sid)
.execute(&app.db)
.await?;
disconnect_session(app, sid).await;
Ok(())
}
async fn revoke_session(
State(app): State<App>,
headers: HeaderMap,
Path(sid): Path<String>,
) -> ApiResult<StatusCode> {
auth(&app, &headers, true).await?.admin()?;
revoke(&app, &sid).await?;
Ok(StatusCode::NO_CONTENT)
}
fn send(client: &Client, value: Value) {
if client
.tx
.try_send(Message::Text(value.to_string().into()))
.is_err()
{
let _ = client.shutdown.send(true);
}
}
async fn disconnect_session(app: &App, sid: &str) {
let mut peers = app.peers.lock().await;
let ids: Vec<_> = peers
.clients
.iter()
.filter(|(_, c)| c.session == sid)
.map(|(id, _)| id.clone())
.collect();
for cid in ids {
remove_peer(&mut peers, &cid);
}
}
fn remove_peer(peers: &mut Peers, cid: &str) {
let Some(client) = peers.clients.remove(cid) else {
return;
};
let _ = client.tx.try_send(Message::Close(None));
let _ = client.shutdown.send(true);
if peers.host.as_deref() == Some(cid) {
peers.host = None;
peers.since = None;
for c in peers.clients.values() {
send(c, json!({"type":"host-offline"}));
}
} else if let Some(host) = peers.host.as_ref().and_then(|h| peers.clients.get(h)) {
send(host, json!({"type":"viewer-left","peer":cid}));
}
}
async fn status(State(app): State<App>, headers: HeaderMap) -> ApiResult<Json<Value>> {
let a = auth(&app, &headers, true).await?;
let peers = app.peers.lock().await;
let viewers: Vec<_> = peers
.clients
.iter()
.filter(|(_, c)| c.mode == "viewer")
.map(|(id, c)| {
if a.role == "admin" {
json!({"id":id,"session_id":c.session,"username":c.name,"user_id":c.user})
} else {
json!({})
}
})
.collect();
Ok(Json(
json!({"live":peers.host.is_some(),"since":peers.since,"viewer_count":viewers.len(),"viewers":if a.role=="admin" {viewers} else {vec![]},"local_url":format!("{}/watch",app.origin)}),
))
}
#[derive(Deserialize)]
struct SignalQuery {
mode: Option<String>,
}
async fn signal(
State(app): State<App>,
headers: HeaderMap,
Query(q): Query<SignalQuery>,
ws: WebSocketUpgrade,
) -> ApiResult<Response> {
let a = auth(&app, &headers, true).await?;
let mode = q.mode.unwrap_or("viewer".into());
if mode != "host" && mode != "viewer" {
return Err(bad("mode"));
}
if mode == "host" {
a.admin()?;
}
Ok(ws
.max_message_size(64 * 1024)
.max_frame_size(64 * 1024)
.on_upgrade(move |socket| connection(app, a, mode, socket)))
}
async fn connection(app: App, a: Auth, mode: String, socket: WebSocket) {
let cid = id();
let (tx, mut rx) = mpsc::channel(64);
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
let (mut sink, mut source) = socket.split();
{
let mut peers = app.peers.lock().await;
if peers.clients.len() >= 32
|| (mode == "host" && peers.host.is_some())
|| peers.clients.values().any(|c| c.session == a.session)
{
drop(peers);
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
sink.send(Message::Text(
json!({"type":"error","error":"hostBusy"})
.to_string()
.into(),
)),
)
.await;
return;
}
peers.clients.insert(
cid.clone(),
Client {
session: a.session.clone(),
user: a.user,
name: a.name,
mode: mode.clone(),
tx,
shutdown: shutdown_tx,
},
);
if mode == "host" {
peers.host = Some(cid.clone());
peers.since = Some(now());
for (vid, c) in &peers.clients {
if c.mode == "viewer" {
send(
peers.clients.get(&cid).unwrap(),
json!({"type":"viewer-joined","peer":vid}),
);
send(c, json!({"type":"host-online"}));
}
}
} else if let Some(host) = peers.host.as_ref().and_then(|h| peers.clients.get(h)) {
send(host, json!({"type":"viewer-joined","peer":cid}));
}
send(
peers.clients.get(&cid).unwrap(),
json!({"type":"welcome","peer":cid,"live":peers.host.is_some()}),
);
}
let mut tick = tokio::time::interval(std::time::Duration::from_secs(2));
let mut count = 0;
let mut window = now();
loop {
tokio::select! {
_ = shutdown_rx.changed() => break,
_ = tick.tick() => { if session_by_id(&app,&a.session).await.is_err() { break; } }
msg = rx.recv() => { match msg { Some(msg) => { let close = matches!(msg, Message::Close(_)); if !tokio::time::timeout(std::time::Duration::from_secs(2), sink.send(msg)).await.is_ok_and(|result| result.is_ok()) || close { break; } }, None => break } }
msg = source.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
if now() != window { window = now(); count = 0; } count += 1; if count > 120 { break; }
if session_by_id(&app,&a.session).await.is_err() { break; }
let Ok(v) = serde_json::from_str::<Value>(&text) else { continue; };
let Some(kind) = v["type"].as_str() else { continue; };
let peers = app.peers.lock().await;
if !peers.clients.contains_key(&cid) { break; }
let Some(target_id) = v["target"].as_str() else { continue; };
let Some(target) = peers.clients.get(target_id) else { continue; };
let allowed = if mode == "host" { peers.host.as_deref() == Some(&cid) && target.mode=="viewer" && matches!(kind,"offer"|"ice") } else { peers.host.as_deref()==Some(target_id) && matches!(kind,"answer"|"ice"|"renegotiate") };
if allowed { send(target,json!({"type":kind,"peer":cid,"data":v["data"]})); }
}
Some(Ok(Message::Ping(data))) => { if !tokio::time::timeout(std::time::Duration::from_secs(2), sink.send(Message::Pong(data))).await.is_ok_and(|result| result.is_ok()) { break; } }
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
_ => {}
}
}
}
}
remove_peer(&mut *app.peers.lock().await, &cid);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), sink.close()).await;
}
fn router(app: App, assets: PathBuf) -> Router {
Router::new()
.route("/api/bootstrap", get(bootstrap_status).post(bootstrap))
.route("/api/login", post(login))
.route("/api/logout", post(logout))
.route("/api/session", get(me))
.route("/api/accounts", get(accounts).post(create_account))
.route("/api/accounts/{id}", delete(delete_account))
.route("/api/accounts/{id}/password", post(reset_password))
.route("/api/sessions", get(sessions))
.route("/api/sessions/{id}", delete(revoke_session))
.route("/api/status", get(status))
.route("/api/signal", get(signal))
.fallback_service(
ServeDir::new(&assets).not_found_service(ServeFile::new(assets.join("index.html"))),
)
.layer(axum::extract::DefaultBodyLimit::max(8192))
.layer(middleware::from_fn_with_state(app.clone(), guard))
.with_state(app)
}
fn secret_file(path: &std::path::Path) -> std::io::Result<String> {
use std::io::Write;
if path.exists() {
return std::fs::read_to_string(path).map(|s| s.trim().to_owned());
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
let secret = format!("{}{}", id(), id());
file.write_all(secret.as_bytes())?;
Ok(secret)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let data = PathBuf::from(std::env::var("WEBKA_DATA").unwrap_or("data".into()));
std::fs::create_dir_all(&data)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o700))?;
}
let key = secret_file(&data.join("session-key"))?;
let setup = secret_file(&data.join("setup-token"))?;
let origin = std::env::var("WEBKA_ORIGIN")
.unwrap_or("http://localhost:3000".into())
.trim_end_matches('/')
.to_owned();
let addr: SocketAddr = std::env::var("WEBKA_BIND")
.unwrap_or("127.0.0.1:3001".into())
.parse()?;
let app = App {
db: database(&format!("sqlite://{}", data.join("webka.db").display())).await?,
key: Arc::new(key.into_bytes()),
setup: Arc::new(setup),
secure: origin.starts_with("https://"),
origin: Arc::new(origin),
peers: Default::default(),
limits: Default::default(),
hashes: Arc::new(tokio::sync::Semaphore::new(2)),
};
let dummy = hash_password(&app, id())
.await
.map_err(|_| "hash initialization failed")?;
sqlx::query("INSERT OR IGNORE INTO settings(key,value) VALUES('dummy_hash',?)")
.bind(dummy)
.execute(&app.db)
.await?;
let mdns = if std::env::var("WEBKA_MDNS").as_deref() == Ok("1") {
match (mdns_sd::ServiceDaemon::new(), local_ip_address::local_ip()) {
(Ok(daemon), Ok(ip)) => {
let port: u16 = std::env::var("WEBKA_PUBLIC_PORT")
.unwrap_or(if app.secure { "443" } else { "3000" }.into())
.parse()?;
let service = mdns_sd::ServiceInfo::new(
"_webka._tcp.local.",
"Webka",
"webka.local.",
ip,
port,
[
("path", "/watch"),
("scheme", if app.secure { "https" } else { "http" }),
]
.as_slice(),
)?;
daemon.register(service)?;
Some(daemon)
}
_ => {
eprintln!("mDNS unavailable; use the configured local URL.");
None
}
}
} else {
None
};
let listener = tokio::net::TcpListener::bind(addr).await?;
println!(
"Webka listening at {addr}. Open {}. First-run setup token: {}",
app.origin,
data.join("setup-token").display()
);
let assets = std::env::var("WEBKA_ASSETS").unwrap_or("frontend/.output/public".into());
axum::serve(
listener,
router(app, assets.into()).into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
if let Some(daemon) = mdns {
let _ = daemon.shutdown();
}
Ok(())
}
#[cfg(test)]
mod tests;

558
backend/src/tests.rs Normal file
View File

@@ -0,0 +1,558 @@
use super::*;
use axum::{body::Body, http::Request};
use tower::ServiceExt;
async fn app() -> (App, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let app = App {
db: database(&format!(
"sqlite://{}",
dir.path().join("test.db").display()
))
.await
.unwrap(),
key: Arc::new(vec![42; 32]),
setup: Arc::new("setup-test".into()),
origin: Arc::new("http://localhost:3000".into()),
secure: false,
peers: Default::default(),
limits: Default::default(),
hashes: Arc::new(tokio::sync::Semaphore::new(2)),
};
let dummy = hash_password(&app, "dummy".into()).await.unwrap();
sqlx::query("INSERT INTO settings(key,value) VALUES('dummy_hash',?)")
.bind(dummy)
.execute(&app.db)
.await
.unwrap();
(app, dir)
}
async fn request(app: &App, method: &str, path: &str, cookie: &str, data: Value) -> Response {
let mut req = Request::builder()
.method(method)
.uri(path)
.header("origin", "http://localhost:3000")
.header("content-type", "application/json")
.header("cookie", cookie)
.body(Body::from(data.to_string()))
.unwrap();
req.extensions_mut()
.insert(ConnectInfo("127.0.0.1:1234".parse::<SocketAddr>().unwrap()));
router(app.clone(), "/nonexistent".into())
.oneshot(req)
.await
.unwrap()
}
async fn setup(app: &App) -> String {
let response = request(
app,
"POST",
"/api/bootstrap",
"",
json!({"username":"owner","password":"strong-password123","setup_token":"setup-test"}),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
login_as(app, "owner", "strong-password123").await
}
async fn login_as(app: &App, username: &str, password: &str) -> String {
let response = request(
app,
"POST",
"/api/login",
"",
json!({"username":username,"password":password}),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()[header::SET_COOKIE].to_str().unwrap();
assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict"));
cookie.split(';').next().unwrap().into()
}
async fn json_body(response: Response) -> Value {
serde_json::from_slice(
&axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn argon2id_hashes_are_salted_and_verify() {
let (app, _dir) = app().await;
let a = hash_password(&app, "a long password 123".into())
.await
.unwrap();
let b = hash_password(&app, "a long password 123".into())
.await
.unwrap();
assert!(a.starts_with("$argon2id$"));
assert_ne!(a, b);
assert!(verify_password(&app, "a long password 123".into(), a.clone()).await);
assert!(!verify_password(&app, "wrong".into(), a).await);
assert!(!password_valid("short1"));
assert!(!password_valid("longwithoutnumbers"));
assert!(password_valid("длинныйпароль123"));
}
#[tokio::test]
async fn bootstrap_is_token_protected_and_one_time() {
let (app, _dir) = app().await;
assert_eq!(
request(
&app,
"POST",
"/api/bootstrap",
"",
json!({"username":"owner","password":"strong-password123","setup_token":"wrong"})
)
.await
.status(),
StatusCode::FORBIDDEN
);
setup(&app).await;
assert_eq!(
request(
&app,
"POST",
"/api/bootstrap",
"",
json!({"username":"second","password":"strong-password123","setup_token":"setup-test"})
)
.await
.status(),
StatusCode::CONFLICT
);
assert_eq!(
json_body(request(&app, "GET", "/api/bootstrap", "", json!(null)).await).await["needs_setup"],
false
);
}
#[tokio::test]
async fn sessions_expire_on_idle_and_absolute_deadlines_and_reject_tampering() {
let (app, _dir) = app().await;
let cookie = setup(&app).await;
assert_eq!(
request(
&app,
"GET",
"/api/session",
&format!("{cookie}0"),
json!(null)
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
sqlx::query("UPDATE sessions SET last_seen=?")
.bind(now() - IDLE)
.execute(&app.db)
.await
.unwrap();
assert_eq!(
request(&app, "GET", "/api/session", &cookie, json!(null))
.await
.status(),
StatusCode::UNAUTHORIZED
);
let cookie = login_as(&app, "owner", "strong-password123").await;
sqlx::query("UPDATE sessions SET created=?")
.bind(now() - ABSOLUTE)
.execute(&app.db)
.await
.unwrap();
assert_eq!(
request(&app, "GET", "/api/session", &cookie, json!(null))
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn accounts_enforce_roles_and_reset_revokes_sessions() {
let (app, _dir) = app().await;
let owner = setup(&app).await;
assert_eq!(
request(
&app,
"POST",
"/api/accounts",
&owner,
json!({"username":"viewer","password":"viewer-password123"})
)
.await
.status(),
StatusCode::CREATED
);
assert_eq!(
request(
&app,
"POST",
"/api/accounts",
&owner,
json!({"username":"VIEWER","password":"viewer-password123"})
)
.await
.status(),
StatusCode::CONFLICT
);
let viewer = login_as(&app, "viewer", "viewer-password123").await;
assert_eq!(
request(&app, "GET", "/api/accounts", &viewer, json!(null))
.await
.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
request(&app, "GET", "/api/sessions", &viewer, json!(null))
.await
.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
request(
&app,
"POST",
"/api/accounts",
&viewer,
json!({"username":"intruder","password":"viewer-password123"})
)
.await
.status(),
StatusCode::FORBIDDEN
);
let me = json_body(request(&app, "GET", "/api/session", &viewer, json!(null)).await).await;
let uid = me["id"].as_str().unwrap();
assert_eq!(
request(
&app,
"POST",
&format!("/api/accounts/{uid}/password"),
&owner,
json!({"password":"replacement-pass123"})
)
.await
.status(),
StatusCode::NO_CONTENT
);
assert_eq!(
request(&app, "GET", "/api/session", &viewer, json!(null))
.await
.status(),
StatusCode::UNAUTHORIZED
);
let viewer = login_as(&app, "viewer", "replacement-pass123").await;
assert_eq!(
request(
&app,
"DELETE",
&format!("/api/accounts/{uid}"),
&owner,
json!(null)
)
.await
.status(),
StatusCode::NO_CONTENT
);
assert_eq!(
request(&app, "GET", "/api/session", &viewer, json!(null))
.await
.status(),
StatusCode::UNAUTHORIZED
);
let owner_me = json_body(request(&app, "GET", "/api/session", &owner, json!(null)).await).await;
assert_eq!(
request(
&app,
"DELETE",
&format!("/api/accounts/{}", owner_me["id"].as_str().unwrap()),
&owner,
json!(null)
)
.await
.status(),
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn origins_and_bruteforce_are_rejected() {
let (app, _dir) = app().await;
let req = Request::builder()
.method("POST")
.uri("/api/login")
.header("origin", "https://evil.example")
.body(Body::empty())
.unwrap();
assert_eq!(
router(app.clone(), "/none".into())
.oneshot(req)
.await
.unwrap()
.status(),
StatusCode::FORBIDDEN
);
let req = Request::builder()
.uri("/api/signal")
.header("origin", "https://evil.example")
.body(Body::empty())
.unwrap();
assert_eq!(
router(app.clone(), "/none".into())
.oneshot(req)
.await
.unwrap()
.status(),
StatusCode::FORBIDDEN
);
for _ in 0..10 {
assert!(throttle(&app, "test".into()).await.is_ok());
}
assert_eq!(
throttle(&app, "test".into()).await.unwrap_err().0,
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn logout_revokes_the_cookie() {
let (app, _dir) = app().await;
let owner = setup(&app).await;
assert_eq!(
request(&app, "POST", "/api/logout", &owner, json!(null))
.await
.status(),
StatusCode::NO_CONTENT
);
assert_eq!(
request(&app, "GET", "/api/session", &owner, json!(null))
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn revoking_viewer_closes_transport_and_notifies_host() {
let (app, _dir) = app().await;
let (host_tx, mut host_rx) = mpsc::channel(16);
let (viewer_tx, mut viewer_rx) = mpsc::channel(16);
{
let mut peers = app.peers.lock().await;
peers.host = Some("host".into());
peers.clients.insert(
"host".into(),
Client {
session: "hs".into(),
user: "h".into(),
name: "h".into(),
mode: "host".into(),
tx: host_tx,
shutdown: tokio::sync::watch::channel(false).0,
},
);
peers.clients.insert(
"viewer".into(),
Client {
session: "vs".into(),
user: "v".into(),
name: "v".into(),
mode: "viewer".into(),
tx: viewer_tx,
shutdown: tokio::sync::watch::channel(false).0,
},
);
}
disconnect_session(&app, "vs").await;
assert!(matches!(viewer_rx.recv().await, Some(Message::Close(_))));
let Some(Message::Text(msg)) = host_rx.recv().await else {
panic!("missing leave notification")
};
assert_eq!(
serde_json::from_str::<Value>(&msg).unwrap()["type"],
"viewer-left"
);
assert!(!app.peers.lock().await.clients.contains_key("viewer"));
}
#[tokio::test]
async fn revoking_host_notifies_viewers_to_stop_media() {
let (app, _dir) = app().await;
let (host_tx, _host_rx) = mpsc::channel(16);
let (viewer_tx, mut viewer_rx) = mpsc::channel(16);
{
let mut peers = app.peers.lock().await;
peers.host = Some("host".into());
peers.since = Some(now());
peers.clients.insert(
"host".into(),
Client {
session: "hs".into(),
user: "h".into(),
name: "h".into(),
mode: "host".into(),
tx: host_tx,
shutdown: tokio::sync::watch::channel(false).0,
},
);
peers.clients.insert(
"viewer".into(),
Client {
session: "vs".into(),
user: "v".into(),
name: "v".into(),
mode: "viewer".into(),
tx: viewer_tx,
shutdown: tokio::sync::watch::channel(false).0,
},
);
}
disconnect_session(&app, "hs").await;
let Some(Message::Text(msg)) = viewer_rx.recv().await else {
panic!("missing offline notification")
};
assert_eq!(
serde_json::from_str::<Value>(&msg).unwrap()["type"],
"host-offline"
);
assert!(app.peers.lock().await.host.is_none());
}
#[tokio::test]
async fn websocket_routes_only_authorized_peers_and_closes_on_revocation() {
use tokio_tungstenite::{
connect_async,
tungstenite::{Message as WsMessage, client::IntoClientRequest},
};
let (app, _dir) = app().await;
let host_cookie = setup(&app).await;
request(
&app,
"POST",
"/api/accounts",
&host_cookie,
json!({"username":"viewer","password":"viewer-password123"}),
)
.await;
let viewer_cookie = login_as(&app, "viewer", "viewer-password123").await;
let viewer_me =
json_body(request(&app, "GET", "/api/session", &viewer_cookie, json!(null)).await).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let service = router(app.clone(), "/nonexistent".into())
.into_make_service_with_connect_info::<SocketAddr>();
let server = tokio::spawn(async move {
axum::serve(listener, service)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await
.unwrap();
});
let ws_request = |mode: &str, cookie: &str| {
let mut req = format!("ws://{addr}/api/signal?mode={mode}")
.into_client_request()
.unwrap();
req.headers_mut()
.insert("origin", "http://localhost:3000".parse().unwrap());
req.headers_mut().insert("cookie", cookie.parse().unwrap());
req
};
let forbidden = connect_async(ws_request("host", &viewer_cookie))
.await
.unwrap_err();
assert!(
matches!(forbidden, tokio_tungstenite::tungstenite::Error::Http(ref r) if r.status()==StatusCode::FORBIDDEN)
);
let anonymous = connect_async(ws_request("viewer", "")).await.unwrap_err();
assert!(
matches!(anonymous, tokio_tungstenite::tungstenite::Error::Http(ref r) if r.status()==StatusCode::UNAUTHORIZED)
);
let (mut host, _) = connect_async(ws_request("host", &host_cookie))
.await
.unwrap();
let host_welcome = host.next().await.unwrap().unwrap().into_text().unwrap();
let host_id = serde_json::from_str::<Value>(&host_welcome).unwrap()["peer"]
.as_str()
.unwrap()
.to_owned();
let (mut viewer, _) = connect_async(ws_request("viewer", &viewer_cookie))
.await
.unwrap();
let viewer_welcome = viewer.next().await.unwrap().unwrap().into_text().unwrap();
let viewer_id = serde_json::from_str::<Value>(&viewer_welcome).unwrap()["peer"]
.as_str()
.unwrap()
.to_owned();
let joined = host.next().await.unwrap().unwrap().into_text().unwrap();
assert_eq!(
serde_json::from_str::<Value>(&joined).unwrap()["type"],
"viewer-joined"
);
host.send(WsMessage::Text(json!({"type":"offer","target":viewer_id,"peer":"forged","data":{"type":"offer","sdp":"test"}}).to_string().into())).await.unwrap();
let offer = viewer.next().await.unwrap().unwrap().into_text().unwrap();
let offer: Value = serde_json::from_str(&offer).unwrap();
assert_eq!(offer["peer"], host_id);
assert_eq!(offer["type"], "offer");
viewer
.send(WsMessage::Text(
json!({"type":"offer","target":host_id,"data":{}})
.to_string()
.into(),
))
.await
.unwrap();
assert!(
tokio::time::timeout(std::time::Duration::from_millis(100), host.next())
.await
.is_err()
);
viewer
.send(WsMessage::Text(
json!({"type":"answer","target":host_id,"data":{"type":"answer","sdp":"test"}})
.to_string()
.into(),
))
.await
.unwrap();
let answer = host.next().await.unwrap().unwrap().into_text().unwrap();
assert_eq!(
serde_json::from_str::<Value>(&answer).unwrap()["peer"],
viewer_id
);
revoke(&app, viewer_me["session_id"].as_str().unwrap())
.await
.unwrap();
let closed = tokio::time::timeout(std::time::Duration::from_secs(3), viewer.next())
.await
.unwrap();
assert!(matches!(closed, Some(Ok(WsMessage::Close(_)))));
let left = host.next().await.unwrap().unwrap().into_text().unwrap();
assert_eq!(
serde_json::from_str::<Value>(&left).unwrap()["type"],
"viewer-left"
);
host.close(None).await.unwrap();
drop(viewer);
drop(host);
let _ = shutdown_tx.send(());
tokio::time::timeout(std::time::Duration::from_secs(3), server)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn saturated_signaling_queue_fails_closed() {
let (tx, _rx) = mpsc::channel(1);
let (shutdown, rx) = tokio::sync::watch::channel(false);
let client = Client {
session: "s".into(),
user: "u".into(),
name: "u".into(),
mode: "host".into(),
tx,
shutdown,
};
send(&client, json!({"type":"offer"}));
assert!(!*rx.borrow());
send(&client, json!({"type":"viewer-left"}));
assert!(*rx.borrow());
}

37
docs/API.md Normal file
View File

@@ -0,0 +1,37 @@
# API and transport
All API paths are same-origin. Mutations and WebSocket upgrades require an `Origin` matching `WEBKA_ORIGIN` exactly. JSON requests use `Content-Type: application/json`. Authentication is the signed `webka_session` cookie. Error responses are `{ "error": "translationKey" }`; framework validation errors are shown as a translated generic error by the frontend.
| Method | Path | Access | Result |
|---|---|---|---|
| GET | `/api/bootstrap` | Public | `{ needs_setup }` |
| POST | `/api/bootstrap` | Setup token, first run only | `{ username, password, setup_token }` → 201 |
| POST | `/api/login` | Public, throttled | `{ username, password }` → signed cookie |
| POST | `/api/logout` | Signed in | Deletes current session and closes its transport |
| GET | `/api/session` | Signed in | `{ id, username, role, session_id }`; refreshes activity |
| GET/POST | `/api/accounts` | Admin | List accounts / create viewer `{ username, password }` |
| DELETE | `/api/accounts/:id` | Admin | Delete viewer and cascade sessions; admin protected |
| POST | `/api/accounts/:id/password` | Admin | `{ password }`; revokes all target sessions |
| GET | `/api/sessions` | Admin | Active sessions and last activity |
| DELETE | `/api/sessions/:id` | Admin | Force revoke session |
| GET | `/api/status` | Signed in | Live state, uptime origin, count, share URL; identities visible only to admin |
| GET upgrade | `/api/signal?mode=host\|viewer` | Signed in; host mode admin only | WebSocket JSON signaling |
One active host and one transport per login session. Up to 32 sockets, 64 KiB per WebSocket message, 120 signaling messages per second per socket; API bodies capped at 8 KiB. Roles are always enforced server-side. Login/setup permit ten attempts per IP per minute. Argon2 work is limited to two concurrent blocking jobs.
Signaling is not a media relay. The browser uses `RTCPeerConnection({ iceServers: [] })`, video-only tracks, trickle ICE and peer-to-peer encrypted WebRTC. The server chooses the sender identity from the authenticated connection; it never accepts a claimed `peer` field from a client.
Server events:
- `welcome { peer, live }`
- `viewer-joined { peer }`, `viewer-left { peer }` (host only)
- `host-online`, `host-offline` (viewers)
- `error { error }` (capacity/session/host conflict)
Routed messages: `{ type, target, data }` become `{ type, peer, data }`.
- Host → its viewers: `offer` (SDP), `ice` (ICE candidate).
- Viewer → current host: `answer`, `ice`, `renegotiate`.
- Viewer-to-viewer and forged role messages are dropped.
The server revalidates the session before each inbound signal and every two seconds. Logout, account deletion, password reset and session revocation remove affected connections and notify the host/viewers to close media peers. The frontend clears peers when signaling disconnects, retries with 1–10 second exponential backoff, and rechecks authentication before reconnecting. There is no recording or server-side video storage.

41
docs/MANUAL_TEST_PLAN.md Normal file
View File

@@ -0,0 +1,41 @@
# Real-device acceptance test
These are required manual checks, not a claim that physical devices have been tested. Use a trusted HTTPS origin, two separate browser profiles/devices on one LAN, and the Rust service with a fresh database.
## Setup and access
1. Open `/login` in a clean profile. Confirm all labels render in English. Switch to Russian and Spanish, refresh, and confirm the choice persists and the login card fits.
2. Try a wrong setup token and a weak password. Confirm rejection. Create the owner using the local setup token and a 12+ character password containing letters and digits. Try setup again: it must be closed.
3. Add two viewer accounts. Try a duplicate username with different casing: rejected. Sign in as a viewer; direct navigation and direct API calls to account management must be rejected.
4. Try incorrect credentials 11 times within one minute from the same IP: the login endpoint returns 429. Wait a minute; valid login works.
## Capture and source switching
1. On `/host`, no camera should activate before user interaction. Enable preview; accept permission. A local video appears, but another viewer remains waiting until **Go live**.
2. Deny permission in another profile. Confirm an actionable translated error and no false Live status. Open a LAN HTTP URL: confirm the secure-context error rather than a broken capture attempt.
3. Grant camera access and inspect front/back controls on a dual-camera phone. Select back camera, then front. Confirm the correct lens, front preview mirroring only, and no duplicate media tracks. With one camera, unavailable back camera stays disabled.
4. Start broadcasting. Join in a separate viewer session via the QR code. Confirm moving video and the host's viewer count. Compare host and viewer clocks/frame motion for LAN latency.
5. While live, switch front → back → screen → front. Select a screen/window in the browser prompt. Confirm the existing viewer updates without signing in again. On platforms unable to open both lenses concurrently, note the error; stop preview and retry the other lens.
6. Cancel the screen picker: the previous source must continue. Click the browser’s **Stop sharing** control: hosting stops, camera indicator turns off, and viewer shows waiting.
7. Switch bitrate between low/adaptive/high. Inspect browser WebRTC stats: max bitrate changes (actual resolution depends on capture and congestion).
8. Stop hosting, navigate to settings, close the tab, and log out separately. Each must stop the host camera and close outgoing peer streams.
## Viewer lifecycle and session revocation
1. Join before the host is live; automatic playback should start once the host starts. Check fullscreen, language switching while fullscreen, quality/resolution, and disconnect.
2. Join from two viewers and revoke one session. Within two seconds its signaling closes; the host removes that peer and the revoked viewer returns to login. Other viewers continue normally.
3. Reset a viewer password; all its sessions stop and old credentials fail. Delete that viewer account and confirm subsequent login is denied.
4. Reset the administrator password while streaming. All its sessions are logged out and viewers stop receiving media.
5. Interrupt Wi-Fi briefly. Confirm reconnect state and bounded exponential retry. Restore connectivity; playback should recover. Reload/stop/start the host and confirm waiting viewers recover.
6. Attempt a second host tab with the same or another administrator session: it must show a conflict, not replace the active broadcast.
7. Leave a session hidden/inactive for 30 minutes and reopen it; it must return to login. Absolute expiry after 12 hours applies even with heartbeats (also covered by automated backend tests).
## Visual, accessibility, and LAN checks
- Check all five routes at 390px and 1440px widths, portrait/landscape, and 200% text enlargement. No clipped dialogs or unreachable controls; the account table may scroll horizontally.
- Check keyboard-only navigation, skip link, focus indicators, native dialog focus containment/Escape/return focus, language dropdown, and form error announcements.
- Check dark/light modes and all four accents. Enable OS reduced motion: highlights and morph/ripple animations stop.
- Scroll account rows to observe compact navigation. Check notches and fullscreen bottom safe areas.
- Scan the QR on a second device; its URL must match the configured HTTPS origin. Test `webka.local` with mDNS on/off and the documented local DNS/IP fallback.
- Inspect browser network traffic: assets, API and WebSocket stay at the configured origin; media connects only between LAN peers. There should be no public STUN/TURN, external image/font, telemetry, or cloud requests.
- Confirm behavior on desktop Chromium/Firefox/Safari, Android Chrome, and iOS Safari where available. Record unsupported screen share, background suspension, camera switching, and client-isolation limits.

53
frontend/app.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
const { locale } = useI18n();
const session = useSessionStore();
const theme = useCookie("webka_theme", { default: () => "dark" });
const accent = useCookie("webka_accent", { default: () => "#c3f66b" });
useHead({
htmlAttrs: { lang: locale, "data-theme": theme },
bodyAttrs: {
style: computed(
() =>
`--accent: ${/^#[a-f\d]{6}$/i.test(accent.value) ? accent.value : "#c3f66b"}`,
),
},
});
let heartbeat: ReturnType<typeof setInterval>;
onMounted(() => {
heartbeat = setInterval(async () => {
if (
session.user &&
document.visibilityState === "visible" &&
!(await session.refresh())
)
await navigateTo("/login");
}, 60000);
});
onBeforeUnmount(() => clearInterval(heartbeat));
</script>
<template>
<div class="app-shell">
<svg class="glass-filter" aria-hidden="true" width="0" height="0">
<defs>
<filter id="glass-lens" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.012 0.018"
numOctaves="2"
seed="8"
result="noise"
/>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale="3"
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</defs></svg
><a class="skip-link" href="#main">{{ $t("common.skip") }}</a
><GlassNavBar />
<main id="main"><NuxtPage /></main>
</div>
</template>

1706
frontend/assets/css/main.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: "primary" | "danger" | "quiet";
type?: "button" | "submit";
disabled?: boolean;
}>(),
{ variant: "quiet", type: "button" },
);
const ripple = ref(false);
let timer: ReturnType<typeof setTimeout>;
function press() {
ripple.value = false;
requestAnimationFrame(() => {
ripple.value = true;
timer = setTimeout(() => (ripple.value = false), 500);
});
}
onBeforeUnmount(() => clearTimeout(timer));
</script>
<template>
<button
:type="type"
:disabled="disabled"
class="glass-button"
:class="[variant, { ripple }]"
@pointerdown="press"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ as?: string }>(), { as: "section" });
function highlight(e: PointerEvent) {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const element = e.currentTarget as HTMLElement;
const r = element.getBoundingClientRect();
element.style.setProperty("--glass-x", `${e.clientX - r.left}px`);
element.style.setProperty("--glass-y", `${e.clientY - r.top}px`);
}
</script>
<template>
<component :is="props.as" class="glass" @pointermove="highlight"
><slot
/></component>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
const props = defineProps<{ open: boolean; title: string }>();
const emit = defineEmits<{ close: [] }>();
const dialog = ref<HTMLDialogElement>();
const titleId = useId();
watch(
() => props.open,
async (open) => {
await nextTick();
if (open) dialog.value?.showModal();
else dialog.value?.close();
},
);
onMounted(() => {
if (props.open) dialog.value?.showModal();
});
</script>
<template>
<dialog
ref="dialog"
class="glass modal"
:aria-labelledby="titleId"
@cancel.prevent="emit('close')"
@click="
(e) => {
if (e.target === dialog) emit('close');
}
"
>
<div class="section-heading">
<h2 :id="titleId">{{ title }}</h2>
<GlassButton
:aria-label="$t('common.close')"
class="icon-button"
@click="emit('close')"
><X :size="20"
/></GlassButton>
</div>
<slot />
</dialog>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import {
Radio,
UsersRound,
SlidersHorizontal,
LogOut,
Eye,
Video,
} from "lucide-vue-next";
const session = useSessionStore();
const compact = ref(false);
function scroll() {
compact.value = window.scrollY > 40;
}
onMounted(() => window.addEventListener("scroll", scroll, { passive: true }));
onBeforeUnmount(() => window.removeEventListener("scroll", scroll));
</script>
<template>
<header class="topbar" :class="{ compact }">
<NuxtLink
:to="session.user?.role === 'admin' ? '/host' : '/watch'"
class="brand"
aria-label="Webka"
><span class="brand-mark"><Video :size="22" /></span>webka<span
class="brand-dot"
>.</span
></NuxtLink
>
<GlassCard
v-if="session.user"
as="nav"
class="main-nav"
:aria-label="$t('nav.main')"
>
<NuxtLink v-if="session.user.role === 'admin'" to="/host"
><Radio :size="17" /><span>{{ $t("nav.host") }}</span></NuxtLink
>
<NuxtLink v-else to="/watch"
><Eye :size="17" /><span>{{ $t("nav.watch") }}</span></NuxtLink
>
<NuxtLink v-if="session.user.role === 'admin'" to="/settings/accounts"
><UsersRound :size="17" /><span>{{
$t("nav.accounts")
}}</span></NuxtLink
>
<NuxtLink to="/settings/general"
><SlidersHorizontal :size="17" /><span>{{
$t("nav.settings")
}}</span></NuxtLink
>
</GlassCard>
<div class="topbar-actions">
<LanguageSwitcher /><GlassButton
v-if="session.user"
class="icon-button logout"
:aria-label="$t('common.logout')"
@click="session.logout"
><LogOut :size="19"
/></GlassButton>
</div>
</header>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { Globe2, ChevronDown } from "lucide-vue-next";
const { locale, setLocale } = useI18n();
const open = ref(false);
const root = ref<HTMLElement>();
const languages = [
{ code: "en", name: "English" },
{ code: "ru", name: "Русский" },
{ code: "es", name: "Español" },
] as const;
function outside(e: PointerEvent) {
if (!root.value?.contains(e.target as Node)) open.value = false;
}
onMounted(() => document.addEventListener("pointerdown", outside));
onBeforeUnmount(() => document.removeEventListener("pointerdown", outside));
</script>
<template>
<div ref="root" class="language" @keydown.esc="open = false">
<GlassButton
:aria-label="$t('general.language')"
:aria-expanded="open"
aria-controls="language-options"
@click="open = !open"
><Globe2 :size="17" /><span>{{ locale.toUpperCase() }}</span
><ChevronDown :size="14"
/></GlassButton>
<GlassCard
v-if="open"
id="language-options"
class="language-menu"
role="group"
:aria-label="$t('general.language')"
><button
v-for="lang in languages"
:key="lang.code"
:aria-pressed="locale === lang.code"
@click="
setLocale(lang.code);
open = false;
"
>
{{ lang.name }}<span v-if="locale === lang.code">✓</span>
</button></GlassCard
>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const props = defineProps<{ stream: MediaStream | null; mirror?: boolean }>();
const video = ref<HTMLVideoElement>();
watch(
() => props.stream,
async (stream) => {
await nextTick();
if (video.value) {
video.value.srcObject = stream;
if (stream) void video.value.play().catch(() => {});
}
},
{ immediate: true },
);
</script>
<template>
<video ref="video" autoplay playsinline muted :class="{ mirrored: mirror }" />
</template>

View File

@@ -0,0 +1,7 @@
export function useApiError() {
const { t, te } = useI18n();
return (error: any) => {
const key = `errors.${error?.data?.error || error?.name || "server"}`;
return te(key) ? t(key) : t("errors.server");
};
}

View File

@@ -0,0 +1,321 @@
export function useStream(mode: "host" | "viewer") {
const media = shallowRef<MediaStream | null>(null);
const remote = shallowRef<MediaStream | null>(null);
const state = ref("offline");
const error = ref("");
const source = ref<"front" | "back" | "screen">("front");
const rearAvailable = ref(false);
const screenAvailable = ref(false);
const resolution = ref("");
const quality = ref("auto");
let socket: WebSocket | null = null;
const peers = new Map<string, RTCPeerConnection>();
const pending = new Map<string, RTCIceCandidateInit[]>();
let retry: ReturnType<typeof setTimeout> | undefined;
let stats: ReturnType<typeof setInterval> | undefined;
let reconnects = 0;
let desired = false;
let captureVersion = 0;
const apiError = useApiError();
const session = useSessionStore();
function send(type: string, target: string, data?: unknown) {
if (socket?.readyState === WebSocket.OPEN)
socket.send(JSON.stringify({ type, target, data }));
}
function clearPeers() {
for (const p of peers.values()) p.close();
peers.clear();
pending.clear();
remote.value = null;
resolution.value = "";
}
function removePeer(id: string) {
peers.get(id)?.close();
peers.delete(id);
pending.delete(id);
}
async function setQuality(value: string) {
quality.value = value;
for (const peer of peers.values())
for (const sender of peer.getSenders()) {
if (sender.track?.kind !== "video") continue;
const params = sender.getParameters();
if (!params.encodings?.length) params.encodings = [{}];
params.encodings[0]!.maxBitrate =
value === "low" ? 600_000 : value === "high" ? 4_000_000 : 2_000_000;
try {
await sender.setParameters(params);
} catch {
/* Browser may not expose encoding controls. */
}
}
}
function peer(id: string) {
const p = new RTCPeerConnection({ iceServers: [] }); // LAN-only: no external STUN/TURN services.
peers.set(id, p);
p.onicecandidate = (e) => {
if (e.candidate) send("ice", id, e.candidate.toJSON());
};
p.ontrack = (e) => {
remote.value = e.streams[0] ?? new MediaStream([e.track]);
state.value = "live";
};
p.onconnectionstatechange = () => {
if (p.connectionState === "connected") {
if (mode === "viewer") state.value = "live";
void setQuality(quality.value);
}
if (p.connectionState === "disconnected" && mode === "viewer")
state.value = "reconnecting";
if (p.connectionState === "failed") {
removePeer(id);
if (mode === "viewer") {
state.value = "reconnecting";
send("renegotiate", id);
}
}
};
if (mode === "host")
media.value
?.getTracks()
.forEach((track) => p.addTrack(track, media.value!));
return p;
}
async function offer(id: string) {
removePeer(id);
const p = peer(id);
await p.setLocalDescription(await p.createOffer());
send("offer", id, p.localDescription);
}
async function drain(id: string, p: RTCPeerConnection) {
for (const ice of pending.get(id) ?? []) await p.addIceCandidate(ice);
pending.delete(id);
}
async function message(v: any) {
if (v.type === "error") {
desired = false;
error.value = apiError({ data: { error: v.error } });
state.value = "offline";
socket?.close();
return;
}
if (v.type === "welcome") {
state.value =
mode === "host" ? "live" : v.live ? "connecting" : "waiting";
reconnects = 0;
}
if (v.type === "host-offline") {
clearPeers();
state.value = "waiting";
}
if (v.type === "host-online") state.value = "connecting";
if (v.type === "viewer-left") removePeer(v.peer);
if (
(v.type === "viewer-joined" || v.type === "renegotiate") &&
mode === "host"
)
await offer(v.peer);
if (v.type === "offer" && mode === "viewer") {
// ICE messages are serialized behind this handler.
const earlyIce = pending.get(v.peer) ?? [];
removePeer(v.peer);
pending.set(v.peer, earlyIce);
const p = peer(v.peer);
await p.setRemoteDescription(v.data);
await drain(v.peer, p);
await p.setLocalDescription(await p.createAnswer());
send("answer", v.peer, p.localDescription);
}
if (v.type === "answer") {
const p = peers.get(v.peer);
if (p) {
await p.setRemoteDescription(v.data);
await drain(v.peer, p);
}
}
if (v.type === "ice") {
const p = peers.get(v.peer);
if (p?.remoteDescription) await p.addIceCandidate(v.data);
else pending.set(v.peer, [...(pending.get(v.peer) ?? []), v.data]);
}
}
function connect() {
if (!desired) return;
state.value = reconnects ? "reconnecting" : "connecting";
const ws = new WebSocket(
`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/signal?mode=${mode}`,
);
socket = ws;
let queue = Promise.resolve();
ws.onmessage = (e) => {
queue = queue
.then(() => {
if (socket === ws && desired) return message(JSON.parse(e.data));
})
.catch((e) => {
error.value = apiError(e);
});
};
ws.onclose = async () => {
if (socket !== ws) return;
socket = null;
clearPeers();
if (!desired) return;
state.value = "reconnecting";
if (!(await session.refresh())) {
desired = false;
stop();
await navigateTo("/login");
return;
}
if (!desired) return;
retry = setTimeout(connect, Math.min(1000 * 2 ** reconnects++, 10000));
};
}
async function detectSources() {
screenAvailable.value = !!navigator.mediaDevices?.getDisplayMedia;
try {
const devices = await navigator.mediaDevices?.enumerateDevices();
rearAvailable.value =
!!devices?.some(
(d) =>
d.kind === "videoinput" && /back|rear|environment/i.test(d.label),
) || (devices?.filter((d) => d.kind === "videoinput").length ?? 0) > 1;
} catch {
rearAvailable.value = false;
}
}
async function capture(selected = source.value) {
const version = ++captureVersion;
error.value = "";
if (!window.isSecureContext || !navigator.mediaDevices) {
error.value = apiError({ data: { error: "secureContext" } });
return false;
}
let next: MediaStream | null = null;
try {
const acquire = () =>
selected === "screen"
? navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30 },
audio: false,
})
: navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode:
selected === "back" ? { exact: "environment" } : "user",
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30, max: 30 },
},
});
try {
next = await acquire();
} catch (e: any) {
// Some mobile browsers require releasing the previous camera before opening another lens.
if (
e.name !== "NotReadableError" ||
!media.value ||
source.value === "screen" ||
selected === "screen"
)
throw e;
media.value.getTracks().forEach((t) => t.stop());
try {
next = await acquire();
} catch (retryError) {
stop();
throw retryError;
}
}
if (version !== captureVersion) {
next.getTracks().forEach((t) => t.stop());
return false;
}
const track = next.getVideoTracks()[0]!;
const renegotiate: string[] = [];
for (const [id, p] of peers) {
const sender = p.getSenders().find((s) => s.track?.kind === "video");
try {
await sender?.replaceTrack(track);
} catch {
renegotiate.push(id);
}
}
const previous = media.value;
media.value = next;
source.value = selected;
for (const id of renegotiate) await offer(id);
previous?.getTracks().forEach((t) => t.stop());
track.onended = () => {
if (media.value === next) stop();
};
await detectSources();
return true;
} catch (e) {
next?.getTracks().forEach((t) => t.stop());
error.value = apiError(e);
return false;
}
}
async function start() {
error.value = "";
if (desired) return;
if (mode === "host" && !media.value && !(await capture())) return;
desired = true;
reconnects = 0;
connect();
}
function stop() {
desired = false;
captureVersion++;
clearTimeout(retry);
const old = socket;
socket = null;
old?.close();
clearPeers();
media.value?.getTracks().forEach((t) => t.stop());
media.value = null;
state.value = "offline";
}
onMounted(() => {
void detectSources();
stats = setInterval(async () => {
if (mode !== "viewer") return;
for (const p of peers.values()) {
try {
(await p.getStats()).forEach((report) => {
if (
report.type === "inbound-rtp" &&
report.kind === "video" &&
report.frameWidth
)
resolution.value = `${report.frameWidth} × ${report.frameHeight}`;
});
} catch {
/* A closing peer has no stats. */
}
}
}, 3000);
});
onBeforeUnmount(() => {
stop();
clearInterval(stats);
});
return {
media,
remote,
state,
error,
source,
rearAvailable,
screenAvailable,
resolution,
quality,
capture,
start,
stop,
setQuality,
};
}

View File

@@ -0,0 +1,177 @@
{
"nav": {
"main": "Main navigation",
"host": "Host studio",
"watch": "Watch",
"accounts": "Accounts",
"settings": "Settings"
},
"common": {
"close": "Close",
"logout": "Sign out",
"skip": "Skip to content",
"working": "Working…",
"loading": "Loading…",
"cancel": "Cancel",
"save": "Save changes",
"refresh": "Refresh"
},
"login": {
"local": "Your network. Your space.",
"headline": "A little closer.",
"headlineAccent": "Wherever you are.",
"description": "Turn a device into a live window. Share your camera or screen, privately on your local network.",
"privacy": "No cloud. Just a connection.",
"title": "Welcome back",
"subtitle": "Sign in to this host to join the stream.",
"setupTitle": "Make yourself at home",
"setupDescription": "Create the administrator account for this host.",
"usernamePlaceholder": "Your username",
"passwordPlaceholder": "Your password",
"setupToken": "Setup token",
"tokenHint": "Use the token in the setup-token file created by the local server.",
"create": "Create host account",
"signin": "Sign in",
"hostAccounts": "Accounts belong to this host only",
"localUtility": "A local streaming utility"
},
"host": {
"eyebrow": "Your local workspace",
"title": "Host studio",
"subtitle": "A live window into your world. You’re in control.",
"start": "Go live",
"stop": "Stop hosting",
"previewTitle": "Your view starts here",
"previewHint": "Choose a source below, then go live when you’re ready.",
"enableCamera": "Enable preview",
"private": "Private stream",
"broadcasting": "Broadcasting to your network",
"previewOnly": "Only you can see this preview",
"audioOff": "Video only · No audio",
"videoSource": "Video source",
"switchAnytime": "Switch anytime",
"rearHint": "Enable the camera first to discover available lenses.",
"uptime": "Time live",
"viewers": "Viewers",
"quality": "Send quality",
"connectDevice": "Connect a device",
"scanHint": "Scan to open the stream on another device.",
"qrAlt": "QR code for the local watch address",
"localAddress": "Local address",
"copy": "Copy address",
"copied": "Address copied",
"sameNetwork": "Connect to the same Wi-Fi",
"connected": "Connected viewers",
"noViewers": "A quiet room, for now",
"inviteHint": "Share the link to invite your first viewer.",
"kick": "Disconnect",
"manageAccess": "Manage access",
"footer": "Local by design. Your stream stays on your network."
},
"sources": {
"front": "Front camera",
"back": "Back camera",
"screen": "Screen"
},
"stream": {
"offline": "Offline",
"connecting": "Connecting",
"reconnecting": "Reconnecting",
"waiting": "Waiting for host",
"live": "Live"
},
"quality": {
"auto": "Adaptive",
"high": "High · 4 Mbps",
"low": "Low · 600 Kbps"
},
"watch": {
"fullscreen": "Toggle fullscreen",
"disconnect": "Disconnect",
"reconnect": "Reconnect",
"waitingTitle": "Ready when they are",
"waitingHint": "The host isn’t live yet. The stream will appear here automatically.",
"connectingTitle": "Bringing you closer",
"connectingHint": "Connecting to the host on your local network."
},
"accounts": {
"eyebrow": "Who’s welcome",
"title": "Accounts & security",
"subtitle": "A private space, with access on your terms.",
"add": "Add viewer",
"protected": "Every connection starts with permission",
"protectedHint": "Sign-in is required. Passwords are hashed. Access can be revoked anytime.",
"localAccounts": "Local accounts",
"account": "Account",
"role": "Role",
"lastLogin": "Last sign-in",
"sessions": "Sessions",
"actions": "Actions",
"you": "You",
"admin": "Host / Admin",
"viewer": "Viewer",
"username": "Username",
"password": "Password",
"usernameHint": "3–40 characters: Latin letters, numbers, dots, underscores or hyphens.",
"passwordHint": "At least 12 characters, including a letter and a number.",
"never": "Not yet",
"reset": "Reset password",
"delete": "Delete account",
"saved": "Access settings updated.",
"activeSessions": "Active sessions",
"sessionHint": "Sessions expire after 30 minutes of inactivity or 12 hours.",
"current": "This session",
"lastActive": "Last active",
"revoke": "Revoke",
"resetHint": "All sessions for {name} will be signed out immediately.",
"deleteHint": "Delete {name}? This removes the account and immediately disconnects all its sessions."
},
"general": {
"eyebrow": "Make it yours",
"title": "Settings",
"subtitle": "Small details. Your kind of space.",
"appearance": "Appearance",
"dark": "Dark glass",
"light": "Light glass",
"accent": "Accent color",
"accentHint": "A little personality for your controls.",
"language": "Language",
"displayLanguage": "Display language",
"languageHint": "Used throughout Webka on this browser.",
"privacy": "Privacy & connection",
"loginRequired": "Require sign-in",
"loginHint": "Only accounts created on this host can watch.",
"alwaysOn": "Always on",
"localOnly": "Local network only",
"localHint": "No cloud relay, analytics, or external media services.",
"savedLocally": "Your preferences are saved in this browser."
},
"colors": {
"lime": "Lime",
"blue": "Blue",
"violet": "Violet",
"peach": "Peach"
},
"errors": {
"server": "Couldn’t complete the request. Check that the local server is running and try again.",
"unauthorized": "Your credentials are incorrect or your session has expired. Please sign in again.",
"forbidden": "This action requires a host account.",
"origin": "This address does not match the server’s configured address. Open the configured local URL.",
"setupToken": "The setup token is incorrect.",
"alreadySetup": "This host already has an administrator. Sign in instead.",
"username": "Use 3–40 Latin letters, numbers, dots, underscores or hyphens.",
"password": "Use at least 12 characters, with a letter and a number.",
"rateLimited": "Too many attempts. Wait a minute and try again.",
"duplicate": "That username is already taken.",
"protectedAccount": "The host account cannot be deleted.",
"notFound": "This account no longer exists.",
"hostBusy": "This session is already connected or another host is live. Close the other tab and try again.",
"secureContext": "Camera and screen access need HTTPS, or localhost on the server device. Open the secure host address.",
"NotAllowedError": "Access was cancelled or denied. Allow camera or screen access in your browser and try again.",
"NotFoundError": "No matching camera was found on this device.",
"NotReadableError": "The camera is busy. Close other apps using it and try again.",
"OverconstrainedError": "This camera source is unavailable. Try the front camera.",
"clipboard": "Couldn’t copy automatically. Select and copy the address above.",
"fullscreen": "Fullscreen isn’t available in this browser."
}
}

View File

@@ -0,0 +1,177 @@
{
"nav": {
"main": "Navegación principal",
"host": "Estudio",
"watch": "Ver",
"accounts": "Cuentas",
"settings": "Ajustes"
},
"common": {
"close": "Cerrar",
"logout": "Cerrar sesión",
"skip": "Ir al contenido",
"working": "Procesando…",
"loading": "Cargando…",
"cancel": "Cancelar",
"save": "Guardar cambios",
"refresh": "Actualizar"
},
"login": {
"local": "Tu red. Tu espacio.",
"headline": "Un poco más cerca.",
"headlineAccent": "Estés donde estés.",
"description": "Convierte un dispositivo en una ventana en directo. Comparte tu cámara o pantalla en tu red local.",
"privacy": "Sin nube. Solo una conexión.",
"title": "Te damos la bienvenida",
"subtitle": "Inicia sesión en este servidor para ver la transmisión.",
"setupTitle": "Este es tu espacio",
"setupDescription": "Crea la cuenta de administrador de este servidor.",
"usernamePlaceholder": "Tu nombre de usuario",
"passwordPlaceholder": "Tu contraseña",
"setupToken": "Código de configuración",
"tokenHint": "Usa el código del archivo setup-token creado por el servidor local.",
"create": "Crear cuenta",
"signin": "Iniciar sesión",
"hostAccounts": "Las cuentas pertenecen solo a este servidor",
"localUtility": "Transmisiones locales"
},
"host": {
"eyebrow": "Tu espacio local",
"title": "Estudio de emisión",
"subtitle": "Una ventana a tu mundo. Tú tienes el control.",
"start": "Emitir en directo",
"stop": "Dejar de emitir",
"previewTitle": "Tu vista empieza aquí",
"previewHint": "Elige una fuente y empieza a emitir cuando quieras.",
"enableCamera": "Activar vista previa",
"private": "Emisión privada",
"broadcasting": "Emitiendo en tu red",
"previewOnly": "Solo tú puedes ver esta vista previa",
"audioOff": "Solo vídeo · Sin audio",
"videoSource": "Fuente de vídeo",
"switchAnytime": "Cambia cuando quieras",
"rearHint": "Activa la cámara para detectar las lentes disponibles.",
"uptime": "Tiempo en directo",
"viewers": "Espectadores",
"quality": "Calidad de envío",
"connectDevice": "Conectar un dispositivo",
"scanHint": "Escanea para abrir la emisión en otro dispositivo.",
"qrAlt": "Código QR de la dirección local",
"localAddress": "Dirección local",
"copy": "Copiar dirección",
"copied": "Dirección copiada",
"sameNetwork": "Conéctate a la misma red Wi-Fi",
"connected": "Espectadores conectados",
"noViewers": "Por ahora, todo tranquilo",
"inviteHint": "Comparte el enlace para invitar a alguien.",
"kick": "Desconectar",
"manageAccess": "Gestionar acceso",
"footer": "Diseñado para tu red. Tu emisión se queda en ella."
},
"sources": {
"front": "Cámara frontal",
"back": "Cámara trasera",
"screen": "Pantalla"
},
"stream": {
"offline": "Desconectado",
"connecting": "Conectando",
"reconnecting": "Reconectando",
"waiting": "Esperando al anfitrión",
"live": "En directo"
},
"quality": {
"auto": "Adaptable",
"high": "Alta · 4 Mbps",
"low": "Baja · 600 Kbps"
},
"watch": {
"fullscreen": "Alternar pantalla completa",
"disconnect": "Desconectar",
"reconnect": "Reconectar",
"waitingTitle": "Todo listo para empezar",
"waitingHint": "El anfitrión aún no está emitiendo. El vídeo aparecerá automáticamente.",
"connectingTitle": "Conectando contigo",
"connectingHint": "Conectando al servidor de tu red local."
},
"accounts": {
"eyebrow": "Quién puede entrar",
"title": "Cuentas y seguridad",
"subtitle": "Un espacio privado, con acceso a tu manera.",
"add": "Añadir espectador",
"protected": "Cada conexión empieza con un permiso",
"protectedHint": "El inicio de sesión es obligatorio. Las contraseñas se almacenan con hash. Puedes revocar el acceso.",
"localAccounts": "Cuentas locales",
"account": "Cuenta",
"role": "Rol",
"lastLogin": "Último acceso",
"sessions": "Sesiones",
"actions": "Acciones",
"you": "Tú",
"admin": "Administrador",
"viewer": "Espectador",
"username": "Nombre de usuario",
"password": "Contraseña",
"usernameHint": "3–40 caracteres: letras latinas, números, puntos, guiones o guiones bajos.",
"passwordHint": "Al menos 12 caracteres, con una letra y un número.",
"never": "Todavía no",
"reset": "Restablecer contraseña",
"delete": "Eliminar cuenta",
"saved": "Ajustes de acceso actualizados.",
"activeSessions": "Sesiones activas",
"sessionHint": "Las sesiones caducan tras 30 minutos de inactividad o 12 horas.",
"current": "Esta sesión",
"lastActive": "Última actividad",
"revoke": "Revocar",
"resetHint": "Se cerrarán todas las sesiones de {name} de inmediato.",
"deleteHint": "¿Eliminar a {name}? Se borrará la cuenta y se desconectarán todas sus sesiones."
},
"general": {
"eyebrow": "A tu gusto",
"title": "Ajustes",
"subtitle": "Pequeños detalles para tu espacio.",
"appearance": "Apariencia",
"dark": "Cristal oscuro",
"light": "Cristal claro",
"accent": "Color de acento",
"accentHint": "Dale personalidad a los controles.",
"language": "Idioma",
"displayLanguage": "Idioma de la interfaz",
"languageHint": "Se usa en toda la aplicación en este navegador.",
"privacy": "Privacidad y conexión",
"loginRequired": "Exigir inicio de sesión",
"loginHint": "Solo las cuentas de este servidor pueden ver la emisión.",
"alwaysOn": "Siempre activo",
"localOnly": "Solo red local",
"localHint": "Sin retransmisión en la nube, analítica ni servicios multimedia externos.",
"savedLocally": "Tus preferencias se guardan en este navegador."
},
"colors": {
"lime": "Lima",
"blue": "Azul",
"violet": "Violeta",
"peach": "Melocotón"
},
"errors": {
"server": "No se pudo completar la solicitud. Comprueba que el servidor local esté en marcha.",
"unauthorized": "Las credenciales no son correctas o la sesión ha caducado. Vuelve a iniciar sesión.",
"forbidden": "Esta acción requiere una cuenta de administrador.",
"origin": "Esta dirección no coincide con la del servidor. Abre la URL local configurada.",
"setupToken": "El código de configuración no es correcto.",
"alreadySetup": "Este servidor ya tiene un administrador. Inicia sesión.",
"username": "Usa 3–40 letras latinas, números, puntos o guiones.",
"password": "Usa al menos 12 caracteres, con una letra y un número.",
"rateLimited": "Demasiados intentos. Espera un minuto.",
"duplicate": "Ese nombre de usuario ya existe.",
"protectedAccount": "No se puede eliminar la cuenta del administrador.",
"notFound": "Esta cuenta ya no existe.",
"hostBusy": "Esta sesión ya está conectada u otro anfitrión está emitiendo. Cierra la otra pestaña.",
"secureContext": "La cámara y la pantalla necesitan HTTPS o localhost en el servidor. Abre la dirección segura.",
"NotAllowedError": "Se canceló o denegó el acceso. Permite el acceso a la cámara o pantalla.",
"NotFoundError": "No se encontró una cámara compatible.",
"NotReadableError": "La cámara está ocupada. Cierra las otras aplicaciones que la usan.",
"OverconstrainedError": "Esta fuente no está disponible. Prueba la cámara frontal.",
"clipboard": "No se pudo copiar automáticamente. Selecciona y copia la dirección.",
"fullscreen": "La pantalla completa no está disponible en este navegador."
}
}

View File

@@ -0,0 +1,177 @@
{
"nav": {
"main": "Основная навигация",
"host": "Студия",
"watch": "Просмотр",
"accounts": "Аккаунты",
"settings": "Настройки"
},
"common": {
"close": "Закрыть",
"logout": "Выйти",
"skip": "К содержимому",
"working": "Подождите…",
"loading": "Загрузка…",
"cancel": "Отмена",
"save": "Сохранить",
"refresh": "Обновить"
},
"login": {
"local": "Ваша сеть. Ваше пространство.",
"headline": "Немного ближе.",
"headlineAccent": "Где бы вы ни были.",
"description": "Превратите устройство в окно прямого эфира. Делитесь камерой или экраном в своей локальной сети.",
"privacy": "Без облака. Только связь.",
"title": "С возвращением",
"subtitle": "Войдите на этот сервер для просмотра.",
"setupTitle": "Добро пожаловать домой",
"setupDescription": "Создайте аккаунт администратора этого сервера.",
"usernamePlaceholder": "Имя пользователя",
"passwordPlaceholder": "Пароль",
"setupToken": "Код первоначальной настройки",
"tokenHint": "Введите код из файла setup-token, созданного локальным сервером.",
"create": "Создать аккаунт",
"signin": "Войти",
"hostAccounts": "Аккаунты доступны только на этом сервере",
"localUtility": "Локальные трансляции"
},
"host": {
"eyebrow": "Ваше локальное пространство",
"title": "Студия трансляций",
"subtitle": "Окно в ваш мир. Всё под вашим контролем.",
"start": "Начать эфир",
"stop": "Остановить",
"previewTitle": "Здесь начинается ваш эфир",
"previewHint": "Выберите источник и начните эфир, когда будете готовы.",
"enableCamera": "Включить предпросмотр",
"private": "Приватный эфир",
"broadcasting": "Эфир в вашей сети",
"previewOnly": "Предпросмотр виден только вам",
"audioOff": "Только видео · Без звука",
"videoSource": "Источник видео",
"switchAnytime": "Меняйте в любой момент",
"rearHint": "Включите камеру, чтобы найти доступные объективы.",
"uptime": "Время эфира",
"viewers": "Зрители",
"quality": "Качество передачи",
"connectDevice": "Подключить устройство",
"scanHint": "Отсканируйте код на другом устройстве.",
"qrAlt": "QR-код адреса просмотра",
"localAddress": "Локальный адрес",
"copy": "Копировать адрес",
"copied": "Адрес скопирован",
"sameNetwork": "Подключитесь к той же Wi-Fi сети",
"connected": "Подключённые зрители",
"noViewers": "Пока здесь тихо",
"inviteHint": "Поделитесь ссылкой с первым зрителем.",
"kick": "Отключить",
"manageAccess": "Управление доступом",
"footer": "Локально по замыслу. Эфир остаётся в вашей сети."
},
"sources": {
"front": "Передняя камера",
"back": "Задняя камера",
"screen": "Экран"
},
"stream": {
"offline": "Не в эфире",
"connecting": "Подключение",
"reconnecting": "Переподключение",
"waiting": "Ожидание эфира",
"live": "В эфире"
},
"quality": {
"auto": "Автоматически",
"high": "Высокое · 4 Мбит/с",
"low": "Низкое · 600 Кбит/с"
},
"watch": {
"fullscreen": "Полноэкранный режим",
"disconnect": "Отключиться",
"reconnect": "Подключиться снова",
"waitingTitle": "Готовы к началу эфира",
"waitingHint": "Эфир ещё не начался. Видео появится здесь автоматически.",
"connectingTitle": "Устанавливаем связь",
"connectingHint": "Подключаемся к серверу в вашей локальной сети."
},
"accounts": {
"eyebrow": "Кому открыт доступ",
"title": "Аккаунты и безопасность",
"subtitle": "Личное пространство с доступом на ваших условиях.",
"add": "Добавить зрителя",
"protected": "Каждое подключение — с разрешения",
"protectedHint": "Вход обязателен. Пароли хешируются. Доступ можно отозвать в любой момент.",
"localAccounts": "Локальные аккаунты",
"account": "Аккаунт",
"role": "Роль",
"lastLogin": "Последний вход",
"sessions": "Сеансы",
"actions": "Действия",
"you": "Вы",
"admin": "Владелец",
"viewer": "Зритель",
"username": "Имя пользователя",
"password": "Пароль",
"usernameHint": "3–40 символов: латинские буквы, цифры, точки, подчёркивания или дефисы.",
"passwordHint": "Не менее 12 символов, включая букву и цифру.",
"never": "Ещё не входил",
"reset": "Сменить пароль",
"delete": "Удалить аккаунт",
"saved": "Настройки доступа обновлены.",
"activeSessions": "Активные сеансы",
"sessionHint": "Сеансы истекают через 30 минут бездействия или через 12 часов.",
"current": "Этот сеанс",
"lastActive": "Последняя активность",
"revoke": "Отозвать",
"resetHint": "Все сеансы пользователя {name} будут завершены.",
"deleteHint": "Удалить {name}? Аккаунт будет удалён, все его сеансы завершатся."
},
"general": {
"eyebrow": "На ваш вкус",
"title": "Настройки",
"subtitle": "Маленькие детали вашего пространства.",
"appearance": "Внешний вид",
"dark": "Тёмное стекло",
"light": "Светлое стекло",
"accent": "Цвет акцента",
"accentHint": "Добавьте индивидуальности элементам управления.",
"language": "Язык",
"displayLanguage": "Язык интерфейса",
"languageHint": "Используется во всём приложении в этом браузере.",
"privacy": "Приватность и подключение",
"loginRequired": "Обязательный вход",
"loginHint": "Просмотр доступен только аккаунтам этого сервера.",
"alwaysOn": "Всегда включено",
"localOnly": "Только локальная сеть",
"localHint": "Без облачных ретрансляторов, аналитики и внешних медиасервисов.",
"savedLocally": "Предпочтения сохраняются в этом браузере."
},
"colors": {
"lime": "Лайм",
"blue": "Голубой",
"violet": "Фиолетовый",
"peach": "Персиковый"
},
"errors": {
"server": "Не удалось выполнить запрос. Проверьте, что локальный сервер запущен.",
"unauthorized": "Неверные данные для входа или сеанс истёк. Войдите снова.",
"forbidden": "Для этого действия нужен аккаунт владельца.",
"origin": "Адрес не совпадает с настройкой сервера. Откройте настроенный локальный URL.",
"setupToken": "Неверный код первоначальной настройки.",
"alreadySetup": "Администратор уже создан. Войдите в аккаунт.",
"username": "Используйте 3–40 латинских букв, цифр, точек, подчёркиваний или дефисов.",
"password": "Используйте не менее 12 символов, включая букву и цифру.",
"rateLimited": "Слишком много попыток. Подождите минуту.",
"duplicate": "Это имя пользователя уже занято.",
"protectedAccount": "Аккаунт владельца нельзя удалить.",
"notFound": "Этот аккаунт больше не существует.",
"hostBusy": "Этот сеанс уже подключён или другой владелец ведёт эфир. Закройте другую вкладку.",
"secureContext": "Для доступа к камере и экрану нужен HTTPS или localhost на устройстве сервера. Откройте защищённый адрес.",
"NotAllowedError": "Доступ отменён или запрещён. Разрешите камеру или экран в браузере.",
"NotFoundError": "На устройстве не найдена подходящая камера.",
"NotReadableError": "Камера занята. Закройте другие использующие её приложения.",
"OverconstrainedError": "Этот источник недоступен. Попробуйте переднюю камеру.",
"clipboard": "Не удалось скопировать автоматически. Выделите и скопируйте адрес выше.",
"fullscreen": "Полноэкранный режим недоступен в этом браузере."
}
}

View File

@@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === "/login") return;
const session = useSessionStore();
if (!(await session.refresh())) return navigateTo("/login");
if (
(to.path === "/host" || to.path.startsWith("/settings/accounts")) &&
session.user?.role !== "admin"
)
return navigateTo("/watch");
if (to.path === "/")
return navigateTo(session.user?.role === "admin" ? "/host" : "/watch");
});

52
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,52 @@
export default defineNuxtConfig({
compatibilityDate: "2025-07-01",
ssr: false,
devtools: { enabled: false },
modules: ["@pinia/nuxt", "@nuxtjs/i18n"],
css: ["~/assets/css/main.css"],
app: {
head: {
title: "Webka",
meta: [
{
name: "viewport",
content: "width=device-width, initial-scale=1, viewport-fit=cover",
},
{ name: "theme-color", content: "#101217" },
],
},
},
i18n: {
strategy: "no_prefix",
defaultLocale: "en",
locales: [
{ code: "en", name: "English", file: "en.json" },
{ code: "ru", name: "Русский", file: "ru.json" },
{ code: "es", name: "Español", file: "es.json" },
],
detectBrowserLanguage: {
useCookie: true,
cookieKey: "webka_locale",
fallbackLocale: "en",
redirectOn: "root",
},
},
nitro: {
devProxy: {
"/api": {
target: "http://127.0.0.1:3001/api",
ws: true,
changeOrigin: false,
},
},
prerender: {
routes: [
"/login",
"/host",
"/watch",
"/settings/accounts",
"/settings/general",
],
},
},
});

13592
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "webka-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev --host 127.0.0.1",
"build": "nuxt generate",
"typecheck": "nuxt typecheck",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/i18n": "10.1.1",
"@pinia/nuxt": "0.11.3",
"lucide-vue-next": "^0.468.0",
"nuxt": "3.21.11",
"pinia": "^3.0.0",
"qrcode": "^1.5.4",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"typescript": "^5.9.0",
"vue-tsc": "^3.1.0"
},
"engines": {
"node": ">=22.12.0"
}
}

315
frontend/pages/host.vue Normal file
View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import {
Video,
SwitchCamera,
MonitorUp,
ArrowUpRight,
Copy,
Check,
ShieldCheck,
UsersRound,
Radio,
Square,
Maximize2,
Wifi,
RefreshCw,
} from "lucide-vue-next";
import QRCode from "qrcode";
const { t } = useI18n();
useHead({ title: computed(() => `${t("nav.host")} · Webka`) });
const {
media,
state,
error,
source,
rearAvailable,
screenAvailable,
quality,
capture,
start,
stop,
setQuality,
} = useStream("host");
interface HostStatus {
live: boolean;
since: number | null;
viewer_count: number;
viewers: { id: string; username: string; session_id: string }[];
local_url: string;
}
const status = ref<HostStatus | null>(null);
const qr = ref(""),
copied = ref(false),
busy = ref(false),
networkError = ref(""),
tick = ref(Date.now()),
preview = ref<HTMLElement>();
const apiError = useApiError();
let timer: ReturnType<typeof setInterval>;
const live = computed(() => state.value === "live");
const elapsed = computed(() => {
const seconds = status.value?.since
? Math.max(0, Math.floor(tick.value / 1000) - status.value.since)
: 0;
return `${Math.floor(seconds / 3600)
.toString()
.padStart(2, "0")}:${Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
});
async function refresh() {
try {
status.value = await $fetch<HostStatus>("/api/status");
networkError.value = "";
if (status.value?.local_url)
qr.value = await QRCode.toDataURL(status.value.local_url, {
margin: 1,
width: 176,
color: { dark: "#111418", light: "#ffffff" },
});
} catch (e) {
networkError.value = apiError(e);
}
}
onMounted(() => {
void refresh();
timer = setInterval(() => {
tick.value = Date.now();
void refresh();
}, 3000);
});
onBeforeUnmount(() => clearInterval(timer));
async function choose(value: "front" | "back" | "screen") {
busy.value = true;
await capture(value);
busy.value = false;
}
async function toggle() {
busy.value = true;
if (state.value !== "offline") stop();
else await start();
busy.value = false;
await refresh();
}
async function copy() {
try {
await navigator.clipboard.writeText(status.value?.local_url ?? "");
copied.value = true;
setTimeout(() => (copied.value = false), 1800);
} catch {
networkError.value = t("errors.clipboard");
}
}
async function kick(sid: string) {
try {
await $fetch(`/api/sessions/${sid}`, { method: "DELETE" });
await refresh();
} catch (e) {
networkError.value = apiError(e);
}
}
async function fullscreen() {
try {
await preview.value?.requestFullscreen();
} catch {
networkError.value = t("errors.fullscreen");
}
}
</script>
<template>
<div class="page host-page">
<div class="page-heading">
<div>
<div class="eyebrow">{{ $t("host.eyebrow") }}</div>
<h1>{{ $t("host.title") }}<span class="heading-dot">.</span></h1>
<p class="muted">{{ $t("host.subtitle") }}</p>
</div>
<GlassButton
:variant="live ? 'danger' : 'primary'"
:disabled="busy"
@click="toggle"
><Square v-if="live" :size="16" fill="currentColor" /><Radio
v-else
:size="18"
/>{{
$t(state !== "offline" ? "host.stop" : "host.start")
}}</GlassButton
>
</div>
<p v-if="error || networkError" role="alert" class="error">
{{ error || networkError }}
</p>
<div class="host-grid">
<section class="feed-section">
<div ref="preview" class="preview">
<StreamVideo
v-if="media"
:stream="media"
:mirror="source === 'front'"
/>
<div v-else class="preview-empty">
<div class="camera-orbit">
<Video :size="38" :stroke-width="1.3" />
</div>
<h2>{{ $t("host.previewTitle") }}</h2>
<p>{{ $t("host.previewHint") }}</p>
<GlassButton :disabled="busy" @click="choose(source)"
><Video :size="17" />{{ $t("host.enableCamera") }}</GlassButton
>
</div>
<div class="preview-top">
<span class="glass status-pill" role="status"
><span class="status-dot" :class="{ live }" />{{
$t(`stream.${state}`)
}}</span
><span class="glass private-pill"
><ShieldCheck :size="14" />{{ $t("host.private") }}</span
>
</div>
<div v-if="media" class="preview-bottom">
<span class="glass status-pill">{{ $t(`sources.${source}`) }}</span
><GlassButton
class="icon-button"
:aria-label="$t('watch.fullscreen')"
@click="fullscreen"
><Maximize2 :size="18"
/></GlassButton>
</div>
</div>
<div class="feed-caption">
<span
><span class="status-dot" :class="{ live }" />{{
$t(live ? "host.broadcasting" : "host.previewOnly")
}}</span
><span>{{ $t("host.audioOff") }}</span>
</div>
<GlassCard class="source-toolbar"
><div class="toolbar-label">
<span class="eyebrow">{{ $t("host.videoSource") }}</span
><small>{{ $t("host.switchAnytime") }}</small>
</div>
<div class="source-options">
<button
:class="{ selected: source === 'front' }"
:disabled="busy"
@click="choose('front')"
>
<Video :size="20" /><span>{{ $t("sources.front") }}</span></button
><button
:class="{ selected: source === 'back' }"
:disabled="busy || !rearAvailable"
:title="!rearAvailable ? $t('host.rearHint') : undefined"
@click="choose('back')"
>
<SwitchCamera :size="20" /><span>{{
$t("sources.back")
}}</span></button
><button
:class="{ selected: source === 'screen' }"
:disabled="busy || !screenAvailable"
@click="choose('screen')"
>
<MonitorUp :size="20" /><span>{{ $t("sources.screen") }}</span>
</button>
</div></GlassCard
>
<div class="stats-row">
<div>
<span class="muted">{{ $t("host.uptime") }}</span
><strong class="mono">{{ elapsed }}</strong>
</div>
<div>
<span class="muted">{{ $t("host.viewers") }}</span
><strong
>{{ status?.viewer_count ?? 0 }}<small> / 31</small></strong
>
</div>
<div>
<label class="muted" for="quality">{{ $t("host.quality") }}</label
><select
id="quality"
:value="quality"
@change="setQuality(($event.target as HTMLSelectElement).value)"
>
<option value="auto">{{ $t("quality.auto") }}</option>
<option value="high">{{ $t("quality.high") }}</option>
<option value="low">{{ $t("quality.low") }}</option>
</select>
</div>
</div>
</section>
<aside class="host-aside">
<GlassCard class="share-panel"
><div class="section-heading">
<h2>{{ $t("host.connectDevice") }}</h2>
<ArrowUpRight :size="20" />
</div>
<p class="muted">{{ $t("host.scanHint") }}</p>
<div class="qr-wrap">
<img
v-if="qr"
:src="qr"
:alt="$t('host.qrAlt')"
width="154"
height="154"
/>
<div v-else class="qr-loading"><RefreshCw :size="24" /></div>
</div>
<div class="url-copy">
<input
:value="status?.local_url ?? ''"
readonly
:aria-label="$t('host.localAddress')"
/><GlassButton
class="icon-button"
:disabled="!status"
:aria-label="$t('host.copy')"
@click="copy"
><Check v-if="copied" :size="18" /><Copy v-else :size="18"
/></GlassButton>
</div>
<span class="local-note"
><Wifi :size="14" />{{
$t(copied ? "host.copied" : "host.sameNetwork")
}}</span
></GlassCard
>
<section class="viewers-panel">
<div class="section-heading">
<h2>
{{ $t("host.connected")
}}<span class="count-badge">{{ status?.viewer_count ?? 0 }}</span>
</h2>
<UsersRound :size="19" />
</div>
<div v-if="!status?.viewers.length" class="empty-viewers">
<UsersRound :size="28" :stroke-width="1.3" />
<p>{{ $t("host.noViewers") }}</p>
<small>{{ $t("host.inviteHint") }}</small>
</div>
<ul v-else class="viewer-list">
<li v-for="viewer in status.viewers" :key="viewer.id">
<span class="avatar">{{
viewer.username.slice(0, 1).toUpperCase()
}}</span
><span
>{{ viewer.username
}}<small>{{ $t("stream.live") }}</small></span
><GlassButton
class="small-button"
@click="kick(viewer.session_id)"
>{{ $t("host.kick") }}</GlassButton
>
</li>
</ul>
<NuxtLink class="text-link" to="/settings/accounts"
>{{ $t("host.manageAccess") }}<ArrowUpRight :size="16"
/></NuxtLink>
</section>
</aside>
</div>
<footer class="page-footer">
<ShieldCheck :size="16" />{{ $t("host.footer") }}<span>WEBKA / LAN</span>
</footer>
</div>
</template>

1
frontend/pages/index.vue Normal file
View File

@@ -0,0 +1 @@
<template><div /></template>

140
frontend/pages/login.vue Normal file
View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { ArrowUpRight, LockKeyhole, Video, ShieldCheck } from "lucide-vue-next";
const { t } = useI18n();
useHead({ title: computed(() => `${t("login.title")} · Webka`) });
const session = useSessionStore();
const username = ref(""),
password = ref(""),
token = ref(""),
setup = ref(false),
loading = ref(true),
error = ref("");
const apiError = useApiError();
onMounted(async () => {
try {
if (await session.refresh()) {
await navigateTo(session.user?.role === "admin" ? "/host" : "/watch");
return;
}
setup.value = (
await $fetch<{ needs_setup: boolean }>("/api/bootstrap")
).needs_setup;
} catch (e) {
error.value = apiError(e);
} finally {
loading.value = false;
}
});
async function submit() {
error.value = "";
loading.value = true;
try {
if (setup.value)
await $fetch("/api/bootstrap", {
method: "POST",
body: {
username: username.value,
password: password.value,
setup_token: token.value,
},
});
await $fetch("/api/login", {
method: "POST",
body: { username: username.value, password: password.value },
});
await session.refresh();
await navigateTo(session.user?.role === "admin" ? "/host" : "/watch");
} catch (e) {
error.value = apiError(e);
try {
setup.value = (
await $fetch<{ needs_setup: boolean }>("/api/bootstrap")
).needs_setup;
} catch {}
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-layout">
<div class="login-intro">
<span class="eyebrow"
><span class="status-dot" />{{ $t("login.local") }}</span
>
<h1>
{{ $t("login.headline") }}<br /><span>{{
$t("login.headlineAccent")
}}</span>
</h1>
<p>{{ $t("login.description") }}</p>
<div class="login-principle">
<ShieldCheck :size="22" /><span>{{ $t("login.privacy") }}</span>
</div>
</div>
<GlassCard class="login-card"
><div class="login-icon"><Video :size="27" /></div>
<h2>{{ $t(setup ? "login.setupTitle" : "login.title") }}</h2>
<p class="muted">
{{ $t(setup ? "login.setupDescription" : "login.subtitle") }}
</p>
<form @submit.prevent="submit">
<label
>{{ $t("accounts.username")
}}<input
v-model="username"
autocomplete="username"
required
minlength="3"
maxlength="40"
pattern="[A-Za-z0-9_.\-]+"
:placeholder="$t('login.usernamePlaceholder')"
/></label>
<label
>{{ $t("accounts.password")
}}<input
v-model="password"
type="password"
:autocomplete="setup ? 'new-password' : 'current-password'"
required
:minlength="setup ? 12 : 1"
maxlength="256"
:pattern="
setup ? '(?=.*[A-Za-zА-Яа-яЁёÀ-ÿ])(?=.*[0-9]).{12,}' : undefined
"
:placeholder="$t('login.passwordPlaceholder')"
/></label>
<template v-if="setup"
><p class="field-help">{{ $t("accounts.passwordHint") }}</p>
<label
>{{ $t("login.setupToken")
}}<input v-model="token" required autocomplete="off"
/></label>
<p class="field-help">{{ $t("login.tokenHint") }}</p></template
>
<p v-if="error" role="alert" class="error">{{ error }}</p>
<GlassButton
variant="primary"
type="submit"
class="full-width"
:disabled="loading"
>{{
$t(
loading
? "common.working"
: setup
? "login.create"
: "login.signin",
)
}}<ArrowUpRight :size="19"
/></GlassButton>
</form>
<div class="login-foot">
<LockKeyhole :size="14" />{{ $t("login.hostAccounts") }}
</div>
</GlassCard>
<div class="login-bottom">
WEBKA <span>01 / {{ $t("login.localUtility") }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import {
Plus,
ShieldCheck,
KeyRound,
Trash2,
UsersRound,
} from "lucide-vue-next";
interface Account {
id: string;
username: string;
role: string;
last_login: number | null;
active_sessions: number;
}
interface Session {
id: string;
username: string;
last_seen: number;
created: number;
}
const { t, locale } = useI18n();
useHead({ title: computed(() => `${t("accounts.title")} · Webka`) });
const current = useSessionStore();
const accounts = ref<Account[]>([]),
sessions = ref<Session[]>([]),
loading = ref(true),
busy = ref(false),
error = ref(""),
success = ref("");
const modal = ref<"create" | "reset" | "delete" | null>(null),
selected = ref<Account | null>(null),
username = ref(""),
password = ref("");
const apiError = useApiError();
async function load() {
try {
[accounts.value, sessions.value] = await Promise.all([
$fetch<Account[]>("/api/accounts"),
$fetch<Session[]>("/api/sessions"),
]);
} catch (e) {
error.value = apiError(e);
} finally {
loading.value = false;
}
}
onMounted(load);
function open(
action: "create" | "reset" | "delete",
account: Account | null = null,
) {
modal.value = action;
selected.value = account;
username.value = "";
password.value = "";
error.value = "";
success.value = "";
}
function date(value: number | null) {
return value
? new Intl.DateTimeFormat(locale.value, {
dateStyle: "medium",
timeStyle: "short",
}).format(value * 1000)
: t("accounts.never");
}
async function submit() {
busy.value = true;
error.value = "";
try {
if (modal.value === "create")
await $fetch("/api/accounts", {
method: "POST",
body: { username: username.value, password: password.value },
});
if (modal.value === "reset")
await $fetch(`/api/accounts/${selected.value!.id}/password`, {
method: "POST",
body: { password: password.value },
});
if (modal.value === "delete")
await $fetch(`/api/accounts/${selected.value!.id}`, { method: "DELETE" });
if (modal.value === "reset" && selected.value?.id === current.user?.id) {
modal.value = null;
await current.refresh();
await navigateTo("/login");
return;
}
modal.value = null;
success.value = t("accounts.saved");
await load();
} catch (e) {
error.value = apiError(e);
} finally {
busy.value = false;
}
}
async function revoke(id: string) {
busy.value = true;
error.value = "";
try {
await $fetch(`/api/sessions/${id}`, { method: "DELETE" });
await load();
} catch (e) {
error.value = apiError(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="page settings-page">
<div class="page-heading">
<div>
<div class="eyebrow">{{ $t("accounts.eyebrow") }}</div>
<h1>{{ $t("accounts.title") }}<span class="heading-dot">.</span></h1>
<p class="muted">{{ $t("accounts.subtitle") }}</p>
</div>
<GlassButton variant="primary" @click="open('create')"
><Plus :size="18" />{{ $t("accounts.add") }}</GlassButton
>
</div>
<p v-if="error && !modal" role="alert" class="error">{{ error }}</p>
<p v-if="success" role="status" class="success">{{ success }}</p>
<div class="security-banner">
<ShieldCheck :size="23" />
<div>
<strong>{{ $t("accounts.protected") }}</strong>
<p>{{ $t("accounts.protectedHint") }}</p>
</div>
<span class="status-dot live" />
</div>
<section class="content-panel">
<div class="section-heading">
<h2><UsersRound :size="20" />{{ $t("accounts.localAccounts") }}</h2>
<span class="count-badge">{{ accounts.length }}</span>
</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<div v-else class="table-scroll">
<table>
<thead>
<tr>
<th>{{ $t("accounts.account") }}</th>
<th>{{ $t("accounts.role") }}</th>
<th>{{ $t("accounts.lastLogin") }}</th>
<th>{{ $t("accounts.sessions") }}</th>
<th>
<span class="sr-only">{{ $t("accounts.actions") }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="account in accounts" :key="account.id">
<td>
<span class="account-name"
><span class="avatar">{{
account.username[0]?.toUpperCase()
}}</span
>{{ account.username
}}<small v-if="account.id === current.user?.id">{{
$t("accounts.you")
}}</small></span
>
</td>
<td>
<span class="role-tag" :class="account.role">{{
$t(`accounts.${account.role}`)
}}</span>
</td>
<td class="muted">{{ date(account.last_login) }}</td>
<td>{{ account.active_sessions }}</td>
<td>
<div class="row-actions">
<GlassButton
class="icon-button"
:aria-label="`${$t('accounts.reset')} — ${account.username}`"
@click="open('reset', account)"
><KeyRound :size="17" /></GlassButton
><GlassButton
v-if="account.role !== 'admin'"
class="icon-button"
:aria-label="`${$t('accounts.delete')} — ${account.username}`"
@click="open('delete', account)"
><Trash2 :size="17"
/></GlassButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-panel">
<div class="section-heading">
<h2>{{ $t("accounts.activeSessions") }}</h2>
<GlassButton class="small-button" @click="load">{{
$t("common.refresh")
}}</GlassButton>
</div>
<p class="muted">{{ $t("accounts.sessionHint") }}</p>
<div class="session-row" v-for="session in sessions" :key="session.id">
<span class="avatar">{{ session.username[0]?.toUpperCase() }}</span>
<div>
<strong
>{{ session.username }}
<small v-if="session.id === current.user?.session_id">{{
$t("accounts.current")
}}</small></strong
><small
>{{ $t("accounts.lastActive") }} ·
{{ date(session.last_seen) }}</small
>
</div>
<GlassButton
v-if="session.id !== current.user?.session_id"
class="small-button"
:disabled="busy"
@click="revoke(session.id)"
>{{ $t("accounts.revoke") }}</GlassButton
>
</div>
</section>
<GlassModal
:open="!!modal"
:title="
$t(
modal === 'create'
? 'accounts.add'
: modal === 'reset'
? 'accounts.reset'
: 'accounts.delete',
)
"
@close="!busy && (modal = null)"
><form @submit.prevent="submit">
<template v-if="modal === 'create'"
><label
>{{ $t("accounts.username")
}}<input
v-model="username"
required
minlength="3"
maxlength="40"
pattern="[A-Za-z0-9_.\-]+"
autocomplete="off"
/></label>
<p class="field-help">{{ $t("accounts.usernameHint") }}</p></template
><template v-if="modal !== 'delete'"
><label
>{{ $t("accounts.password")
}}<input
v-model="password"
type="password"
required
minlength="12"
maxlength="256"
pattern="(?=.*[A-Za-zА-Яа-яЁёÀ-ÿ])(?=.*[0-9]).{12,}"
autocomplete="new-password"
/></label>
<p class="field-help">{{ $t("accounts.passwordHint") }}</p>
<p v-if="modal === 'reset'" class="muted">
{{ $t("accounts.resetHint", { name: selected?.username }) }}
</p></template
>
<p v-else>
{{ $t("accounts.deleteHint", { name: selected?.username }) }}
</p>
<p v-if="error" role="alert" class="error">{{ error }}</p>
<div class="modal-actions">
<GlassButton :disabled="busy" @click="modal = null">{{
$t("common.cancel")
}}</GlassButton
><GlassButton
:variant="modal === 'delete' ? 'danger' : 'primary'"
type="submit"
:disabled="busy"
>{{
$t(
busy
? "common.working"
: modal === "delete"
? "accounts.delete"
: "common.save",
)
}}</GlassButton
>
</div>
</form></GlassModal
>
</div>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import {
Moon,
Sun,
Check,
Globe2,
Palette,
ShieldCheck,
} from "lucide-vue-next";
const { locale, setLocale, t } = useI18n();
useHead({ title: computed(() => `${t("general.title")} · Webka`) });
const theme = useCookie("webka_theme", { default: () => "dark" });
const accent = useCookie("webka_accent", { default: () => "#c3f66b" });
const colors = [
{ color: "#c3f66b", key: "lime" },
{ color: "#91c9ff", key: "blue" },
{ color: "#c9afff", key: "violet" },
{ color: "#ffc899", key: "peach" },
];
</script>
<template>
<div class="page settings-page narrow">
<div class="page-heading">
<div>
<div class="eyebrow">{{ $t("general.eyebrow") }}</div>
<h1>{{ $t("general.title") }}<span class="heading-dot">.</span></h1>
<p class="muted">{{ $t("general.subtitle") }}</p>
</div>
</div>
<section class="content-panel">
<div class="section-heading">
<h2><Palette :size="21" />{{ $t("general.appearance") }}</h2>
</div>
<div class="theme-options">
<button :class="{ selected: theme === 'dark' }" @click="theme = 'dark'">
<div class="theme-preview dark-preview"><span /><span /><span /></div>
<span
><Moon :size="18" />{{ $t("general.dark")
}}<Check v-if="theme === 'dark'" :size="17"
/></span></button
><button
:class="{ selected: theme === 'light' }"
@click="theme = 'light'"
>
<div class="theme-preview light-preview">
<span /><span /><span />
</div>
<span
><Sun :size="18" />{{ $t("general.light")
}}<Check v-if="theme === 'light'" :size="17"
/></span>
</button>
</div>
<div class="setting-row">
<div>
<h3>{{ $t("general.accent") }}</h3>
<p>{{ $t("general.accentHint") }}</p>
</div>
<div class="swatches">
<button
v-for="color in colors"
:key="color.color"
:style="{ background: color.color }"
:aria-label="$t(`colors.${color.key}`)"
:aria-pressed="accent === color.color"
@click="accent = color.color"
>
<Check v-if="accent === color.color" :size="19" />
</button>
</div>
</div>
</section>
<section class="content-panel">
<div class="section-heading">
<h2><Globe2 :size="21" />{{ $t("general.language") }}</h2>
</div>
<div class="setting-row">
<div>
<h3>{{ $t("general.displayLanguage") }}</h3>
<p>{{ $t("general.languageHint") }}</p>
</div>
<select
:value="locale"
:aria-label="$t('general.language')"
@change="
setLocale(
($event.target as HTMLSelectElement).value as 'en' | 'ru' | 'es',
)
"
>
<option value="en">English</option>
<option value="ru">Русский</option>
<option value="es">Español</option>
</select>
</div>
</section>
<section class="content-panel">
<div class="section-heading">
<h2><ShieldCheck :size="21" />{{ $t("general.privacy") }}</h2>
</div>
<div class="setting-row">
<div>
<h3>{{ $t("general.loginRequired") }}</h3>
<p>{{ $t("general.loginHint") }}</p>
</div>
<span class="role-tag admin">{{ $t("general.alwaysOn") }}</span>
</div>
<div class="setting-row">
<div>
<h3>{{ $t("general.localOnly") }}</h3>
<p>{{ $t("general.localHint") }}</p>
</div>
<span class="status-dot live" />
</div>
</section>
<p class="settings-footnote">{{ $t("general.savedLocally") }}</p>
</div>
</template>

88
frontend/pages/watch.vue Normal file
View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import {
Maximize2,
Minimize2,
LogOut,
Radio,
Wifi,
RotateCw,
} from "lucide-vue-next";
const { t } = useI18n();
useHead({ title: computed(() => `${t("nav.watch")} · Webka`) });
const session = useSessionStore();
const { remote, state, error, resolution, start, stop } = useStream("viewer");
const player = ref<HTMLElement>(),
full = ref(false),
actionError = ref("");
onMounted(() => {
void start();
document.addEventListener("fullscreenchange", changed);
});
onBeforeUnmount(() =>
document.removeEventListener("fullscreenchange", changed),
);
function changed() {
full.value = !!document.fullscreenElement;
}
async function fullscreen() {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await player.value?.requestFullscreen();
} catch {
actionError.value = t("errors.fullscreen");
}
}
async function disconnect() {
stop();
await session.logout();
}
</script>
<template>
<div ref="player" class="watch-page">
<StreamVideo v-if="remote" :stream="remote" />
<div v-else class="watch-empty">
<Radio :size="44" :stroke-width="1.2" />
<div class="eyebrow">{{ $t(`stream.${state}`) }}</div>
<h1>
{{
$t(
state === "waiting"
? "watch.waitingTitle"
: "watch.connectingTitle",
)
}}
</h1>
<p>
{{
$t(state === "waiting" ? "watch.waitingHint" : "watch.connectingHint")
}}
</p>
<GlassButton v-if="state === 'offline'" @click="start"
><RotateCw :size="18" />{{ $t("watch.reconnect") }}</GlassButton
>
</div>
<p v-if="error || actionError" class="error watch-error" role="alert">
{{ error || actionError }}
</p>
<GlassCard class="watch-controls"
><span class="watch-state" role="status"
><span class="status-dot" :class="{ live: state === 'live' }" />{{
$t(`stream.${state}`)
}}</span
><span class="watch-quality"
><Wifi :size="17" />{{ resolution || $t("quality.auto") }}</span
><GlassButton
class="icon-button"
:aria-label="$t('watch.fullscreen')"
@click="fullscreen"
><Minimize2 v-if="full" :size="20" /><Maximize2
v-else
:size="20" /></GlassButton
><GlassButton @click="disconnect"
><LogOut :size="18" /><span>{{
$t("watch.disconnect")
}}</span></GlassButton
><LanguageSwitcher v-if="full"
/></GlassCard>
</div>
</template>

View File

@@ -0,0 +1,37 @@
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
const root = fileURLToPath(new URL("../", import.meta.url));
const flatten = (value, prefix = "") =>
Object.entries(value).flatMap(([key, v]) =>
typeof v === "object"
? flatten(v, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
const locales = ["en", "ru", "es"].map((code) =>
JSON.parse(readFileSync(join(root, `i18n/locales/${code}.json`), "utf8")),
);
const expected = flatten(locales[0]).sort();
for (let i = 1; i < locales.length; i++)
if (JSON.stringify(flatten(locales[i]).sort()) !== JSON.stringify(expected))
throw Error("Locale key mismatch");
const files = (path) =>
readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? files(join(path, entry.name))
: [join(path, entry.name)],
);
for (const file of [
...files(join(root, "pages")),
...files(join(root, "components")),
join(root, "app.vue"),
]) {
for (const match of readFileSync(file, "utf8").matchAll(
/\b(?:\$t|t)\(['"]([\w.]+)['"]/g,
))
if (!expected.includes(match[1]))
throw Error(`${file}: Missing ${match[1]}`);
}
console.log(
`${expected.length} keys match in en/ru/es; static template keys verified.`,
);

View File

@@ -0,0 +1,31 @@
import { defineStore } from "pinia";
export interface User {
id: string;
username: string;
role: "admin" | "viewer";
session_id: string;
}
export const useSessionStore = defineStore("session", () => {
const user = ref<User | null>(null);
const ready = ref(false);
async function refresh() {
try {
user.value = await $fetch<User>("/api/session");
return true;
} catch {
user.value = null;
return false;
} finally {
ready.value = true;
}
}
async function logout() {
try {
await $fetch("/api/logout", { method: "POST" });
} finally {
user.value = null;
await navigateTo("/login");
}
}
return { user, ready, refresh, logout };
});

1
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{ "extends": "./.nuxt/tsconfig.json" }

62
scripts/smoke.py Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Exercise the actual Rust/static HTTP service; keep all service work foreground."""
import http.cookiejar
import json
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
root = Path(__file__).resolve().parents[1]
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
origin = f'http://127.0.0.1:{port}'
with tempfile.TemporaryDirectory(prefix='webka-smoke-') as data:
env = dict(os.environ, WEBKA_DATA=data, WEBKA_BIND=f'127.0.0.1:{port}', WEBKA_ORIGIN=origin, WEBKA_MDNS='0')
proc = subprocess.Popen([str(root / 'backend/target/debug/webka')], cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
for _ in range(100):
try:
with urllib.request.urlopen(origin + '/api/bootstrap', timeout=1) as response:
assert json.load(response)['needs_setup'] is True
break
except (urllib.error.URLError, ConnectionError):
if proc.poll() is not None:
raise RuntimeError(proc.stderr.read().decode())
time.sleep(0.1)
else:
raise RuntimeError('Service did not become ready')
for route in ['/', '/login', '/host', '/watch', '/settings/accounts', '/settings/general']:
with urllib.request.urlopen(origin + route) as response:
assert response.status == 200, route
assert 'text/html' in response.headers['Content-Type'], route
assert response.headers['X-Content-Type-Options'] == 'nosniff'
cookies = http.cookiejar.CookieJar()
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
def request(path, body=None):
return client.open(urllib.request.Request(origin + path, data=json.dumps(body).encode() if body is not None else None, headers={'Origin': origin, 'Content-Type': 'application/json'}))
password = 'smoke-test-password-123'
with request('/api/bootstrap', {'username': 'owner', 'password': password, 'setup_token': Path(data, 'setup-token').read_text()}) as response:
assert response.status == 201
with request('/api/login', {'username': 'owner', 'password': password}) as response:
assert response.status == 200
with request('/api/session') as response:
assert json.load(response)['role'] == 'admin'
with request('/api/status') as response:
status = json.load(response)
assert status['local_url'] == origin + '/watch' and status['live'] is False
with request('/api/logout', {}) as response:
assert response.status == 204
print('PASS: all six static routes, first-run setup, login, cookie/session, local share URL, logout, security headers.')
finally:
proc.terminate()
try:
proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()