oxedyne/fe2o3/fe2o3_steel/src/srv/tiles.rs
19.1 KiB, 17 runs
created by r1870400018:59923, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Map tiles from a local archive, served so that nothing about the viewer is kept. |
| 2 | //! |
| 3 | //! A tile request names a place at street resolution, so a record of tile requests is a record of |
| 4 | //! where each viewer looked. The route is built so that no such record can be made by it: |
| 5 | //! |
| 6 | //! - It is answered in `srv::https` before the request is logged, before the session cookie is |
| 7 | //! read and before the traffic recorder sees it, and the vhost carrying it must set |
| 8 | //! `access_log: false`, which silences the connection lines as well. |
| 9 | //! - [`TileRequest`] holds the method, path, `Origin` and `Accept-Encoding` and nothing else, so a |
| 10 | //! cookie or an identifier in the request cannot reach the code that answers it. |
| 11 | //! - No response sets a cookie, and a failed read logs the build, never the tile. |
| 12 | //! |
| 13 | //! The archive is reached through [`TileArchive`] alone: the directory index and the byte-range |
| 14 | //! read belong to the PMTiles reader in `fe2o3_geom::tile::pmtiles`, and this module asks it only |
| 15 | //! for the bytes stored for one tile. |
| 16 | //! |
| 17 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 18 | //! Anthropic Claude |
| 19 | |
| 20 | use crate::srv::cfg::TileConfig; |
| 21 | |
| 22 | use oxedyne_fe2o3_core::prelude::*; |
| 23 | use oxedyne_fe2o3_geom::tile::pmtiles::{ |
| 24 | Archive, |
| 25 | Compression, |
| 26 | FileSource, |
| 27 | TileType, |
| 28 | }; |
| 29 | use oxedyne_fe2o3_jdat::string::enc::escape_json_string; |
| 30 | use oxedyne_fe2o3_net::http::{ |
| 31 | encoding::{ |
| 32 | self, |
| 33 | ContentCoding, |
| 34 | }, |
| 35 | fields::{ |
| 36 | HeaderFieldValue, |
| 37 | HeaderName, |
| 38 | }, |
| 39 | header::{ |
| 40 | HttpHeadline, |
| 41 | HttpMethod, |
| 42 | }, |
| 43 | msg::HttpMessage, |
| 44 | status::HttpStatus, |
| 45 | }; |
| 46 | |
| 47 | use std::{ |
| 48 | collections::BTreeMap, |
| 49 | path::Path, |
| 50 | sync::Arc, |
| 51 | }; |
| 52 | |
| 53 | |
| 54 | // Cache lifetimes |
| 55 | pub const TILE_MAX_AGE_SECS: u64 = 31_536_000; // a year: the URL names the build |
| 56 | pub const INDEX_MAX_AGE_SECS: u64 = 300; // how soon a new build is picked up |
| 57 | pub const INDEX_NAME: &str = "tiles.json"; |
| 58 | |
| 59 | /// What a tile archive holds, as its header states it. |
| 60 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 61 | pub struct TileInfo { |
| 62 | pub kind: TileKind, |
| 63 | pub coding: ContentCoding, // how each stored tile is compressed |
| 64 | pub min_zoom: u8, |
| 65 | pub max_zoom: u8, |
| 66 | pub bounds_e7: [i32; 4], // min lon, min lat, max lon, max lat, in degrees × 10⁷ |
| 67 | } |
| 68 | |
| 69 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 70 | pub enum TileKind { |
| 71 | Mvt, |
| 72 | Png, |
| 73 | Jpeg, |
| 74 | Webp, |
| 75 | Avif, |
| 76 | } |
| 77 | |
| 78 | impl TileKind { |
| 79 | /// The extension a tile URL ends in. |
| 80 | pub fn ext(&self) -> &'static str { |
| 81 | match self { |
| 82 | Self::Mvt => "mvt", |
| 83 | Self::Png => "png", |
| 84 | Self::Jpeg => "jpg", |
| 85 | Self::Webp => "webp", |
| 86 | Self::Avif => "avif", |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | pub fn media_type(&self) -> &'static str { |
| 91 | match self { |
| 92 | Self::Mvt => "application/vnd.mapbox-vector-tile", |
| 93 | Self::Png => "image/png", |
| 94 | Self::Jpeg => "image/jpeg", |
| 95 | Self::Webp => "image/webp", |
| 96 | Self::Avif => "image/avif", |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | /// The narrow interface between this route and a tile archive reader. |
| 102 | /// |
| 103 | /// `tile` returns the bytes stored for one tile, still in the archive's coding, or `None` where |
| 104 | /// the archive holds nothing for it. It blocks on file I/O, so the route calls it off the async |
| 105 | /// workers. |
| 106 | pub trait TileArchive: Send + Sync + 'static { |
| 107 | fn info(&self) -> TileInfo; |
| 108 | fn tile(&self, z: u8, x: u32, y: u32) -> Outcome<Option<Vec<u8>>>; |
| 109 | } |
| 110 | |
| 111 | /// The archive readers this build of Steel can open. |
| 112 | pub enum TileSource { |
| 113 | Pmtiles { |
| 114 | archive: Archive<FileSource>, |
| 115 | info: TileInfo, |
| 116 | }, |
| 117 | } |
| 118 | |
| 119 | impl std::fmt::Debug for TileSource { |
| 120 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 121 | match self { |
| 122 | Self::Pmtiles { info, .. } => write!(f, "TileSource::Pmtiles({:?})", info), |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | impl TileSource { |
| 128 | /// Opens a build's PMTiles archive, refusing one this route cannot serve as it stands: a |
| 129 | /// tile kind with no media type here, or a compression other than none and gzip. The |
| 130 | /// errors name the build and the file, never a tile. |
| 131 | pub fn open(build: &str, path: &Path) -> Outcome<Self> { |
| 132 | let archive = match FileSource::open(path).and_then(Archive::open) { |
| 133 | Ok(a) => a, |
| 134 | Err(e) => return Err(err!(e, |
| 135 | "Tiles: build '{}' at {:?} cannot be opened as a PMTiles archive.", build, path; |
| 136 | Configuration, File)), |
| 137 | }; |
| 138 | let h = archive.header(); |
| 139 | let kind = match h.tile_type { |
| 140 | TileType::Mvt => TileKind::Mvt, |
| 141 | TileType::Png => TileKind::Png, |
| 142 | TileType::Jpeg => TileKind::Jpeg, |
| 143 | TileType::Webp => TileKind::Webp, |
| 144 | TileType::Avif => TileKind::Avif, |
| 145 | other => return Err(err!( |
| 146 | "Tiles: build '{}' at {:?} holds {:?} tiles, which this route does not serve.", |
| 147 | build, path, other; Unimplemented, Configuration)), |
| 148 | }; |
| 149 | let coding = match h.tile_compression { |
| 150 | Compression::None => ContentCoding::Identity, |
| 151 | Compression::Gzip => ContentCoding::Gzip, |
| 152 | other => return Err(err!( |
| 153 | "Tiles: build '{}' at {:?} stores its tiles with {:?} compression; only none and \ |
| 154 | gzip are served.", build, path, other; Unimplemented, Configuration)), |
| 155 | }; |
| 156 | let info = TileInfo { |
| 157 | kind, |
| 158 | coding, |
| 159 | min_zoom: h.min_zoom, |
| 160 | max_zoom: h.max_zoom, |
| 161 | bounds_e7: h.bounds_e7(), |
| 162 | }; |
| 163 | Ok(Self::Pmtiles { archive, info }) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | impl TileArchive for TileSource { |
| 168 | fn info(&self) -> TileInfo { |
| 169 | match self { |
| 170 | Self::Pmtiles { info, .. } => *info, |
| 171 | } |
| 172 | } |
| 173 | fn tile(&self, z: u8, x: u32, y: u32) -> Outcome<Option<Vec<u8>>> { |
| 174 | match self { |
| 175 | Self::Pmtiles { archive, .. } => archive.tile(z, x, y), |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /// Everything the route reads from a request. A cookie, an `Authorization` field or the peer's |
| 181 | /// address is not here, so nothing that answers a tile request can read one. |
| 182 | #[derive(Clone, Debug)] |
| 183 | pub struct TileRequest { |
| 184 | pub method: HttpMethod, |
| 185 | pub path: String, |
| 186 | pub origin: Option<String>, |
| 187 | pub accept_encoding: Option<String>, |
| 188 | } |
| 189 | |
| 190 | impl TileRequest { |
| 191 | /// Takes the four parts the route reads, or `None` for a message that is not a request. |
| 192 | pub fn of(msg: &HttpMessage) -> Option<Self> { |
| 193 | match &msg.header.headline { |
| 194 | HttpHeadline::Request { method, loc } => Some(Self { |
| 195 | method: method.clone(), |
| 196 | path: loc.path.as_string().to_string(), |
| 197 | origin: msg.header.fields.get_one(&HeaderName::Origin) |
| 198 | .map(|v| fmt!("{}", v)), |
| 199 | accept_encoding: encoding::accept_encoding(&msg.header.fields), |
| 200 | }), |
| 201 | HttpHeadline::Response { .. } => None, |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | /// One vhost's tile route: a prefix, the builds served under it, and who may fetch them. |
| 207 | #[derive(Debug)] |
| 208 | pub struct TileService<A: TileArchive> { |
| 209 | prefix: String, |
| 210 | current: String, |
| 211 | builds: BTreeMap<String, Arc<A>>, |
| 212 | origins: Vec<String>, |
| 213 | attribution: String, |
| 214 | base_url: String, // `https://<primary hostname>`, for the index |
| 215 | hsts_secs: u64, |
| 216 | } |
| 217 | |
| 218 | // The parts of a path under the prefix. |
| 219 | #[derive(Debug, Eq, PartialEq)] |
| 220 | enum Target<'a> { |
| 221 | Index, |
| 222 | Tile { build: &'a str, z: u8, x: u32, y: u32, ext: &'a str }, |
| 223 | Unknown, |
| 224 | } |
| 225 | |
| 226 | impl<A: TileArchive> TileService<A> { |
| 227 | |
| 228 | /// Builds the route from its config, opening each build with `open`. |
| 229 | pub fn new<F>( |
| 230 | cfg: &TileConfig, |
| 231 | hostname: &str, |
| 232 | hsts_secs: u64, |
| 233 | mut open: F, |
| 234 | ) |
| 235 | -> Outcome<Self> |
| 236 | where |
| 237 | F: FnMut(&str, &Path) -> Outcome<A>, |
| 238 | { |
| 239 | let mut builds = BTreeMap::new(); |
| 240 | for (build, path) in &cfg.builds { |
| 241 | let archive = res!(open(build, path)); |
| 242 | builds.insert(build.clone(), Arc::new(archive)); |
| 243 | } |
| 244 | if !builds.contains_key(&cfg.current) { |
| 245 | return Err(err!( |
| 246 | "Tiles: the current build '{}' is not among the configured builds {:?}.", |
| 247 | cfg.current, builds.keys().collect::<Vec<_>>(); |
| 248 | Invalid, Configuration, Missing)); |
| 249 | } |
| 250 | Ok(Self { |
| 251 | prefix: cfg.prefix.clone(), |
| 252 | current: cfg.current.clone(), |
| 253 | builds, |
| 254 | origins: cfg.allow_origins.clone(), |
| 255 | attribution: cfg.attribution.clone(), |
| 256 | base_url: fmt!("https://{}", hostname), |
| 257 | hsts_secs, |
| 258 | }) |
| 259 | } |
| 260 | |
| 261 | /// Does the path fall under this route's prefix? Everything under it is answered here, so |
| 262 | /// nothing under it reaches the logging dispatch that follows. |
| 263 | pub fn owns(&self, path: &str) -> bool { |
| 264 | match path.strip_prefix(self.prefix.as_str()) { |
| 265 | Some(rest) => rest.is_empty() || rest.starts_with('/'), |
| 266 | None => false, |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | /// The answer to a request under the prefix, or `None` when the path is not this route's. |
| 271 | pub async fn respond(&self, req: TileRequest) -> Option<HttpMessage> { |
| 272 | if !self.owns(&req.path) { |
| 273 | return None; |
| 274 | } |
| 275 | // An origin that is present and not listed is refused outright, so a page elsewhere |
| 276 | // cannot draw these tiles even where it would not be allowed to read them. |
| 277 | let origin = match &req.origin { |
| 278 | Some(o) if self.origins.iter().any(|a| a == o) => Some(o.clone()), |
| 279 | Some(_) => return Some(self.finish( |
| 280 | HttpMessage::respond_with_text(HttpStatus::Forbidden, "Origin not permitted."), |
| 281 | None, false, None)), |
| 282 | None => None, |
| 283 | }; |
| 284 | let head_only = match req.method { |
| 285 | HttpMethod::GET => false, |
| 286 | HttpMethod::HEAD => true, |
| 287 | HttpMethod::OPTIONS => return Some(self.preflight(origin)), |
| 288 | _ => { |
| 289 | let msg = HttpMessage::respond_with_text( |
| 290 | HttpStatus::MethodNotAllowed, "Method not allowed.") |
| 291 | .with_field(HeaderName::Allow, |
| 292 | HeaderFieldValue::Generic("GET, HEAD, OPTIONS".to_string())); |
| 293 | return Some(self.finish(msg, origin, false, None)); |
| 294 | } |
| 295 | }; |
| 296 | let msg = match self.target(&req.path) { |
| 297 | Target::Index => self.index(origin), |
| 298 | Target::Tile { build, z, x, y, ext } => |
| 299 | self.tile(build, z, x, y, ext, origin, req.accept_encoding.as_deref()).await, |
| 300 | Target::Unknown => self.not_found(origin), |
| 301 | }; |
| 302 | Some(if head_only { msg.head_only() } else { msg }) |
| 303 | } |
| 304 | |
| 305 | fn target<'a>(&self, path: &'a str) -> Target<'a> { |
| 306 | let rest = match path.strip_prefix(self.prefix.as_str()) |
| 307 | .and_then(|r| r.strip_prefix('/')) |
| 308 | { |
| 309 | Some(r) => r, |
| 310 | None => return Target::Unknown, |
| 311 | }; |
| 312 | if rest == INDEX_NAME { |
| 313 | return Target::Index; |
| 314 | } |
| 315 | let mut parts = rest.split('/'); |
| 316 | let (build, z, x, file) = match ( |
| 317 | parts.next(), parts.next(), parts.next(), parts.next(), parts.next(), |
| 318 | ) { |
| 319 | (Some(b), Some(z), Some(x), Some(f), None) => (b, z, x, f), |
| 320 | _ => return Target::Unknown, |
| 321 | }; |
| 322 | let (y, ext) = match file.split_once('.') { |
| 323 | Some(ye) => ye, |
| 324 | None => return Target::Unknown, |
| 325 | }; |
| 326 | match (digits::<u8>(z), digits::<u32>(x), digits::<u32>(y)) { |
| 327 | (Some(z), Some(x), Some(y)) => Target::Tile { build, z, x, y, ext }, |
| 328 | _ => Target::Unknown, |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | async fn tile( |
| 333 | &self, |
| 334 | build: &str, |
| 335 | z: u8, |
| 336 | x: u32, |
| 337 | y: u32, |
| 338 | ext: &str, |
| 339 | origin: Option<String>, |
| 340 | accept: Option<&str>, |
| 341 | ) |
| 342 | -> HttpMessage |
| 343 | { |
| 344 | let archive = match self.builds.get(build) { |
| 345 | Some(a) => a.clone(), |
| 346 | None => return self.not_found(origin), |
| 347 | }; |
| 348 | let info = archive.info(); |
| 349 | // A zoom outside the archive, a column or row off the edge of the world, or an |
| 350 | // extension that is not the archive's kind names no tile. |
| 351 | if z < info.min_zoom || z > info.max_zoom || z > 31 |
| 352 | || (x as u64) >> z != 0 || (y as u64) >> z != 0 |
| 353 | || ext != info.kind.ext() |
| 354 | { |
| 355 | return self.not_found(origin); |
| 356 | } |
| 357 | let read = tokio::task::spawn_blocking(move || archive.tile(z, x, y)).await; |
| 358 | let stored = match read { |
| 359 | Ok(Ok(stored)) => stored, |
| 360 | // The tile is not named, since the log must not hold where anyone looked. |
| 361 | Ok(Err(_)) | Err(_) => { |
| 362 | error!(err!("Tiles: build '{}' could not be read; the archive may be \ |
| 363 | damaged.", build; IO, Read)); |
| 364 | return self.finish(HttpMessage::respond_with_text( |
| 365 | HttpStatus::InternalServerError, "Tile read failed."), origin, false, None); |
| 366 | } |
| 367 | }; |
| 368 | let bytes = match stored { |
| 369 | Some(b) if !b.is_empty() => b, |
| 370 | // Nothing stored is an empty tile, which is as permanent as a full one. |
| 371 | _ => return self.finish( |
| 372 | HttpMessage::new_response(HttpStatus::NoContent), origin, true, None), |
| 373 | }; |
| 374 | let mut msg = HttpMessage::new_response(HttpStatus::OK) |
| 375 | .with_field(HeaderName::ContentType, |
| 376 | HeaderFieldValue::Generic(info.kind.media_type().to_string())); |
| 377 | // The stored gzip is passed through to a client that takes it, which is every browser, |
| 378 | // and decoded for one that does not. |
| 379 | msg = match info.coding { |
| 380 | ContentCoding::Identity => msg.with_body(bytes), |
| 381 | ContentCoding::Gzip => match encoding::negotiate(accept) { |
| 382 | ContentCoding::Gzip => msg |
| 383 | .with_field(HeaderName::ContentEncoding, |
| 384 | HeaderFieldValue::Generic("gzip".to_string())) |
| 385 | .with_body(bytes), |
| 386 | ContentCoding::Identity => match encoding::gunzip(&bytes) { |
| 387 | Ok(plain) => msg.with_body(plain), |
| 388 | Err(_) => { |
| 389 | error!(err!("Tiles: build '{}' holds a tile that is not valid gzip.", |
| 390 | build; Decode)); |
| 391 | return self.finish(HttpMessage::respond_with_text( |
| 392 | HttpStatus::InternalServerError, "Tile read failed."), |
| 393 | origin, false, None); |
| 394 | } |
| 395 | }, |
| 396 | }, |
| 397 | }; |
| 398 | let vary = match info.coding { |
| 399 | ContentCoding::Gzip => Some("Accept-Encoding"), |
| 400 | ContentCoding::Identity => None, |
| 401 | }; |
| 402 | self.finish(msg, origin, true, vary) |
| 403 | } |
| 404 | |
| 405 | fn index(&self, origin: Option<String>) -> HttpMessage { |
| 406 | let archive = match self.builds.get(&self.current) { |
| 407 | Some(a) => a, |
| 408 | None => return self.not_found(origin), |
| 409 | }; |
| 410 | let info = archive.info(); |
| 411 | let b = info.bounds_e7; |
| 412 | let deg = |v: i32| fmt!("{}", v as f64 / 1e7); |
| 413 | let body = fmt!( |
| 414 | "{{\"tilejson\":\"3.0.0\",\"build\":\"{}\",\ |
| 415 | \"tiles\":[\"{}{}/{}/{{z}}/{{x}}/{{y}}.{}\"],\ |
| 416 | \"minzoom\":{},\"maxzoom\":{},\"bounds\":[{},{},{},{}],\"attribution\":\"{}\"}}", |
| 417 | escape_json_string(&self.current), |
| 418 | escape_json_string(&self.base_url), |
| 419 | escape_json_string(&self.prefix), |
| 420 | escape_json_string(&self.current), |
| 421 | info.kind.ext(), |
| 422 | info.min_zoom, info.max_zoom, |
| 423 | deg(b[0]), deg(b[1]), deg(b[2]), deg(b[3]), |
| 424 | escape_json_string(&self.attribution), |
| 425 | ); |
| 426 | let msg = HttpMessage::new_response(HttpStatus::OK) |
| 427 | .with_field(HeaderName::ContentType, |
| 428 | HeaderFieldValue::Generic("application/json".to_string())) |
| 429 | .with_field(HeaderName::CacheControl, |
| 430 | HeaderFieldValue::Generic(fmt!("public, max-age={}", INDEX_MAX_AGE_SECS))) |
| 431 | .with_body(body.into_bytes()); |
| 432 | self.finish(msg, origin, false, None) |
| 433 | } |
| 434 | |
| 435 | fn preflight(&self, origin: Option<String>) -> HttpMessage { |
| 436 | let msg = HttpMessage::new_response(HttpStatus::NoContent) |
| 437 | .with_field(HeaderName::AccessControlAllowMethods, |
| 438 | HeaderFieldValue::Generic("GET, HEAD, OPTIONS".to_string())) |
| 439 | .with_field(HeaderName::AccessControlMaxAge, |
| 440 | HeaderFieldValue::Generic("86400".to_string())); |
| 441 | self.finish(msg, origin, false, None) |
| 442 | } |
| 443 | |
| 444 | fn not_found(&self, origin: Option<String>) -> HttpMessage { |
| 445 | self.finish(HttpMessage::respond_with_text(HttpStatus::NotFound, "No such tile."), |
| 446 | origin, false, None) |
| 447 | } |
| 448 | |
| 449 | /// The fields every answer carries. `immutable` marks a tile, whose URL names its build and |
| 450 | /// so never changes; anything else that has not set its own lifetime is not stored. |
| 451 | fn finish( |
| 452 | &self, |
| 453 | mut msg: HttpMessage, |
| 454 | origin: Option<String>, |
| 455 | immutable: bool, |
| 456 | vary: Option<&str>, |
| 457 | ) |
| 458 | -> HttpMessage |
| 459 | { |
| 460 | let fields = &mut msg.header.fields; |
| 461 | if immutable { |
| 462 | fields.insert(HeaderName::CacheControl, HeaderFieldValue::Generic( |
| 463 | fmt!("public, max-age={}, immutable", TILE_MAX_AGE_SECS)), None); |
| 464 | } else if fields.get_one(&HeaderName::CacheControl).is_none() { |
| 465 | fields.insert(HeaderName::CacheControl, |
| 466 | HeaderFieldValue::Generic("no-store".to_string()), None); |
| 467 | } |
| 468 | // The answer depends on the Origin, so a shared cache must key on it. |
| 469 | let vary = match vary { |
| 470 | Some(v) => fmt!("Origin, {}", v), |
| 471 | None => "Origin".to_string(), |
| 472 | }; |
| 473 | fields.insert(HeaderName::Vary, HeaderFieldValue::Generic(vary), None); |
| 474 | if let Some(o) = origin { |
| 475 | fields.insert(HeaderName::AccessControlAllowOrigin, |
| 476 | HeaderFieldValue::Generic(o), None); |
| 477 | } |
| 478 | fields.insert(HeaderName::CrossOriginResourcePolicy, |
| 479 | HeaderFieldValue::Generic("same-site".to_string()), None); |
| 480 | fields.insert(HeaderName::XContentTypeOptions, |
| 481 | HeaderFieldValue::Generic("nosniff".to_string()), None); |
| 482 | fields.insert(HeaderName::ReferrerPolicy, |
| 483 | HeaderFieldValue::Generic("no-referrer".to_string()), None); |
| 484 | fields.insert(HeaderName::ContentSecurityPolicy, |
| 485 | HeaderFieldValue::Generic("default-src 'none'; frame-ancestors 'none'".to_string()), |
| 486 | None); |
| 487 | if self.hsts_secs > 0 { |
| 488 | fields.insert(HeaderName::StrictTransportSecurity, HeaderFieldValue::Generic( |
| 489 | fmt!("max-age={}; includeSubDomains", self.hsts_secs)), None); |
| 490 | } |
| 491 | msg |
| 492 | } |
| 493 | |
| 494 | pub fn current(&self) -> &str { &self.current } |
| 495 | pub fn prefix(&self) -> &str { &self.prefix } |
| 496 | } |
| 497 | |
| 498 | // ASCII digits only, with no sign and no leading zero, so one tile has one URL. |
| 499 | fn digits<N: std::str::FromStr>(s: &str) -> Option<N> { |
| 500 | if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) || (s.len() > 1 && s.starts_with('0')) |
| 501 | { |
| 502 | return None; |
| 503 | } |
| 504 | s.parse::<N>().ok() |
| 505 | } |