Files
rustpad/src/app/mod.rs
T

400 lines
14 KiB
Rust

/*
* 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::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("/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(
"/files/{directory}/{filename}",
get(api::download_legacy_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}/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(TraceLayer::new_for_http())
.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 => {
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,
}
fn response_header_policy(path: &str) -> ResponseHeaderPolicy {
if is_asset_path(path) || is_icon_path(path) {
ResponseHeaderPolicy::StaticAsset
} else {
ResponseHeaderPolicy::Application
}
}
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)]
mod tests {
use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy};
use axum::http::{HeaderMap, HeaderValue, header};
#[test]
fn classifies_assets_and_icons_as_static_assets() {
for path in [
"/assets/app.js",
"/assets",
"/favicon.ico",
"/icons/favicon.svg",
"/icons/missing.svg",
] {
assert_eq!(
response_header_policy(path),
ResponseHeaderPolicy::StaticAsset
);
}
}
#[test]
fn keeps_file_routes_on_the_application_policy() {
for path in [
"/f",
"/f/token/image.png",
"/files",
"/files/legacy/image.png",
] {
assert_eq!(
response_header_policy(path),
ResponseHeaderPolicy::Application
);
}
}
#[test]
fn classifies_other_routes_as_application() {
for path in ["/", "/api/auth/me", "/static/missing.css", "/unknown"] {
assert_eq!(
response_header_policy(path),
ResponseHeaderPolicy::Application
);
}
}
#[test]
fn static_asset_policy_only_adds_nosniff() {
let mut headers = HeaderMap::new();
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=3600"),
);
apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers);
assert_eq!(headers.len(), 2);
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
assert!(!headers.contains_key("x-frame-options"));
assert!(!headers.contains_key("cross-origin-opener-policy"));
assert!(!headers.contains_key("cross-origin-resource-policy"));
assert!(!headers.contains_key("referrer-policy"));
assert!(!headers.contains_key("permissions-policy"));
}
#[test]
fn application_policy_preserves_handler_headers() {
let mut headers = HeaderMap::new();
headers.insert(
"content-security-policy",
HeaderValue::from_static("default-src 'none'; sandbox"),
);
apply_response_headers(ResponseHeaderPolicy::Application, &mut headers);
assert_eq!(
headers["content-security-policy"],
"default-src 'none'; sandbox"
);
assert_eq!(headers["x-frame-options"], "DENY");
assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
assert_eq!(headers["cross-origin-resource-policy"], "same-origin");
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
assert_eq!(
headers["referrer-policy"],
"strict-origin-when-cross-origin"
);
assert!(headers.contains_key("permissions-policy"));
}
}