/* * 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. */ use std::error::Error; const FILE_ROUTE_PREFIX: &str = "/f/"; pub fn normalize_public_base(value: Option) -> Result, Box> { let Some(value) = value else { return Ok(None); }; let value = value.trim(); if value.is_empty() { return Ok(None); } let candidate = if value.starts_with("http://") || value.starts_with("https://") { value.trim_end_matches('/').to_owned() } else { format!("https://{}", value.trim_end_matches('/')) }; let authority = candidate .strip_prefix("https://") .or_else(|| candidate.strip_prefix("http://")) .ok_or("FILES_PUBLIC_URL must use http:// or https://")?; if authority.is_empty() || authority.chars().any(|character| { matches!(character, '/' | '\\' | '?' | '#' | '@') || character.is_whitespace() }) { return Err( "FILES_PUBLIC_URL must be a domain or HTTP(S) origin without a path, query, credentials, or fragment" .into(), ); } Ok(Some(candidate)) } pub fn canonical_file_path(value: &str) -> Option { let value = value.trim(); let path = if value.starts_with('/') { value } else { let (_, after_scheme) = value.split_once("://")?; let path_start = after_scheme.find('/')?; &after_scheme[path_start..] }; let path = path .split(|character| matches!(character, '?' | '#')) .next() .unwrap_or(path); if !path.starts_with(FILE_ROUTE_PREFIX) { return None; } let mut parts = path.trim_start_matches('/').split('/'); let route = parts.next()?; let token = parts.next()?; let filename = parts.next()?; if route != "f" || token.is_empty() || filename.is_empty() || parts.next().is_some() { return None; } Some(format!("/f/{token}/{filename}")) } pub fn stored_file_path(token: &str, filename: &str) -> String { format!("/f/{token}/{filename}") } pub fn public_file_url(public_base: Option<&str>, stored_url: &str) -> String { let Some(canonical) = canonical_file_path(stored_url) else { return stored_url.to_owned(); }; match public_base { Some(base) => format!("{}{canonical}", base.trim_end_matches('/')), None => canonical, } } #[cfg(test)] mod tests { use super::*; #[test] fn normalizes_bare_domain_and_http_origins() { assert_eq!( normalize_public_base(Some("files.note.example.com".into())).unwrap(), Some("https://files.note.example.com".into()) ); assert_eq!( normalize_public_base(Some("http://localhost:3001/".into())).unwrap(), Some("http://localhost:3001".into()) ); assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None); } #[test] fn rejects_non_origin_public_urls() { assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err()); assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err()); assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err()); assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err()); } #[test] fn extracts_canonical_path_from_relative_and_absolute_urls() { assert_eq!( canonical_file_path("/f/token/image.png"), Some("/f/token/image.png".into()) ); assert_eq!( canonical_file_path("https://files.example.com/f/token/image.png"), Some("/f/token/image.png".into()) ); assert_eq!( canonical_file_path("https://files.example.com/f/token/image.png?download=1"), Some("/f/token/image.png".into()) ); assert_eq!(canonical_file_path("/files/token/image.png"), None); } #[test] fn switches_between_custom_origin_and_application_path() { let stored = "/f/token/manual.pdf"; assert_eq!(public_file_url(None, stored), stored); assert_eq!( public_file_url(Some("https://files.example.com"), stored), "https://files.example.com/f/token/manual.pdf" ); assert_eq!( public_file_url(None, "https://old.example.com/f/token/manual.pdf"), stored ); assert_eq!( public_file_url(Some("https://files.example.com"), "/invalid/path"), "/invalid/path" ); } }