vbytes-lan-attendence/api/src/main.rs
2025-09-20 15:18:41 +02:00

464 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = "<payload>")]
async fn login(
cookies: &CookieJar<'_>,
state: &State<AppState>,
payload: Json<LoginRequest>,
) -> Result<Json<LoginResponse>, 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<AppState>) -> 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?<q>")]
async fn search_persons(
_user: AuthUser,
state: &State<AppState>,
q: &str,
) -> Result<Json<PersonsResponse>, 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::<i32>().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?<q>&<status>&<checked>")]
async fn list_checked_in(
_user: AuthUser,
state: &State<AppState>,
q: Option<&str>,
status: Option<&str>,
checked: Option<&str>,
) -> Result<Json<PersonsResponse>, 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::<i32>().ok());
let mut qb = QueryBuilder::<sqlx::Postgres>::new(
"SELECT id, name, age, phone_number, checked_in, inside FROM persons",
);
let mut first_condition = true;
let mut append_condition = |qb: &mut QueryBuilder<sqlx::Postgres>| {
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::<Person>().fetch_all(&state.db).await?;
let persons = persons.into_iter().map(PersonResponse::from).collect();
Ok(Json(PersonsResponse { persons }))
}
#[post("/<id>/checkin")]
async fn checkin_person(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_checked_in(state, id, true).await
}
#[post("/<id>/checkout")]
async fn checkout_person(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_checked_in(state, id, false).await
}
#[post("/<id>/inside")]
async fn mark_inside(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_inside(state, id, true).await
}
#[post("/<id>/outside")]
async fn mark_outside(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_inside(state, id, false).await
}
#[post("/", data = "<payload>")]
async fn create_person(
_user: AuthUser,
state: &State<AppState>,
payload: Json<NewPersonRequest>,
) -> Result<Json<PersonActionResponse>, 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<AppState>,
id: i32,
value: bool,
) -> Result<Json<PersonActionResponse>, 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<AppState>,
id: i32,
value: bool,
) -> Result<Json<PersonActionResponse>, 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.")
}