/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ mod pages; use axum::{ Json, Router, extract::{DefaultBodyLimit, Request}, http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, }; use pages::*; use tower::{ServiceBuilder, service_fn}; use tower_http::{ services::ServeDir, set_header::SetResponseHeaderLayer, trace::{MakeSpan, TraceLayer}, }; use tracing::Span; use crate::{api, auth, state::SharedState, websocket}; use std::convert::Infallible; #[derive(Clone, Copy)] struct PathOnlyMakeSpan; impl MakeSpan for PathOnlyMakeSpan { fn make_span(&mut self, request: &axum::http::Request) -> Span { tracing::info_span!( "http_request", method = %request.method(), path = %request.uri().path(), version = ?request.version(), ) } } pub fn router( state: SharedState, static_dir: &str, upload_body_limit_bytes: usize, asset_cache_max_age_seconds: u64, ) -> Router { let asset_version = state.asset_version.clone(); let asset_not_found = service_fn(move |_request| { let asset_version = asset_version.clone(); async move { Ok::<_, Infallible>(error_response( StatusCode::NOT_FOUND, "404", "File not found", "The requested application asset does not exist.", "/", "Home page", &asset_version, )) } }); let asset_cache_control = HeaderValue::from_str(&crate::cache::cache_control(asset_cache_max_age_seconds)) .expect("valid asset cache-control header"); Router::new() .route("/", get(home)) .route("/auth/confirm/{token}", get(home)) .route("/auth/reset-password/{token}", get(home)) .route("/auth/account-action/{token}", get(home)) .route("/p/{slug}", get(pad)) .route("/s/{token}", get(public_page)) .route("/w/{workspace_slug}", get(workspace)) .route("/w/{workspace_slug}/n/{note_slug}", get(note)) .route("/health", get(health)) .route("/robots.txt", get(robots_txt)) .route("/favicon.ico", get(favicon)) .route("/favicon.svg", get(favicon_svg)) .route("/favicon-32.png", get(favicon_png)) .route("/apple-touch-icon.png", get(apple_touch_icon)) .route("/icons/favicon.ico", get(favicon)) .route("/icons/favicon.svg", get(favicon_svg)) .route("/icons/favicon-32.png", get(favicon_png)) .route("/icons/apple-touch-icon.png", get(apple_touch_icon)) .route("/f/{token}/{filename}", get(api::download_file)) .route("/api/auth/identity", post(auth::identity)) .route( "/api/security/csrf", get(crate::security::csrf_token_endpoint), ) .route("/api/access-token", post(api::create_resource_access_token)) .route("/api/auth/register", post(auth::register)) .route("/api/auth/login", post(auth::login)) .route("/api/auth/confirm-account", post(auth::confirm_account)) .route( "/api/auth/resend-confirmation", post(auth::resend_confirmation), ) .route("/api/auth/me", get(auth::me)) .route("/api/auth/profile", post(auth::update_profile)) .route( "/api/auth/account/delete", post(auth::request_account_deletion), ) .route( "/api/auth/account-action/confirm", post(auth::confirm_account_action), ) .route("/api/auth/logout", post(auth::logout)) .route( "/api/auth/resources", get(auth::resources) .put(auth::update_resource) .delete(auth::delete_resource), ) .route( "/api/auth/resources/privacy", post(auth::set_resource_privacy), ) .route( "/api/auth/resources/sharing", get(auth::resource_sharing) .post(auth::share_resource_users) .delete(auth::remove_resource_user), ) .route( "/api/auth/resources/share-links", post(auth::create_share_link) .put(auth::update_share_link) .delete(auth::revoke_share_link), ) .route( "/share-invitations/{token}/accept", get(home).post(auth::accept_share_invitation), ) .route("/api/auth/password-reset", post(auth::request_reset)) .route( "/api/auth/password-reset/confirm", post(auth::confirm_reset), ) .route("/api/public/{token}", get(api::public_page)) .route("/api/public/{token}/tasks", post(api::update_public_task)) .route("/api/pads", post(api::create_pad)) .route("/api/pads/{slug}", get(api::pad_info)) .route("/api/pads/{slug}/history", post(api::pad_history)) .route( "/api/pads/{slug}/editor-color", get(api::pad_editor_color).post(api::set_pad_editor_color), ) .route( "/api/pads/{slug}/editor-settings", post(api::set_pad_editor_settings), ) .route("/api/pads/{slug}/publish", post(api::publish_pad_page)) .route("/api/pads/{slug}/password", post(api::set_pad_password)) .route("/api/pads/{slug}/restore", post(api::pad_restore)) .route( "/api/pads/{slug}/files", post(api::upload_pad_file).put(api::pad_files), ) .route( "/api/pads/{slug}/files/{file_id}", axum::routing::delete(api::delete_pad_file), ) .route("/api/workspaces", post(api::create_workspace)) .route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) .route( "/api/workspaces/{workspace_slug}/password", post(api::set_workspace_password), ) .route( "/api/workspaces/{workspace_slug}/open", post(api::open_workspace), ) .route( "/api/workspaces/{workspace_slug}/notes", post(api::create_note), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}", get(api::note_info).delete(api::delete_note), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color", get(api::note_editor_color).post(api::set_note_editor_color), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-settings", post(api::set_note_editor_settings), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/publish", post(api::publish_note_page), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/history", post(api::history), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/restore", post(api::restore), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/files", post(api::upload_note_file).put(api::note_files), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}", axum::routing::delete(api::delete_note_file), ) .route("/ws/p/{slug}", get(websocket::upgrade_pad)) .route( "/ws/watch/workspace/{workspace_slug}", get(websocket::upgrade_workspace_watch), ) .route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade)) .route("/static", get(static_not_found)) .route("/static/{*path}", get(static_not_found)) .nest_service( "/assets", ServiceBuilder::new() .layer(SetResponseHeaderLayer::overriding( header::CACHE_CONTROL, asset_cache_control, )) .service(ServeDir::new(static_dir).not_found_service(asset_not_found)), ) .fallback(not_found) .method_not_allowed_fallback(method_not_allowed) .layer(DefaultBodyLimit::max(upload_body_limit_bytes)) .layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)) .layer(middleware::from_fn(require_csrf_token)) .layer(middleware::from_fn(apply_response_header_policy)) .with_state(state) } async fn require_csrf_token(request: Request, next: Next) -> Response { let method = request.method(); let unsafe_method = method == Method::POST || method == Method::PUT || method == Method::PATCH || method == Method::DELETE; if unsafe_method && !crate::security::csrf_request_is_valid(request.headers()) { return ( StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Security token is missing or expired. Refresh the page and try again." })), ) .into_response(); } next.run(request).await } async fn apply_response_header_policy(request: Request, next: Next) -> Response { let policy = response_header_policy(request.uri().path()); let mut response = next.run(request).await; apply_response_headers(policy, response.headers_mut()); response } fn apply_response_headers(policy: ResponseHeaderPolicy, headers: &mut HeaderMap) { match policy { ResponseHeaderPolicy::StaticAsset | ResponseHeaderPolicy::File => { headers .entry(header::X_CONTENT_TYPE_OPTIONS) .or_insert(HeaderValue::from_static("nosniff")); } ResponseHeaderPolicy::Application => { headers .entry("x-frame-options") .or_insert(HeaderValue::from_static("DENY")); headers .entry("cross-origin-opener-policy") .or_insert(HeaderValue::from_static("same-origin")); headers .entry("cross-origin-resource-policy") .or_insert(HeaderValue::from_static("same-origin")); headers .entry(header::X_CONTENT_TYPE_OPTIONS) .or_insert(HeaderValue::from_static("nosniff")); headers .entry("referrer-policy") .or_insert(HeaderValue::from_static("strict-origin-when-cross-origin")); headers .entry("permissions-policy") .or_insert(HeaderValue::from_static( "camera=(), microphone=(), geolocation=(), payment=(), usb=()", )); } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ResponseHeaderPolicy { Application, StaticAsset, File, } fn response_header_policy(path: &str) -> ResponseHeaderPolicy { if is_file_path(path) { ResponseHeaderPolicy::File } else if is_asset_path(path) || is_icon_path(path) { ResponseHeaderPolicy::StaticAsset } else { ResponseHeaderPolicy::Application } } fn is_file_path(path: &str) -> bool { path == "/f" || path.starts_with("/f/") } fn is_asset_path(path: &str) -> bool { path == "/assets" || path.starts_with("/assets/") } fn is_icon_path(path: &str) -> bool { path == "/icons" || path.starts_with("/icons/") || matches!( path, "/favicon.svg" | "/favicon.ico" | "/favicon-32.png" | "/apple-touch-icon.png" ) } #[cfg(test)] #[path = "../tests/app.rs"] mod tests;