Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c293ba3
Initial plan
Copilot Jan 27, 2026
db58203
Implement URL redirects for common mistakes and tile format suffixes
Copilot Jan 27, 2026
d5a0cb5
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 27, 2026
aa2101e
Merge branch 'main' into copilot/add-permanent-redirects
CommanderStorm Jan 27, 2026
0fe8868
Fix clippy warnings in redirects module
Copilot Jan 27, 2026
3eab04b
Refactor: co-locate redirects with their target handlers
Copilot Jan 27, 2026
e317695
Change redirect handler visibility to pub(crate)
Copilot Jan 27, 2026
be45c8a
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 27, 2026
ba0b997
Apply suggestions from code review
CommanderStorm Jan 27, 2026
22a272a
Apply suggestion from @CommanderStorm
CommanderStorm Jan 27, 2026
69aabd9
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 27, 2026
71062a8
Apply suggestions from code review
CommanderStorm Jan 27, 2026
edffef7
Apply suggestions from code review
CommanderStorm Jan 27, 2026
f87ccf9
Apply suggestions from code review
CommanderStorm Jan 27, 2026
d0236cc
Apply suggestion from @CommanderStorm
CommanderStorm Jan 27, 2026
ef8d7c4
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 27, 2026
7438f1a
Add integration tests for URL redirects in test.sh
Copilot Jan 27, 2026
3714de7
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 27, 2026
4823da5
Fix test_redirect function to not create litter files
Copilot Jan 27, 2026
d8efebc
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 27, 2026
17834d2
simplify testcase
CommanderStorm Jan 27, 2026
20c29ad
move to a more sensible order
CommanderStorm Jan 28, 2026
5f7d192
add a debounced warning
CommanderStorm Jan 28, 2026
f6de020
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 28, 2026
abc9138
Merge branch 'main' into copilot/add-permanent-redirects
CommanderStorm Jan 28, 2026
963703a
Refactor redirect warnings to use DebouncedWarning helper
Copilot Jan 28, 2026
58513e8
Refactor DebouncedWarning API to use closure for better tracing integ…
Copilot Jan 29, 2026
0c9c79d
Apply suggestions from code review
CommanderStorm Jan 29, 2026
3d60266
Apply suggestion from @CommanderStorm
CommanderStorm Jan 29, 2026
7e5e34b
Apply suggestion from @CommanderStorm
CommanderStorm Jan 29, 2026
5f65c70
Apply suggestions from code review
CommanderStorm Jan 29, 2026
a4e5fbd
Update martin/src/srv/tiles/content.rs
CommanderStorm Jan 29, 2026
624d608
Update martin/src/srv/fonts.rs
CommanderStorm Jan 29, 2026
812475c
Update martin/src/srv/fonts.rs
CommanderStorm Jan 29, 2026
4c2392b
Update martin/src/srv/sprites.rs
CommanderStorm Jan 29, 2026
3f181d3
Update martin/src/srv/styles.rs
CommanderStorm Jan 29, 2026
82560d2
Apply suggestion from @CommanderStorm
CommanderStorm Jan 29, 2026
e8d343f
chore(fmt): apply pre-commit formatting fixes
pre-commit-ci[bot] Jan 29, 2026
0eda4fa
chore: update blessed test snapshots across all components
autofix-ci[bot] Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion martin/src/srv/fonts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ use std::string::ToString;

use actix_middleware_etag::Etag;
use actix_web::error::{ErrorBadRequest, ErrorNotFound};
use actix_web::http::header::LOCATION;
use actix_web::middleware::Compress;
use actix_web::web::{Data, Path};
use actix_web::{HttpResponse, Result as ActixResult, route};
use martin_core::fonts::{FontError, FontSources, OptFontCache};
use serde::Deserialize;
use tracing::warn;

use crate::srv::server::map_internal_error;
use crate::srv::server::{DebouncedWarning, map_internal_error};

#[derive(Deserialize, Debug)]
struct FontRequest {
Expand Down Expand Up @@ -43,6 +45,28 @@ async fn get_font(
.body(data))
}

/// Redirect `/fonts/{fontstack}/{start}-{end}` to `/font/{fontstack}/{start}-{end}` (HTTP 301)
#[route("/fonts/{fontstack}/{start}-{end}", method = "GET", method = "HEAD")]
pub async fn redirect_fonts(path: Path<FontRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();

WARNING
.once_per_hour(|| {
warn!(
"Request to /fonts/{}/{}-{} caused unnecessary redirect. Use /font/{}/{}-{} to avoid extra round-trip latency.",
path.fontstack, path.start, path.end, path.fontstack, path.start, path.end
);
})
.await;

HttpResponse::MovedPermanently()
.insert_header((
LOCATION,
format!("/font/{}/{}-{}", path.fontstack, path.start, path.end),
))
.finish()
}

pub fn map_font_error(e: FontError) -> actix_web::Error {
match e {
FontError::FontNotFound(_) => ErrorNotFound(e.to_string()),
Expand Down
64 changes: 59 additions & 5 deletions martin/src/srv/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,46 @@ pub fn map_internal_error<T: std::fmt::Display>(e: T) -> actix_web::Error {
actix_web::error::ErrorInternalServerError(e.to_string())
}

/// Helper struct for debounced warning messages in redirect handlers.
/// Ensures warnings are logged no more than once per hour to avoid log spam.
#[cfg(any(
feature = "_tiles",
feature = "fonts",
feature = "sprites",
feature = "styles"
))]
pub struct DebouncedWarning {
last_warning: std::sync::LazyLock<tokio::sync::Mutex<std::time::Instant>>,
}

#[cfg(any(
feature = "_tiles",
feature = "fonts",
feature = "sprites",
feature = "styles"
))]
impl DebouncedWarning {
/// Create a new `DebouncedWarning` instance
pub const fn new() -> Self {
Self {
last_warning: std::sync::LazyLock::new(|| {
tokio::sync::Mutex::new(std::time::Instant::now())
}),
}
}

/// Execute the provided closure at most once per hour.
/// This allows tracing's log filtering to work correctly by keeping the warn! call site
/// in the caller's context.
pub async fn once_per_hour<F: FnOnce()>(&self, f: F) {
let mut last = self.last_warning.lock().await;
if last.elapsed() >= Duration::from_secs(3600) {
*last = std::time::Instant::now();
f();
}
}
}

/// Return 200 OK if healthy. Used for readiness and liveness probes.
#[route("/health", method = "GET", method = "HEAD")]
async fn get_health() -> impl Responder {
Expand All @@ -56,20 +96,34 @@ fn register_services(cfg: &mut web::ServiceConfig, #[allow(unused_variables)] us
.service(crate::srv::admin::get_catalog);

#[cfg(feature = "_tiles")]
cfg.service(crate::srv::tiles::metadata::get_source_info)
.service(crate::srv::tiles::content::get_tile);
{
// Register tile format suffix redirects BEFORE the main tile route
// because Actix-Web matches routes in registration order
cfg.service(crate::srv::tiles::content::redirect_tile_ext)
.service(crate::srv::tiles::metadata::get_source_info)
.service(crate::srv::tiles::content::get_tile);

// Register /tiles/ prefix redirect after main tile route
cfg.service(crate::srv::tiles::content::redirect_tiles);
}

#[cfg(feature = "sprites")]
cfg.service(crate::srv::sprites::get_sprite_sdf_json)
.service(crate::srv::sprites::redirect_sdf_sprites_json)
.service(crate::srv::sprites::get_sprite_json)
.service(crate::srv::sprites::redirect_sprites_json)
.service(crate::srv::sprites::get_sprite_sdf_png)
.service(crate::srv::sprites::get_sprite_png);
.service(crate::srv::sprites::redirect_sdf_sprites_png)
.service(crate::srv::sprites::get_sprite_png)
.service(crate::srv::sprites::redirect_sprites_png);

#[cfg(feature = "fonts")]
cfg.service(crate::srv::fonts::get_font);
cfg.service(crate::srv::fonts::get_font)
.service(crate::srv::fonts::redirect_fonts);

#[cfg(feature = "styles")]
cfg.service(crate::srv::styles::get_style_json);
cfg.service(crate::srv::styles::get_style_json)
.service(crate::srv::styles::redirect_styles);

#[cfg(all(feature = "unstable-rendering", target_os = "linux"))]
cfg.service(crate::srv::styles_rendering::get_style_rendered);
Expand Down
81 changes: 79 additions & 2 deletions martin/src/srv/sprites.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ use std::string::ToString;

use actix_middleware_etag::Etag;
use actix_web::error::ErrorNotFound;
use actix_web::http::header::ContentType;
use actix_web::http::header::{ContentType, LOCATION};
use actix_web::middleware::Compress;
use actix_web::web::{Bytes, Data, Path};
use actix_web::{HttpResponse, Result as ActixResult, route};
use martin_core::sprites::{OptSpriteCache, SpriteError, SpriteSources};
use serde::Deserialize;
use tracing::warn;

use crate::srv::server::map_internal_error;
use crate::srv::server::{DebouncedWarning, map_internal_error};

#[derive(Deserialize)]
pub struct SourceIDsRequest {
Expand Down Expand Up @@ -42,6 +43,25 @@ async fn get_sprite_png(
.body(png))
}

/// Redirect `/sprites/{source_ids}.png` to `/sprite/{source_ids}.png` (HTTP 301)
#[route("/sprites/{source_ids}.png", method = "GET", method = "HEAD")]
pub async fn redirect_sprites_png(path: Path<SourceIDsRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let SourceIDsRequest { source_ids } = path.as_ref();

WARNING
.once_per_hour(|| {
warn!(
"Request to /sprites/{source_ids}.png caused unnecessary redirect. Use /sprite/{source_ids}.png to avoid extra round-trip latency."
);
})
.await;

HttpResponse::MovedPermanently()
.insert_header((LOCATION, format!("/sprite/{source_ids}.png")))
.finish()
}

#[route(
"/sdf_sprite/{source_ids}.png",
method = "GET",
Expand All @@ -68,6 +88,25 @@ async fn get_sprite_sdf_png(
.body(png))
}

/// Redirect `/sdf_sprites/{source_ids}.png` to `/sdf_sprite/{source_ids}.png` (HTTP 301)
#[route("/sdf_sprites/{source_ids}.png", method = "GET", method = "HEAD")]
pub async fn redirect_sdf_sprites_png(path: Path<SourceIDsRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let SourceIDsRequest { source_ids } = path.as_ref();

WARNING
.once_per_hour(|| {
warn!(
"Request to /sdf_sprites/{source_ids}.png caused unnecessary redirect. Use /sdf_sprite/{source_ids}.png to avoid extra round-trip latency."
);
})
.await;

HttpResponse::MovedPermanently()
.insert_header((LOCATION, format!("/sdf_sprite/{source_ids}.png")))
.finish()
}

#[route(
"/sprite/{source_ids}.json",
method = "GET",
Expand Down Expand Up @@ -95,6 +134,25 @@ async fn get_sprite_json(
.body(json))
}

/// Redirect `/sprites/{source_ids}.json` to `/sprite/{source_ids}.json` (HTTP 301)
#[route("/sprites/{source_ids}.json", method = "GET", method = "HEAD")]
pub async fn redirect_sprites_json(path: Path<SourceIDsRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let SourceIDsRequest { source_ids } = path.as_ref();

WARNING
.once_per_hour(|| {
warn!(
"Request to /sprites/{source_ids}.json caused unnecessary redirect. Use /sprite/{source_ids}.json to avoid extra round-trip latency."
);
})
.await;

HttpResponse::MovedPermanently()
.insert_header((LOCATION, format!("/sprite/{source_ids}.json")))
.finish()
}

#[route(
"/sdf_sprite/{source_ids}.json",
method = "GET",
Expand Down Expand Up @@ -122,6 +180,25 @@ async fn get_sprite_sdf_json(
.body(json))
}

/// Redirect `/sdf_sprites/{source_ids}.json` to `/sdf_sprite/{source_ids}.json` (HTTP 301)
#[route("/sdf_sprites/{source_ids}.json", method = "GET", method = "HEAD")]
pub async fn redirect_sdf_sprites_json(path: Path<SourceIDsRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let SourceIDsRequest { source_ids } = path.as_ref();

WARNING
.once_per_hour(|| {
warn!(
"Request to /sdf_sprites/{source_ids}.json caused unnecessary redirect. Use /sdf_sprite/{source_ids}.json to avoid extra round-trip latency."
);
})
.await;

HttpResponse::MovedPermanently()
.insert_header((LOCATION, format!("/sdf_sprite/{source_ids}.json")))
.finish()
}

async fn get_sprite(source_ids: &str, sprites: &SpriteSources, as_sdf: bool) -> ActixResult<Bytes> {
let sheet = sprites
.get_sprites(source_ids, as_sdf)
Expand Down
25 changes: 23 additions & 2 deletions martin/src/srv/styles.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use actix_middleware_etag::Etag;
use actix_web::http::header::ContentType;
use actix_web::http::header::{ContentType, LOCATION};
use actix_web::middleware::Compress;
use actix_web::web::{Data, Path};
use actix_web::{HttpResponse, route};
use martin_core::styles::StyleSources;
use serde::Deserialize;
use tracing::error;
use tracing::{error, warn};

use crate::srv::server::DebouncedWarning;

#[derive(Deserialize, Debug)]
struct StyleRequest {
Expand Down Expand Up @@ -48,3 +50,22 @@ async fn get_style_json(path: Path<StyleRequest>, styles: Data<StyleSources>) ->
}
}
}

/// Redirect `/styles/{style_id}` to `/style/{style_id}` (HTTP 301)
/// This handles common pluralization mistakes
#[route("/styles/{style_id}", method = "GET", method = "HEAD")]
pub(crate) async fn redirect_styles(path: Path<StyleRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let StyleRequest { style_id } = path.as_ref();
WARNING
.once_per_hour(|| {
warn!(
"Request to /styles/{style_id} caused unnecessary redirect. Use /style/{style_id} to avoid extra round-trip latency."
);
})
.await;

HttpResponse::MovedPermanently()
.insert_header((LOCATION, format!("/style/{style_id}")))
.finish()
}
72 changes: 71 additions & 1 deletion martin/src/srv/tiles/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use actix_http::header::Quality;
use actix_web::error::{ErrorBadRequest, ErrorNotAcceptable, ErrorNotFound};
use actix_web::http::header::{
AcceptEncoding, CONTENT_ENCODING, ETAG, Encoding as HeaderEnc, EntityTag, IfNoneMatch,
Preference,
LOCATION, Preference,
};
use actix_web::web::{Data, Path, Query};
use actix_web::{HttpMessage, HttpRequest, HttpResponse, Result as ActixResult, route};
Expand All @@ -14,10 +14,12 @@ use martin_tile_utils::{
encode_gzip,
};
use serde::Deserialize;
use tracing::warn;

use crate::config::args::PreferredEncoding;
use crate::config::file::srv::SrvConfig;
use crate::source::TileSources;
use crate::srv::server::DebouncedWarning;
use crate::srv::server::map_internal_error;

const SUPPORTED_ENC: &[HeaderEnc] = &[
Expand Down Expand Up @@ -61,6 +63,74 @@ async fn get_tile(
.await
}

#[derive(Deserialize, Clone)]
pub struct RedirectTileRequest {
ids: String,
z: u8,
x: u32,
y: u32,
ext: String,
}

/// Redirect `/{source_ids}/{z}/{x}/{y}.{extension}` to `/{source_ids}/{z}/{x}/{y}` (HTTP 301)
/// Registered before main tile route to match more specific pattern first
#[route("/{ids}/{z}/{x}/{y}.{ext}", method = "GET", method = "HEAD")]
pub async fn redirect_tile_ext(req: HttpRequest, path: Path<RedirectTileRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let RedirectTileRequest { ids, z, x, y, ext } = path.as_ref();

WARNING
.once_per_hour(|| {
warn!(
"Request to /{ids}/{z}/{x}/{y}.{ext} caused unnecessary redirect. Use /{ids}/{z}/{x}/{y} to avoid extra round-trip latency."
);
})
.await;

redirect_tile_with_query(ids, *z, *x, *y, req.query_string())
}

/// Redirect `/tiles/{source_ids}/{z}/{x}/{y}` to `/{source_ids}/{z}/{x}/{y}` (HTTP 301)
#[route("/tiles/{source_ids}/{z}/{x}/{y}", method = "GET", method = "HEAD")]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is /tiles/ prefix still a thing? Also, what happens if the new prefix configuration is set to /tiles?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well.. the other apis (fonts, sprites, ...) also exist.
I think this is a fairly unlikely case, but still if we are user friendly in other aspects, we likely also should be here.

If a prefix exists, this is "pushed to the right" by said prefix.
So with prefix "foobar" this would be "/foobar/tiles/..."

pub async fn redirect_tiles(req: HttpRequest, path: Path<TileRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let TileRequest {
source_ids,
z,
x,
y,
} = path.as_ref();

WARNING
.once_per_hour(|| {
warn!(
"Request to /tiles/{source_ids}/{z}/{x}/{y} caused unnecessary redirect. Use /{source_ids}/{z}/{x}/{y} to avoid extra round-trip latency."
);
})
.await;

redirect_tile_with_query(source_ids, *z, *x, *y, req.query_string())
}

/// Helper function to create a 301 redirect for tiles with query string preservation
fn redirect_tile_with_query(
source_ids: &str,
z: u8,
x: u32,
y: u32,
query_string: &str,
) -> HttpResponse {
let location = format!("/{source_ids}/{z}/{x}/{y}");
let location = if query_string.is_empty() {
location
} else {
format!("{location}?{query_string}")
};
HttpResponse::MovedPermanently()
.insert_header((LOCATION, location))
.finish()
}

pub struct DynTileSource<'a> {
pub sources: Vec<BoxedSource>,
pub info: TileInfo,
Expand Down
Loading
Loading