Add derived attributes

This commit is contained in:
Sebastian 2025-09-20 17:03:21 +02:00
parent 88d7738409
commit 19df7c8962
7 changed files with 155 additions and 31 deletions

8
.env Normal file
View file

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

View file

@ -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,
})

View file

@ -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<PersonActionResponse>,
}
#[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<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_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<AppState>) -> Status {
Status::NoContent
}
#[get("/events")]
fn events(_user: AuthUser, state: &State<AppState>) -> 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?<q>")]
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 {
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 {
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<AppState>, response: &PersonActionResponse) {
let _ = state.event_sender.send(response.clone());
}

View file

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

View file

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

44
web/src/hooks.server.ts Normal file
View file

@ -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);
};

View file

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