From 19df7c89627bf0284bcf00edb1d7c63da8a90d0c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 20 Sep 2025 17:03:21 +0200 Subject: [PATCH] Add derived attributes --- .env | 8 ++++++ api/src/config.rs | 13 +++++++++ api/src/main.rs | 59 +++++++++++++++++++++++++++++++++-------- docker-compose.yml | 25 ++++++++--------- web/Dockerfile | 14 +++++++--- web/src/hooks.server.ts | 44 ++++++++++++++++++++++++++++++ web/svelte.config.js | 23 +++++++++++++--- 7 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 .env create mode 100644 web/src/hooks.server.ts diff --git a/.env b/.env new file mode 100644 index 0000000..39c0d65 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +POSTGRES_PASSWORD=postgrespass123 +JWT_SECRET=supersecretjwtkey +ADMIN_USERNAME=admin +ADMIN_PASSWORD=AdminPass!234 +JWT_COOKIE_SECURE=false +ENABLE_HTTPS_REDIRECT=false +WEB_PORT=3000 +CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000 diff --git a/api/src/config.rs b/api/src/config.rs index 2c9a426..618f340 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -1,5 +1,6 @@ use std::env; +use rocket::http::SameSite; use thiserror::Error; #[derive(Debug, Error)] @@ -17,6 +18,7 @@ pub struct AppConfig { pub jwt_ttl_seconds: i64, pub cookie_name: String, pub cookie_secure: bool, + pub cookie_same_site: SameSite, pub admin_username: String, pub admin_password: String, } @@ -46,6 +48,16 @@ impl AppConfig { .map(|v| matches!(v.as_str(), "true" | "1" | "yes")) .unwrap_or(false); + let cookie_same_site = match env::var("JWT_COOKIE_SAME_SITE") + .ok() + .map(|v| v.to_lowercase()) + .as_deref() + { + Some("strict") => SameSite::Strict, + Some("none") => SameSite::None, + Some("lax") | Some(_) | None => SameSite::Lax, + }; + 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()); @@ -57,6 +69,7 @@ impl AppConfig { jwt_ttl_seconds, cookie_name, cookie_secure, + cookie_same_site, admin_username, admin_password, }) diff --git a/api/src/main.rs b/api/src/main.rs index 697e896..ef92106 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -12,8 +12,10 @@ use models::{ PersonsResponse, User, }; use rocket::http::{Cookie, CookieJar, SameSite, Status}; +use rocket::response::stream::{Event, EventStream}; use rocket::serde::json::Json; use rocket::time::Duration; +use rocket::tokio::sync::broadcast::{self, error::RecvError}; use rocket::{get, post, routes, State}; use sqlx::postgres::PgPoolOptions; use sqlx::{PgPool, QueryBuilder}; @@ -25,6 +27,8 @@ pub struct AppState { pub jwt_ttl_seconds: i64, pub cookie_name: String, pub cookie_secure: bool, + pub cookie_same_site: SameSite, + pub event_sender: broadcast::Sender, } #[rocket::main] @@ -47,6 +51,8 @@ async fn main() -> Result<(), rocket::Error> { .await .expect("Misslyckades att seed:a databasen"); + let (event_sender, _) = broadcast::channel(128); + let state = AppState { db: pool.clone(), jwt_secret: config.jwt_secret, @@ -54,11 +60,13 @@ async fn main() -> Result<(), rocket::Error> { jwt_ttl_seconds: config.jwt_ttl_seconds, cookie_name: config.cookie_name, cookie_secure: config.cookie_secure, + cookie_same_site: config.cookie_same_site, + event_sender, }; let rocket = rocket::build() .manage(state) - .mount("/", routes![healthz, login, logout]) + .mount("/", routes![healthz, login, logout, events]) .mount( "/persons", routes![ @@ -119,7 +127,7 @@ async fn login( let mut cookie = Cookie::new(state.cookie_name.clone(), token); cookie.set_http_only(true); - cookie.set_same_site(Some(SameSite::Lax)); + cookie.set_same_site(Some(state.cookie_same_site)); cookie.set_path("/".to_string()); cookie.set_max_age(Duration::seconds(state.inner().jwt_ttl_seconds)); if state.cookie_secure { @@ -137,7 +145,7 @@ async fn login( 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_same_site(Some(state.cookie_same_site)); cookie.set_path("/".to_string()); if state.cookie_secure { cookie.set_secure(true); @@ -147,6 +155,21 @@ fn logout(cookies: &CookieJar<'_>, state: &State) -> Status { Status::NoContent } +#[get("/events")] +fn events(_user: AuthUser, state: &State) -> EventStream![Event + '_] { + let mut receiver = state.event_sender.subscribe(); + + EventStream! { + loop { + match receiver.recv().await { + Ok(update) => yield Event::json(&update), + Err(RecvError::Closed) => break, + Err(RecvError::Lagged(_)) => continue, + } + } + } +} + #[get("/search?")] async fn search_persons( _user: AuthUser, @@ -374,9 +397,11 @@ async fn create_person( .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, }; - Ok(Json(PersonActionResponse { + let response = PersonActionResponse { person: person.into(), - })) + }; + broadcast_person_update(state, &response); + Ok(Json(response)) } async fn update_checked_in( @@ -399,9 +424,13 @@ async fn update_checked_in( .await?; match person { - Some(person) => Ok(Json(PersonActionResponse { - person: person.into(), - })), + Some(person) => { + let response = PersonActionResponse { + person: person.into(), + }; + broadcast_person_update(state, &response); + Ok(Json(response)) + } None => Err(ApiError::not_found("Personen hittades inte.")), } } @@ -426,9 +455,13 @@ async fn update_inside( .await?; match person { - Some(person) => Ok(Json(PersonActionResponse { - person: person.into(), - })), + Some(person) => { + let response = PersonActionResponse { + person: person.into(), + }; + broadcast_person_update(state, &response); + Ok(Json(response)) + } None => { let checked = sqlx::query_scalar::<_, bool>("SELECT checked_in FROM persons WHERE id = $1") @@ -462,3 +495,7 @@ fn map_db_error(err: sqlx::Error, context: &str) -> ApiError { eprintln!("Database error ({}): {:?}", context, err); ApiError::internal("Databasfel.") } + +fn broadcast_person_update(state: &State, response: &PersonActionResponse) { + let _ = state.event_sender.send(response.clone()); +} diff --git a/docker-compose.yml b/docker-compose.yml index 3db78da..5229e1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ -version: '3.9' - services: db: - image: postgres:16-alpine + image: postgres:17-alpine environment: POSTGRES_DB: vbytes POSTGRES_USER: postgres @@ -34,23 +32,22 @@ services: restart: unless-stopped web: - build: ./web + build: + context: ./web + dockerfile: Dockerfile + args: + CSRF_ALLOWED_ORIGINS: ${CSRF_ALLOWED_ORIGINS:-} environment: API_BASE_URL: http://api:8080 HOST: 0.0.0.0 - PORT: 3000 + PORT: ${WEB_PORT:-3000} + CSRF_ALLOWED_ORIGINS: ${CSRF_ALLOWED_ORIGINS:-} + ENABLE_HTTPS_REDIRECT: ${ENABLE_HTTPS_REDIRECT:-false} depends_on: api: condition: service_started - restart: unless-stopped - - cloudflared: - image: cloudflare/cloudflared:latest - command: ['tunnel', 'run', '--token', '${TUNNEL_TOKEN}'] - environment: - TUNNEL_TOKEN: ${TUNNEL_TOKEN} - depends_on: - - web + ports: + - "${WEB_PORT:-3000}:${WEB_PORT:-3000}" restart: unless-stopped volumes: diff --git a/web/Dockerfile b/web/Dockerfile index 0bd7db7..cb90529 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,17 +1,25 @@ FROM node:20-alpine AS builder WORKDIR /app -COPY package.json ./ -RUN npm install --no-save +ARG CSRF_ALLOWED_ORIGINS +ENV CSRF_ALLOWED_ORIGINS=${CSRF_ALLOWED_ORIGINS} + +COPY package.json package-lock.json* ./ +RUN npm install COPY . . +RUN npm run prepare RUN npm run build -RUN npm install --omit=dev --prefix build FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production +ARG CSRF_ALLOWED_ORIGINS +ENV CSRF_ALLOWED_ORIGINS=${CSRF_ALLOWED_ORIGINS} + +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev COPY --from=builder /app/build ./build EXPOSE 3000 diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts new file mode 100644 index 0000000..7bc8a28 --- /dev/null +++ b/web/src/hooks.server.ts @@ -0,0 +1,44 @@ +import { redirect, error, type Handle } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; + +const allowedOrigins = env.CSRF_ALLOWED_ORIGINS + ? env.CSRF_ALLOWED_ORIGINS.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + : []; + +const enableHttpsRedirect = env.ENABLE_HTTPS_REDIRECT === 'true'; + +const protectedMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +export const handle: Handle = async ({ event, resolve }) => { + if (enableHttpsRedirect) { + const forwardedProto = event.request.headers.get('x-forwarded-proto'); + const isHttps = forwardedProto + ? forwardedProto.split(',').map((v) => v.trim().toLowerCase()).includes('https') + : event.url.protocol === 'https:'; + + if (!isHttps) { + throw redirect(308, `https://${event.url.host}${event.url.pathname}${event.url.search}`); + } + } + + if (protectedMethods.has(event.request.method.toUpperCase())) { + const origin = event.request.headers.get('origin'); + + // Allow server-to-server requests (no origin header) + if (!origin) { + return resolve(event); + } + + const isSameOrigin = origin === event.url.origin; + const isAllowed = + isSameOrigin || (allowedOrigins.length > 0 && allowedOrigins.includes(origin)); + + if (!isAllowed) { + throw error(403, 'Otillåten källa för förfrågan.'); + } + } + + return resolve(event); +}; diff --git a/web/svelte.config.js b/web/svelte.config.js index 64f2093..34ecb07 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -1,13 +1,30 @@ +import 'dotenv/config'; import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +const csrfOriginEnv = process.env.CSRF_ALLOWED_ORIGINS; +const csrfOrigins = csrfOriginEnv + ? csrfOriginEnv + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + : []; + +const csrfConfig = { checkOrigin: true }; +if (csrfOrigins.length > 0) { + csrfConfig.trustedOrigins = csrfOrigins; +} + +const kitConfig = { + adapter: adapter({ out: 'build' }), + csrf: csrfConfig +}; + /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), - kit: { - adapter: adapter({ out: 'build' }) - }, + kit: kitConfig, compilerOptions: { runes: true }