From 2bca6f0e905c98728e8c5b898b8a02b6510b8776 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sun, 9 Aug 2026 17:09:06 -0400 Subject: [PATCH] Add Rust server Owns the database, PIN rotation and all LiveKit token minting. Kiosk registration, session-stable joins, admin auth, layouts, branding, media uploads and the hosted installer script. Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD --- server/.env.example | 21 ++ server/Cargo.toml | 23 ++ server/Dockerfile | 17 ++ server/assets/install.sh | 309 +++++++++++++++++++++++++++ server/migrations/0001_init.sql | 49 +++++ server/migrations/0002_metrics.sql | 2 + server/migrations/0003_settings.sql | 5 + server/src/auth.rs | 135 ++++++++++++ server/src/bootstrap.rs | 89 ++++++++ server/src/clock.rs | 9 + server/src/config.rs | 68 ++++++ server/src/error.rs | 73 +++++++ server/src/layout.rs | 193 +++++++++++++++++ server/src/livekit.rs | 78 +++++++ server/src/main.rs | 121 +++++++++++ server/src/models.rs | 60 ++++++ server/src/pins.rs | 136 ++++++++++++ server/src/routes/admin.rs | 317 ++++++++++++++++++++++++++++ server/src/routes/install.rs | 45 ++++ server/src/routes/join.rs | 182 ++++++++++++++++ server/src/routes/kiosk.rs | 193 +++++++++++++++++ server/src/routes/media.rs | 150 +++++++++++++ server/src/routes/mod.rs | 53 +++++ server/src/routes/organization.rs | 114 ++++++++++ server/src/state.rs | 20 ++ 25 files changed, 2462 insertions(+) create mode 100644 server/.env.example create mode 100644 server/Cargo.toml create mode 100644 server/Dockerfile create mode 100644 server/assets/install.sh create mode 100644 server/migrations/0001_init.sql create mode 100644 server/migrations/0002_metrics.sql create mode 100644 server/migrations/0003_settings.sql create mode 100644 server/src/auth.rs create mode 100644 server/src/bootstrap.rs create mode 100644 server/src/clock.rs create mode 100644 server/src/config.rs create mode 100644 server/src/error.rs create mode 100644 server/src/layout.rs create mode 100644 server/src/livekit.rs create mode 100644 server/src/main.rs create mode 100644 server/src/models.rs create mode 100644 server/src/pins.rs create mode 100644 server/src/routes/admin.rs create mode 100644 server/src/routes/install.rs create mode 100644 server/src/routes/join.rs create mode 100644 server/src/routes/kiosk.rs create mode 100644 server/src/routes/media.rs create mode 100644 server/src/routes/mod.rs create mode 100644 server/src/routes/organization.rs create mode 100644 server/src/state.rs diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..c07257f --- /dev/null +++ b/server/.env.example @@ -0,0 +1,21 @@ +BIND_ADDRESS=0.0.0.0:8080 +DATABASE_URL=sqlite://data/pistation.db?mode=rwc +MEDIA_DIR=data/media +PACKAGE_DIR=data/packages +PUBLIC_API_URL= + +LIVEKIT_URL=ws://localhost:7880 +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=devsecretdevsecretdevsecretdevsecret + +SESSION_SECRET=change-me-to-a-long-random-string +SESSION_TTL_HOURS=12 + +BOOTSTRAP_ADMIN_EMAIL=admin@pistation.local +BOOTSTRAP_ADMIN_PASSWORD=changeme + +PIN_ROTATION_SECONDS=45 +PIN_GRACE_SECONDS=15 + +PUBLIC_WEB_URL=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://localhost:1420 diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..056019b --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "pistation-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["cors", "fs", "trace"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "migrate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +jsonwebtoken = "9" +argon2 = { version = "0.5", features = ["std"] } +rand = "0.8" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenvy = "0.15" +sha2 = "0.10" +hex = "0.4" diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..ebaa80c --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY server/Cargo.toml ./Cargo.toml +COPY server/src ./src +COPY server/assets ./assets +COPY server/migrations ./migrations +RUN cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /build/target/release/pistation-server /usr/local/bin/pistation-server +RUN mkdir -p /data +EXPOSE 8080 +CMD ["pistation-server"] diff --git a/server/assets/install.sh b/server/assets/install.sh new file mode 100644 index 0000000..ab5dcb7 --- /dev/null +++ b/server/assets/install.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVER_URL="__PISTATION_SERVER_URL__" +PACKAGE_URL="" +ENROLLMENT_KEY="" +JOIN_URL="" +KIOSK_USER="pistation" +SKIP_REBOOT="no" +FORCED_PROFILE="" +FORCED_SWAP="" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + cat <<'USAGE' +PiStation kiosk installer + + curl -fsSL /install.sh | sudo bash -s -- --key + +Options + --key Enrollment token from the admin panel, required + --server PiStation API base URL, defaults to the host this script came from + --join-url Address shown on screen for people joining, defaults to the server host + --package-url Override where the kiosk .deb is downloaded from + --user System user to run the kiosk as, defaults to pistation + --profile Force a video profile: zero2, pi4, pi5 or generic + --swap Swap size in MB, defaults to the profile's value + --skip-reboot Install and enable, but do not reboot at the end + --help Show this message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --key) ENROLLMENT_KEY="${2:-}"; shift 2 ;; + --key=*) ENROLLMENT_KEY="${1#*=}"; shift ;; + --server) SERVER_URL="${2:-}"; shift 2 ;; + --server=*) SERVER_URL="${1#*=}"; shift ;; + --join-url) JOIN_URL="${2:-}"; shift 2 ;; + --join-url=*) JOIN_URL="${1#*=}"; shift ;; + --package-url) PACKAGE_URL="${2:-}"; shift 2 ;; + --package-url=*) PACKAGE_URL="${1#*=}"; shift ;; + --user) KIOSK_USER="${2:-}"; shift 2 ;; + --user=*) KIOSK_USER="${1#*=}"; shift ;; + --profile) FORCED_PROFILE="${2:-}"; shift 2 ;; + --profile=*) FORCED_PROFILE="${1#*=}"; shift ;; + --swap) FORCED_SWAP="${2:-}"; shift 2 ;; + --swap=*) FORCED_SWAP="${1#*=}"; shift ;; + --skip-reboot) SKIP_REBOOT="yes"; shift ;; + --help|-h) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done + +[[ $EUID -eq 0 ]] || fail "run this with sudo, for example: curl -fsSL $SERVER_URL/install.sh | sudo bash -s -- --key ..." +[[ -n "$ENROLLMENT_KEY" ]] || { usage; fail "missing --key"; } +[[ -n "$SERVER_URL" ]] || fail "missing --server" + +# The media stack ships prebuilt for arm64 and x86_64 only. A 32 bit Raspberry Pi OS will +# never work, and failing here is far kinder than failing halfway through a build. +ARCH="$(uname -m)" +case "$ARCH" in + aarch64 | arm64 | x86_64) ;; + armv7l | armv6l) + fail "this is a 32 bit system ($ARCH). PiStation needs the 64 bit Raspberry Pi OS." + ;; + *) warn "unrecognised architecture $ARCH, continuing anyway" ;; +esac + +# Model decides how hard the kiosk may push video. A Zero 2W has four slow cores and half a +# gigabyte of memory, so it gets very different settings from a Pi 5. +MODEL="$(tr -d '\0' /dev/null || echo unknown)" + +case "$FORCED_PROFILE" in + zero2) MODEL="Raspberry Pi Zero 2 W" ;; + pi4) MODEL="Raspberry Pi 4 Model B" ;; + pi5) MODEL="Raspberry Pi 5" ;; + generic) MODEL="generic" ;; + "") ;; + *) fail "unknown profile: $FORCED_PROFILE" ;; +esac + +case "$MODEL" in + *"Zero 2"*) + PROFILE="Pi Zero 2 W" + VIDEO_MAX_WIDTH=960 + VIDEO_FPS=12 + VIDEO_QUALITY=70 + GPU_MEM=128 + SWAP_MB=2048 + ;; + *"Pi 5"*) + PROFILE="Pi 5" + VIDEO_MAX_WIDTH=1920 + VIDEO_FPS=60 + VIDEO_QUALITY=85 + GPU_MEM="" + SWAP_MB=512 + ;; + *"Pi 4"*|*"Compute Module 4"*) + PROFILE="Pi 4" + VIDEO_MAX_WIDTH=1600 + VIDEO_FPS=30 + VIDEO_QUALITY=80 + GPU_MEM=128 + SWAP_MB=1024 + ;; + *) + PROFILE="generic" + VIDEO_MAX_WIDTH=1280 + VIDEO_FPS=30 + VIDEO_QUALITY=80 + GPU_MEM=128 + SWAP_MB=1024 + ;; +esac + +[[ -n "$FORCED_SWAP" ]] && SWAP_MB="$FORCED_SWAP" + +SERVER_URL="${SERVER_URL%/}" +[[ -n "$PACKAGE_URL" ]] || PACKAGE_URL="$SERVER_URL/downloads/pistation-kiosk.deb" +[[ -n "$JOIN_URL" ]] || JOIN_URL="$(printf '%s' "$SERVER_URL" | sed -e 's|^https\?://||' -e 's|:.*$||')" + +if [[ -d /boot/firmware ]]; then + BOOT_DIR="/boot/firmware" +elif [[ -d /boot ]]; then + BOOT_DIR="/boot" +else + fail "could not find the boot partition" +fi + +log "installing runtime packages" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + cage \ + seatd \ + libwebkit2gtk-4.1-0 \ + libgtk-3-0 \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-libav \ + gstreamer1.0-nice \ + libnice10 \ + libgles2 + +log "downloading the kiosk package from $PACKAGE_URL" +PACKAGE_FILE="$(mktemp /tmp/pistation-kiosk.XXXXXX.deb)" +trap 'rm -f "$PACKAGE_FILE"' EXIT + +if ! curl -fsSL "$PACKAGE_URL" -o "$PACKAGE_FILE"; then + fail "could not download the kiosk package. Upload a build in the admin panel, or pass --package-url" +fi + +[[ -s "$PACKAGE_FILE" ]] || fail "the downloaded package was empty" + +log "installing the kiosk application" +apt-get install -y "$PACKAGE_FILE" + +# Raspberry Pi OS ships with 100 MB of swap. WebKit plus a media stack will exhaust that on +# a 512 MB Zero 2W and the kernel will start killing processes, which looks like the kiosk +# randomly restarting. +configure_swap() { + local wanted="$1" + local current_mb=0 + + if [[ -f /proc/meminfo ]]; then + current_mb=$(($(awk '/SwapTotal/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) / 1024)) + fi + + if [[ "$current_mb" -ge "$wanted" ]]; then + log "swap is already ${current_mb} MB, leaving it alone" + return + fi + + if [[ -f /etc/dphys-swapfile ]]; then + log "raising swap from ${current_mb} MB to ${wanted} MB" + sed -i "/^CONF_SWAPSIZE=/d;/^CONF_MAXSWAP=/d" /etc/dphys-swapfile + { + echo "CONF_SWAPSIZE=$wanted" + echo "CONF_MAXSWAP=$wanted" + } >>/etc/dphys-swapfile + + dphys-swapfile swapoff >/dev/null 2>&1 || true + dphys-swapfile setup >/dev/null 2>&1 || warn "could not resize the swap file" + dphys-swapfile swapon >/dev/null 2>&1 || warn "could not enable swap" + return + fi + + if [[ -f /swapfile ]]; then + log "a swap file already exists, leaving it alone" + return + fi + + log "creating a ${wanted} MB swap file" + fallocate -l "${wanted}M" /swapfile 2>/dev/null || + dd if=/dev/zero of=/swapfile bs=1M count="$wanted" status=none + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile || warn "could not enable the swap file" + grep -q '^/swapfile' /etc/fstab || echo "/swapfile none swap sw 0 0" >>/etc/fstab +} + +configure_swap "$SWAP_MB" + +if ! id "$KIOSK_USER" >/dev/null 2>&1; then + log "creating the $KIOSK_USER user" + useradd --create-home --shell /bin/bash "$KIOSK_USER" +fi + +for group in video render input seat; do + getent group "$group" >/dev/null 2>&1 && usermod -aG "$group" "$KIOSK_USER" +done + +systemctl enable seatd >/dev/null 2>&1 || warn "could not enable seatd" + +log "writing the kiosk configuration" +cat >"$BOOT_DIR/pistation.json" <>"$BOOT_CONFIG" +else + warn "no config.txt at $BOOT_CONFIG, skipping video tuning" +fi + +KIOSK_UID="$(id -u "$KIOSK_USER")" +KIOSK_BINARY="$(command -v pistation-kiosk || echo /usr/bin/pistation-kiosk)" + +log "installing the systemd unit" +cat >/etc/systemd/system/pistation-kiosk.service </dev/null 2>&1 || true +systemctl disable --now triggerhappy.service >/dev/null 2>&1 || true + +log "done" +echo +echo " model $MODEL" +echo " profile $PROFILE, ${VIDEO_MAX_WIDTH}px at ${VIDEO_FPS}fps, quality ${VIDEO_QUALITY}" +echo " swap ${SWAP_MB} MB" +echo " server $SERVER_URL" +echo " join url $JOIN_URL" +echo " config $BOOT_DIR/pistation.json" +echo + +if [[ "$SKIP_REBOOT" == "yes" ]]; then + log "reboot when ready, or run: systemctl start pistation-kiosk" +else + log "rebooting into the kiosk in 5 seconds, press ctrl c to cancel" + sleep 5 + reboot +fi diff --git a/server/migrations/0001_init.sql b/server/migrations/0001_init.sql new file mode 100644 index 0000000..3eae370 --- /dev/null +++ b/server/migrations/0001_init.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS admins ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS kiosks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + location TEXT NOT NULL DEFAULT '', + room_name TEXT NOT NULL UNIQUE, + hardware_id TEXT, + enrollment_token_hash TEXT, + kiosk_token_hash TEXT, + status TEXT NOT NULL DEFAULT 'offline', + last_seen_at INTEGER, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS kiosk_pins ( + pin TEXT PRIMARY KEY, + kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, + issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_kiosk_pins_kiosk ON kiosk_pins(kiosk_id, issued_at DESC); +CREATE INDEX IF NOT EXISTS idx_kiosk_pins_expiry ON kiosk_pins(expires_at); + +CREATE TABLE IF NOT EXISTS kiosk_layouts ( + kiosk_id TEXT PRIMARY KEY REFERENCES kiosks(id) ON DELETE CASCADE, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, + participant_id TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_sessions_kiosk ON sessions(kiosk_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at); diff --git a/server/migrations/0002_metrics.sql b/server/migrations/0002_metrics.sql new file mode 100644 index 0000000..e508552 --- /dev/null +++ b/server/migrations/0002_metrics.sql @@ -0,0 +1,2 @@ +ALTER TABLE kiosks ADD COLUMN metrics TEXT; +ALTER TABLE kiosks ADD COLUMN metrics_at INTEGER; diff --git a/server/migrations/0003_settings.sql b/server/migrations/0003_settings.sql new file mode 100644 index 0000000..39bb37d --- /dev/null +++ b/server/migrations/0003_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..e6eb112 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,135 @@ +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use chrono::Utc; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +pub fn hash_password(password: &str) -> AppResult { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| AppError::Internal(format!("password hashing failed: {error}"))) +} + +pub fn verify_password(password: &str, stored_hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(stored_hash) else { + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +pub fn generate_opaque_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +pub fn hash_opaque_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +#[derive(Serialize, Deserialize)] +struct AdminClaims { + sub: String, + email: String, + exp: i64, +} + +pub fn mint_admin_token( + secret: &str, + admin_id: &str, + email: &str, + ttl_hours: i64, +) -> AppResult<(String, i64)> { + let expires_at = Utc::now().timestamp() + ttl_hours * 3600; + let claims = AdminClaims { + sub: admin_id.to_string(), + email: email.to_string(), + exp: expires_at, + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?; + Ok((token, expires_at)) +} + +pub struct AdminIdentity { + pub admin_id: String, + pub email: String, +} + +pub struct KioskIdentity { + pub kiosk_id: String, + pub room_name: String, +} + +impl FromRequestParts for AdminIdentity { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let token = bearer_token(parts)?; + let data = decode::( + &token, + &DecodingKey::from_secret(state.config.session_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| AppError::Unauthorized("invalid or expired admin session".into()))?; + + Ok(AdminIdentity { + admin_id: data.claims.sub, + email: data.claims.email, + }) + } +} + +impl FromRequestParts for KioskIdentity { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let token = bearer_token(parts)?; + let token_hash = hash_opaque_token(&token); + + let row: Option<(String, String)> = + sqlx::query_as("SELECT id, room_name FROM kiosks WHERE kiosk_token_hash = ?") + .bind(&token_hash) + .fetch_optional(&state.db) + .await?; + + let (kiosk_id, room_name) = + row.ok_or_else(|| AppError::Unauthorized("unknown kiosk token".into()))?; + + Ok(KioskIdentity { + kiosk_id, + room_name, + }) + } +} + +fn bearer_token(parts: &Parts) -> AppResult { + let header = parts + .headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| AppError::Unauthorized("missing authorization header".into()))?; + + header + .strip_prefix("Bearer ") + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) + .ok_or_else(|| AppError::Unauthorized("malformed authorization header".into())) +} diff --git a/server/src/bootstrap.rs b/server/src/bootstrap.rs new file mode 100644 index 0000000..872f88a --- /dev/null +++ b/server/src/bootstrap.rs @@ -0,0 +1,89 @@ +use std::path::Path; + +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::auth::hash_password; +use crate::clock::now_ms; +use crate::config::Config; +use crate::error::{AppError, AppResult}; + +pub async fn connect_database(config: &Config) -> AppResult { + ensure_database_directory(&config.database_url)?; + + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect(&config.database_url) + .await?; + + sqlx::query("PRAGMA journal_mode = WAL") + .execute(&pool) + .await?; + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&pool) + .await?; + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .map_err(|error| AppError::Internal(format!("migration failed: {error}")))?; + + Ok(pool) +} + +pub async fn ensure_bootstrap_admin(pool: &SqlitePool, config: &Config) -> AppResult<()> { + let (Some(email), Some(password)) = ( + config.bootstrap_admin_email.as_ref(), + config.bootstrap_admin_password.as_ref(), + ) else { + return Ok(()); + }; + + let email = email.trim().to_lowercase(); + if email.is_empty() || password.is_empty() { + return Ok(()); + } + + let existing: Option<(String,)> = sqlx::query_as("SELECT id FROM admins WHERE email = ?") + .bind(&email) + .fetch_optional(pool) + .await?; + + if existing.is_some() { + return Ok(()); + } + + sqlx::query("INSERT INTO admins (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)") + .bind(Uuid::new_v4().to_string()) + .bind(&email) + .bind(hash_password(password)?) + .bind(now_ms()) + .execute(pool) + .await?; + + tracing::info!(%email, "created bootstrap admin account"); + Ok(()) +} + +fn ensure_database_directory(database_url: &str) -> AppResult<()> { + let without_scheme = database_url + .strip_prefix("sqlite://") + .or_else(|| database_url.strip_prefix("sqlite:")) + .unwrap_or(database_url); + + let path = without_scheme.split('?').next().unwrap_or(without_scheme); + if path.is_empty() || path == ":memory:" { + return Ok(()); + } + + if let Some(parent) = Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|error| { + AppError::Internal(format!("could not create database directory: {error}")) + })?; + } + } + + Ok(()) +} diff --git a/server/src/clock.rs b/server/src/clock.rs new file mode 100644 index 0000000..7b46fe1 --- /dev/null +++ b/server/src/clock.rs @@ -0,0 +1,9 @@ +use chrono::Utc; + +pub fn now_ms() -> i64 { + Utc::now().timestamp_millis() +} + +pub fn seconds_to_ms(seconds: i64) -> i64 { + seconds * 1000 +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..4ff6a7c --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,68 @@ +use std::env; + +#[derive(Clone)] +pub struct Config { + pub bind_address: String, + pub database_url: String, + pub livekit_url: String, + pub livekit_api_key: String, + pub livekit_api_secret: String, + pub session_secret: String, + pub session_ttl_hours: i64, + pub bootstrap_admin_email: Option, + pub bootstrap_admin_password: Option, + pub pin_rotation_seconds: i64, + pub pin_grace_seconds: i64, + pub public_web_url: String, + pub cors_origins: Vec, + pub media_dir: String, + pub package_dir: String, + pub public_api_url: String, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + bind_address: optional("BIND_ADDRESS", "0.0.0.0:8080"), + database_url: optional("DATABASE_URL", "sqlite://data/pistation.db?mode=rwc"), + livekit_url: required("LIVEKIT_URL")?, + livekit_api_key: required("LIVEKIT_API_KEY")?, + livekit_api_secret: required("LIVEKIT_API_SECRET")?, + session_secret: required("SESSION_SECRET")?, + session_ttl_hours: number("SESSION_TTL_HOURS", 12), + bootstrap_admin_email: env::var("BOOTSTRAP_ADMIN_EMAIL").ok(), + bootstrap_admin_password: env::var("BOOTSTRAP_ADMIN_PASSWORD").ok(), + pin_rotation_seconds: number("PIN_ROTATION_SECONDS", 45).clamp(30, 60), + pin_grace_seconds: number("PIN_GRACE_SECONDS", 15).clamp(0, 60), + public_web_url: optional("PUBLIC_WEB_URL", "http://localhost:5173"), + cors_origins: list("CORS_ORIGINS"), + media_dir: optional("MEDIA_DIR", "data/media"), + package_dir: optional("PACKAGE_DIR", "data/packages"), + public_api_url: optional("PUBLIC_API_URL", ""), + }) + } +} + +fn required(key: &str) -> Result { + env::var(key).map_err(|_| format!("missing required environment variable {key}")) +} + +fn optional(key: &str, fallback: &str) -> String { + env::var(key).unwrap_or_else(|_| fallback.to_string()) +} + +fn number(key: &str, fallback: i64) -> i64 { + env::var(key) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn list(key: &str) -> Vec { + env::var(key) + .unwrap_or_default() + .split(',') + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect() +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..cb41b69 --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,73 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("{0}")] + BadRequest(String), + #[error("{0}")] + Unauthorized(String), + #[error("{0}")] + Forbidden(String), + #[error("{0}")] + NotFound(String), + #[error("{0}")] + Conflict(String), + #[error("database error")] + Database(#[from] sqlx::Error), + #[error("token error")] + Token(#[from] jsonwebtoken::errors::Error), + #[error("{0}")] + Internal(String), +} + +impl AppError { + fn parts(&self) -> (StatusCode, &'static str, String) { + match self { + AppError::BadRequest(message) => { + (StatusCode::BAD_REQUEST, "bad_request", message.clone()) + } + AppError::Unauthorized(message) => { + (StatusCode::UNAUTHORIZED, "unauthorized", message.clone()) + } + AppError::Forbidden(message) => (StatusCode::FORBIDDEN, "forbidden", message.clone()), + AppError::NotFound(message) => (StatusCode::NOT_FOUND, "not_found", message.clone()), + AppError::Conflict(message) => (StatusCode::CONFLICT, "conflict", message.clone()), + AppError::Database(error) => { + tracing::error!(%error, "database failure"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + "internal server error".to_string(), + ) + } + AppError::Token(error) => { + tracing::error!(%error, "token failure"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + "internal server error".to_string(), + ) + } + AppError::Internal(message) => { + tracing::error!(%message, "internal failure"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + "internal server error".to_string(), + ) + } + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, code, message) = self.parts(); + (status, Json(json!({ "error": code, "message": message }))).into_response() + } +} + +pub type AppResult = Result; diff --git a/server/src/layout.rs b/server/src/layout.rs new file mode 100644 index 0000000..336e9c8 --- /dev/null +++ b/server/src/layout.rs @@ -0,0 +1,193 @@ +use serde_json::{json, Value}; +use sqlx::SqlitePool; + +use crate::clock::now_ms; +use crate::error::AppResult; + +pub fn default_background() -> Value { + json!({ + "imageUrl": "", + "images": [], + "rotationSeconds": 60, + "fit": "cover", + "dim": 0.35 + }) +} + +/// Layouts saved before wallpaper rotation existed carry a single `imageUrl`. Fold it into +/// the list so clients only ever have to read `images`. +fn migrate_background(background: Option<&mut Value>) { + let Some(Value::Object(background)) = background else { + return; + }; + + let has_images = background + .get("images") + .and_then(|images| images.as_array()) + .map(|images| !images.is_empty()) + .unwrap_or(false); + + if has_images { + return; + } + + let legacy = background + .get("imageUrl") + .and_then(|url| url.as_str()) + .unwrap_or_default() + .to_string(); + + if !legacy.is_empty() { + background.insert("images".into(), json!([legacy])); + } +} + +pub fn default_widget_style() -> Value { + json!({ + "padding": 5, + "align": "start", + "verticalAlign": "center", + "backgroundColor": "", + "opacity": null + }) +} + +pub fn default_night_mode() -> Value { + json!({ + "enabled": false, + "startTime": "22:00", + "endTime": "06:30", + "timeZone": "", + "showPin": true, + "brightness": 0.45 + }) +} + +pub fn default_layout(kiosk_id: &str) -> Value { + json!({ + "kioskId": kiosk_id, + "backgroundColor": "#0b0d10", + "foregroundColor": "#f4f6f8", + "widgetOpacity": 0.5, + "background": default_background(), + "nightMode": default_night_mode(), + "widgets": [ + { + "widgetId": "clock", + "kind": "clock", + "placement": { "column": 1, "row": 1, "columnSpan": 5, "rowSpan": 2 }, + "settings": { "timeZone": "UTC", "showSeconds": false, "showDate": true, "hour12": true }, + "style": default_widget_style(), + "enabled": true + }, + { + "widgetId": "weather", + "kind": "weather", + "placement": { "column": 8, "row": 1, "columnSpan": 5, "rowSpan": 2 }, + "settings": { + "latitude": 38.8304, + "longitude": -77.3078, + "locationLabel": "Fairfax", + "units": "imperial" + }, + "enabled": true + }, + { + "widgetId": "pin", + "kind": "pin", + "placement": { "column": 1, "row": 4, "columnSpan": 12, "rowSpan": 4 }, + "settings": { "label": "Join at", "showJoinUrl": true }, + "enabled": true + } + ], + "customDefinitions": [], + "updatedAt": now_ms() + }) +} + +fn apply_defaults(kiosk_id: &str, mut layout: Value) -> Value { + let Some(object) = layout.as_object_mut() else { + return default_layout(kiosk_id); + }; + + object.entry("kioskId").or_insert_with(|| json!(kiosk_id)); + object + .entry("backgroundColor") + .or_insert_with(|| json!("#0b0d10")); + object + .entry("foregroundColor") + .or_insert_with(|| json!("#f4f6f8")); + object.entry("widgetOpacity").or_insert_with(|| json!(0.5)); + object.entry("background").or_insert_with(default_background); + object.entry("nightMode").or_insert_with(default_night_mode); + object.entry("widgets").or_insert_with(|| json!([])); + object + .entry("customDefinitions") + .or_insert_with(|| json!([])); + object.entry("updatedAt").or_insert_with(|| json!(now_ms())); + + fill_nested_defaults(object.get_mut("background"), default_background()); + migrate_background(object.get_mut("background")); + fill_nested_defaults(object.get_mut("nightMode"), default_night_mode()); + fill_widget_defaults(object.get_mut("widgets")); + + layout +} + +fn fill_widget_defaults(widgets: Option<&mut Value>) { + let Some(Value::Array(widgets)) = widgets else { + return; + }; + + for widget in widgets.iter_mut() { + let Some(object) = widget.as_object_mut() else { + continue; + }; + + object.entry("style").or_insert_with(default_widget_style); + fill_nested_defaults(object.get_mut("style"), default_widget_style()); + } +} + +fn fill_nested_defaults(target: Option<&mut Value>, defaults: Value) { + let (Some(Value::Object(target)), Value::Object(defaults)) = (target, defaults) else { + return; + }; + + for (key, value) in defaults { + target.entry(key).or_insert(value); + } +} + +pub async fn load_layout(db: &SqlitePool, kiosk_id: &str) -> AppResult { + let row: Option<(String,)> = sqlx::query_as("SELECT data FROM kiosk_layouts WHERE kiosk_id = ?") + .bind(kiosk_id) + .fetch_optional(db) + .await?; + + let stored = match row { + Some((data,)) => serde_json::from_str(&data).unwrap_or_else(|_| default_layout(kiosk_id)), + None => default_layout(kiosk_id), + }; + + Ok(apply_defaults(kiosk_id, stored)) +} + +pub async fn save_layout(db: &SqlitePool, kiosk_id: &str, layout: &Value) -> AppResult<()> { + let now = now_ms(); + let mut stored = apply_defaults(kiosk_id, layout.clone()); + stored["kioskId"] = json!(kiosk_id); + stored["updatedAt"] = json!(now); + + sqlx::query( + "INSERT INTO kiosk_layouts (kiosk_id, data, updated_at) VALUES (?, ?, ?) + ON CONFLICT(kiosk_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at", + ) + .bind(kiosk_id) + .bind(stored.to_string()) + .bind(now) + .execute(db) + .await?; + + Ok(()) +} diff --git a/server/src/livekit.rs b/server/src/livekit.rs new file mode 100644 index 0000000..6874c89 --- /dev/null +++ b/server/src/livekit.rs @@ -0,0 +1,78 @@ +use chrono::Utc; +use jsonwebtoken::{encode, EncodingKey, Header}; +use serde::Serialize; + +use crate::error::AppResult; + +#[derive(Serialize)] +struct VideoGrant { + room: String, + #[serde(rename = "roomJoin")] + room_join: bool, + #[serde(rename = "canPublish")] + can_publish: bool, + #[serde(rename = "canSubscribe")] + can_subscribe: bool, + #[serde(rename = "canPublishData")] + can_publish_data: bool, + #[serde(rename = "canUpdateOwnMetadata")] + can_update_own_metadata: bool, + #[serde(rename = "roomAdmin")] + room_admin: bool, +} + +#[derive(Serialize)] +struct AccessTokenClaims { + iss: String, + sub: String, + nbf: i64, + exp: i64, + name: String, + metadata: String, + video: VideoGrant, +} + +pub struct TokenRequest<'a> { + pub room_name: &'a str, + pub identity: &'a str, + pub display_name: &'a str, + pub role: &'a str, + pub can_publish: bool, + pub room_admin: bool, + pub ttl_seconds: i64, +} + +pub fn mint_access_token( + api_key: &str, + api_secret: &str, + request: TokenRequest<'_>, +) -> AppResult<(String, i64)> { + let issued_at = Utc::now().timestamp(); + let expires_at = issued_at + request.ttl_seconds; + + let claims = AccessTokenClaims { + iss: api_key.to_string(), + sub: request.identity.to_string(), + nbf: issued_at - 10, + exp: expires_at, + name: request.display_name.to_string(), + metadata: serde_json::json!({ "role": request.role }).to_string(), + video: VideoGrant { + room: request.room_name.to_string(), + room_join: true, + can_publish: request.can_publish, + can_subscribe: true, + can_publish_data: true, + can_update_own_metadata: true, + room_admin: request.room_admin, + }, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(api_secret.as_bytes()), + )?; + + Ok((token, expires_at)) +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..786c1ce --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,121 @@ +mod auth; +mod bootstrap; +mod clock; +mod config; +mod error; +mod layout; +mod livekit; +mod models; +mod pins; +mod routes; +mod state; + +use axum::http::{HeaderValue, Method}; +use axum::Router; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; + +use crate::config::Config; +use crate::state::AppState; + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "pistation_server=info,tower_http=warn".into()), + ) + .init(); + + let config = match Config::from_env() { + Ok(config) => config, + Err(message) => { + eprintln!("configuration error: {message}"); + std::process::exit(1); + } + }; + + let pool = match bootstrap::connect_database(&config).await { + Ok(pool) => pool, + Err(error) => { + eprintln!("database startup failed: {error}"); + std::process::exit(1); + } + }; + + if let Err(error) = bootstrap::ensure_bootstrap_admin(&pool, &config).await { + eprintln!("admin bootstrap failed: {error}"); + std::process::exit(1); + } + + let bind_address = config.bind_address.clone(); + let cors = build_cors(&config); + let state = AppState::new(config, pool); + + pins::spawn_rotation_task(state.clone()); + + for directory in [&state.config.media_dir, &state.config.package_dir] { + if let Err(error) = std::fs::create_dir_all(directory) { + eprintln!("could not create directory {directory}: {error}"); + std::process::exit(1); + } + } + + let kiosk_cors = CorsLayer::new() + .allow_methods(Any) + .allow_headers(Any) + .allow_origin(Any); + + let app = Router::new() + .route("/", axum::routing::get(routes::service_index)) + .route("/install.sh", axum::routing::get(routes::install_script)) + .nest("/api/kiosk", routes::kiosk_router().layer(kiosk_cors)) + .nest("/api", routes::api_router().layer(cors)) + .nest_service("/media", ServeDir::new(state.config.media_dir.clone())) + .nest_service("/downloads", ServeDir::new(state.config.package_dir.clone())) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let listener = match tokio::net::TcpListener::bind(&bind_address).await { + Ok(listener) => listener, + Err(error) => { + eprintln!("could not bind {bind_address}: {error}"); + std::process::exit(1); + } + }; + + tracing::info!(%bind_address, "pistation server listening"); + + if let Err(error) = axum::serve(listener, app).await { + eprintln!("server stopped: {error}"); + std::process::exit(1); + } +} + +fn build_cors(config: &Config) -> CorsLayer { + let base = CorsLayer::new() + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers(Any); + + if config.cors_origins.is_empty() { + return base.allow_origin(Any); + } + + let origins: Vec = config + .cors_origins + .iter() + .filter_map(|origin| origin.parse().ok()) + .collect(); + + base.allow_origin(origins) +} diff --git a/server/src/models.rs b/server/src/models.rs new file mode 100644 index 0000000..f8c3364 --- /dev/null +++ b/server/src/models.rs @@ -0,0 +1,60 @@ +use serde::Serialize; +use serde_json::Value; +use sqlx::FromRow; + +#[derive(FromRow)] +pub struct KioskRow { + pub id: String, + pub name: String, + pub location: String, + pub room_name: String, + pub status: String, + pub last_seen_at: Option, + pub created_at: i64, + pub metrics: Option, + pub metrics_at: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KioskView { + pub kiosk_id: String, + pub name: String, + pub location: String, + pub room_name: String, + pub status: String, + pub last_seen_at: Option, + pub created_at: i64, + pub metrics: Option, + pub metrics_at: Option, +} + +impl From for KioskView { + fn from(row: KioskRow) -> Self { + Self { + kiosk_id: row.id, + name: row.name, + location: row.location, + room_name: row.room_name, + status: row.status, + last_seen_at: row.last_seen_at, + created_at: row.created_at, + metrics: row + .metrics + .and_then(|raw| serde_json::from_str(&raw).ok()), + metrics_at: row.metrics_at, + } + } +} + +pub const KIOSK_COLUMNS: &str = + "id, name, location, room_name, status, last_seen_at, created_at, metrics, metrics_at"; + +pub const OFFLINE_AFTER_MS: i64 = 90_000; + +pub fn derive_status(last_seen_at: Option, now: i64) -> &'static str { + match last_seen_at { + Some(seen) if now - seen <= OFFLINE_AFTER_MS => "online", + _ => "offline", + } +} diff --git a/server/src/pins.rs b/server/src/pins.rs new file mode 100644 index 0000000..7e87662 --- /dev/null +++ b/server/src/pins.rs @@ -0,0 +1,136 @@ +use rand::Rng; +use serde::Serialize; +use sqlx::SqlitePool; + +use crate::clock::{now_ms, seconds_to_ms}; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssuedPin { + pub pin: String, + pub issued_at: i64, + pub expires_at: i64, +} + +pub async fn issue_pin( + db: &SqlitePool, + kiosk_id: &str, + rotation_seconds: i64, + grace_seconds: i64, +) -> AppResult { + let issued_at = now_ms(); + let expires_at = issued_at + seconds_to_ms(rotation_seconds + grace_seconds); + let pin = allocate_unique_pin(db, issued_at).await?; + + sqlx::query("INSERT INTO kiosk_pins (pin, kiosk_id, issued_at, expires_at) VALUES (?, ?, ?, ?)") + .bind(&pin) + .bind(kiosk_id) + .bind(issued_at) + .bind(expires_at) + .execute(db) + .await?; + + Ok(IssuedPin { + pin, + issued_at, + expires_at, + }) +} + +pub async fn current_pin(db: &SqlitePool, kiosk_id: &str) -> AppResult> { + let row: Option<(String, i64, i64)> = sqlx::query_as( + "SELECT pin, issued_at, expires_at FROM kiosk_pins + WHERE kiosk_id = ? AND expires_at > ? + ORDER BY issued_at DESC LIMIT 1", + ) + .bind(kiosk_id) + .bind(now_ms()) + .fetch_optional(db) + .await?; + + Ok(row.map(|(pin, issued_at, expires_at)| IssuedPin { + pin, + issued_at, + expires_at, + })) +} + +pub async fn ensure_pin( + db: &SqlitePool, + kiosk_id: &str, + rotation_seconds: i64, + grace_seconds: i64, +) -> AppResult { + match current_pin(db, kiosk_id).await? { + Some(pin) => Ok(pin), + None => issue_pin(db, kiosk_id, rotation_seconds, grace_seconds).await, + } +} + +pub async fn resolve_pin(db: &SqlitePool, pin: &str) -> AppResult> { + let row: Option<(String,)> = + sqlx::query_as("SELECT kiosk_id FROM kiosk_pins WHERE pin = ? AND expires_at > ?") + .bind(pin) + .bind(now_ms()) + .fetch_optional(db) + .await?; + + Ok(row.map(|(kiosk_id,)| kiosk_id)) +} + +pub async fn purge_expired(db: &SqlitePool) -> AppResult<()> { + sqlx::query("DELETE FROM kiosk_pins WHERE expires_at <= ?") + .bind(now_ms()) + .execute(db) + .await?; + Ok(()) +} + +async fn allocate_unique_pin(db: &SqlitePool, now: i64) -> AppResult { + for _ in 0..32 { + let candidate = format!("{:06}", rand::thread_rng().gen_range(0..1_000_000)); + let taken: Option<(String,)> = + sqlx::query_as("SELECT pin FROM kiosk_pins WHERE pin = ? AND expires_at > ?") + .bind(&candidate) + .bind(now) + .fetch_optional(db) + .await?; + if taken.is_none() { + return Ok(candidate); + } + } + Err(AppError::Internal("could not allocate a unique pin".into())) +} + +pub fn spawn_rotation_task(state: AppState) { + tokio::spawn(async move { + let rotation = state.config.pin_rotation_seconds; + let grace = state.config.pin_grace_seconds; + let mut ticker = + tokio::time::interval(std::time::Duration::from_secs(rotation.max(1) as u64)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + ticker.tick().await; + if let Err(error) = rotate_all(&state, rotation, grace).await { + tracing::error!(%error, "pin rotation cycle failed"); + } + } + }); +} + +async fn rotate_all(state: &AppState, rotation: i64, grace: i64) -> AppResult<()> { + purge_expired(&state.db).await?; + + let kiosks: Vec<(String,)> = sqlx::query_as("SELECT id FROM kiosks") + .fetch_all(&state.db) + .await?; + + for (kiosk_id,) in kiosks { + issue_pin(&state.db, &kiosk_id, rotation, grace).await?; + } + + Ok(()) +} diff --git a/server/src/routes/admin.rs b/server/src/routes/admin.rs new file mode 100644 index 0000000..f7dadf0 --- /dev/null +++ b/server/src/routes/admin.rs @@ -0,0 +1,317 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::auth::{ + generate_opaque_token, hash_opaque_token, mint_admin_token, verify_password, AdminIdentity, +}; +use crate::clock::now_ms; +use crate::error::{AppError, AppResult}; +use crate::layout; +use crate::models::{derive_status, KioskRow, KioskView, KIOSK_COLUMNS}; +use crate::pins; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/login", post(login)) + .route("/kiosks", get(list_kiosks).post(create_kiosk)) + .route( + "/kiosks/{kiosk_id}", + get(kiosk_detail).patch(update_kiosk).delete(delete_kiosk), + ) + .route("/kiosks/{kiosk_id}/layout", put(update_layout)) + .route("/kiosks/{kiosk_id}/enrollment", post(rotate_enrollment)) +} + +#[derive(Deserialize)] +struct LoginRequest { + email: String, + password: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LoginResponse { + access_token: String, + email: String, + expires_at: i64, +} + +async fn login( + State(state): State, + Json(body): Json, +) -> AppResult> { + let email = body.email.trim().to_lowercase(); + + let row: Option<(String, String)> = + sqlx::query_as("SELECT id, password_hash FROM admins WHERE email = ?") + .bind(&email) + .fetch_optional(&state.db) + .await?; + + let (admin_id, password_hash) = + row.ok_or_else(|| AppError::Unauthorized("invalid email or password".into()))?; + + if !verify_password(&body.password, &password_hash) { + return Err(AppError::Unauthorized("invalid email or password".into())); + } + + let (access_token, expires_at) = mint_admin_token( + &state.config.session_secret, + &admin_id, + &email, + state.config.session_ttl_hours, + )?; + + Ok(Json(LoginResponse { + access_token, + email, + expires_at: expires_at * 1000, + })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct KioskListResponse { + kiosks: Vec, +} + +async fn list_kiosks( + State(state): State, + _admin: AdminIdentity, +) -> AppResult> { + let rows: Vec = + sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks ORDER BY created_at")) + .fetch_all(&state.db) + .await?; + + let now = now_ms(); + let kiosks = rows + .into_iter() + .map(|row| { + let mut view = KioskView::from(row); + view.status = derive_status(view.last_seen_at, now).to_string(); + view + }) + .collect(); + + Ok(Json(KioskListResponse { kiosks })) +} + +#[derive(Deserialize)] +struct CreateKioskRequest { + name: String, + #[serde(default)] + location: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateKioskResponse { + kiosk: KioskView, + enrollment_token: String, +} + +async fn create_kiosk( + State(state): State, + _admin: AdminIdentity, + Json(body): Json, +) -> AppResult> { + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("kiosk name is required".into())); + } + + let kiosk_id = Uuid::new_v4().to_string(); + let room_name = format!("kiosk-{}", &kiosk_id[..8]); + let enrollment_token = generate_opaque_token(); + let created_at = now_ms(); + + sqlx::query( + "INSERT INTO kiosks (id, name, location, room_name, enrollment_token_hash, status, created_at) + VALUES (?, ?, ?, ?, ?, 'offline', ?)", + ) + .bind(&kiosk_id) + .bind(name) + .bind(body.location.trim()) + .bind(&room_name) + .bind(hash_opaque_token(&enrollment_token)) + .bind(created_at) + .execute(&state.db) + .await?; + + layout::save_layout(&state.db, &kiosk_id, &layout::default_layout(&kiosk_id)).await?; + pins::ensure_pin( + &state.db, + &kiosk_id, + state.config.pin_rotation_seconds, + state.config.pin_grace_seconds, + ) + .await?; + + Ok(Json(CreateKioskResponse { + kiosk: KioskView { + kiosk_id, + name: name.to_string(), + location: body.location.trim().to_string(), + room_name, + status: "offline".to_string(), + last_seen_at: None, + created_at, + metrics: None, + metrics_at: None, + }, + enrollment_token, + })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct KioskDetailResponse { + kiosk: KioskView, + layout: Value, + current_pin: Option, +} + +async fn kiosk_detail( + State(state): State, + _admin: AdminIdentity, + Path(kiosk_id): Path, +) -> AppResult> { + let row: Option = + sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks WHERE id = ?")) + .bind(&kiosk_id) + .fetch_optional(&state.db) + .await?; + + let row = row.ok_or_else(|| AppError::NotFound("kiosk not found".into()))?; + let mut kiosk = KioskView::from(row); + kiosk.status = derive_status(kiosk.last_seen_at, now_ms()).to_string(); + + let layout = layout::load_layout(&state.db, &kiosk_id).await?; + let current_pin = pins::current_pin(&state.db, &kiosk_id) + .await? + .map(|issued| issued.pin); + + Ok(Json(KioskDetailResponse { + kiosk, + layout, + current_pin, + })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdateKioskRequest { + name: String, + #[serde(default)] + location: String, +} + +async fn update_kiosk( + State(state): State, + _admin: AdminIdentity, + Path(kiosk_id): Path, + Json(body): Json, +) -> AppResult> { + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("kiosk name is required".into())); + } + + let result = sqlx::query("UPDATE kiosks SET name = ?, location = ? WHERE id = ?") + .bind(name) + .bind(body.location.trim()) + .bind(&kiosk_id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("kiosk not found".into())); + } + + let row: KioskRow = sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks WHERE id = ?")) + .bind(&kiosk_id) + .fetch_one(&state.db) + .await?; + + let mut kiosk = KioskView::from(row); + kiosk.status = derive_status(kiosk.last_seen_at, now_ms()).to_string(); + + Ok(Json(kiosk)) +} + +#[derive(Deserialize)] +struct UpdateLayoutRequest { + layout: Value, +} + +async fn update_layout( + State(state): State, + _admin: AdminIdentity, + Path(kiosk_id): Path, + Json(body): Json, +) -> AppResult> { + let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM kiosks WHERE id = ?") + .bind(&kiosk_id) + .fetch_optional(&state.db) + .await?; + + if exists.is_none() { + return Err(AppError::NotFound("kiosk not found".into())); + } + + layout::save_layout(&state.db, &kiosk_id, &body.layout).await?; + let stored = layout::load_layout(&state.db, &kiosk_id).await?; + Ok(Json(stored)) +} + +async fn delete_kiosk( + State(state): State, + _admin: AdminIdentity, + Path(kiosk_id): Path, +) -> AppResult { + let result = sqlx::query("DELETE FROM kiosks WHERE id = ?") + .bind(&kiosk_id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("kiosk not found".into())); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EnrollmentResponse { + enrollment_token: String, +} + +async fn rotate_enrollment( + State(state): State, + _admin: AdminIdentity, + Path(kiosk_id): Path, +) -> AppResult> { + let enrollment_token = generate_opaque_token(); + + let result = sqlx::query( + "UPDATE kiosks SET enrollment_token_hash = ?, kiosk_token_hash = NULL WHERE id = ?", + ) + .bind(hash_opaque_token(&enrollment_token)) + .bind(&kiosk_id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("kiosk not found".into())); + } + + Ok(Json(EnrollmentResponse { enrollment_token })) +} diff --git a/server/src/routes/install.rs b/server/src/routes/install.rs new file mode 100644 index 0000000..d425689 --- /dev/null +++ b/server/src/routes/install.rs @@ -0,0 +1,45 @@ +use axum::extract::State; +use axum::http::header::{HeaderMap, CONTENT_TYPE}; +use axum::http::HeaderValue; +use axum::response::{IntoResponse, Response}; + +use crate::state::AppState; + +const INSTALL_SCRIPT: &str = include_str!("../../assets/install.sh"); +const SERVER_URL_PLACEHOLDER: &str = "__PISTATION_SERVER_URL__"; + +pub async fn install_script(State(state): State, headers: HeaderMap) -> Response { + let server_url = resolve_server_url(&state, &headers); + let body = INSTALL_SCRIPT.replace(SERVER_URL_PLACEHOLDER, &server_url); + + let mut response = body.into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/x-shellscript; charset=utf-8"), + ); + response +} + +fn resolve_server_url(state: &AppState, headers: &HeaderMap) -> String { + let host = header_value(headers, "x-forwarded-host").or_else(|| header_value(headers, "host")); + + if let Some(host) = host { + let scheme = + header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".to_string()); + return format!("{scheme}://{host}"); + } + + if !state.config.public_api_url.is_empty() { + return state.config.public_api_url.trim_end_matches('/').to_string(); + } + + "http://localhost:8080".to_string() +} + +fn header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(',').next().unwrap_or(value).trim().to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/server/src/routes/join.rs b/server/src/routes/join.rs new file mode 100644 index 0000000..e4a9a0c --- /dev/null +++ b/server/src/routes/join.rs @@ -0,0 +1,182 @@ +use axum::extract::State; +use axum::routing::post; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::clock::{now_ms, seconds_to_ms}; +use crate::error::{AppError, AppResult}; +use crate::livekit::{mint_access_token, TokenRequest}; +use crate::pins; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/join", post(join)) + .route("/session/refresh", post(refresh)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JoinRequest { + pin: String, + #[serde(default)] + display_name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JoinResponse { + session_id: String, + room_name: String, + kiosk_name: String, + livekit_url: String, + access_token: String, + participant_id: String, + display_name: String, + role: String, + expires_at: i64, +} + +async fn join( + State(state): State, + Json(body): Json, +) -> AppResult> { + let pin = body.pin.trim().replace(' ', ""); + if pin.len() != 6 || !pin.chars().all(|character| character.is_ascii_digit()) { + return Err(AppError::BadRequest("pin must be six digits".into())); + } + + let kiosk_id = pins::resolve_pin(&state.db, &pin) + .await? + .ok_or_else(|| AppError::NotFound("that pin is not valid right now".into()))?; + + let (kiosk_name, room_name): (String, String) = + sqlx::query_as("SELECT name, room_name FROM kiosks WHERE id = ?") + .bind(&kiosk_id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::NotFound("kiosk no longer exists".into()))?; + + let display_name = sanitize_display_name(&body.display_name)?; + let role = "presenter".to_string(); + + let session_id = Uuid::new_v4().to_string(); + let participant_id = format!("web-{session_id}"); + let created_at = now_ms(); + let expires_at = created_at + seconds_to_ms(state.config.session_ttl_hours * 3600); + + sqlx::query( + "INSERT INTO sessions (id, kiosk_id, participant_id, display_name, role, created_at, expires_at, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?, 0)", + ) + .bind(&session_id) + .bind(&kiosk_id) + .bind(&participant_id) + .bind(&display_name) + .bind(&role) + .bind(created_at) + .bind(expires_at) + .execute(&state.db) + .await?; + + let access_token = mint_for_session(&state, &room_name, &participant_id, &display_name, &role)?; + + Ok(Json(JoinResponse { + session_id, + room_name, + kiosk_name, + livekit_url: state.config.livekit_url.clone(), + access_token, + participant_id, + display_name, + role, + expires_at, + })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RefreshRequest { + session_id: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RefreshResponse { + room_name: String, + livekit_url: String, + access_token: String, + participant_id: String, + display_name: String, + role: String, + expires_at: i64, +} + +async fn refresh( + State(state): State, + Json(body): Json, +) -> AppResult> { + let row: Option<(String, String, String, i64, String)> = sqlx::query_as( + "SELECT sessions.participant_id, sessions.display_name, sessions.role, + sessions.expires_at, kiosks.room_name + FROM sessions JOIN kiosks ON kiosks.id = sessions.kiosk_id + WHERE sessions.id = ? AND sessions.revoked = 0", + ) + .bind(&body.session_id) + .fetch_optional(&state.db) + .await?; + + let (participant_id, display_name, role, expires_at, room_name) = + row.ok_or_else(|| AppError::Unauthorized("unknown session".into()))?; + + if expires_at <= now_ms() { + return Err(AppError::Unauthorized("session expired".into())); + } + + let access_token = mint_for_session(&state, &room_name, &participant_id, &display_name, &role)?; + + Ok(Json(RefreshResponse { + room_name, + livekit_url: state.config.livekit_url.clone(), + access_token, + participant_id, + display_name, + role, + expires_at, + })) +} + +fn mint_for_session( + state: &AppState, + room_name: &str, + participant_id: &str, + display_name: &str, + role: &str, +) -> AppResult { + // Everyone who joins may publish. Only one screen share runs at a time, and that is + // enforced by the clients against the room's live tracks rather than by the token. + let can_publish = true; + let (token, _) = mint_access_token( + &state.config.livekit_api_key, + &state.config.livekit_api_secret, + TokenRequest { + room_name, + identity: participant_id, + display_name, + role, + can_publish, + room_admin: can_publish, + ttl_seconds: state.config.session_ttl_hours * 3600, + }, + )?; + Ok(token) +} + +fn sanitize_display_name(raw: &str) -> AppResult { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AppError::BadRequest("please enter your name".into())); + } + Ok(trimmed.chars().take(32).collect()) +} diff --git a/server/src/routes/kiosk.rs b/server/src/routes/kiosk.rs new file mode 100644 index 0000000..b1c80a4 --- /dev/null +++ b/server/src/routes/kiosk.rs @@ -0,0 +1,193 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +use crate::auth::{generate_opaque_token, hash_opaque_token, KioskIdentity}; +use crate::clock::now_ms; +use crate::error::{AppError, AppResult}; +use crate::layout; +use crate::livekit::{mint_access_token, TokenRequest}; +use crate::pins; +use crate::state::AppState; + +const KIOSK_TOKEN_TTL_SECONDS: i64 = 12 * 3600; + +pub fn router() -> Router { + Router::new() + .route("/register", post(register)) + .route("/pin", get(pin)) + .route("/session", get(session)) + .route("/layout", get(kiosk_layout)) + .route("/heartbeat", post(heartbeat)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisterRequest { + enrollment_token: String, + hardware_id: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RegisterResponse { + kiosk_id: String, + kiosk_token: String, + room_name: String, + livekit_url: String, + rotation_seconds: i64, +} + +async fn register( + State(state): State, + Json(body): Json, +) -> AppResult> { + let enrollment_hash = hash_opaque_token(body.enrollment_token.trim()); + + let row: Option<(String, String)> = + sqlx::query_as("SELECT id, room_name FROM kiosks WHERE enrollment_token_hash = ?") + .bind(&enrollment_hash) + .fetch_optional(&state.db) + .await?; + + let (kiosk_id, room_name) = + row.ok_or_else(|| AppError::Unauthorized("invalid enrollment token".into()))?; + + let kiosk_token = generate_opaque_token(); + + // The name belongs to whoever created the kiosk in the admin panel. Enrolling a Pi + // must never rename it. + sqlx::query( + "UPDATE kiosks + SET kiosk_token_hash = ?, enrollment_token_hash = NULL, hardware_id = ?, + status = 'online', last_seen_at = ? + WHERE id = ?", + ) + .bind(hash_opaque_token(&kiosk_token)) + .bind(body.hardware_id.trim()) + .bind(now_ms()) + .bind(&kiosk_id) + .execute(&state.db) + .await?; + + pins::ensure_pin( + &state.db, + &kiosk_id, + state.config.pin_rotation_seconds, + state.config.pin_grace_seconds, + ) + .await?; + + Ok(Json(RegisterResponse { + kiosk_id, + kiosk_token, + room_name, + livekit_url: state.config.livekit_url.clone(), + rotation_seconds: state.config.pin_rotation_seconds, + })) +} + +async fn pin( + State(state): State, + identity: KioskIdentity, +) -> AppResult> { + let issued = pins::ensure_pin( + &state.db, + &identity.kiosk_id, + state.config.pin_rotation_seconds, + state.config.pin_grace_seconds, + ) + .await?; + + Ok(Json(issued)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionResponse { + room_name: String, + livekit_url: String, + access_token: String, + participant_id: String, +} + +async fn session( + State(state): State, + identity: KioskIdentity, +) -> AppResult> { + let participant_id = format!("kiosk-{}", identity.kiosk_id); + + let (access_token, _) = mint_access_token( + &state.config.livekit_api_key, + &state.config.livekit_api_secret, + TokenRequest { + room_name: &identity.room_name, + identity: &participant_id, + display_name: "Kiosk", + role: "kiosk", + can_publish: false, + room_admin: true, + ttl_seconds: KIOSK_TOKEN_TTL_SECONDS, + }, + )?; + + Ok(Json(SessionResponse { + room_name: identity.room_name, + livekit_url: state.config.livekit_url.clone(), + access_token, + participant_id, + })) +} + +async fn kiosk_layout( + State(state): State, + identity: KioskIdentity, +) -> AppResult> { + let layout = layout::load_layout(&state.db, &identity.kiosk_id).await?; + Ok(Json(layout)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HeartbeatRequest { + #[serde(default)] + metrics: Option, +} + +async fn heartbeat( + State(state): State, + identity: KioskIdentity, + body: Option>, +) -> AppResult { + let now = now_ms(); + let metrics = body.and_then(|Json(body)| body.metrics); + + match metrics { + Some(metrics) => { + sqlx::query( + "UPDATE kiosks + SET last_seen_at = ?, status = 'online', metrics = ?, metrics_at = ? + WHERE id = ?", + ) + .bind(now) + .bind(metrics.to_string()) + .bind(now) + .bind(&identity.kiosk_id) + .execute(&state.db) + .await?; + } + None => { + sqlx::query("UPDATE kiosks SET last_seen_at = ?, status = 'online' WHERE id = ?") + .bind(now) + .bind(&identity.kiosk_id) + .execute(&state.db) + .await?; + } + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/routes/media.rs b/server/src/routes/media.rs new file mode 100644 index 0000000..4655458 --- /dev/null +++ b/server/src/routes/media.rs @@ -0,0 +1,150 @@ +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, Path, State}; +use axum::http::HeaderMap; +use axum::routing::put; +use axum::{Json, Router}; +use serde::Serialize; +use uuid::Uuid; + +use crate::auth::AdminIdentity; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +pub const MAX_UPLOAD_BYTES: usize = 8 * 1024 * 1024; + +const ALLOWED_TYPES: [(&str, &str); 4] = [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/webp", "webp"), + ("image/gif", "gif"), +]; + +pub const MAX_PACKAGE_BYTES: usize = 256 * 1024 * 1024; + +/// Validates an uploaded image and writes it under the media directory, returning the path +/// clients should store. Shared by kiosk wallpapers and the organisation logo. +pub async fn store_image( + state: &AppState, + headers: &HeaderMap, + body: &Bytes, + prefix: &str, +) -> AppResult { + if body.is_empty() { + return Err(AppError::BadRequest("the uploaded file was empty".into())); + } + + if body.len() > MAX_UPLOAD_BYTES { + return Err(AppError::BadRequest("images must be 8 MB or smaller".into())); + } + + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or(value).trim().to_lowercase()) + .unwrap_or_default(); + + let extension = ALLOWED_TYPES + .iter() + .find(|(mime, _)| *mime == content_type) + .map(|(_, extension)| *extension) + .ok_or_else(|| { + AppError::BadRequest("images must be a PNG, JPEG, WEBP or GIF".into()) + })?; + + let directory = std::path::Path::new(&state.config.media_dir); + tokio::fs::create_dir_all(directory) + .await + .map_err(|error| AppError::Internal(format!("cannot create media directory: {error}")))?; + + let file_name = format!("{prefix}-{}.{extension}", Uuid::new_v4()); + tokio::fs::write(directory.join(&file_name), body) + .await + .map_err(|error| AppError::Internal(format!("cannot write image: {error}")))?; + + Ok(format!("/media/{file_name}")) +} + +pub fn router() -> Router { + Router::new() + .route("/kiosks/{kiosk_id}/wallpaper", put(upload_wallpaper)) + .route("/organization/logo", put(upload_logo)) + .layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)) + .merge( + Router::new() + .route("/packages/kiosk", put(upload_package)) + .layer(DefaultBodyLimit::max(MAX_PACKAGE_BYTES)), + ) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PackageResponse { + download_url: String, + size_bytes: usize, +} + +async fn upload_package( + State(state): State, + _admin: AdminIdentity, + body: Bytes, +) -> AppResult> { + if body.is_empty() { + return Err(AppError::BadRequest("the uploaded file was empty".into())); + } + + if !body.starts_with(b"!\ndebian-binary") { + return Err(AppError::BadRequest( + "that does not look like a .deb package".into(), + )); + } + + let directory = std::path::Path::new(&state.config.package_dir); + tokio::fs::create_dir_all(directory) + .await + .map_err(|error| AppError::Internal(format!("cannot create package directory: {error}")))?; + + tokio::fs::write(directory.join("pistation-kiosk.deb"), &body) + .await + .map_err(|error| AppError::Internal(format!("cannot write package: {error}")))?; + + Ok(Json(PackageResponse { + download_url: "/downloads/pistation-kiosk.deb".to_string(), + size_bytes: body.len(), + })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UploadResponse { + image_url: String, +} + +async fn upload_wallpaper( + State(state): State, + _admin: AdminIdentity, + Path(kiosk_id): Path, + headers: HeaderMap, + body: Bytes, +) -> AppResult> { + let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM kiosks WHERE id = ?") + .bind(&kiosk_id) + .fetch_optional(&state.db) + .await?; + + if exists.is_none() { + return Err(AppError::NotFound("kiosk not found".into())); + } + + let image_url = store_image(&state, &headers, &body, &kiosk_id).await?; + Ok(Json(UploadResponse { image_url })) +} + +async fn upload_logo( + State(state): State, + _admin: AdminIdentity, + headers: HeaderMap, + body: Bytes, +) -> AppResult> { + let image_url = store_image(&state, &headers, &body, "logo").await?; + Ok(Json(UploadResponse { image_url })) +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs new file mode 100644 index 0000000..92e966c --- /dev/null +++ b/server/src/routes/mod.rs @@ -0,0 +1,53 @@ +pub mod admin; +pub mod install; +pub mod join; +pub mod kiosk; +pub mod media; +pub mod organization; + +pub use install::install_script; + +use axum::routing::get; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use crate::state::AppState; + +/// Routes browsers call. These carry the configured origin allowlist. +pub fn api_router() -> Router { + Router::new() + .route("/health", get(health)) + .merge(join::router()) + .merge(organization::public_router()) + .nest( + "/admin", + admin::router() + .merge(media::router()) + .merge(organization::admin_router()), + ) +} + +/// Routes only kiosks call, mounted separately because they are not called from a web +/// origin. A Tauri webview reports `http://tauri.localhost` in production and whatever the +/// dev server uses otherwise, so an origin allowlist can only ever lock kiosks out. These +/// endpoints authenticate with a bearer token and never with a cookie, so an origin check +/// would add no protection anyway. +pub fn kiosk_router() -> Router { + kiosk::router() +} + +async fn health() -> Json { + Json(json!({ "status": "ok" })) +} + +pub async fn service_index() -> Json { + Json(json!({ + "service": "pistation-server", + "message": "This is the PiStation API. The web client is served separately, by default on port 3000.", + "endpoints": { + "health": "/api/health", + "join": "POST /api/join", + "admin": "POST /api/admin/login" + } + })) +} diff --git a/server/src/routes/organization.rs b/server/src/routes/organization.rs new file mode 100644 index 0000000..480fca2 --- /dev/null +++ b/server/src/routes/organization.rs @@ -0,0 +1,114 @@ +use axum::extract::State; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::SqlitePool; + +use crate::auth::AdminIdentity; +use crate::clock::now_ms; +use crate::error::AppResult; +use crate::state::AppState; + +const SETTINGS_KEY: &str = "organization"; + +pub fn public_router() -> Router { + Router::new().route("/organization", get(read_public)) +} + +pub fn admin_router() -> Router { + Router::new().route("/organization", put(update)) +} + +fn defaults() -> Value { + json!({ + "name": "PiStation", + "headline": "Any screen becomes a shared screen.", + "description": "Type the code shown on screen to present, draw on what is being shown, or open a whiteboard together. No accounts, no installs, and nothing leaves the network it runs on.", + "logoUrl": "", + "accentColor": "#4f7cff", + "joinLabel": "Enter the code on screen", + "footerNote": "", + "showSourceLink": true, + "landingMode": "full", + "theme": { + "surface0": "#0b0d10", + "surface1": "#14181d", + "surface2": "#1c2229", + "surface3": "#262e37", + "ink0": "#f4f6f8", + "ink1": "#a8b3c0", + "ink2": "#6b7885" + }, + "links": [], + "updatedAt": 0 + }) +} + +fn apply_defaults(mut branding: Value) -> Value { + let Some(object) = branding.as_object_mut() else { + return defaults(); + }; + + if let Value::Object(fallback) = defaults() { + for (key, value) in fallback { + object.entry(key).or_insert(value); + } + } + + // The palette is nested, so a stored branding written before a colour existed still + // needs that one filling in rather than the whole object being replaced. + if let (Some(Value::Object(theme)), Value::Object(defaults)) = + (object.get_mut("theme"), defaults()["theme"].clone()) + { + for (key, value) in defaults { + theme.entry(key).or_insert(value); + } + } + + branding +} + +pub async fn load(db: &SqlitePool) -> AppResult { + let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?") + .bind(SETTINGS_KEY) + .fetch_optional(db) + .await?; + + let stored = row + .and_then(|(value,)| serde_json::from_str(&value).ok()) + .unwrap_or_else(defaults); + + Ok(apply_defaults(stored)) +} + +async fn read_public(State(state): State) -> AppResult> { + Ok(Json(load(&state.db).await?)) +} + +#[derive(Deserialize)] +struct UpdateRequest { + branding: Value, +} + +async fn update( + State(state): State, + _admin: AdminIdentity, + Json(body): Json, +) -> AppResult> { + let now = now_ms(); + let mut branding = apply_defaults(body.branding); + branding["updatedAt"] = json!(now); + + sqlx::query( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(SETTINGS_KEY) + .bind(branding.to_string()) + .bind(now) + .execute(&state.db) + .await?; + + Ok(Json(branding)) +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..43465ce --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use sqlx::SqlitePool; + +use crate::config::Config; + +#[derive(Clone)] +pub struct AppState { + pub config: Arc, + pub db: SqlitePool, +} + +impl AppState { + pub fn new(config: Config, db: SqlitePool) -> Self { + Self { + config: Arc::new(config), + db, + } + } +}