/* * 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::{ Router, extract::{DefaultBodyLimit, Request}, http::{HeaderName, HeaderValue, StatusCode, header}, middleware::{self, Next}, response::Response, routing::{get, post}, }; use pages::*; use tower::{ServiceBuilder, service_fn}; use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; use crate::{api, auth, state::SharedState, websocket}; use std::convert::Infallible; pub fn router( state: SharedState, static_dir: &str, upload_max_size_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("/f/{token}/{filename}", get(api::download_file)) .route( "/files/{directory}/{filename}", get(api::download_legacy_file), ) .route("/api/auth/identity", post(auth::identity)) .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}/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}/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/{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_max_size_bytes.saturating_add(1024 * 1024), )) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("x-frame-options"), HeaderValue::from_static("DENY"), )) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("cross-origin-opener-policy"), HeaderValue::from_static("same-origin"), )) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("cross-origin-resource-policy"), HeaderValue::from_static("same-origin"), )) .layer(TraceLayer::new_for_http()) .layer(middleware::from_fn(add_non_asset_security_headers)) .with_state(state) } async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { let path = request.uri().path(); let is_asset = path.starts_with("/assets/"); let is_file = path.starts_with("/f/"); let mut response = next.run(request).await; if is_file { let headers = response.headers_mut(); headers.remove("x-frame-options"); headers.remove("cross-origin-opener-policy"); headers.remove("cross-origin-resource-policy"); headers.remove("referrer-policy"); headers.remove("permissions-policy"); } else if !is_asset { let headers = response.headers_mut(); headers .entry("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=()", )); } response }