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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE kiosks ADD COLUMN metrics TEXT;
|
||||
ALTER TABLE kiosks ADD COLUMN metrics_at INTEGER;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user