mod auth; mod config; mod error; mod models; mod seed; use auth::{generate_token, AuthUser}; use config::AppConfig; use error::ApiError; use models::{ LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse, PersonsResponse, User, }; use rocket::http::{Cookie, CookieJar, SameSite, Status}; use rocket::serde::json::Json; use rocket::time::Duration; use rocket::{get, post, routes, State}; use sqlx::postgres::PgPoolOptions; use sqlx::{PgPool, QueryBuilder}; pub struct AppState { pub db: PgPool, pub jwt_secret: String, pub jwt_issuer: String, pub jwt_ttl_seconds: i64, pub cookie_name: String, pub cookie_secure: bool, } #[rocket::main] async fn main() -> Result<(), rocket::Error> { dotenvy::dotenv().ok(); let config = AppConfig::from_env().expect("Kunde inte läsa konfiguration"); let pool = PgPoolOptions::new() .max_connections(config.database_max_connections) .connect(&config.database_url) .await .expect("Kunde inte ansluta till databasen"); sqlx::migrate!() .run(&pool) .await .expect("Misslyckades att köra migrationer"); seed::run(&pool, &config.admin_username, &config.admin_password) .await .expect("Misslyckades att seed:a databasen"); let state = AppState { db: pool.clone(), jwt_secret: config.jwt_secret, jwt_issuer: config.jwt_issuer, jwt_ttl_seconds: config.jwt_ttl_seconds, cookie_name: config.cookie_name, cookie_secure: config.cookie_secure, }; let rocket = rocket::build() .manage(state) .mount("/", routes![healthz, login, logout]) .mount( "/persons", routes![ search_persons, list_checked_in, checkin_person, checkout_person, mark_inside, mark_outside, create_person ], ); rocket.launch().await?; Ok(()) } #[get("/healthz")] fn healthz() -> &'static str { "ok" } #[post("/login", data = "")] async fn login( cookies: &CookieJar<'_>, state: &State, payload: Json, ) -> Result, ApiError> { let username = payload.username.trim(); let password = payload.password.trim(); if username.is_empty() || password.is_empty() { return Err(ApiError::bad_request("Användarnamn och lösenord krävs.")); } let user = sqlx::query_as::<_, User>( r#" SELECT id, username, password_hash, created_at FROM users WHERE username = $1 "#, ) .bind(username) .fetch_optional(&state.db) .await? .ok_or_else(|| ApiError::unauthorized("Felaktigt användarnamn eller lösenord."))?; let valid = bcrypt::verify(password, &user.password_hash) .map_err(|_| ApiError::unauthorized("Felaktigt användarnamn eller lösenord."))?; if !valid { return Err(ApiError::unauthorized( "Felaktigt användarnamn eller lösenord.", )); } let token = generate_token(state.inner(), user.id, &user.username)?; let mut cookie = Cookie::new(state.cookie_name.clone(), token); cookie.set_http_only(true); cookie.set_same_site(Some(SameSite::Lax)); cookie.set_path("/".to_string()); cookie.set_max_age(Duration::seconds(state.inner().jwt_ttl_seconds)); if state.cookie_secure { cookie.set_secure(true); } cookies.add(cookie); Ok(Json(LoginResponse { username: user.username, })) } #[post("/logout")] fn logout(cookies: &CookieJar<'_>, state: &State) -> Status { let mut cookie = Cookie::new(state.cookie_name.clone(), String::new()); cookie.set_http_only(true); cookie.set_same_site(Some(SameSite::Lax)); cookie.set_path("/".to_string()); if state.cookie_secure { cookie.set_secure(true); } cookies.remove(cookie); Status::NoContent } #[get("/search?")] async fn search_persons( _user: AuthUser, state: &State, q: &str, ) -> Result, ApiError> { let query = q.trim(); if query.is_empty() { return Err(ApiError::bad_request("Söktext krävs.")); } let like_pattern = format!("%{}%", query); let id_value = query.parse::().ok(); let persons = if let Some(id) = id_value { sqlx::query_as::<_, Person>( r#" SELECT id, name, age, phone_number, checked_in, inside FROM persons WHERE name ILIKE $1 OR phone_number ILIKE $1 OR id = $2 ORDER BY name LIMIT 100 "#, ) .bind(&like_pattern) .bind(id) .fetch_all(&state.db) .await? } else { sqlx::query_as::<_, Person>( r#" SELECT id, name, age, phone_number, checked_in, inside FROM persons WHERE name ILIKE $1 OR phone_number ILIKE $1 ORDER BY name LIMIT 100 "#, ) .bind(&like_pattern) .fetch_all(&state.db) .await? }; let persons = persons.into_iter().map(PersonResponse::from).collect(); Ok(Json(PersonsResponse { persons })) } #[get("/checked-in?&&")] async fn list_checked_in( _user: AuthUser, state: &State, q: Option<&str>, status: Option<&str>, checked: Option<&str>, ) -> Result, ApiError> { let inside_filter = match status.map(|s| s.trim().to_lowercase()) { Some(value) if value.is_empty() || value == "all" => None, Some(value) if value == "inside" => Some(true), Some(value) if value == "outside" => Some(false), Some(_) => return Err(ApiError::bad_request("Ogiltigt statusvärde.")), None => None, }; let checked_filter = match checked.map(|s| s.trim().to_lowercase()) { Some(value) if value.is_empty() || value == "all" => None, Some(value) if value == "true" || value == "checked" || value == "in" => Some(true), Some(value) if value == "false" || value == "unchecked" || value == "out" => Some(false), Some(_) => return Err(ApiError::bad_request("Ogiltigt filter för incheckning.")), None => None, }; let search_term = q .map(|raw| raw.trim()) .filter(|trimmed| !trimmed.is_empty()) .map(|trimmed| trimmed.to_string()); let like_pattern = search_term.as_ref().map(|s| format!("%{}%", s)); let id_value = search_term.as_ref().and_then(|s| s.parse::().ok()); let mut qb = QueryBuilder::::new( "SELECT id, name, age, phone_number, checked_in, inside FROM persons", ); let mut first_condition = true; let mut append_condition = |qb: &mut QueryBuilder| { if first_condition { qb.push(" WHERE "); first_condition = false; } else { qb.push(" AND "); } }; if let Some(checked) = checked_filter { append_condition(&mut qb); qb.push("checked_in = ").push_bind(checked); } if let Some(inside) = inside_filter { append_condition(&mut qb); qb.push("inside = ").push_bind(inside); } if let Some(like) = like_pattern.as_ref() { append_condition(&mut qb); qb.push("("); qb.push("name ILIKE ").push_bind(like); qb.push(" OR phone_number ILIKE ").push_bind(like); if let Some(id) = id_value { qb.push(" OR id = ").push_bind(id); } qb.push(")"); } if let Some(id) = id_value { qb.push(" ORDER BY CASE WHEN id = ") .push_bind(id) .push(" THEN 0 ELSE 1 END, id, name"); } else { qb.push(" ORDER BY id, name"); } if like_pattern.is_some() { qb.push(" LIMIT 1000"); } let persons = qb.build_query_as::().fetch_all(&state.db).await?; let persons = persons.into_iter().map(PersonResponse::from).collect(); Ok(Json(PersonsResponse { persons })) } #[post("//checkin")] async fn checkin_person( _user: AuthUser, state: &State, id: i32, ) -> Result, ApiError> { update_checked_in(state, id, true).await } #[post("//checkout")] async fn checkout_person( _user: AuthUser, state: &State, id: i32, ) -> Result, ApiError> { update_checked_in(state, id, false).await } #[post("//inside")] async fn mark_inside( _user: AuthUser, state: &State, id: i32, ) -> Result, ApiError> { update_inside(state, id, true).await } #[post("//outside")] async fn mark_outside( _user: AuthUser, state: &State, id: i32, ) -> Result, ApiError> { update_inside(state, id, false).await } #[post("/", data = "")] async fn create_person( _user: AuthUser, state: &State, payload: Json, ) -> Result, ApiError> { let name = payload.name.trim(); if name.is_empty() { return Err(ApiError::bad_request("Namn får inte vara tomt.")); } if payload.age < 0 { return Err(ApiError::bad_request("Ålder måste vara noll eller högre.")); } let phone_number = payload.phone_number.trim(); if phone_number.is_empty() { return Err(ApiError::bad_request("Telefonnummer krävs.")); } let age = payload.age; let checked_in = payload.checked_in.unwrap_or(false); let inside = payload.inside.unwrap_or(false); let person = match payload.id { Some(id) => sqlx::query_as::<_, Person>( r#" INSERT INTO persons (id, name, age, phone_number, checked_in, inside) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, age, phone_number, checked_in, inside "#, ) .bind(id) .bind(name) .bind(age) .bind(phone_number) .bind(checked_in) .bind(inside) .fetch_one(&state.db) .await .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, None => sqlx::query_as::<_, Person>( r#" INSERT INTO persons (name, age, phone_number, checked_in, inside) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, age, phone_number, checked_in, inside "#, ) .bind(name) .bind(age) .bind(phone_number) .bind(checked_in) .bind(inside) .fetch_one(&state.db) .await .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, }; Ok(Json(PersonActionResponse { person: person.into(), })) } async fn update_checked_in( state: &State, id: i32, value: bool, ) -> Result, ApiError> { let person = sqlx::query_as::<_, Person>( r#" UPDATE persons SET checked_in = $2, inside = $2 WHERE id = $1 RETURNING id, name, age, phone_number, checked_in, inside "#, ) .bind(id) .bind(value) .fetch_optional(&state.db) .await?; match person { Some(person) => Ok(Json(PersonActionResponse { person: person.into(), })), None => Err(ApiError::not_found("Personen hittades inte.")), } } async fn update_inside( state: &State, id: i32, value: bool, ) -> Result, ApiError> { let person = sqlx::query_as::<_, Person>( r#" UPDATE persons SET inside = $2 WHERE id = $1 AND checked_in = TRUE RETURNING id, name, age, phone_number, checked_in, inside "#, ) .bind(id) .bind(value) .fetch_optional(&state.db) .await?; match person { Some(person) => Ok(Json(PersonActionResponse { person: person.into(), })), None => { let checked = sqlx::query_scalar::<_, bool>("SELECT checked_in FROM persons WHERE id = $1") .bind(id) .fetch_optional(&state.db) .await?; match checked { Some(_) => Err(ApiError::bad_request( "Personen måste vara incheckad för att uppdatera inne/ute.", )), None => Err(ApiError::not_found("Personen hittades inte.")), } } } } fn map_db_error(err: sqlx::Error, context: &str) -> ApiError { if let sqlx::Error::Database(db_err) = &err { if let Some(code) = db_err.code() { if code == "23505" { return ApiError::bad_request( "Krock i databasen – kontrollera id eller telefonnummer.", ); } } eprintln!("Database error ({}): {:?}", context, db_err); return ApiError::internal("Databasfel."); } eprintln!("Database error ({}): {:?}", context, err); ApiError::internal("Databasfel.") }