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:
2026-08-09 17:09:06 -04:00
parent bad511657e
commit 6606e283bf
25 changed files with 2462 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post, put};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::auth::{
generate_opaque_token, hash_opaque_token, mint_admin_token, verify_password, AdminIdentity,
};
use crate::clock::now_ms;
use crate::error::{AppError, AppResult};
use crate::layout;
use crate::models::{derive_status, KioskRow, KioskView, KIOSK_COLUMNS};
use crate::pins;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/kiosks", get(list_kiosks).post(create_kiosk))
.route(
"/kiosks/{kiosk_id}",
get(kiosk_detail).patch(update_kiosk).delete(delete_kiosk),
)
.route("/kiosks/{kiosk_id}/layout", put(update_layout))
.route("/kiosks/{kiosk_id}/enrollment", post(rotate_enrollment))
}
#[derive(Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LoginResponse {
access_token: String,
email: String,
expires_at: i64,
}
async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> AppResult<Json<LoginResponse>> {
let email = body.email.trim().to_lowercase();
let row: Option<(String, String)> =
sqlx::query_as("SELECT id, password_hash FROM admins WHERE email = ?")
.bind(&email)
.fetch_optional(&state.db)
.await?;
let (admin_id, password_hash) =
row.ok_or_else(|| AppError::Unauthorized("invalid email or password".into()))?;
if !verify_password(&body.password, &password_hash) {
return Err(AppError::Unauthorized("invalid email or password".into()));
}
let (access_token, expires_at) = mint_admin_token(
&state.config.session_secret,
&admin_id,
&email,
state.config.session_ttl_hours,
)?;
Ok(Json(LoginResponse {
access_token,
email,
expires_at: expires_at * 1000,
}))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct KioskListResponse {
kiosks: Vec<KioskView>,
}
async fn list_kiosks(
State(state): State<AppState>,
_admin: AdminIdentity,
) -> AppResult<Json<KioskListResponse>> {
let rows: Vec<KioskRow> =
sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks ORDER BY created_at"))
.fetch_all(&state.db)
.await?;
let now = now_ms();
let kiosks = rows
.into_iter()
.map(|row| {
let mut view = KioskView::from(row);
view.status = derive_status(view.last_seen_at, now).to_string();
view
})
.collect();
Ok(Json(KioskListResponse { kiosks }))
}
#[derive(Deserialize)]
struct CreateKioskRequest {
name: String,
#[serde(default)]
location: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateKioskResponse {
kiosk: KioskView,
enrollment_token: String,
}
async fn create_kiosk(
State(state): State<AppState>,
_admin: AdminIdentity,
Json(body): Json<CreateKioskRequest>,
) -> AppResult<Json<CreateKioskResponse>> {
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("kiosk name is required".into()));
}
let kiosk_id = Uuid::new_v4().to_string();
let room_name = format!("kiosk-{}", &kiosk_id[..8]);
let enrollment_token = generate_opaque_token();
let created_at = now_ms();
sqlx::query(
"INSERT INTO kiosks (id, name, location, room_name, enrollment_token_hash, status, created_at)
VALUES (?, ?, ?, ?, ?, 'offline', ?)",
)
.bind(&kiosk_id)
.bind(name)
.bind(body.location.trim())
.bind(&room_name)
.bind(hash_opaque_token(&enrollment_token))
.bind(created_at)
.execute(&state.db)
.await?;
layout::save_layout(&state.db, &kiosk_id, &layout::default_layout(&kiosk_id)).await?;
pins::ensure_pin(
&state.db,
&kiosk_id,
state.config.pin_rotation_seconds,
state.config.pin_grace_seconds,
)
.await?;
Ok(Json(CreateKioskResponse {
kiosk: KioskView {
kiosk_id,
name: name.to_string(),
location: body.location.trim().to_string(),
room_name,
status: "offline".to_string(),
last_seen_at: None,
created_at,
metrics: None,
metrics_at: None,
},
enrollment_token,
}))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct KioskDetailResponse {
kiosk: KioskView,
layout: Value,
current_pin: Option<String>,
}
async fn kiosk_detail(
State(state): State<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
) -> AppResult<Json<KioskDetailResponse>> {
let row: Option<KioskRow> =
sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks WHERE id = ?"))
.bind(&kiosk_id)
.fetch_optional(&state.db)
.await?;
let row = row.ok_or_else(|| AppError::NotFound("kiosk not found".into()))?;
let mut kiosk = KioskView::from(row);
kiosk.status = derive_status(kiosk.last_seen_at, now_ms()).to_string();
let layout = layout::load_layout(&state.db, &kiosk_id).await?;
let current_pin = pins::current_pin(&state.db, &kiosk_id)
.await?
.map(|issued| issued.pin);
Ok(Json(KioskDetailResponse {
kiosk,
layout,
current_pin,
}))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateKioskRequest {
name: String,
#[serde(default)]
location: String,
}
async fn update_kiosk(
State(state): State<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
Json(body): Json<UpdateKioskRequest>,
) -> AppResult<Json<KioskView>> {
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("kiosk name is required".into()));
}
let result = sqlx::query("UPDATE kiosks SET name = ?, location = ? WHERE id = ?")
.bind(name)
.bind(body.location.trim())
.bind(&kiosk_id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("kiosk not found".into()));
}
let row: KioskRow = sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks WHERE id = ?"))
.bind(&kiosk_id)
.fetch_one(&state.db)
.await?;
let mut kiosk = KioskView::from(row);
kiosk.status = derive_status(kiosk.last_seen_at, now_ms()).to_string();
Ok(Json(kiosk))
}
#[derive(Deserialize)]
struct UpdateLayoutRequest {
layout: Value,
}
async fn update_layout(
State(state): State<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
Json(body): Json<UpdateLayoutRequest>,
) -> AppResult<Json<Value>> {
let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM kiosks WHERE id = ?")
.bind(&kiosk_id)
.fetch_optional(&state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("kiosk not found".into()));
}
layout::save_layout(&state.db, &kiosk_id, &body.layout).await?;
let stored = layout::load_layout(&state.db, &kiosk_id).await?;
Ok(Json(stored))
}
async fn delete_kiosk(
State(state): State<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
) -> AppResult<StatusCode> {
let result = sqlx::query("DELETE FROM kiosks WHERE id = ?")
.bind(&kiosk_id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("kiosk not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct EnrollmentResponse {
enrollment_token: String,
}
async fn rotate_enrollment(
State(state): State<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
) -> AppResult<Json<EnrollmentResponse>> {
let enrollment_token = generate_opaque_token();
let result = sqlx::query(
"UPDATE kiosks SET enrollment_token_hash = ?, kiosk_token_hash = NULL WHERE id = ?",
)
.bind(hash_opaque_token(&enrollment_token))
.bind(&kiosk_id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("kiosk not found".into()));
}
Ok(Json(EnrollmentResponse { enrollment_token }))
}
+45
View File
@@ -0,0 +1,45 @@
use axum::extract::State;
use axum::http::header::{HeaderMap, CONTENT_TYPE};
use axum::http::HeaderValue;
use axum::response::{IntoResponse, Response};
use crate::state::AppState;
const INSTALL_SCRIPT: &str = include_str!("../../assets/install.sh");
const SERVER_URL_PLACEHOLDER: &str = "__PISTATION_SERVER_URL__";
pub async fn install_script(State(state): State<AppState>, headers: HeaderMap) -> Response {
let server_url = resolve_server_url(&state, &headers);
let body = INSTALL_SCRIPT.replace(SERVER_URL_PLACEHOLDER, &server_url);
let mut response = body.into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/x-shellscript; charset=utf-8"),
);
response
}
fn resolve_server_url(state: &AppState, headers: &HeaderMap) -> String {
let host = header_value(headers, "x-forwarded-host").or_else(|| header_value(headers, "host"));
if let Some(host) = host {
let scheme =
header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".to_string());
return format!("{scheme}://{host}");
}
if !state.config.public_api_url.is_empty() {
return state.config.public_api_url.trim_end_matches('/').to_string();
}
"http://localhost:8080".to_string()
}
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(',').next().unwrap_or(value).trim().to_string())
.filter(|value| !value.is_empty())
}
+182
View File
@@ -0,0 +1,182 @@
use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::clock::{now_ms, seconds_to_ms};
use crate::error::{AppError, AppResult};
use crate::livekit::{mint_access_token, TokenRequest};
use crate::pins;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/join", post(join))
.route("/session/refresh", post(refresh))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JoinRequest {
pin: String,
#[serde(default)]
display_name: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JoinResponse {
session_id: String,
room_name: String,
kiosk_name: String,
livekit_url: String,
access_token: String,
participant_id: String,
display_name: String,
role: String,
expires_at: i64,
}
async fn join(
State(state): State<AppState>,
Json(body): Json<JoinRequest>,
) -> AppResult<Json<JoinResponse>> {
let pin = body.pin.trim().replace(' ', "");
if pin.len() != 6 || !pin.chars().all(|character| character.is_ascii_digit()) {
return Err(AppError::BadRequest("pin must be six digits".into()));
}
let kiosk_id = pins::resolve_pin(&state.db, &pin)
.await?
.ok_or_else(|| AppError::NotFound("that pin is not valid right now".into()))?;
let (kiosk_name, room_name): (String, String) =
sqlx::query_as("SELECT name, room_name FROM kiosks WHERE id = ?")
.bind(&kiosk_id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("kiosk no longer exists".into()))?;
let display_name = sanitize_display_name(&body.display_name)?;
let role = "presenter".to_string();
let session_id = Uuid::new_v4().to_string();
let participant_id = format!("web-{session_id}");
let created_at = now_ms();
let expires_at = created_at + seconds_to_ms(state.config.session_ttl_hours * 3600);
sqlx::query(
"INSERT INTO sessions (id, kiosk_id, participant_id, display_name, role, created_at, expires_at, revoked)
VALUES (?, ?, ?, ?, ?, ?, ?, 0)",
)
.bind(&session_id)
.bind(&kiosk_id)
.bind(&participant_id)
.bind(&display_name)
.bind(&role)
.bind(created_at)
.bind(expires_at)
.execute(&state.db)
.await?;
let access_token = mint_for_session(&state, &room_name, &participant_id, &display_name, &role)?;
Ok(Json(JoinResponse {
session_id,
room_name,
kiosk_name,
livekit_url: state.config.livekit_url.clone(),
access_token,
participant_id,
display_name,
role,
expires_at,
}))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RefreshRequest {
session_id: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RefreshResponse {
room_name: String,
livekit_url: String,
access_token: String,
participant_id: String,
display_name: String,
role: String,
expires_at: i64,
}
async fn refresh(
State(state): State<AppState>,
Json(body): Json<RefreshRequest>,
) -> AppResult<Json<RefreshResponse>> {
let row: Option<(String, String, String, i64, String)> = sqlx::query_as(
"SELECT sessions.participant_id, sessions.display_name, sessions.role,
sessions.expires_at, kiosks.room_name
FROM sessions JOIN kiosks ON kiosks.id = sessions.kiosk_id
WHERE sessions.id = ? AND sessions.revoked = 0",
)
.bind(&body.session_id)
.fetch_optional(&state.db)
.await?;
let (participant_id, display_name, role, expires_at, room_name) =
row.ok_or_else(|| AppError::Unauthorized("unknown session".into()))?;
if expires_at <= now_ms() {
return Err(AppError::Unauthorized("session expired".into()));
}
let access_token = mint_for_session(&state, &room_name, &participant_id, &display_name, &role)?;
Ok(Json(RefreshResponse {
room_name,
livekit_url: state.config.livekit_url.clone(),
access_token,
participant_id,
display_name,
role,
expires_at,
}))
}
fn mint_for_session(
state: &AppState,
room_name: &str,
participant_id: &str,
display_name: &str,
role: &str,
) -> AppResult<String> {
// Everyone who joins may publish. Only one screen share runs at a time, and that is
// enforced by the clients against the room's live tracks rather than by the token.
let can_publish = true;
let (token, _) = mint_access_token(
&state.config.livekit_api_key,
&state.config.livekit_api_secret,
TokenRequest {
room_name,
identity: participant_id,
display_name,
role,
can_publish,
room_admin: can_publish,
ttl_seconds: state.config.session_ttl_hours * 3600,
},
)?;
Ok(token)
}
fn sanitize_display_name(raw: &str) -> AppResult<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest("please enter your name".into()));
}
Ok(trimmed.chars().take(32).collect())
}
+193
View File
@@ -0,0 +1,193 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::auth::{generate_opaque_token, hash_opaque_token, KioskIdentity};
use crate::clock::now_ms;
use crate::error::{AppError, AppResult};
use crate::layout;
use crate::livekit::{mint_access_token, TokenRequest};
use crate::pins;
use crate::state::AppState;
const KIOSK_TOKEN_TTL_SECONDS: i64 = 12 * 3600;
pub fn router() -> Router<AppState> {
Router::new()
.route("/register", post(register))
.route("/pin", get(pin))
.route("/session", get(session))
.route("/layout", get(kiosk_layout))
.route("/heartbeat", post(heartbeat))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterRequest {
enrollment_token: String,
hardware_id: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegisterResponse {
kiosk_id: String,
kiosk_token: String,
room_name: String,
livekit_url: String,
rotation_seconds: i64,
}
async fn register(
State(state): State<AppState>,
Json(body): Json<RegisterRequest>,
) -> AppResult<Json<RegisterResponse>> {
let enrollment_hash = hash_opaque_token(body.enrollment_token.trim());
let row: Option<(String, String)> =
sqlx::query_as("SELECT id, room_name FROM kiosks WHERE enrollment_token_hash = ?")
.bind(&enrollment_hash)
.fetch_optional(&state.db)
.await?;
let (kiosk_id, room_name) =
row.ok_or_else(|| AppError::Unauthorized("invalid enrollment token".into()))?;
let kiosk_token = generate_opaque_token();
// The name belongs to whoever created the kiosk in the admin panel. Enrolling a Pi
// must never rename it.
sqlx::query(
"UPDATE kiosks
SET kiosk_token_hash = ?, enrollment_token_hash = NULL, hardware_id = ?,
status = 'online', last_seen_at = ?
WHERE id = ?",
)
.bind(hash_opaque_token(&kiosk_token))
.bind(body.hardware_id.trim())
.bind(now_ms())
.bind(&kiosk_id)
.execute(&state.db)
.await?;
pins::ensure_pin(
&state.db,
&kiosk_id,
state.config.pin_rotation_seconds,
state.config.pin_grace_seconds,
)
.await?;
Ok(Json(RegisterResponse {
kiosk_id,
kiosk_token,
room_name,
livekit_url: state.config.livekit_url.clone(),
rotation_seconds: state.config.pin_rotation_seconds,
}))
}
async fn pin(
State(state): State<AppState>,
identity: KioskIdentity,
) -> AppResult<Json<pins::IssuedPin>> {
let issued = pins::ensure_pin(
&state.db,
&identity.kiosk_id,
state.config.pin_rotation_seconds,
state.config.pin_grace_seconds,
)
.await?;
Ok(Json(issued))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SessionResponse {
room_name: String,
livekit_url: String,
access_token: String,
participant_id: String,
}
async fn session(
State(state): State<AppState>,
identity: KioskIdentity,
) -> AppResult<Json<SessionResponse>> {
let participant_id = format!("kiosk-{}", identity.kiosk_id);
let (access_token, _) = mint_access_token(
&state.config.livekit_api_key,
&state.config.livekit_api_secret,
TokenRequest {
room_name: &identity.room_name,
identity: &participant_id,
display_name: "Kiosk",
role: "kiosk",
can_publish: false,
room_admin: true,
ttl_seconds: KIOSK_TOKEN_TTL_SECONDS,
},
)?;
Ok(Json(SessionResponse {
room_name: identity.room_name,
livekit_url: state.config.livekit_url.clone(),
access_token,
participant_id,
}))
}
async fn kiosk_layout(
State(state): State<AppState>,
identity: KioskIdentity,
) -> AppResult<Json<Value>> {
let layout = layout::load_layout(&state.db, &identity.kiosk_id).await?;
Ok(Json(layout))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct HeartbeatRequest {
#[serde(default)]
metrics: Option<Value>,
}
async fn heartbeat(
State(state): State<AppState>,
identity: KioskIdentity,
body: Option<Json<HeartbeatRequest>>,
) -> AppResult<StatusCode> {
let now = now_ms();
let metrics = body.and_then(|Json(body)| body.metrics);
match metrics {
Some(metrics) => {
sqlx::query(
"UPDATE kiosks
SET last_seen_at = ?, status = 'online', metrics = ?, metrics_at = ?
WHERE id = ?",
)
.bind(now)
.bind(metrics.to_string())
.bind(now)
.bind(&identity.kiosk_id)
.execute(&state.db)
.await?;
}
None => {
sqlx::query("UPDATE kiosks SET last_seen_at = ?, status = 'online' WHERE id = ?")
.bind(now)
.bind(&identity.kiosk_id)
.execute(&state.db)
.await?;
}
}
Ok(StatusCode::NO_CONTENT)
}
+150
View File
@@ -0,0 +1,150 @@
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::HeaderMap;
use axum::routing::put;
use axum::{Json, Router};
use serde::Serialize;
use uuid::Uuid;
use crate::auth::AdminIdentity;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
pub const MAX_UPLOAD_BYTES: usize = 8 * 1024 * 1024;
const ALLOWED_TYPES: [(&str, &str); 4] = [
("image/png", "png"),
("image/jpeg", "jpg"),
("image/webp", "webp"),
("image/gif", "gif"),
];
pub const MAX_PACKAGE_BYTES: usize = 256 * 1024 * 1024;
/// Validates an uploaded image and writes it under the media directory, returning the path
/// clients should store. Shared by kiosk wallpapers and the organisation logo.
pub async fn store_image(
state: &AppState,
headers: &HeaderMap,
body: &Bytes,
prefix: &str,
) -> AppResult<String> {
if body.is_empty() {
return Err(AppError::BadRequest("the uploaded file was empty".into()));
}
if body.len() > MAX_UPLOAD_BYTES {
return Err(AppError::BadRequest("images must be 8 MB or smaller".into()));
}
let content_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim().to_lowercase())
.unwrap_or_default();
let extension = ALLOWED_TYPES
.iter()
.find(|(mime, _)| *mime == content_type)
.map(|(_, extension)| *extension)
.ok_or_else(|| {
AppError::BadRequest("images must be a PNG, JPEG, WEBP or GIF".into())
})?;
let directory = std::path::Path::new(&state.config.media_dir);
tokio::fs::create_dir_all(directory)
.await
.map_err(|error| AppError::Internal(format!("cannot create media directory: {error}")))?;
let file_name = format!("{prefix}-{}.{extension}", Uuid::new_v4());
tokio::fs::write(directory.join(&file_name), body)
.await
.map_err(|error| AppError::Internal(format!("cannot write image: {error}")))?;
Ok(format!("/media/{file_name}"))
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/kiosks/{kiosk_id}/wallpaper", put(upload_wallpaper))
.route("/organization/logo", put(upload_logo))
.layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES))
.merge(
Router::new()
.route("/packages/kiosk", put(upload_package))
.layer(DefaultBodyLimit::max(MAX_PACKAGE_BYTES)),
)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PackageResponse {
download_url: String,
size_bytes: usize,
}
async fn upload_package(
State(state): State<AppState>,
_admin: AdminIdentity,
body: Bytes,
) -> AppResult<Json<PackageResponse>> {
if body.is_empty() {
return Err(AppError::BadRequest("the uploaded file was empty".into()));
}
if !body.starts_with(b"!<arch>\ndebian-binary") {
return Err(AppError::BadRequest(
"that does not look like a .deb package".into(),
));
}
let directory = std::path::Path::new(&state.config.package_dir);
tokio::fs::create_dir_all(directory)
.await
.map_err(|error| AppError::Internal(format!("cannot create package directory: {error}")))?;
tokio::fs::write(directory.join("pistation-kiosk.deb"), &body)
.await
.map_err(|error| AppError::Internal(format!("cannot write package: {error}")))?;
Ok(Json(PackageResponse {
download_url: "/downloads/pistation-kiosk.deb".to_string(),
size_bytes: body.len(),
}))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UploadResponse {
image_url: String,
}
async fn upload_wallpaper(
State(state): State<AppState>,
_admin: AdminIdentity,
Path(kiosk_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<UploadResponse>> {
let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM kiosks WHERE id = ?")
.bind(&kiosk_id)
.fetch_optional(&state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("kiosk not found".into()));
}
let image_url = store_image(&state, &headers, &body, &kiosk_id).await?;
Ok(Json(UploadResponse { image_url }))
}
async fn upload_logo(
State(state): State<AppState>,
_admin: AdminIdentity,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<UploadResponse>> {
let image_url = store_image(&state, &headers, &body, "logo").await?;
Ok(Json(UploadResponse { image_url }))
}
+53
View File
@@ -0,0 +1,53 @@
pub mod admin;
pub mod install;
pub mod join;
pub mod kiosk;
pub mod media;
pub mod organization;
pub use install::install_script;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use crate::state::AppState;
/// Routes browsers call. These carry the configured origin allowlist.
pub fn api_router() -> Router<AppState> {
Router::new()
.route("/health", get(health))
.merge(join::router())
.merge(organization::public_router())
.nest(
"/admin",
admin::router()
.merge(media::router())
.merge(organization::admin_router()),
)
}
/// Routes only kiosks call, mounted separately because they are not called from a web
/// origin. A Tauri webview reports `http://tauri.localhost` in production and whatever the
/// dev server uses otherwise, so an origin allowlist can only ever lock kiosks out. These
/// endpoints authenticate with a bearer token and never with a cookie, so an origin check
/// would add no protection anyway.
pub fn kiosk_router() -> Router<AppState> {
kiosk::router()
}
async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
pub async fn service_index() -> Json<Value> {
Json(json!({
"service": "pistation-server",
"message": "This is the PiStation API. The web client is served separately, by default on port 3000.",
"endpoints": {
"health": "/api/health",
"join": "POST /api/join",
"admin": "POST /api/admin/login"
}
}))
}
+114
View File
@@ -0,0 +1,114 @@
use axum::extract::State;
use axum::routing::{get, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::SqlitePool;
use crate::auth::AdminIdentity;
use crate::clock::now_ms;
use crate::error::AppResult;
use crate::state::AppState;
const SETTINGS_KEY: &str = "organization";
pub fn public_router() -> Router<AppState> {
Router::new().route("/organization", get(read_public))
}
pub fn admin_router() -> Router<AppState> {
Router::new().route("/organization", put(update))
}
fn defaults() -> Value {
json!({
"name": "PiStation",
"headline": "Any screen becomes a shared screen.",
"description": "Type the code shown on screen to present, draw on what is being shown, or open a whiteboard together. No accounts, no installs, and nothing leaves the network it runs on.",
"logoUrl": "",
"accentColor": "#4f7cff",
"joinLabel": "Enter the code on screen",
"footerNote": "",
"showSourceLink": true,
"landingMode": "full",
"theme": {
"surface0": "#0b0d10",
"surface1": "#14181d",
"surface2": "#1c2229",
"surface3": "#262e37",
"ink0": "#f4f6f8",
"ink1": "#a8b3c0",
"ink2": "#6b7885"
},
"links": [],
"updatedAt": 0
})
}
fn apply_defaults(mut branding: Value) -> Value {
let Some(object) = branding.as_object_mut() else {
return defaults();
};
if let Value::Object(fallback) = defaults() {
for (key, value) in fallback {
object.entry(key).or_insert(value);
}
}
// The palette is nested, so a stored branding written before a colour existed still
// needs that one filling in rather than the whole object being replaced.
if let (Some(Value::Object(theme)), Value::Object(defaults)) =
(object.get_mut("theme"), defaults()["theme"].clone())
{
for (key, value) in defaults {
theme.entry(key).or_insert(value);
}
}
branding
}
pub async fn load(db: &SqlitePool) -> AppResult<Value> {
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(SETTINGS_KEY)
.fetch_optional(db)
.await?;
let stored = row
.and_then(|(value,)| serde_json::from_str(&value).ok())
.unwrap_or_else(defaults);
Ok(apply_defaults(stored))
}
async fn read_public(State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(load(&state.db).await?))
}
#[derive(Deserialize)]
struct UpdateRequest {
branding: Value,
}
async fn update(
State(state): State<AppState>,
_admin: AdminIdentity,
Json(body): Json<UpdateRequest>,
) -> AppResult<Json<Value>> {
let now = now_ms();
let mut branding = apply_defaults(body.branding);
branding["updatedAt"] = json!(now);
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(SETTINGS_KEY)
.bind(branding.to_string())
.bind(now)
.execute(&state.db)
.await?;
Ok(Json(branding))
}