diff --git a/api/Cargo.toml b/api/Cargo.toml index 8a2438f..2c40522 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -14,5 +14,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "macros", "migrate"] } thiserror = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "process"] } csv = "1.3" diff --git a/api/Dockerfile b/api/Dockerfile index 1cc93c0..8441131 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,5 @@ FROM rustlang/rust:nightly as builder +RUN apt-get update && apt-get install -y pkg-config libssl-dev WORKDIR /app # Cache dependencies @@ -10,8 +11,8 @@ RUN cargo build --release COPY . . RUN cargo build --release -FROM debian:bookworm-slim as runtime -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ +FROM debian:trixie-slim as runtime +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /app/target/release/api /usr/local/bin/api diff --git a/api/src/config.rs b/api/src/config.rs index 618f340..34f3b3a 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -21,10 +21,24 @@ pub struct AppConfig { pub cookie_same_site: SameSite, pub admin_username: String, pub admin_password: String, + + pub external_persons_url: Option, + pub external_persons_api_key: Option, + pub external_client_cert_file: Option, + pub external_client_key_file: Option, + pub external_ca_cert_file: Option, + pub external_danger_accept_invalid_certs: bool, } impl AppConfig { pub fn from_env() -> Result { + fn opt_nonempty(key: &str) -> Option { + env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + } + let database_url = env::var("DATABASE_URL") .map_err(|_| ConfigError::MissingEnv("DATABASE_URL".to_string()))?; let jwt_secret = env::var("JWT_SECRET") @@ -61,6 +75,17 @@ impl AppConfig { let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string()); let admin_password = env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string()); + let external_persons_url = opt_nonempty("EXTERNAL_PERSONS_URL"); + let external_persons_api_key = opt_nonempty("EXTERNAL_PERSONS_API_KEY"); + let external_client_cert_file = opt_nonempty("EXTERNAL_CLIENT_CERT_FILE"); + let external_client_key_file = opt_nonempty("EXTERNAL_CLIENT_KEY_FILE"); + let external_ca_cert_file = opt_nonempty("EXTERNAL_CA_CERT_FILE"); + let external_danger_accept_invalid_certs = + opt_nonempty("EXTERNAL_DANGER_ACCEPT_INVALID_CERTS") + .map(|v| v.to_lowercase()) + .map(|v| matches!(v.as_str(), "true" | "1" | "yes")) + .unwrap_or(false); + Ok(Self { database_url, database_max_connections, @@ -72,6 +97,13 @@ impl AppConfig { cookie_same_site, admin_username, admin_password, + + external_persons_url, + external_persons_api_key, + external_client_cert_file, + external_client_key_file, + external_ca_cert_file, + external_danger_accept_invalid_certs, }) } } diff --git a/api/src/main.rs b/api/src/main.rs index 8e43e92..40ee88e 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -27,6 +27,13 @@ pub struct AppState { pub cookie_secure: bool, pub cookie_same_site: SameSite, pub event_sender: broadcast::Sender, + + pub external_persons_url: Option, + pub external_persons_api_key: Option, + pub external_client_cert_file: Option, + pub external_client_key_file: Option, + pub external_ca_cert_file: Option, + pub external_danger_accept_invalid_certs: bool, } #[rocket::main] @@ -60,6 +67,13 @@ async fn main() -> Result<(), rocket::Error> { cookie_secure: config.cookie_secure, cookie_same_site: config.cookie_same_site, event_sender, + + external_persons_url: config.external_persons_url, + external_persons_api_key: config.external_persons_api_key, + external_client_cert_file: config.external_client_cert_file, + external_client_key_file: config.external_client_key_file, + external_ca_cert_file: config.external_ca_cert_file, + external_danger_accept_invalid_certs: config.external_danger_accept_invalid_certs, }; let rocket = rocket::build() diff --git a/api/src/routes/persons.rs b/api/src/routes/persons.rs index c63042a..cc424c5 100644 --- a/api/src/routes/persons.rs +++ b/api/src/routes/persons.rs @@ -9,6 +9,7 @@ use csv::ReaderBuilder; use rocket::data::ToByteUnit; use rocket::serde::json::Json; use rocket::Route; +use serde::Deserialize; use sqlx::{postgres::PgRow, QueryBuilder, Row}; enum SearchCondition { @@ -27,10 +28,279 @@ pub fn routes() -> Vec { mark_outside, create_person, update_person, - import_persons + import_persons, + import_persons_external ] } +#[derive(Debug, Deserialize)] +struct ExternalParticipant { + pub id: i32, + pub lan_id: i32, + pub first_name: String, + pub surname: String, + #[serde(default)] + pub grade: Option, + #[serde(default)] + pub guardian_name: Option, + #[serde(default)] + pub guardian_phone: Option, + #[serde(default)] + pub is_visiting: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ExternalImportResponse { + Wrapper { + #[serde(default)] + code: Option, + participants: Vec, + }, + Participants(Vec), +} + +#[rocket::post("/import/external")] +pub async fn import_persons_external( + _user: AuthUser, + state: &rocket::State, +) -> Result, ApiError> { + let url = state + .external_persons_url + .as_deref() + .ok_or_else(|| ApiError::bad_request("Saknar konfiguration: EXTERNAL_PERSONS_URL"))?; + + let mut cmd = tokio::process::Command::new("curl"); + cmd.arg("-s") + .arg("-v") // Added verbose for better debugging + .arg("-L") + .arg("--max-time") + .arg("30"); + + if state.external_danger_accept_invalid_certs { + cmd.arg("-k"); + } + + if let Some(ca_path) = state.external_ca_cert_file.as_deref() { + cmd.arg("--cacert").arg(ca_path); + } + + if let (Some(cert_path), Some(key_path)) = ( + state.external_client_cert_file.as_deref(), + state.external_client_key_file.as_deref(), + ) { + cmd.arg("--cert").arg(cert_path); + cmd.arg("--key").arg(key_path); + } + + if let Some(api_key) = state.external_persons_api_key.as_deref() { + cmd.arg("-H").arg(format!("x-api-key: {}", api_key)); + } + + cmd.arg(url); + + let output = cmd.output().await.map_err(|err| { + eprintln!("Failed to execute curl: {err:?}"); + ApiError::internal("Kunde inte starta HTTP-klient (curl).") + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + eprintln!("curl failed with status {}: {}", output.status, stderr); + eprintln!("curl stdout (body if any): {}", stdout); + return Err(ApiError::internal(format!( + "Extern källa svarade med fel (curl status {}). Body: {}", + output.status, stdout + ))); + } + + let bytes = output.stdout; + + let participants: Vec = + match serde_json::from_slice::(&bytes) { + Ok(ExternalImportResponse::Wrapper { + code: _, + participants, + }) => participants, + Ok(ExternalImportResponse::Participants(participants)) => participants, + Err(err) => { + eprintln!("Failed to parse external import JSON: {err:?}"); + return Err(ApiError::internal( + "Kunde inte tolka data från extern källa.", + )); + } + }; + + let mut imported = 0usize; + let mut updated = 0usize; + let mut errors: Vec = Vec::new(); + + for (index, participant) in participants.into_iter().enumerate() { + let line_number = index + 1; + + let first_name = participant.first_name.trim().to_string(); + if first_name.is_empty() { + errors.push(ImportPersonError { + line: line_number, + message: "Förnamn saknas.".to_string(), + }); + continue; + } + + let last_name = participant.surname.trim().to_string(); + if last_name.is_empty() { + errors.push(ImportPersonError { + line: line_number, + message: "Efternamn saknas.".to_string(), + }); + continue; + } + + let grade = participant + .grade + .as_deref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(|v| v.parse::()) + .transpose(); + + let grade = match grade { + Ok(value) => value, + Err(_) => { + errors.push(ImportPersonError { + line: line_number, + message: "Ogiltigt klassvärde.".to_string(), + }); + continue; + } + }; + + if let Some(value) = grade { + if value < 0 { + errors.push(ImportPersonError { + line: line_number, + message: "Klass måste vara ett tal större än eller lika med 0.".to_string(), + }); + continue; + } + } + + let parent_name = participant + .guardian_name + .as_deref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()); + + let parent_phone_number = normalize_phone_number_lenient( + participant + .guardian_phone + .as_deref() + .map(|v| v.trim().to_string()), + ); + + let visitor = participant.is_visiting.unwrap_or(0) != 0; + let sleeping_spot = false; + + let id = if participant.lan_id > 0 { + participant.lan_id + } else { + participant.id + }; + + let result = sqlx::query( + r#" + INSERT INTO persons ( + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + ) VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE, $7, $8) + ON CONFLICT (id) DO UPDATE SET + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + grade = EXCLUDED.grade, + parent_name = EXCLUDED.parent_name, + parent_phone_number = EXCLUDED.parent_phone_number, + checked_in = EXCLUDED.checked_in, + inside = EXCLUDED.inside, + visitor = EXCLUDED.visitor, + sleeping_spot = EXCLUDED.sleeping_spot + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot, + (xmax = 0) AS inserted + "#, + ) + .bind(id) + .bind(&first_name) + .bind(&last_name) + .bind(grade) + .bind(parent_name) + .bind(parent_phone_number) + .bind(visitor) + .bind(sleeping_spot) + .fetch_one(&state.db) + .await; + + match result { + Ok(row) => { + let person = match build_person_from_row(&row) { + Ok(person) => person, + Err(message) => { + errors.push(ImportPersonError { + line: line_number, + message, + }); + continue; + } + }; + + let inserted = row.try_get::("inserted").unwrap_or(false); + if inserted { + imported += 1; + } else { + updated += 1; + } + + let response: PersonResponse = person.into(); + let _ = state + .event_sender + .send(AppEvent::PersonUpdated { person: response }); + } + Err(err) => { + eprintln!("Failed to upsert person during external import: {err:?}"); + errors.push(ImportPersonError { + line: line_number, + message: "Kunde inte spara personen i databasen.".to_string(), + }); + } + } + } + + Ok(Json(ImportPersonsResponse { + imported, + updated, + failed: errors.len(), + errors, + })) +} + #[rocket::get("/search?")] pub async fn search_persons( _user: AuthUser, diff --git a/docker-compose.yml b/docker-compose.yml index 5229e1d..0186f7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: volumes: - postgres-data:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 @@ -26,6 +26,14 @@ services: JWT_COOKIE_SECURE: ${JWT_COOKIE_SECURE:-false} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} + EXTERNAL_PERSONS_URL: ${EXTERNAL_PERSONS_URL:-} + EXTERNAL_PERSONS_API_KEY: ${EXTERNAL_PERSONS_API_KEY:-} + EXTERNAL_CLIENT_CERT_FILE: ${EXTERNAL_CLIENT_CERT_FILE:-} + EXTERNAL_CLIENT_KEY_FILE: ${EXTERNAL_CLIENT_KEY_FILE:-} + EXTERNAL_CA_CERT_FILE: ${EXTERNAL_CA_CERT_FILE:-} + EXTERNAL_DANGER_ACCEPT_INVALID_CERTS: ${EXTERNAL_DANGER_ACCEPT_INVALID_CERTS:-false} + volumes: + - ./certs:/app/certs:ro depends_on: db: condition: service_healthy diff --git a/web/src/routes/(admin)/admin/checkin/+page.svelte b/web/src/routes/(admin)/admin/checkin/+page.svelte index f30d8a1..c38d4e5 100644 --- a/web/src/routes/(admin)/admin/checkin/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/+page.svelte @@ -256,11 +256,13 @@ {/if} - {#if isLowerGrade(person)} +
+
-
@@ -208,16 +150,8 @@ {#if importSummary}

{importSummary}

{/if} - {#if importPreview.length} -
-

- Förhandsgranskning (första {importPreview.length} rader) -

-
{importPreview.join('\n')}
-
- {/if} {#if importLoading} -

Importerar...

+

Hämtar...

{/if} {#if importResult && !importLoading}
diff --git a/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte b/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte index d229405..24f740c 100644 --- a/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte @@ -270,11 +270,13 @@ {/if}
+