Oregami
Repositories/oxedyne/fe2o3

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
20use crate::srv::cfg::TileConfig;
21
22use oxedyne_fe2o3_core::prelude::*;
23use oxedyne_fe2o3_geom::tile::pmtiles::{
24 Archive,
25 Compression,
26 FileSource,
27 TileType,
28};
29use oxedyne_fe2o3_jdat::string::enc::escape_json_string;
30use 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
47use std::{
48 collections::BTreeMap,
49 path::Path,
50 sync::Arc,
51};
52
53
54// Cache lifetimes
55pub const TILE_MAX_AGE_SECS: u64 = 31_536_000; // a year: the URL names the build
56pub const INDEX_MAX_AGE_SECS: u64 = 300; // how soon a new build is picked up
57pub 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)]
61pub 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)]
70pub enum TileKind {
71 Mvt,
72 Png,
73 Jpeg,
74 Webp,
75 Avif,
76}
77
78impl 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.
106pub 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.
112pub enum TileSource {
113 Pmtiles {
114 archive: Archive<FileSource>,
115 info: TileInfo,
116 },
117}
118
119impl 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
127impl 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
167impl 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)]
183pub struct TileRequest {
184 pub method: HttpMethod,
185 pub path: String,
186 pub origin: Option<String>,
187 pub accept_encoding: Option<String>,
188}
189
190impl 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)]
208pub 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)]
220enum Target<'a> {
221 Index,
222 Tile { build: &'a str, z: u8, x: u32, y: u32, ext: &'a str },
223 Unknown,
224}
225
226impl<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.
499fn 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}