mod store; use axum::{ extract::{DefaultBodyLimit, Form, Path, State}, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, routing::get, Json, Router, }; use forma_ir::PageProps; use forma_server::{assets, load_ir_modules, sw, PageHandle, RenderCtx, RouteMode}; use ring::rand::{SecureRandom, SystemRandom}; use rust_embed::Embed; use serde::Deserialize; use serde_json::json; use std::{path::PathBuf, sync::Arc}; use store::{SaveError, Store}; #[derive(Embed)] #[folder = "admin/dist/"] struct Assets; #[derive(PageProps)] struct ItemView { id: String, name: String, quantity: u32, version: u32, note: String, draft_quantity: String, action: String, reload_href: String, csrf: String, message: String, } #[derive(PageProps)] struct InventoryProps { items: Vec, notice: String, } struct AppState { home: PageHandle, store: Store, csrf: String, port: u16, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Edit { quantity: String, note: String, version: u32, csrf: String, } #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); let manifest = assets::try_load_manifest::()?; if manifest.route("/").map(|route| route.mode) != Some(RouteMode::Ssr) { return Err("Field Notes requires native SSR; run npm run build in admin first".into()); } let pages = load_ir_modules::(&manifest).map_err(|errors| format!("{errors:?}"))?; let home = pages .bind::("home") .map_err(|errors| format!("{errors:?}"))?; pages.finalize().map_err(|errors| format!("{errors:?}"))?; sw::configure(sw::ServiceWorker::off()).map_err(|error| format!("{error:?}"))?; let data_file = std::env::var_os("DATA_FILE") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("data/inventory.json")); let store = Store::open(data_file).await?; let mut random = [0u8; 32]; SystemRandom::new() .fill(&mut random) .map_err(|_| "could not generate request token")?; let csrf: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); let port = std::env::var("PORT") .unwrap_or_else(|_| "4176".into()) .parse::()?; // This educational, unauthenticated application is intentionally local only. let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).await?; let port = listener.local_addr()?.port(); let state = Arc::new(AppState { home, store, csrf, port, }); let app = Router::new() .route("/", get(home_page)) .route("/items/{id}", axum::routing::post(save_item)) .route("/items/{id}/reload", get(reload_item)) .route("/healthz", get(|| async { "ok\n" })) .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT })) .route("/sw.js", get(sw::serve_sw::)) .route("/_assets/{*filename}", get(assets::serve_asset::)) .layer(DefaultBodyLimit::max(4096)) .with_state(state); println!("FIELD_NOTES_LISTENING http://127.0.0.1:{port}"); axum::serve(listener, app) .with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await; }) .await?; Ok(()) } async fn render_inventory( state: &AppState, notice: &str, failed: Option<(&str, &Edit, &str)>, ) -> Response { let items = state .store .snapshot() .await .into_iter() .map(|item| { let failure = failed.filter(|(id, _, _)| *id == item.id); ItemView { // The browser keeps this fragment for a refused ordinary POST; // fragments are not sent to the server or used by fetch. action: format!("/items/{}#{}", item.id, item.id), reload_href: format!("/items/{}/reload", item.id), draft_quantity: failure.map_or_else( || item.quantity.to_string(), |(_, edit, _)| edit.quantity.clone(), ), note: failure.map_or(item.note, |(_, edit, _)| edit.note.clone()), message: failure.map_or("Ready to save a change.".into(), |(_, _, message)| { message.into() }), id: item.id, name: item.name, quantity: item.quantity, // A rejected draft never gains the latest write version merely // by being re-rendered. A GET/reload is the explicit refresh. version: failure.map_or(item.version, |(_, edit, _)| edit.version), csrf: state.csrf.clone(), } }) .collect(); let props = InventoryProps { items, notice: notice.into(), }; match state.home.render( &props, &RenderCtx::new().with_title("Field Notes — a Forma application"), ) { Ok(page) => page.into_response(), Err(error) => { tracing::error!(code = error.code(), route = error.route(), "{error}"); ( StatusCode::INTERNAL_SERVER_ERROR, [(header::CACHE_CONTROL, "no-store")], "The inventory could not be rendered.", ) .into_response() } } } async fn home_page(State(state): State>) -> Response { render_inventory( &state, "These values came from Rust. Edits are confirmed only after the local file is saved.", None, ) .await } async fn reload_item(State(state): State>, Path(id): Path) -> Response { if !state .store .snapshot() .await .iter() .any(|item| item.id == id) { return ( StatusCode::NOT_FOUND, [(header::CACHE_CONTROL, "no-store")], "That inventory item does not exist.", ) .into_response(); } // A distinct request URL forces a fresh document even when the browser is // already at /#item. This GET only reads; saving remains an explicit POST. ( [(header::CACHE_CONTROL, "no-store")], Redirect::to(&format!("/#{id}")), ) .into_response() } fn same_origin(headers: &HeaderMap, port: u16) -> bool { let hosts = [format!("127.0.0.1:{port}"), format!("localhost:{port}")]; let host = headers .get(header::HOST) .and_then(|value| value.to_str().ok()); if !hosts.iter().any(|allowed| Some(allowed.as_str()) == host) { return false; } if let Some(origin) = headers.get(header::ORIGIN) { return hosts .iter() .any(|allowed| origin.to_str().ok() == Some(format!("http://{allowed}").as_str())); } true } async fn refusal( state: &AppState, id: &str, edit: &Edit, json_response: bool, status: StatusCode, message: &str, ) -> Response { if json_response { return ( status, [(header::CACHE_CONTROL, "no-store")], Json(json!({ "ok": false, "message": message })), ) .into_response(); } let mut response = render_inventory(state, message, Some((id, edit, message))).await; if response.status().is_success() { *response.status_mut() = status; } response } async fn save_item( State(state): State>, Path(id): Path, headers: HeaderMap, Form(edit): Form, ) -> Response { let json_response = headers .get(header::ACCEPT) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("application/json")); if !same_origin(&headers, state.port) || edit.csrf != state.csrf { return refusal( &state, &id, &edit, json_response, StatusCode::FORBIDDEN, "Request refused. Reload this local page before trying again.", ) .await; } let quantity = match store::validate_quantity(&edit.quantity) { Ok(quantity) => quantity, Err(message) => { return refusal( &state, &id, &edit, json_response, StatusCode::UNPROCESSABLE_ENTITY, message, ) .await } }; if let Err(message) = store::validate_note(&edit.note) { return refusal( &state, &id, &edit, json_response, StatusCode::UNPROCESSABLE_ENTITY, message, ) .await; } match state .store .update(&id, quantity, edit.note.clone(), edit.version) .await { Ok(item) if json_response => ( [(header::CACHE_CONTROL, "no-store")], Json(json!({ "ok": true, "item": item })), ) .into_response(), Ok(item) => ( [(header::CACHE_CONTROL, "no-store")], Redirect::to(&format!("/#{}", item.id)), ) .into_response(), Err(error) => { let (status, message) = match error { SaveError::NotFound => (StatusCode::NOT_FOUND, "That inventory item does not exist."), SaveError::Conflict => (StatusCode::CONFLICT, "This item changed in another tab. Your draft was not saved. Open Reload inventory before editing again."), SaveError::Exhausted => (StatusCode::SERVICE_UNAVAILABLE, "The item version limit was reached. Your changes were not saved."), SaveError::Storage(error) => { tracing::error!(%error, "inventory persistence failed"); (StatusCode::INTERNAL_SERVER_ERROR, "The file could not be saved. Your changes were not confirmed.") } }; refusal(&state, &id, &edit, json_response, status, message).await } } }