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,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))
|
||||
}
|
||||
Reference in New Issue
Block a user