464 lines
13 KiB
Rust
464 lines
13 KiB
Rust
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.")
|
||
}
|