1use std::collections::HashMap;
20use std::io::{Read, Write};
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant, SystemTime};
24
25use crate::notify::net;
26use crate::server::access::ASSERTION_HEADER;
27use crate::server::http::{Peer, Request, Response};
28use crate::server::mcp::Authn;
29use crate::server::{AccessValidator, Authenticated, Caller};
30use crate::template::catalog::Catalogs;
31
32pub const MAX_BYTES: usize = 512 * 1024;
34const FRESH_FOR: Duration = Duration::from_secs(7 * 86400);
36const FAILED_FOR: Duration = Duration::from_secs(600);
38const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
40const MAX_REDIRECTS: usize = 3;
41const INDEX_FOR: Duration = Duration::from_secs(60);
44const INDEX_MISS_AFTER: Duration = Duration::from_secs(5);
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Kind {
49 Png,
50 Jpeg,
51 Gif,
52 Webp,
53 Ico,
54 Svg,
55}
56
57impl Kind {
58 pub fn mime(self) -> &'static str {
59 match self {
60 Kind::Png => "image/png",
61 Kind::Jpeg => "image/jpeg",
62 Kind::Gif => "image/gif",
63 Kind::Webp => "image/webp",
64 Kind::Ico => "image/x-icon",
65 Kind::Svg => "image/svg+xml",
66 }
67 }
68}
69
70pub fn sniff(b: &[u8]) -> Option<Kind> {
72 if b.starts_with(b"\x89PNG\r\n\x1a\n") {
73 return Some(Kind::Png);
74 }
75 if b.starts_with(&[0xff, 0xd8, 0xff]) {
76 return Some(Kind::Jpeg);
77 }
78 if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") {
79 return Some(Kind::Gif);
80 }
81 if b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP" {
82 return Some(Kind::Webp);
83 }
84 if b.len() >= 22 && b[..4] == [0, 0, 1, 0] && (b[4] != 0 || b[5] != 0) {
86 return Some(Kind::Ico);
87 }
88 is_svg(b).then_some(Kind::Svg)
89}
90
91fn is_svg(b: &[u8]) -> bool {
94 let Ok(s) = std::str::from_utf8(b) else {
95 return false;
96 };
97 let mut s = s.strip_prefix('\u{feff}').unwrap_or(s);
98 loop {
99 s = s.trim_start();
100 let skip = |s: &str, open: &str, close: &str| -> Option<usize> {
101 s.starts_with(open)
102 .then(|| s.find(close).map(|i| i + close.len()))
103 .flatten()
104 };
105 if s.starts_with("<?") {
106 match skip(s, "<?", "?>") {
107 Some(n) => s = &s[n..],
108 None => return false,
109 }
110 } else if s.starts_with("<!--") {
111 match skip(s, "<!--", "-->") {
112 Some(n) => s = &s[n..],
113 None => return false,
114 }
115 } else if s.len() >= 9 && s[..9].eq_ignore_ascii_case("<!doctype") {
116 let name = s[9..].trim_start();
117 let svg = name.len() > 3
118 && name[..3].eq_ignore_ascii_case("svg")
119 && name[3..].starts_with(|c: char| c.is_ascii_whitespace() || c == '>');
120 match s.find('>') {
121 Some(n) if svg && !s[..n].contains('[') => s = &s[n + 1..],
122 _ => return false,
123 }
124 } else {
125 break;
126 }
127 }
128 s.strip_prefix("<svg")
129 .and_then(|r| r.chars().next())
130 .is_some_and(|c| c.is_ascii_whitespace() || c == '>' || c == '/')
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Image {
136 pub kind: Kind,
137 pub bytes: Vec<u8>,
138}
139
140impl Image {
141 fn of(bytes: Vec<u8>) -> Result<Image, String> {
142 if bytes.len() > MAX_BYTES {
143 return Err(format!("over {} KiB", MAX_BYTES / 1024));
144 }
145 let kind = sniff(&bytes).ok_or("not a PNG, JPEG, GIF, WebP, ICO or SVG image")?;
146 Ok(Image { kind, bytes })
147 }
148
149 pub fn response(&self) -> Response {
150 Response::new(200)
151 .header("Content-Type", self.kind.mime())
152 .header("X-Content-Type-Options", "nosniff")
153 .header("Cache-Control", "private, max-age=86400")
154 .header("Cross-Origin-Resource-Policy", "same-origin")
155 .header(
156 "Content-Security-Policy",
157 "default-src 'none'; style-src 'unsafe-inline'; sandbox",
158 )
159 .body(self.bytes.clone())
160 }
161}
162
163pub type FetchFn = Arc<dyn Fn(&str) -> Result<Vec<u8>, String> + Send + Sync>;
165
166pub struct Logos {
168 dir: PathBuf,
169 fetch: FetchFn,
170 inflight: Mutex<HashMap<String, Arc<Mutex<()>>>>,
172}
173
174fn key(url: &str) -> String {
175 ring::digest::digest(&ring::digest::SHA256, url.as_bytes())
176 .as_ref()
177 .iter()
178 .map(|b| format!("{b:02x}"))
179 .collect()
180}
181
182fn age(p: &Path) -> Option<Duration> {
183 let m = std::fs::metadata(p).ok()?.modified().ok()?;
184 Some(SystemTime::now().duration_since(m).unwrap_or_default())
185}
186
187impl Logos {
188 pub fn new(state: &Path) -> Logos {
189 Logos::with_fetch(state, Arc::new(https_get))
190 }
191
192 pub fn with_fetch(state: &Path, fetch: FetchFn) -> Logos {
193 Logos {
194 dir: state.join("templates").join("logos"),
195 fetch,
196 inflight: Mutex::new(HashMap::new()),
197 }
198 }
199
200 fn cached(&self, k: &str) -> Option<(Duration, Image)> {
202 let p = self.dir.join(k);
203 let a = age(&p)?;
204 let img = Image::of(std::fs::read(&p).ok()?).ok()?;
205 Some((a, img))
206 }
207
208 pub fn get(&self, url: &str) -> Option<Image> {
212 let k = key(url);
213 let failed = self.dir.join(format!("{k}.failed"));
214 let lookup = || -> (Option<Image>, bool) {
215 let cached = self.cached(&k);
216 let fresh = cached.as_ref().is_some_and(|(a, _)| *a < FRESH_FOR)
217 || age(&failed).is_some_and(|a| a < FAILED_FOR);
218 (cached.map(|(_, i)| i), fresh)
219 };
220 if let (img, true) = lookup() {
221 return img;
222 }
223 let lock = self
224 .inflight
225 .lock()
226 .unwrap()
227 .entry(k.clone())
228 .or_default()
229 .clone();
230 let held = lock.lock().unwrap();
231 let (stale, fresh) = lookup();
233 let out = if fresh {
234 stale
235 } else {
236 match (self.fetch)(url).and_then(Image::of) {
237 Ok(img) => {
238 if let Err(e) = crate::app::write_atomic(&self.dir.join(&k), &img.bytes) {
239 eprintln!("isb serve: template logo cache: {e}");
240 }
241 let _ = std::fs::remove_file(&failed);
242 Some(img)
243 }
244 Err(e) => {
245 let host = net::parse_url(url).map(|t| t.host).unwrap_or_default();
246 eprintln!("isb serve: template logo from {host}: {e}");
247 if let Err(e) = crate::app::write_atomic(&failed, b"") {
248 eprintln!("isb serve: template logo cache: {e}");
249 }
250 stale
251 }
252 }
253 };
254 drop(held);
255 self.inflight.lock().unwrap().remove(&k);
256 out
257 }
258}
259
260#[derive(Debug, PartialEq, Eq)]
262enum Answer {
263 Body(Vec<u8>),
264 Redirect(String),
265}
266
267pub fn https_get(url: &str) -> Result<Vec<u8>, String> {
271 let started = Instant::now();
272 let mut url = url.to_string();
273 for _ in 0..=MAX_REDIRECTS {
274 let t = net::parse_url(&url)?;
275 if !t.https {
276 return Err("logos are fetched over https only".into());
277 }
278 let policy = |e: net::SendError| {
280 let m = e.message;
281 m.split("; private targets are off")
282 .next()
283 .unwrap_or(&m)
284 .to_string()
285 };
286 let tcp = net::connect(&t.host, t.port, false).map_err(policy)?;
287 let mut s = net::tls(&net::Net::new(false), &t.host, tcp).map_err(|e| e.message)?;
288 let host = if t.host.contains(':') {
289 format!("[{}]", t.host)
290 } else {
291 t.host.clone()
292 };
293 let host = if t.port == 443 {
294 host
295 } else {
296 format!("{host}:{}", t.port)
297 };
298 let head = format!(
299 "GET {} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: isb/{}\r\nAccept: image/*\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n",
300 t.path,
301 env!("CARGO_PKG_VERSION")
302 );
303 let io = |e: std::io::Error| format!("{}: {e}", t.host);
304 s.write_all(head.as_bytes()).map_err(io)?;
305 s.flush().map_err(io)?;
306 let raw = read_capped(&mut s, started)?;
307 match parse(&raw)? {
308 Answer::Body(b) => return Ok(b),
309 Answer::Redirect(loc) => url = redirect(&t, &loc)?,
310 }
311 }
312 Err(format!("more than {MAX_REDIRECTS} redirects"))
313}
314
315fn redirect(from: &net::Target, location: &str) -> Result<String, String> {
317 let l = location.trim();
318 let next = if l.starts_with("//") {
319 format!("https:{l}")
320 } else if l.starts_with('/') {
321 let host = if from.host.contains(':') {
322 format!("[{}]", from.host)
323 } else {
324 from.host.clone()
325 };
326 format!("https://{host}:{}{l}", from.port)
327 } else {
328 l.to_string()
329 };
330 if !next
331 .get(..8)
332 .is_some_and(|p| p.eq_ignore_ascii_case("https://"))
333 {
334 return Err("a redirect off https".into());
335 }
336 Ok(next)
337}
338
339fn read_capped<S: Read>(s: &mut S, started: Instant) -> Result<Vec<u8>, String> {
341 let cap = MAX_BYTES + 64 * 1024;
343 let mut out = Vec::new();
344 let mut buf = [0u8; 16 * 1024];
345 loop {
346 if started.elapsed() > FETCH_TIMEOUT {
347 return Err(format!("no answer within {FETCH_TIMEOUT:?}"));
348 }
349 match s.read(&mut buf) {
350 Ok(0) => break,
351 Ok(n) => {
352 out.extend_from_slice(&buf[..n]);
353 if out.len() > cap {
354 return Err(format!("over {} KiB", MAX_BYTES / 1024));
355 }
356 if complete(&out) {
357 break;
358 }
359 }
360 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof && !out.is_empty() => break,
362 Err(e) => return Err(e.to_string()),
363 }
364 }
365 Ok(out)
366}
367
368fn header_end(b: &[u8]) -> Option<usize> {
369 b.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
370}
371
372fn complete(b: &[u8]) -> bool {
375 let Some(end) = header_end(b) else {
376 return false;
377 };
378 let mut headers = [httparse::EMPTY_HEADER; 64];
379 let mut r = httparse::Response::new(&mut headers);
380 if r.parse(b).is_err() {
381 return false;
382 }
383 let len = r
384 .headers
385 .iter()
386 .find(|h| h.name.eq_ignore_ascii_case("content-length"))
387 .and_then(|h| {
388 std::str::from_utf8(h.value)
389 .ok()?
390 .trim()
391 .parse::<usize>()
392 .ok()
393 });
394 matches!(len, Some(n) if b.len() >= end + n)
395}
396
397fn parse(raw: &[u8]) -> Result<Answer, String> {
398 let mut headers = [httparse::EMPTY_HEADER; 64];
399 let mut r = httparse::Response::new(&mut headers);
400 let end = match r.parse(raw) {
401 Ok(httparse::Status::Complete(n)) => n,
402 Ok(httparse::Status::Partial) => return Err("an incomplete HTTP answer".into()),
403 Err(e) => return Err(format!("a bad HTTP answer: {e}")),
404 };
405 let status = r.code.unwrap_or(0);
406 let header = |name: &str| {
407 r.headers
408 .iter()
409 .find(|h| h.name.eq_ignore_ascii_case(name))
410 .and_then(|h| std::str::from_utf8(h.value).ok())
411 .map(str::trim)
412 };
413 if matches!(status, 301 | 302 | 303 | 307 | 308) {
414 return header("location")
415 .map(|l| Answer::Redirect(l.to_string()))
416 .ok_or_else(|| format!("HTTP {status} without a Location"));
417 }
418 if status != 200 {
419 return Err(format!("HTTP {status}"));
420 }
421 if header("content-encoding").is_some_and(|e| !e.eq_ignore_ascii_case("identity")) {
422 return Err("an encoded body".into());
423 }
424 let body = &raw[end..];
425 let body = if header("transfer-encoding").is_some_and(|t| t.eq_ignore_ascii_case("chunked")) {
426 dechunk(body)?
427 } else {
428 match header("content-length").and_then(|l| l.parse::<usize>().ok()) {
429 Some(n) if n > MAX_BYTES => return Err(format!("over {} KiB", MAX_BYTES / 1024)),
430 Some(n) if body.len() < n => return Err("a truncated body".into()),
431 Some(n) => body[..n].to_vec(),
432 None => body.to_vec(),
433 }
434 };
435 if body.len() > MAX_BYTES {
436 return Err(format!("over {} KiB", MAX_BYTES / 1024));
437 }
438 Ok(Answer::Body(body))
439}
440
441fn dechunk(mut b: &[u8]) -> Result<Vec<u8>, String> {
442 let bad = || "a bad chunked body".to_string();
443 let mut out = Vec::new();
444 loop {
445 let line = b.windows(2).position(|w| w == b"\r\n").ok_or_else(bad)?;
446 let size = std::str::from_utf8(&b[..line]).map_err(|_| bad())?;
447 let size = size.split(';').next().unwrap_or_default().trim();
448 let n = usize::from_str_radix(size, 16).map_err(|_| bad())?;
449 b = &b[line + 2..];
450 if n == 0 {
451 return Ok(out);
452 }
453 if n > MAX_BYTES || b.len() < n + 2 {
454 return Err(bad());
455 }
456 out.extend_from_slice(&b[..n]);
457 b = &b[n + 2..];
458 }
459}
460
461pub type Admit = Arc<dyn Fn(&Request) -> Result<(), Response> + Send + Sync>;
464
465pub fn admit(authn: Authn, access: Option<Arc<AccessValidator>>, allow_anonymous: bool) -> Admit {
468 Arc::new(move |req: &Request| {
469 let deny = |status: u16, m: &str| {
470 Response::json(
471 status,
472 &serde_json::json!({"error": "forbidden", "message": m}),
473 )
474 };
475 let id = match (&access, req.header(ASSERTION_HEADER)) {
478 (Some(v), Some(t)) => Some(
479 v.validate(t.trim())
480 .map_err(|_| deny(401, "invalid Cloudflare Access assertion"))?,
481 ),
482 _ => None,
483 };
484 let caller = match authn(req, id.as_ref()) {
485 Authenticated::User(p) => Caller::User { principal: p },
486 Authenticated::Superadmin(s) => {
487 if s.source.is_ambient() {
488 crate::server::mcp::ambient_ok(req).map_err(|why| deny(403, why))?;
489 }
490 Caller::Superadmin(s)
491 }
492 Authenticated::Refused => return Err(deny(401, "invalid credentials")),
493 Authenticated::None => match (id, &req.peer) {
494 (Some(id), _) => Caller::Access(id),
495 (None, Peer::Unix { uid }) => Caller::Local { uid: *uid },
496 (None, Peer::Tcp(addr)) => Caller::Unauthenticated { addr: *addr },
497 },
498 };
499 let read = super::super::audit::Class {
500 read_only: true,
501 secret_read: false,
502 };
503 super::super::authorize_class(
504 &caller,
505 "template_list",
506 read,
507 serde_json::json!({}),
508 None,
509 allow_anonymous,
510 )
511 .map(|_| ())
512 .map_err(|e| {
513 let status = if matches!(caller, Caller::Unauthenticated { .. }) {
514 401
515 } else {
516 403
517 };
518 deny(status, &e.to_string())
519 })
520 })
521}
522
523type Index = Mutex<Option<(Instant, Arc<HashMap<String, String>>)>>;
525
526fn logo_of(catalogs: &Catalogs, index: &Index, reference: &str) -> Option<String> {
527 let mut g = index.lock().unwrap();
528 let stale = g.as_ref().is_none_or(|(at, m)| {
529 at.elapsed() >= INDEX_FOR
530 || (at.elapsed() >= INDEX_MISS_AFTER && !m.contains_key(reference))
531 });
532 if stale {
533 let (all, _) = catalogs.list();
534 let m = all
535 .into_iter()
536 .filter_map(|s| Some((s.reference, s.logo?)))
537 .collect();
538 *g = Some((Instant::now(), Arc::new(m)));
539 }
540 g.as_ref()?.1.get(reference).cloned()
541}
542
543pub fn route(catalogs: Arc<Catalogs>, logos: Arc<Logos>, admit: Admit) -> crate::server::Routes {
546 let index: Arc<Index> = Arc::new(Mutex::new(None));
547 Arc::new(move |req: &Request| {
548 let reference = req
549 .path
550 .strip_prefix("/api/v1/templates/")?
551 .strip_suffix("/logo")?;
552 let (catalog, id) = reference.split_once('/')?;
553 if catalog.is_empty() || id.is_empty() || id.contains('/') {
554 return None;
555 }
556 if !matches!(req.method.as_str(), "GET" | "HEAD") {
557 return Some(Response::text(405, "method not allowed").header("Allow", "GET, HEAD"));
558 }
559 if let Err(r) = admit(req) {
560 return Some(r);
561 }
562 let none = || Response::text(404, "no logo").header("Cache-Control", "no-store");
563 let Some(url) = logo_of(&catalogs, &index, reference) else {
564 return Some(none());
565 };
566 let mut r = match logos.get(&url) {
567 Some(img) => img.response(),
568 None => none(),
569 };
570 if req.method == "HEAD" {
571 r.body.clear();
572 }
573 Some(r)
574 })
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use std::sync::atomic::{AtomicUsize, Ordering};
581
582 const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
583 const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>"#;
584
585 fn ico() -> Vec<u8> {
586 let mut b = vec![0, 0, 1, 0, 1, 0];
587 b.extend_from_slice(&[0; 16]);
588 b
589 }
590
591 #[test]
592 fn sniffs_image_types_only() {
593 assert_eq!(sniff(PNG), Some(Kind::Png));
594 assert_eq!(sniff(b"\xff\xd8\xff\xe0\0\x10JFIF"), Some(Kind::Jpeg));
595 assert_eq!(sniff(b"GIF89a\x01\0"), Some(Kind::Gif));
596 assert_eq!(sniff(b"RIFF\0\0\0\0WEBPVP8 "), Some(Kind::Webp));
597 assert_eq!(sniff(&ico()), Some(Kind::Ico));
598 assert_eq!(sniff(SVG), Some(Kind::Svg));
599 assert_eq!(
600 sniff(
601 b"\xef\xbb\xbf<?xml version=\"1.0\"?>\n<!-- hi -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"x.dtd\">\n<svg width=\"1\">"
602 ),
603 Some(Kind::Svg)
604 );
605 for not in [
606 &b"<html><svg></svg></html>"[..],
607 b"<!doctype html><svg>",
608 b"<svgx>",
609 b"<!DOCTYPE svg [<!ENTITY x \"y\">]><svg>",
610 b"<!-- unclosed <svg>",
611 b"{\"not\": \"an image\"}",
612 b"RIFF\0\0\0\0WAVE",
613 b"\0\0\x01\0\0\0",
614 b"",
615 b"\xff\xfe<\0s\0v\0g\0",
616 ] {
617 assert_eq!(sniff(not), None, "{:?}", String::from_utf8_lossy(not));
618 }
619 }
620
621 #[test]
622 fn caps_the_size() {
623 let mut big = PNG.to_vec();
624 big.resize(MAX_BYTES, 0);
625 assert!(Image::of(big.clone()).is_ok());
626 big.push(0);
627 assert!(Image::of(big).unwrap_err().contains("512 KiB"));
628 let head = format!(
629 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
630 MAX_BYTES + 1
631 );
632 assert!(parse(head.as_bytes()).unwrap_err().contains("512 KiB"));
633 let mut s = std::io::Cursor::new(vec![b'x'; MAX_BYTES + 128 * 1024]);
634 assert!(read_capped(&mut s, Instant::now()).is_err());
635 }
636
637 #[test]
638 fn https_and_public_addresses_only() {
639 for (url, why) in [
640 ("http://example.com/logo.png", "https only"),
641 ("ftp://example.com/logo.png", "http"),
642 ("https://127.0.0.1/logo.png", "loopback"),
643 ("https://localhost./logo.png", ""),
644 ("https://10.1.2.3/logo.png", "private"),
645 ("https://169.254.169.254/latest/meta-data", "link-local"),
646 ("https://[::1]/logo.png", "loopback"),
647 ("https://[fd00::1]/logo.png", "unique local"),
648 ("https://0x7f000001/logo.png", "loopback"),
649 ("https://user@example.com/logo.png", "credentials"),
650 ] {
651 let e = https_get(url).unwrap_err();
652 assert!(e.contains(why), "{url}: {e}");
653 }
654 let from = net::parse_url("https://cdn.example.com/a/b.png").unwrap();
655 assert_eq!(
656 redirect(&from, "/c.png").unwrap(),
657 "https://cdn.example.com:443/c.png"
658 );
659 assert_eq!(
660 redirect(&from, "//other.example/x.svg").unwrap(),
661 "https://other.example/x.svg"
662 );
663 assert!(redirect(&from, "http://cdn.example.com/c.png").is_err());
664 assert!(redirect(&from, "c.png").is_err());
665 }
666
667 #[test]
668 fn parses_answers() {
669 let ok = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcdef";
670 assert_eq!(parse(ok).unwrap(), Answer::Body(b"abc".to_vec()));
671 let chunked =
672 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2;x=y\r\nde\r\n0\r\n\r\n";
673 assert_eq!(parse(chunked).unwrap(), Answer::Body(b"abcde".to_vec()));
674 let moved = b"HTTP/1.1 302 Found\r\nLocation: /x.png\r\n\r\n";
675 assert_eq!(parse(moved).unwrap(), Answer::Redirect("/x.png".into()));
676 assert!(parse(b"HTTP/1.1 404 Not Found\r\n\r\n").is_err());
677 assert!(parse(b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\nxx").is_err());
678 assert!(parse(b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\nabc").is_err());
679 assert!(dechunk(b"zz\r\n").is_err());
680 assert!(complete(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nab"));
681 assert!(!complete(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\na"));
682 }
683
684 fn set_age(p: &Path, d: Duration) {
685 let f = std::fs::File::options().write(true).open(p).unwrap();
686 f.set_modified(SystemTime::now() - d).unwrap();
687 }
688
689 fn logos(dir: &Path, answer: Arc<Mutex<Result<Vec<u8>, String>>>) -> (Logos, Arc<AtomicUsize>) {
691 let n = Arc::new(AtomicUsize::new(0));
692 let n2 = n.clone();
693 let l = Logos::with_fetch(
694 dir,
695 Arc::new(move |_u: &str| {
696 n2.fetch_add(1, Ordering::SeqCst);
697 answer.lock().unwrap().clone()
698 }),
699 );
700 (l, n)
701 }
702
703 #[test]
704 fn caches_and_expires() {
705 let dir = tempfile::tempdir().unwrap();
706 let answer = Arc::new(Mutex::new(Ok(PNG.to_vec())));
707 let (l, n) = logos(dir.path(), answer.clone());
708 let url = "https://cdn.example.com/logo.png";
709 assert_eq!(l.get(url).unwrap().kind, Kind::Png);
710 assert_eq!(l.get(url).unwrap().bytes, PNG);
711 assert_eq!(n.load(Ordering::SeqCst), 1, "the second read is a hit");
712 let file = dir.path().join("templates/logos").join(key(url));
713 assert_eq!(std::fs::read(&file).unwrap(), PNG);
714 set_age(&file, FRESH_FOR + Duration::from_secs(1));
716 *answer.lock().unwrap() = Ok(SVG.to_vec());
717 assert_eq!(l.get(url).unwrap().kind, Kind::Svg);
718 assert_eq!(n.load(Ordering::SeqCst), 2);
719 set_age(&file, FRESH_FOR + Duration::from_secs(1));
721 *answer.lock().unwrap() = Err("HTTP 500".into());
722 assert_eq!(l.get(url).unwrap().kind, Kind::Svg);
723 assert_eq!(l.get(url).unwrap().kind, Kind::Svg);
724 assert_eq!(n.load(Ordering::SeqCst), 3);
725 }
726
727 #[test]
728 fn remembers_failures_briefly() {
729 let dir = tempfile::tempdir().unwrap();
730 let answer = Arc::new(Mutex::new(Ok(b"<html>not a logo</html>".to_vec())));
731 let (l, n) = logos(dir.path(), answer.clone());
732 let url = "https://cdn.example.com/x";
733 assert!(l.get(url).is_none());
734 assert!(l.get(url).is_none());
735 assert_eq!(n.load(Ordering::SeqCst), 1);
736 assert!(!dir.path().join("templates/logos").join(key(url)).exists());
737 let failed = dir
738 .path()
739 .join("templates/logos")
740 .join(format!("{}.failed", key(url)));
741 set_age(&failed, FAILED_FOR + Duration::from_secs(1));
742 *answer.lock().unwrap() = Ok(ico());
743 assert_eq!(l.get(url).unwrap().kind, Kind::Ico);
744 assert_eq!(n.load(Ordering::SeqCst), 2);
745 assert!(!failed.exists());
746 }
747
748 #[test]
749 fn serves_with_strict_headers() {
750 let r = Image::of(SVG.to_vec()).unwrap().response();
751 assert_eq!(r.status, 200);
752 assert_eq!(r.get_header("content-type"), Some("image/svg+xml"));
753 assert_eq!(r.get_header("x-content-type-options"), Some("nosniff"));
754 assert_eq!(
755 r.get_header("cache-control"),
756 Some("private, max-age=86400")
757 );
758 assert_eq!(
759 r.get_header("content-security-policy"),
760 Some("default-src 'none'; style-src 'unsafe-inline'; sandbox")
761 );
762 let r = Image::of(PNG.to_vec()).unwrap().response();
763 assert_eq!(r.get_header("content-type"), Some("image/png"));
764 }
765
766 fn req(method: &str, path: &str) -> Request {
767 Request {
768 method: method.into(),
769 path: path.into(),
770 query: None,
771 headers: vec![],
772 body: vec![],
773 peer: Peer::Tcp("127.0.0.1:5000".parse().unwrap()),
774 }
775 }
776
777 #[test]
778 fn route_serves_catalog_logos_to_admitted_callers() {
779 let dir = tempfile::tempdir().unwrap();
780 let catalogs = Arc::new(Catalogs::with_fetch(
781 dir.path(),
782 Arc::new(|_: &str| Err(crate::error::Error::invalid("offline"))),
783 ));
784 let fetched = Arc::new(Mutex::new(Vec::<String>::new()));
785 let f2 = fetched.clone();
786 let logos = Arc::new(Logos::with_fetch(
787 dir.path(),
788 Arc::new(move |u: &str| {
789 f2.lock().unwrap().push(u.to_string());
790 Ok(SVG.to_vec())
791 }),
792 ));
793 let open: Admit = Arc::new(|_| Ok(()));
794 let r = route(catalogs.clone(), logos.clone(), open);
795 let get = |m: &str, p: &str| r(&req(m, p));
796 let ok = get("GET", "/api/v1/templates/builtin/gitea/logo").unwrap();
797 assert_eq!(ok.status, 200);
798 assert_eq!(ok.get_header("content-type"), Some("image/svg+xml"));
799 assert_eq!(ok.body, SVG);
800 let gitea = catalogs.get("builtin/gitea").unwrap().summary.logo.unwrap();
801 assert_eq!(fetched.lock().unwrap().as_slice(), [gitea]);
802 let head = get("HEAD", "/api/v1/templates/builtin/gitea/logo").unwrap();
803 assert_eq!((head.status, head.body.len()), (200, 0));
804 assert_eq!(
806 get("GET", "/api/v1/templates/builtin/whoami/logo")
807 .unwrap()
808 .status,
809 404
810 );
811 assert_eq!(
812 get("GET", "/api/v1/templates/builtin/nope/logo")
813 .unwrap()
814 .status,
815 404
816 );
817 assert_eq!(fetched.lock().unwrap().len(), 1);
818 assert_eq!(
819 get("POST", "/api/v1/templates/builtin/gitea/logo")
820 .unwrap()
821 .status,
822 405
823 );
824 for other in [
825 "/api/v1/templates/gitea/logo",
826 "/api/v1/templates/builtin/a/b/logo",
827 "/api/v1/templates/builtin/gitea",
828 "/api/v1/tools/template_list",
829 ] {
830 assert!(get("GET", other).is_none(), "{other}");
831 }
832 for s in catalogs.list().0 {
834 if let Some(l) = s.logo {
835 assert!(l.starts_with("https://"), "{}: {l}", s.reference);
836 }
837 }
838 let shut: Admit = Arc::new(|_| Err(Response::text(401, "sign in")));
839 let r = route(catalogs, logos, shut);
840 assert_eq!(
841 r(&req("GET", "/api/v1/templates/builtin/gitea/logo"))
842 .unwrap()
843 .status,
844 401
845 );
846 }
847
848 #[test]
849 fn admits_whom_template_list_admits() {
850 let none: Authn = Arc::new(|_, _| Authenticated::None);
851 let refused: Authn = Arc::new(|_, _| Authenticated::Refused);
852 let r = req("GET", "/api/v1/templates/builtin/gitea/logo");
853 assert_eq!(
854 admit(none.clone(), None, false)(&r).unwrap_err().status,
855 401
856 );
857 assert!(admit(none, None, true)(&r).is_ok());
858 assert_eq!(admit(refused, None, true)(&r).unwrap_err().status, 401);
859 }
860}