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
This commit is contained in:
2026-08-09 17:09:06 -04:00
parent d7733a6765
commit 2bca6f0e90
25 changed files with 2462 additions and 0 deletions
+21
View File
@@ -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
+23
View File
@@ -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"
+17
View File
@@ -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"]
+309
View File
@@ -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 <server>/install.sh | sudo bash -s -- --key <enrollment-token>
Options
--key <token> Enrollment token from the admin panel, required
--server <url> PiStation API base URL, defaults to the host this script came from
--join-url <host> Address shown on screen for people joining, defaults to the server host
--package-url <url> Override where the kiosk .deb is downloaded from
--user <name> System user to run the kiosk as, defaults to pistation
--profile <name> Force a video profile: zero2, pi4, pi5 or generic
--swap <mb> 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' </proc/device-tree/model 2>/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" <<JSON
{
"serverUrl": "$SERVER_URL",
"enrollmentToken": "$ENROLLMENT_KEY",
"joinUrl": "$JOIN_URL"
}
JSON
chmod 600 "$BOOT_DIR/pistation.json"
BOOT_CONFIG="$BOOT_DIR/config.txt"
if [[ -f "$BOOT_CONFIG" ]]; then
log "tuning video output for $PROFILE"
sed -i -e '/^gpu_mem=/d' -e '/^dtoverlay=vc4-kms-v3d/d' -e '/^disable_overscan=/d' "$BOOT_CONFIG"
{
# A Pi 5 allocates graphics memory dynamically and ignores gpu_mem entirely.
[[ -n "$GPU_MEM" ]] && echo "gpu_mem=$GPU_MEM"
echo "dtoverlay=vc4-kms-v3d"
echo "disable_overscan=1"
} >>"$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 <<UNIT
[Unit]
Description=PiStation kiosk
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$KIOSK_USER
Group=$KIOSK_USER
PAMName=login
TTYPath=/dev/tty1
StandardInput=tty
StandardOutput=journal
StandardError=journal
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes
Environment=XDG_RUNTIME_DIR=/run/user/$KIOSK_UID
Environment=WLR_LIBINPUT_NO_DEVICES=1
Environment=GDK_BACKEND=wayland
Environment=PISTATION_VIDEO_MAX_WIDTH=$VIDEO_MAX_WIDTH
Environment=PISTATION_VIDEO_FPS=$VIDEO_FPS
Environment=PISTATION_VIDEO_QUALITY=$VIDEO_QUALITY
ExecStart=/usr/bin/cage -d -- $KIOSK_BINARY
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable pistation-kiosk.service
log "disabling services this kiosk does not need"
systemctl disable --now bluetooth.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
+49
View File
@@ -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);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE kiosks ADD COLUMN metrics TEXT;
ALTER TABLE kiosks ADD COLUMN metrics_at INTEGER;
+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
+135
View File
@@ -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<String> {
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<AppState> for AdminIdentity {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let token = bearer_token(parts)?;
let data = decode::<AdminClaims>(
&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<AppState> for KioskIdentity {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
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<String> {
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()))
}
+89
View File
@@ -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<SqlitePool> {
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(())
}
+9
View File
@@ -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
}
+68
View File
@@ -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<String>,
pub bootstrap_admin_password: Option<String>,
pub pin_rotation_seconds: i64,
pub pin_grace_seconds: i64,
pub public_web_url: String,
pub cors_origins: Vec<String>,
pub media_dir: String,
pub package_dir: String,
pub public_api_url: String,
}
impl Config {
pub fn from_env() -> Result<Self, String> {
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<String, String> {
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<String> {
env::var(key)
.unwrap_or_default()
.split(',')
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
+73
View File
@@ -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<T> = Result<T, AppError>;
+193
View File
@@ -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<Value> {
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(())
}
+78
View File
@@ -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))
}
+121
View File
@@ -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<HeaderValue> = config
.cors_origins
.iter()
.filter_map(|origin| origin.parse().ok())
.collect();
base.allow_origin(origins)
}
+60
View File
@@ -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<i64>,
pub created_at: i64,
pub metrics: Option<String>,
pub metrics_at: Option<i64>,
}
#[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<i64>,
pub created_at: i64,
pub metrics: Option<Value>,
pub metrics_at: Option<i64>,
}
impl From<KioskRow> 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<i64>, now: i64) -> &'static str {
match last_seen_at {
Some(seen) if now - seen <= OFFLINE_AFTER_MS => "online",
_ => "offline",
}
}
+136
View File
@@ -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<IssuedPin> {
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<Option<IssuedPin>> {
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<IssuedPin> {
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<Option<String>> {
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<String> {
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(())
}
+317
View File
@@ -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<AppState> {
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<AppState>,
Json(body): Json<LoginRequest>,
) -> AppResult<Json<LoginResponse>> {
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<KioskView>,
}
async fn list_kiosks(
State(state): State<AppState>,
_admin: AdminIdentity,
) -> AppResult<Json<KioskListResponse>> {
let rows: Vec<KioskRow> =
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<AppState>,
_admin: AdminIdentity,
Json(body): Json<CreateKioskRequest>,
) -> AppResult<Json<CreateKioskResponse>> {
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<String>,
}
async fn kiosk_detail(
State(state): State<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
) -> AppResult<Json<KioskDetailResponse>> {
let row: Option<KioskRow> =
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<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
Json(body): Json<UpdateKioskRequest>,
) -> AppResult<Json<KioskView>> {
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<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
Json(body): Json<UpdateLayoutRequest>,
) -> AppResult<Json<Value>> {
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<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
) -> AppResult<StatusCode> {
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<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
) -> AppResult<Json<EnrollmentResponse>> {
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 }))
}
+45
View File
@@ -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<AppState>, 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<String> {
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())
}
+182
View File
@@ -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<AppState> {
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<AppState>,
Json(body): Json<JoinRequest>,
) -> AppResult<Json<JoinResponse>> {
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<AppState>,
Json(body): Json<RefreshRequest>,
) -> AppResult<Json<RefreshResponse>> {
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<String> {
// 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<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest("please enter your name".into()));
}
Ok(trimmed.chars().take(32).collect())
}
+193
View File
@@ -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<AppState> {
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<AppState>,
Json(body): Json<RegisterRequest>,
) -> AppResult<Json<RegisterResponse>> {
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<AppState>,
identity: KioskIdentity,
) -> AppResult<Json<pins::IssuedPin>> {
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<AppState>,
identity: KioskIdentity,
) -> AppResult<Json<SessionResponse>> {
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<AppState>,
identity: KioskIdentity,
) -> AppResult<Json<Value>> {
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<Value>,
}
async fn heartbeat(
State(state): State<AppState>,
identity: KioskIdentity,
body: Option<Json<HeartbeatRequest>>,
) -> AppResult<StatusCode> {
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)
}
+150
View File
@@ -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<String> {
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<AppState> {
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<AppState>,
_admin: AdminIdentity,
body: Bytes,
) -> AppResult<Json<PackageResponse>> {
if body.is_empty() {
return Err(AppError::BadRequest("the uploaded file was empty".into()));
}
if !body.starts_with(b"!<arch>\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<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<UploadResponse>> {
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<AppState>,
_admin: AdminIdentity,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<UploadResponse>> {
let image_url = store_image(&state, &headers, &body, "logo").await?;
Ok(Json(UploadResponse { image_url }))
}
+53
View File
@@ -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<AppState> {
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<AppState> {
kiosk::router()
}
async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
pub async fn service_index() -> Json<Value> {
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"
}
}))
}
+114
View File
@@ -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<AppState> {
Router::new().route("/organization", get(read_public))
}
pub fn admin_router() -> Router<AppState> {
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<Value> {
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<AppState>) -> AppResult<Json<Value>> {
Ok(Json(load(&state.db).await?))
}
#[derive(Deserialize)]
struct UpdateRequest {
branding: Value,
}
async fn update(
State(state): State<AppState>,
_admin: AdminIdentity,
Json(body): Json<UpdateRequest>,
) -> AppResult<Json<Value>> {
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))
}
+20
View File
@@ -0,0 +1,20 @@
use std::sync::Arc;
use sqlx::SqlitePool;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub db: SqlitePool,
}
impl AppState {
pub fn new(config: Config, db: SqlitePool) -> Self {
Self {
config: Arc::new(config),
db,
}
}
}