This commit is contained in:
Sebastian 2026-02-14 00:36:13 +01:00
parent 8e0acb7bc8
commit 710c7acff6
12 changed files with 356 additions and 97 deletions

View file

@ -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"

View file

@ -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

View file

@ -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<String>,
pub external_persons_api_key: Option<String>,
pub external_client_cert_file: Option<String>,
pub external_client_key_file: Option<String>,
pub external_ca_cert_file: Option<String>,
pub external_danger_accept_invalid_certs: bool,
}
impl AppConfig {
pub fn from_env() -> Result<Self, ConfigError> {
fn opt_nonempty(key: &str) -> Option<String> {
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,
})
}
}

View file

@ -27,6 +27,13 @@ pub struct AppState {
pub cookie_secure: bool,
pub cookie_same_site: SameSite,
pub event_sender: broadcast::Sender<AppEvent>,
pub external_persons_url: Option<String>,
pub external_persons_api_key: Option<String>,
pub external_client_cert_file: Option<String>,
pub external_client_key_file: Option<String>,
pub external_ca_cert_file: Option<String>,
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()

View file

@ -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<Route> {
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<String>,
#[serde(default)]
pub guardian_name: Option<String>,
#[serde(default)]
pub guardian_phone: Option<String>,
#[serde(default)]
pub is_visiting: Option<i32>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ExternalImportResponse {
Wrapper {
#[serde(default)]
code: Option<i32>,
participants: Vec<ExternalParticipant>,
},
Participants(Vec<ExternalParticipant>),
}
#[rocket::post("/import/external")]
pub async fn import_persons_external(
_user: AuthUser,
state: &rocket::State<AppState>,
) -> Result<Json<ImportPersonsResponse>, 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<ExternalParticipant> =
match serde_json::from_slice::<ExternalImportResponse>(&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<ImportPersonError> = 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::<i32>())
.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::<bool, _>("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?<q>")]
pub async fn search_persons(
_user: AuthUser,

View file

@ -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

View file

@ -256,11 +256,13 @@
{/if}
</div>
</div>
<!--
{#if isLowerGrade(person)}
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
Observera: elev i årskurs 3 eller yngre ska hem senast 22:00.
</p>
{/if}
-->
<div class="mt-4 flex gap-3">
<button
type="button"

View file

@ -340,11 +340,13 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
{/if}
</div>
</header>
<!--
{#if isLowerGrade(person)}
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
Observera: elev i årskurs 3 eller yngre ska hem senast 22:00.
</p>
{/if}
-->
<div class="mt-4">
<button
type="button"

View file

@ -314,11 +314,13 @@
{/if}
</div>
</div>
<!--
{#if isLowerGrade(person)}
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
Observera: elev i årskurs 3 eller yngre ska hem senast 22:00.
</p>
{/if}
-->
<div class="mt-4 flex gap-3">
<button
type="button"

View file

@ -40,7 +40,6 @@
let importMessage = $state('');
let importSummary = $state('');
let importPreview = $state<string[]>([]);
let importError = $state('');
let importLoading = $state(false);
let importResult = $state<
@ -52,7 +51,6 @@
errors: { line: number; message: string }[];
}
>(null);
let fileInput: HTMLInputElement | null = null;
const values = $derived({
...defaults,
@ -62,73 +60,23 @@
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
const success = $derived(props.form?.success ?? null);
function triggerFileImport() {
fileInput?.click();
}
function handleFileSelection(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0] ?? null;
if (!file) {
importMessage = '';
importError = '';
return;
}
const sizeKb = Math.max(1, Math.round(file.size / 1024));
importMessage = `Vald fil: ${file.name} (${sizeKb} KB)`;
importError = '';
importSummary = '';
importPreview = [];
importResult = null;
readCsvFile(file);
}
function readCsvFile(file: File) {
const reader = new FileReader();
reader.onload = () => {
const text = typeof reader.result === 'string' ? reader.result : '';
const trimmed = text.trim();
if (!trimmed) {
importSummary = 'Filen verkar sakna innehåll.';
importPreview = [];
return;
}
const lines = trimmed.split(/\r?\n/);
const previewCount = Math.min(5, lines.length);
importSummary = `Läste in ${lines.length} rader.`;
importPreview = lines.slice(0, previewCount);
void uploadCsvContent(text, lines.length);
};
reader.onerror = () => {
console.error('Kunde inte läsa CSV-filen', reader.error);
importError = 'Kunde inte läsa innehållet i filen. Försök igen.';
importSummary = '';
importPreview = [];
importResult = null;
};
reader.readAsText(file);
}
async function uploadCsvContent(raw: string, lineCount: number) {
async function importFromExternalDb() {
importLoading = true;
importMessage = 'Hämtar deltagare från extern databas…';
importSummary = '';
importError = '';
importResult = null;
try {
const response = await fetch('/api/persons/import', {
method: 'POST',
headers: {
'content-type': 'text/csv'
},
body: raw
'content-type': 'application/json'
}
});
const text = await response.text();
if (!response.ok) {
let message = 'Kunde inte importera CSV-filen.';
let message = 'Kunde inte hämta deltagare.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
@ -162,14 +110,15 @@
failed: data.failed ?? 0,
errors: data.errors ?? []
};
importSummary = `Läste in ${lineCount} rader.`;
importSummary = 'Import klar.';
importError = '';
} catch (err) {
console.error('Misslyckades att skicka CSV till servern', err);
importError = 'Ett fel uppstod när filen skickades till servern.';
console.error('Misslyckades att importera från extern källa', err);
importError = 'Ett fel uppstod när data hämtades från extern källa.';
importResult = null;
} finally {
importLoading = false;
importMessage = '';
}
}
</script>
@ -185,20 +134,13 @@
</p>
</div>
<div class="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv"
class="hidden"
bind:this={fileInput}
onchange={handleFileSelection}
/>
<button
type="button"
onclick={triggerFileImport}
on:click={() => void importFromExternalDb()}
disabled={importLoading}
class="rounded-full border border-indigo-200 px-3 py-2 text-sm font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{importLoading ? 'Importerar…' : 'Importera från CSV'}
{importLoading ? 'Hämtar…' : 'Hämta deltagare'}
</button>
</div>
</div>
@ -208,16 +150,8 @@
{#if importSummary}
<p class="mb-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{importSummary}</p>
{/if}
{#if importPreview.length}
<div class="mb-4 rounded-md border border-slate-200 bg-slate-50 p-3">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Förhandsgranskning (första {importPreview.length} rader)
</p>
<pre class="max-h-48 overflow-auto whitespace-pre-wrap text-xs text-slate-700">{importPreview.join('\n')}</pre>
</div>
{/if}
{#if importLoading}
<p class="mb-2 text-sm text-slate-600">Importerar...</p>
<p class="mb-2 text-sm text-slate-600">Hämtar...</p>
{/if}
{#if importResult && !importLoading}
<div class="mb-4 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">

View file

@ -270,11 +270,13 @@
{/if}
</div>
</header>
<!--
{#if isLowerGrade(person)}
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
Observera: elev i årskurs 3 eller yngre ska hem senast 22:00.
</p>
{/if}
-->
<div class="mt-4 flex flex-wrap gap-3">
<button
type="button"

View file

@ -3,17 +3,9 @@ import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
export const POST: RequestHandler = async (event) => {
const bodyText = await event.request.text();
if (!bodyText.trim()) {
throw error(400, 'CSV-filen innehåller inga rader.');
}
const { response, setCookies } = await proxyRequest(event, '/persons/import', {
const { response, setCookies } = await proxyRequest(event, '/persons/import/external', {
method: 'POST',
body: bodyText,
headers: {
'content-type': 'text/csv'
}
// No body needed; backend fetches participants from external source.
});
const text = await response.text();