319 lines
8.4 KiB
Rust
319 lines
8.4 KiB
Rust
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 }))
|
|
}
|