// rocie - An enterprise grocery management system // // Copyright (C) 2026 Benedikt Peetz // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of Rocie. // // You should have received a copy of the License along with this program. // If not, see . use actix_web::{ App, HttpServer, cookie::{Key, SameSite}, middleware::Logger, web::Data, }; use clap::Parser; use log::warn; use tokio::{fs::File, io::AsyncReadExt}; use utoipa::OpenApi; use crate::cli::{CliArgs, Command}; mod api; mod app; mod cli; mod storage; use actix_identity::IdentityMiddleware; use actix_session::{SessionMiddleware, storage::CookieSessionStore}; #[actix_web::main] #[expect( clippy::needless_for_each, reason = "utoipa generates this, we can't change it" )] async fn main() -> Result<(), std::io::Error> { #[derive(OpenApi)] #[openapi( paths( api::get::auth::inventory::amount_by_id, api::get::auth::product::product_by_id, api::get::auth::product::product_by_name, api::get::auth::product::product_suggestion_by_name, api::get::auth::product::products_by_product_parent_id_direct, api::get::auth::product::products_by_product_parent_id_indirect, api::get::auth::product::products_in_storage, api::get::auth::product::products_registered, api::get::auth::product::products_without_product_parent, api::get::auth::product_parent::product_parents, api::get::auth::product_parent::product_parents_toplevel, api::get::auth::product_parent::product_parents_under, api::get::auth::recipe::recipe_by_name, api::get::auth::recipe::recipe_by_id, api::get::auth::recipe::recipes, api::get::auth::recipe::recipes_by_recipe_parent_id_direct, api::get::auth::recipe::recipes_by_recipe_parent_id_indirect, api::get::auth::recipe::recipes_without_recipe_parent, api::get::auth::recipe_parent::recipe_parents, api::get::auth::recipe_parent::recipe_parents_toplevel, api::get::auth::recipe_parent::recipe_parents_under, api::get::auth::unit::unit_by_id, api::get::auth::unit::units, api::get::auth::unit::units_by_property_id, api::get::auth::unit_property::unit_properties, api::get::auth::unit_property::unit_property_by_id, api::get::auth::user::user_by_id, api::get::auth::user::users, // api::get::no_auth::state::is_logged_in, api::get::no_auth::state::can_be_provisioned, // api::set::auth::barcode::buy_barcode, api::set::auth::barcode::consume_barcode, api::set::auth::product::associate_barcode, api::set::auth::product::register_product, api::set::auth::product_parent::register_product_parent, api::set::auth::recipe::add_recipe, api::set::auth::recipe_parent::register_recipe_parent, api::set::auth::unit::register_unit, api::set::auth::unit_property::register_unit_property, api::set::auth::user::register_user, // api::set::no_auth::user::login, api::set::no_auth::user::logout, api::set::no_auth::user::provision, ), )] struct ApiDoc; env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); let args = CliArgs::parse(); match args.command { Command::Serve { host, port, db_path, print_port, secret_key_file, } => { let data = Data::new( app::App::new(db_path) .await .map_err(|err| std::io::Error::other(main::Error::AppInit(err)))?, ); let secret_key = if let Some(path) = secret_key_file { let mut file = File::open(&path).await.map_err(|error| { std::io::Error::other(secret_key_read::Error::FileOpen { path: path.clone(), error, }) })?; let mut buf = Vec::with_capacity(64 * 2); file.read_to_end(&mut buf).await.map_err(|error| { std::io::Error::other(secret_key_read::Error::FileRead { error, path: path.clone(), }) })?; let contents = String::from_utf8(buf).map_err(|error| { std::io::Error::other(secret_key_read::Error::Utf8 { error, path }) })?; Key::from(&parse_hex_key(&contents)) } else { // When using `Key::generate()` it is important to initialize outside of the // `HttpServer::new` closure. Key::generate() }; let srv = HttpServer::new(move || { App::new() .wrap(Logger::new( r#"%a "%r" -> %s %b ("%{Referer}i" "%{User-Agent}i" %T s)"#, )) // Install the identity framework before middleware (as actix uses FILO). .wrap(IdentityMiddleware::default()) .wrap( SessionMiddleware::builder( CookieSessionStore::default(), secret_key.clone(), ) .cookie_secure(true) .cookie_http_only(true) .cookie_same_site(SameSite::Strict) .build(), ) .app_data(Data::clone(&data)) .configure(api::get::auth::register_paths) .configure(api::get::no_auth::register_paths) .configure(api::set::auth::register_paths) .configure(api::set::no_auth::register_paths) }) .bind((host, port.unwrap_or(0)))?; let addr = srv.addrs()[0]; let run = srv.run(); if print_port { println!("{}", addr.port()); } eprintln!("Serving at http://{addr}"); run.await } Command::OpenApi => { let openapi = ApiDoc::openapi(); println!("{}", openapi.to_pretty_json().expect("Comp-time constant")); Ok(()) } Command::GenerateKey => { let key = Key::generate(); print!("{}", format_hex_val(key.master())); Ok(()) } } } fn parse_hex_key(key: &str) -> [u8; 64] { let mut out = [0u8; 64]; let back = parse_hex_string(key); if back.len() != 64 { warn!( "Secret key is not 64 bytes long (len: {}), padding with zeros.", back.len() ); } for (index, b) in back.iter().enumerate() { out[index] = *b; } out } fn format_hex_val(key: &[u8]) -> String { fn value_to_hex(value: u8) -> char { match value { 0 => '0', 1 => '1', 2 => '2', 3 => '3', 4 => '4', 5 => '5', 6 => '6', 7 => '7', 8 => '8', 9 => '9', 10 => 'a', 11 => 'b', 12 => 'c', 13 => 'd', 14 => 'e', 15 => 'f', _ => unreachable!("The 'value' is a u4"), } } let mut out = String::new(); for val in key { let first = value_to_hex(val >> 4); let second = value_to_hex(val & 0b0000_1111); out.push(first); out.push(second); } out } fn parse_hex_string(input: &str) -> Vec { let mut out = vec![0u8; input.len().div_ceil(2)]; let i1 = input.chars().step_by(2); let mut i2 = input.chars().skip(1).step_by(2); for (index, b1) in i1.enumerate() { if let Some(b2) = i2.next() { out[index] = parse_hex_pair((b1, b2)); } else { // We only have b1 left out[index] = parse_hex_digit(b1); break; } } out } fn parse_hex_pair(digit: (char, char)) -> u8 { let first = parse_hex_digit(digit.0); let second = parse_hex_digit(digit.1); (first << 4) | second } fn parse_hex_digit(digit: char) -> u8 { match digit.to_ascii_uppercase() { '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9, 'A' => 10, 'B' => 11, 'C' => 12, 'D' => 13, 'E' => 14, 'F' => 15, _ => unreachable!("Invalid hex char: {digit:?}"), } } pub(crate) mod main { use crate::{app::app_create, secret_key_read}; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { #[error("Failed to initialize shared application state: {0}")] AppInit(#[from] app_create::Error), #[error("Failed to load secret key from --secret-key-file arg: {0}")] SecretFileRead(#[from] secret_key_read::Error), } } pub(crate) mod secret_key_read { use std::{path::PathBuf, string::FromUtf8Error}; use tokio::io; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { #[error("Failed to open the secret key file at `{path}`: {error}")] FileOpen { path: PathBuf, error: io::Error }, #[error("Failed to read the contents of the secret key file at `{path}`: {error}")] FileRead { path: PathBuf, error: io::Error }, #[error( "Failed to interprete seceret key file (at `{path}`) contents as utf8 string: {error}" )] Utf8 { path: PathBuf, error: FromUtf8Error }, } } #[cfg(test)] mod test { use crate::{format_hex_val, parse_hex_key, parse_hex_pair, parse_hex_string}; #[test] fn test_hex_parse() { let input = "4c6541594a53a79b4649cce91610271e0b477748477dc89f350f8c3bbfc2f1a3b67ae51c56d2286006e070022529ed5b586d114985d05558cd2200bbc5d641c8"; let out = parse_hex_key(input); assert_eq!( out, [ 76, 101, 65, 89, 74, 83, 167, 155, 70, 73, 204, 233, 22, 16, 39, 30, 11, 71, 119, 72, 71, 125, 200, 159, 53, 15, 140, 59, 191, 194, 241, 163, 182, 122, 229, 28, 86, 210, 40, 96, 6, 224, 112, 2, 37, 41, 237, 91, 88, 109, 17, 73, 133, 208, 85, 88, 205, 34, 0, 187, 197, 214, 65, 200 ] ); assert_eq!(format_hex_val(&out), input.to_owned()); } #[test] fn test_hex_parse_basic() { macro_rules! test { ($input:literal -> $expected:literal) => { let output = parse_hex_pair(( $input.chars().next().unwrap(), $input.chars().skip(1).next().unwrap(), )); assert_eq!(output, $expected); }; } test!("ff" -> 255); test!("10" -> 16); test!("05" -> 5); test!("0f" -> 15); test!("af" -> 175); } #[test] fn test_hex_parse_str() { macro_rules! test { ($input:literal -> [$($expected:literal),*]) => { let output = parse_hex_string($input); assert_eq!(output, vec![$($expected),*]); }; } test!("ffff" -> [255, 255]); test!("1020" -> [16,32]); test!("05ff" -> [5, 255]); test!("0ff" -> [15, 15]); test!("afb" -> [175, 11]); } }