Add derived attributes
This commit is contained in:
parent
88d7738409
commit
19df7c8962
7 changed files with 155 additions and 31 deletions
8
.env
Normal file
8
.env
Normal 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
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
44
web/src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue