//! Serves a repo's label colors at `/{owner}/{repo}/labels.css`. Each label becomes //! `.label-` with its background color and a black or white text color chosen for contrast. //! The stylesheet is generated from the repo's labels, so no page needs an inline style. Same //! visibility as the repo: a hidden repo is 404. use std::fmt::Write as _; use forge_core::Label; use http::StatusCode; use http::header::{ CACHE_CONTROL, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_NONE_MATCH, X_CONTENT_TYPE_OPTIONS, }; use sha2::{Digest, Sha256}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::request::headers; use topcoat::router::{path_param, route}; use crate::pages::repo_ctx::load_repo; use crate::state::AppState; path_param!(owner); path_param!(repo); /// Revalidate on every use: the ETag makes an unchanged stylesheet a cheap 304. const CACHE_REVALIDATE: &str = "no-cache"; /// Relative luminance of an sRGB color, as defined by WCAG 2.x. fn luminance(r: u8, g: u8, b: u8) -> f64 { let linear = |c: u8| { let c = f64::from(c) / 255.0; if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } }; 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) } /// Black or white text, whichever gives the higher contrast ratio against the background. The /// two ratios are equal at a luminance of about 0.179, so black wins above that. pub fn text_color(r: u8, g: u8, b: u8) -> &'static str { if luminance(r, g, b) > 0.179 { "#000" } else { "#fff" } } /// `#rrggbb` to its channels. Only six hex digits with a leading `#` are accepted. fn parse_hex(color: &str) -> Option<(u8, u8, u8)> { let hex = color.strip_prefix('#')?; if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let value = u32::from_str_radix(hex, 16).ok()?; Some(( u8::try_from(value >> 16).ok()?, u8::try_from((value >> 8) & 0xff).ok()?, u8::try_from(value & 0xff).ok()?, )) } /// Label ids go into a CSS class name, so only plain identifier characters are kept. fn class_safe(id: &str) -> bool { !id.is_empty() && id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } /// The stylesheet for a set of labels. Labels with a malformed color or an unsafe id are skipped. pub fn label_css(labels: &[Label]) -> String { let mut css = String::from("/* Label colors, generated from the repository's labels. */\n"); for label in labels { let Some((r, g, b)) = parse_hex(&label.color) else { continue; }; if !class_safe(&label.id) { continue; } // Writing to a String cannot fail. let _ = writeln!( css, ".label-{} {{ background-color: #{r:02x}{g:02x}{b:02x}; color: {}; }}", label.id, text_color(r, g, b) ); } css } /// True when an `If-None-Match` header lists `etag`, `*`, or its weak form. fn etag_matches(if_none_match: &str, etag: &str) -> bool { if_none_match .split(',') .map(str::trim) .any(|token| token == "*" || token.strip_prefix("W/").unwrap_or(token) == etag) } #[route(GET "/{owner}/{repo}/labels.css")] async fn labels_css(cx: &Cx) -> Result<(StatusCode, HeaderMap, String)> { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let if_none_match = headers(cx) .get(IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) .map(str::to_owned); let state = app_context::(cx); let labels = state.store.list_labels(&ctx.repo.id).await?; let css = label_css(&labels); let etag = format!("\"{}\"", hex::encode(Sha256::digest(css.as_bytes()))); let not_modified = if_none_match .as_deref() .is_some_and(|value| etag_matches(value, &etag)); let mut h = HeaderMap::new(); h.insert( CONTENT_TYPE, HeaderValue::from_static("text/css; charset=utf-8"), ); h.insert(CACHE_CONTROL, HeaderValue::from_static(CACHE_REVALIDATE)); h.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); h.insert(ETAG, HeaderValue::try_from(etag)?); if not_modified { Ok((StatusCode::NOT_MODIFIED, h, String::new())) } else { Ok((StatusCode::OK, h, css)) } } #[cfg(test)] mod tests { use forge_core::{Label, Visibility}; use http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH}; use super::{label_css, text_color}; use crate::pages::issues_list::test_util::{OWNER, REPO, fixture, send}; fn label(id: &str, color: &str) -> Label { Label { id: id.to_owned(), repo_id: "repo".to_owned(), name: id.to_owned(), color: color.to_owned(), description: String::new(), } } #[test] fn contrast_picks_black_or_white() { assert_eq!(text_color(255, 255, 255), "#000"); assert_eq!(text_color(255, 255, 0), "#000"); assert_eq!(text_color(0, 0, 0), "#fff"); assert_eq!(text_color(0, 0, 128), "#fff"); assert_eq!(text_color(136, 136, 136), "#000"); } #[test] fn css_has_colors_and_contrast() { let css = label_css(&[ label("red1", "#ff0000"), label("navy", "#000080"), label("bad", "red"), label("x y", "#123456"), ]); assert!( css.contains(".label-red1 { background-color: #ff0000; color: #000; }"), "{css}" ); assert!( css.contains(".label-navy { background-color: #000080; color: #fff; }"), "{css}" ); assert!(!css.contains(".label-bad"), "{css}"); assert!(!css.contains("x y"), "{css}"); } #[tokio::test] async fn labels_css_headers_and_not_modified() { let fx = fixture(Visibility::Public).await; let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); fx.store .create_label(&repo.id, "bug", "#b60205", "") .await .unwrap(); let url = format!("/{OWNER}/{REPO}/labels.css"); let reply = send(&fx.router, "GET", &url, false, None, &[]).await; assert_eq!(reply.status, 200); assert_eq!( reply.header(CONTENT_TYPE.as_str()).as_deref(), Some("text/css; charset=utf-8") ); assert_eq!( reply.header(CACHE_CONTROL.as_str()).as_deref(), Some("no-cache") ); let etag = reply.header(ETAG.as_str()).expect("etag"); assert!( reply .text() .contains("background-color: #b60205; color: #fff;") ); let reply = send( &fx.router, "GET", &url, false, None, &[(IF_NONE_MATCH.as_str(), etag.as_str())], ) .await; assert_eq!(reply.status, 304); assert!(reply.body.is_empty()); } #[tokio::test] async fn labels_css_is_404_for_hidden_repo() { let fx = fixture(Visibility::Private).await; let reply = send( &fx.router, "GET", &format!("/{OWNER}/{REPO}/labels.css"), false, None, &[], ) .await; assert_eq!(reply.status, 404); } }