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.
This commit is contained in:
@@ -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