feat(middleware): add smart public route matching with extension detection

This commit is contained in:
2026-04-29 22:56:08 +02:00
committed by ForgeCode
parent cbf65dfde7
commit db7b26864b
2 changed files with 254 additions and 1 deletions
+178 -1
View File
@@ -7,6 +7,17 @@ use hyper::body::Incoming;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use log::error;
/// Common file extensions that indicate a file path
const FILE_EXTENSIONS: &[&str] = &[
".html", ".htm", ".js", ".mjs", ".css", ".scss", ".sass", ".less",
".json", ".xml", ".yaml", ".yml", ".toml", ".env",
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", ".avif", ".bmp",
".woff", ".woff2", ".ttf", ".eot", ".otf", ".css",
".pdf", ".txt", ".md", ".csv", ".xlsx", ".docx", ".zip", ".tar", ".gz",
".mp4", ".webm", ".mp3", ".wav", ".ogg", ".flac",
".wasm", ".br",
];
pub struct JwtMiddleware {
decoding_key: DecodingKey,
public_routes: Vec<String>,
@@ -25,6 +36,46 @@ impl JwtMiddleware {
})
}
/// Determines if the given path has a file extension.
/// Returns true if the last segment of the path contains a dot followed by a known extension.
pub fn has_file_extension(path: &str) -> bool {
// Get the last segment of the path (after the last '/')
if let Some(segment) = path.rsplit('/').next() {
// Check if it contains a dot and has a known extension
if segment.contains('.') {
let lower = segment.to_lowercase();
return FILE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext));
}
}
false
}
/// Checks if a request path is a public route.
/// - For routes WITH a file extension: exact match required
/// - For routes WITHOUT a file extension: prefix match (allows all subpaths)
/// - Special case: "/" as public route allows everything
pub fn is_public_route(public_routes: &[String], request_path: &str) -> bool {
// Special case: "/" allows everything
if public_routes.iter().any(|r| r == "/") {
return true;
}
public_routes.iter().any(|route| {
// Skip empty routes
if route.is_empty() {
return false;
}
if Self::has_file_extension(route) {
// Exact match for file paths
request_path == route
} else {
// Prefix match for directory paths (allows /route and /route/*)
request_path == route || request_path.starts_with(&format!("{}/", route))
}
})
}
fn validate_request(
&self,
req: &Request<Incoming>,
@@ -53,7 +104,8 @@ impl JwtMiddleware {
impl Middleware for JwtMiddleware {
fn run(&self, mut req: Request<Incoming>) -> MiddlewareFuture<'_> {
let is_public_path = self.public_routes.contains(&req.uri().path().to_string());
let request_path = req.uri().path().to_string();
let is_public_path = Self::is_public_route(&self.public_routes, &request_path);
Box::pin(async move {
match self.validate_request(&req) {
@@ -74,3 +126,128 @@ impl Middleware for JwtMiddleware {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_has_file_extension_with_valid_extensions() {
let paths = vec![
"/static/logo.png",
"/images/banner.jpg",
"/assets/app.js",
"/styles/main.css",
"/data/config.json",
"/docs/README.MD",
];
for path in paths {
assert!(
JwtMiddleware::has_file_extension(path),
"Expected {} to have a file extension",
path
);
}
}
#[test]
fn test_has_file_extension_without_extensions() {
let paths = vec![
"/api",
"/api/users",
"/static",
"/static/css",
"/admin/settings",
"/v1",
];
for path in paths {
assert!(
!JwtMiddleware::has_file_extension(path),
"Expected {} to NOT have a file extension",
path
);
}
}
#[test]
fn test_has_file_extension_unusual_extensions() {
// Paths with dots but not known extensions should return false
assert!(!JwtMiddleware::has_file_extension("/api/v1.0/users"));
assert!(!JwtMiddleware::has_file_extension("/files/.hidden/test"));
}
#[test]
fn test_is_public_route_exact_match_for_files() {
let public_routes = vec!["/static/logo.png".to_string()];
// Exact match should work
assert!(JwtMiddleware::is_public_route(&public_routes, "/static/logo.png"));
// Different file in same directory should NOT be public
assert!(!JwtMiddleware::is_public_route(&public_routes, "/static/other.png"));
assert!(!JwtMiddleware::is_public_route(&public_routes, "/static/image.jpg"));
}
#[test]
fn test_is_public_route_prefix_match_for_directories() {
let public_routes = vec!["/static".to_string()];
// Directory itself should be public
assert!(JwtMiddleware::is_public_route(&public_routes, "/static"));
// Any file under the directory should be public
assert!(JwtMiddleware::is_public_route(&public_routes, "/static/app.js"));
assert!(JwtMiddleware::is_public_route(&public_routes, "/static/css/main.css"));
assert!(JwtMiddleware::is_public_route(&public_routes, "/static/images/logo.png"));
assert!(JwtMiddleware::is_public_route(&public_routes, "/static/deep/nested/path/file.txt"));
}
#[test]
fn test_is_public_route_multiple_routes() {
let public_routes = vec![
"/static".to_string(),
"/public/file.css".to_string(),
"/api/health".to_string(),
];
// Exact file match
assert!(JwtMiddleware::is_public_route(&public_routes, "/public/file.css"));
// Directory prefix match
assert!(JwtMiddleware::is_public_route(&public_routes, "/static"));
assert!(JwtMiddleware::is_public_route(&public_routes, "/static/app.js"));
// API endpoint
assert!(JwtMiddleware::is_public_route(&public_routes, "/api/health"));
assert!(JwtMiddleware::is_public_route(&public_routes, "/api/health/detailed"));
// Non-public paths
assert!(!JwtMiddleware::is_public_route(&public_routes, "/api/users"));
assert!(!JwtMiddleware::is_public_route(&public_routes, "/admin"));
assert!(!JwtMiddleware::is_public_route(&public_routes, "/private/data"));
}
#[test]
fn test_is_public_route_case_insensitive_extensions() {
let public_routes = vec!["/assets/LOGO.PNG".to_string()];
assert!(JwtMiddleware::is_public_route(&public_routes, "/assets/LOGO.PNG"));
// Note: exact match is case-sensitive for the path, only extension check is case-insensitive
}
#[test]
fn test_is_public_route_edge_cases() {
// Root path "/" as public route allows everything (including all subpaths)
let public_routes = vec!["/".to_string()];
assert!(JwtMiddleware::is_public_route(&public_routes, "/"));
assert!(JwtMiddleware::is_public_route(&public_routes, "/any/path"));
assert!(JwtMiddleware::is_public_route(&public_routes, "/deep/nested/route"));
// Empty route should not match anything
let empty_routes = vec!["".to_string()];
assert!(!JwtMiddleware::is_public_route(&empty_routes, "/any/path"));
assert!(!JwtMiddleware::is_public_route(&empty_routes, "/"));
}
}