diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4f792ed..de2c17a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -68,6 +68,11 @@ jobs: - name: Unit tests (backend) run: cargo test --manifest-path backend/Cargo.toml + - name: Security audit + run: | + cargo install cargo-audit --locked + cargo audit --manifest-path backend/Cargo.toml + - name: Build frontend run: pnpm --dir frontend build diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e62f17c..2a80333 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -22,11 +22,12 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: latest + version: '9' - uses: dtolnay/rust-toolchain@master with: toolchain: '1.95.0' + components: clippy, rustfmt - name: Cache Cargo uses: actions/cache@v4 @@ -54,9 +55,23 @@ jobs: - name: Type check (frontend) run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check + - name: Type check (backend) + run: cargo check --manifest-path backend/Cargo.toml + + - name: Clippy + run: cargo clippy --manifest-path backend/Cargo.toml -- -D warnings + + - name: Format check + run: cargo fmt --manifest-path backend/Cargo.toml -- --check + - name: Unit tests (backend) run: cargo test --manifest-path backend/Cargo.toml + - name: Security audit + run: | + cargo install cargo-audit --locked + cargo audit --manifest-path backend/Cargo.toml + - name: Build frontend run: pnpm --dir frontend build diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 94d4281..988a6b9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -14,6 +14,7 @@ serde_json = "1" jsonwebtoken = { version = "10", features = ["rust_crypto"] } bcrypt = "0.19" tower-http = { version = "0.6", features = ["fs", "cors", "trace"] } +tower_governor = "0.6" chrono = { version = "0.4", features = ["serde"] } rand = "0.9" thiserror = "2" diff --git a/backend/src/db.rs b/backend/src/db.rs index 4a8063f..3f051d5 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -6,7 +6,8 @@ pub async fn init() -> Result { let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:/data/attendance.db".into()); let opts = SqliteConnectOptions::from_str(&url)? .create_if_missing(true) - .foreign_keys(true); + .foreign_keys(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); let pool = SqlitePoolOptions::new().connect_with(opts).await?; sqlx::migrate!("./migrations").run(&pool).await?; Ok(pool) diff --git a/backend/src/main.rs b/backend/src/main.rs index 16c5247..b66d394 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -8,6 +8,7 @@ mod routes; mod test_helpers; use axum::routing::get; +use std::net::SocketAddr; use tower_http::services::{ServeDir, ServeFile}; use tracing_subscriber::EnvFilter; @@ -63,11 +64,16 @@ async fn main() { .layer(tower_http::trace::TraceLayer::new_for_http()); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); - let addr = format!("0.0.0.0:{port}"); + let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("invalid address"); let listener = tokio::net::TcpListener::bind(&addr) .await .expect("failed to bind"); - tracing::info!("listening on :{}", port); - axum::serve(listener, app).await.expect("server error"); + tracing::info!("listening on {}", addr); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .expect("failed to serve"); } diff --git a/backend/src/routes/auth_routes.rs b/backend/src/routes/auth_routes.rs index 7f376be..f9c270a 100644 --- a/backend/src/routes/auth_routes.rs +++ b/backend/src/routes/auth_routes.rs @@ -8,6 +8,8 @@ use axum_extra::extract::CookieJar; use axum_extra::extract::cookie::{Cookie, SameSite}; use serde::Deserialize; use serde_json::{Value, json}; +use std::sync::Arc; +use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; #[derive(Deserialize)] struct LoginRequest { @@ -62,8 +64,21 @@ async fn logout(jar: CookieJar) -> CookieJar { } pub fn router() -> Router { + let governor_conf = Arc::new( + GovernorConfigBuilder::default() + .per_second(12) // 1 request every 12 seconds = 5 per minute + .burst_size(5) + .finish() + .unwrap(), + ); + Router::new() - .route("/api/auth/login", post(login)) + .route( + "/api/auth/login", + post(login).layer(GovernorLayer { + config: governor_conf, + }), + ) .route("/api/auth/me", get(me)) .route("/api/auth/logout", post(logout)) } @@ -71,7 +86,7 @@ pub fn router() -> Router { #[cfg(test)] mod tests { use super::*; - use crate::test_helpers::{build_test_app, post_json}; + use crate::test_helpers::post_json; use serde_json::json; #[sqlx::test(migrations = "./migrations")] @@ -88,8 +103,9 @@ mod tests { let state = AppState { pool: pool.clone(), jwt_secret: "testsecret".into(), + test_mode: true, }; - let app = crate::routes::build(state, false); + let app = crate::routes::build(state, true); let (status, body, headers) = crate::test_helpers::post_json_with_headers( app, "/api/auth/login", @@ -123,8 +139,9 @@ mod tests { let state = AppState { pool: pool.clone(), jwt_secret: "testsecret".into(), + test_mode: true, }; - let app = crate::routes::build(state, false); + let app = crate::routes::build(state, true); let (status, _) = post_json( app, "/api/auth/login", diff --git a/backend/src/routes/checkin.rs b/backend/src/routes/checkin.rs index 2380e4f..e8feb6d 100644 --- a/backend/src/routes/checkin.rs +++ b/backend/src/routes/checkin.rs @@ -6,7 +6,6 @@ use axum::{ routing::{get, post}, }; use serde_json::{Value, json}; -use sqlx::SqlitePool; use crate::{ AppState, @@ -31,7 +30,7 @@ fn url_decode_minimal(s: &str) -> String { } async fn get_checkin_info( - State(pool): State, + State(state): State, Path(code): Path, headers: HeaderMap, ) -> Result, AppError> { @@ -41,7 +40,7 @@ async fn get_checkin_info( FROM slots WHERE code = ?", ) .bind(&code) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await? .ok_or(AppError::NotFound)?; @@ -55,7 +54,7 @@ async fn get_checkin_info( let room = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?") .bind(room_id) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await?; if let Some(r) = room { let elements: Vec = @@ -73,7 +72,7 @@ async fn get_checkin_info( "SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?", ) .bind(slot.id) - .fetch_all(&pool) + .fetch_all(&state.pool) .await?; // Parse identity cookie to determine which attendance is "mine" @@ -123,7 +122,7 @@ async fn get_checkin_info( } async fn get_checkin_students( - State(pool): State, + State(state): State, Path(code): Path, ) -> Result, AppError> { // Look up slot by code @@ -132,7 +131,7 @@ async fn get_checkin_students( FROM slots WHERE code = ?", ) .bind(&code) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await? .ok_or(AppError::NotFound)?; @@ -143,7 +142,7 @@ async fn get_checkin_students( // Get course_id from the session let (course_id,): (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?") .bind(slot.session_id) - .fetch_one(&pool) + .fetch_one(&state.pool) .await?; // Return only students enrolled in that course @@ -151,14 +150,14 @@ async fn get_checkin_students( "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name", ) .bind(course_id) - .fetch_all(&pool) + .fetch_all(&state.pool) .await?; Ok(Json(json!(students))) } async fn post_checkin( - State(pool): State, + State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result { @@ -168,7 +167,7 @@ async fn post_checkin( FROM slots WHERE code = ?", ) .bind(&req.code) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await? .ok_or(AppError::NotFound)?; @@ -194,7 +193,7 @@ async fn post_checkin( let room = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?") .bind(room_id) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await?; let room_row = room.ok_or(AppError::NotFound)?; @@ -229,7 +228,7 @@ async fn post_checkin( } // Transaction: delete old attendance for (slot_id, student_id), then insert new - let mut tx = pool.begin().await?; + let mut tx = state.pool.begin().await?; sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?") .bind(slot.id) @@ -266,9 +265,10 @@ async fn post_checkin( .expect("serializing static json shape is infallible") .replace('"', "%22"); + let secure = if state.test_mode { "" } else { " Secure;" }; let cookie_val = format!( - "attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/", - identity_json + "attendance_identity={}; HttpOnly; SameSite=Strict;{} Max-Age=86400; Path=/", + identity_json, secure ); let header_val = axum::http::HeaderValue::from_str(&cookie_val) @@ -289,8 +289,7 @@ pub fn router() -> Router { #[cfg(test)] mod tests { - use super::*; - use crate::test_helpers::{build_test_admin_app, build_test_app, get, patch_json, post_json}; + use crate::test_helpers::{build_test_admin_app, get, patch_json, post_json}; use axum::http::StatusCode; use serde_json::{Value, json}; diff --git a/backend/src/routes/courses.rs b/backend/src/routes/courses.rs index 1d73d14..c0f3595 100644 --- a/backend/src/routes/courses.rs +++ b/backend/src/routes/courses.rs @@ -270,7 +270,6 @@ pub fn router() -> Router { #[cfg(test)] mod tests { - use super::*; use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json}; use axum::http::StatusCode; use serde_json::{Value, json}; diff --git a/backend/src/routes/rooms.rs b/backend/src/routes/rooms.rs index 62c650f..0835628 100644 --- a/backend/src/routes/rooms.rs +++ b/backend/src/routes/rooms.rs @@ -152,7 +152,6 @@ pub fn router() -> Router { #[cfg(test)] mod tests { - use super::*; use crate::test_helpers::{build_test_app, get, post_json, put_json}; use axum::http::StatusCode; use serde_json::{Value, json}; diff --git a/backend/src/test_helpers.rs b/backend/src/test_helpers.rs index e309178..a127997 100644 --- a/backend/src/test_helpers.rs +++ b/backend/src/test_helpers.rs @@ -49,8 +49,9 @@ pub async fn build_test_app(pool: SqlitePool) -> (Router, String) { let state = AppState { pool: pool.clone(), jwt_secret: TEST_SECRET.into(), + test_mode: true, }; - let app = crate::routes::build(state, false); + let app = crate::routes::build(state, true); (app, format!("Bearer {token}")) } @@ -60,8 +61,9 @@ pub async fn build_test_admin_app(pool: SqlitePool) -> (Router, String) { let state = AppState { pool: pool.clone(), jwt_secret: TEST_SECRET.into(), + test_mode: true, }; - let app = crate::routes::build(state, false); + let app = crate::routes::build(state, true); (app, format!("Bearer {token}")) } diff --git a/deploy/templates/deployment.yaml b/deploy/templates/deployment.yaml index 423d4ba..0d4a919 100644 --- a/deploy/templates/deployment.yaml +++ b/deploy/templates/deployment.yaml @@ -60,8 +60,10 @@ spec: initialDelaySeconds: 5 periodSeconds: 10 securityContext: - readOnlyRootFilesystem: false + readOnlyRootFilesystem: true allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1000 resources: {{- toYaml .Values.resources | nindent 12 }} volumes: diff --git a/deploy/values_override.yaml b/deploy/values_override.yaml index 953a6de..ab1d2ba 100644 --- a/deploy/values_override.yaml +++ b/deploy/values_override.yaml @@ -6,5 +6,4 @@ image: tag: v0.1.9 env: - extra: - DEMO: "true" + extra: {} diff --git a/frontend/package.json b/frontend/package.json index 39600d6..b2d1896 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,14 +13,14 @@ }, "devDependencies": { "@playwright/test": "^1.59.1", - "@types/node": "latest", - "@sveltejs/adapter-static": "latest", - "@sveltejs/kit": "latest", - "@sveltejs/vite-plugin-svelte": "latest", + "@types/node": "^22", + "@sveltejs/adapter-static": "^3", + "@sveltejs/kit": "^2", + "@sveltejs/vite-plugin-svelte": "^4", "@typescript/native-preview": "^7.0.0-dev", "svelte": "5.55.5", - "svelte-check": "latest", - "typescript": "latest", - "vite": "latest" + "svelte-check": "^4", + "typescript": "^5", + "vite": "^8" } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fc59912..5222152 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,8 @@ import { browser } from '$app/environment'; +import type { + Course, Tutor, Student, Room, Session, Slot, Attendance, Note +} from './types'; +import { auth } from './auth.svelte'; const BASE = '/api'; @@ -7,12 +11,13 @@ export async function request(path: string, init?: RequestInit): Promise { ...init, credentials: 'include', headers: { - 'Content-Type': 'application/json', + ...(!(init?.body instanceof FormData) && { 'Content-Type': 'application/json' }), ...init?.headers, } }); if (res.status === 401 && browser) { + auth.logout(); throw new Error('Unauthorized'); } @@ -37,27 +42,27 @@ export const api = { }, admin: { courses: { - list: () => request('/admin/courses'), + list: () => request('/admin/courses'), create: (name: string, semester: string) => - request('/admin/courses', { + request('/admin/courses', { method: 'POST', body: JSON.stringify({ name, semester }) }), - listStudents: (course_id: number) => request(`/admin/courses/${course_id}/students`), + listStudents: (course_id: number) => request(`/admin/courses/${course_id}/students`), addStudent: (course_id: number, name: string) => - request(`/admin/courses/${course_id}/students`, { + request(`/admin/courses/${course_id}/students`, { method: 'POST', body: JSON.stringify({ name }) }), importStudents: (course_id: number, file: File) => { const formData = new FormData(); formData.append('file', file); - return fetch(`${BASE}/admin/courses/${course_id}/students/import`, { + return request(`/admin/courses/${course_id}/students/import`, { method: 'POST', body: formData - }).then(res => res.json()); + }); }, - listTutors: (course_id: number) => request(`/admin/courses/${course_id}/tutors`), + listTutors: (course_id: number) => request(`/admin/courses/${course_id}/tutors`), assignTutor: (course_id: number, tutor_id: number) => request(`/admin/courses/${course_id}/tutors`, { method: 'POST', @@ -67,9 +72,9 @@ export const api = { request(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }), }, tutors: { - list: () => request('/admin/tutors'), - create: (tutor: any) => - request('/admin/tutors', { + list: () => request('/admin/tutors'), + create: (tutor: Partial & { password?: string }) => + request('/admin/tutors', { method: 'POST', body: JSON.stringify(tutor) }), @@ -77,27 +82,27 @@ export const api = { }, students: { delete: (id: number) => request(`/admin/students/${id}`, { method: 'DELETE' }), - getAttendance: (id: number) => request(`/admin/students/${id}/attendance`), - getNotes: (id: number) => request(`/admin/students/${id}/notes`), + getAttendance: (id: number) => request(`/admin/students/${id}/attendance`), + getNotes: (id: number) => request(`/admin/students/${id}/notes`), }, rooms: { - list: () => request('/admin/rooms'), + list: () => request('/admin/rooms'), create: (name: string, layout: any[]) => - request('/admin/rooms', { + request('/admin/rooms', { method: 'POST', body: JSON.stringify({ name, layout }) }), - get: (id: number) => request(`/admin/rooms/${id}`), + get: (id: number) => request(`/admin/rooms/${id}`), updateLayout: (id: number, layout: any[]) => - request(`/admin/rooms/${id}/layout`, { + request(`/admin/rooms/${id}/layout`, { method: 'PUT', body: JSON.stringify(layout) }), }, sessions: { - list: (course_id: number) => request(`/admin/sessions?course_id=${course_id}`), + list: (course_id: number) => request(`/admin/sessions?course_id=${course_id}`), create: (course_id: number, week_nr: number, date: string) => - request('/admin/sessions', { + request('/admin/sessions', { method: 'POST', body: JSON.stringify({ course_id, week_nr, date }) }), @@ -105,12 +110,12 @@ export const api = { }, slots: { create: (session_id: number, tutor_id: number, start_time: string, end_time: string, room_id?: number) => - request('/admin/slots', { + request('/admin/slots', { method: 'POST', body: JSON.stringify({ session_id, tutor_id, start_time, end_time, room_id }) }), updateStatus: (id: number, status: string) => - request(`/admin/slots/${id}/status`, { + request(`/admin/slots/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }), @@ -122,7 +127,7 @@ export const api = { }), deleteAttendance: (slot_id: number, student_id: number) => request(`/admin/slots/${slot_id}/attendance/${student_id}`, { method: 'DELETE' }), - getNotes: (id: number) => request(`/admin/slots/${id}/notes`), + getNotes: (id: number) => request(`/admin/slots/${id}/notes`), upsertNote: (slot_id: number, student_id: number, content: string) => request(`/admin/slots/${slot_id}/notes/${student_id}`, { method: 'PUT', @@ -139,7 +144,7 @@ export const api = { }, checkin: { getInfo: (code: string) => request(`/checkin/${code}`), - getStudents: (code: string) => request(`/checkin/${code}/students`), + getStudents: (code: string) => request(`/checkin/${code}/students`), post: (code: string, student_id: number, seat_id?: string) => request('/checkin', { method: 'POST',