| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | use std::fmt::Write as _; |
| 7 | |
| 8 | use forge_core::Label; |
| 9 | use http::StatusCode; |
| 10 | use http::header::{ |
| 11 | CACHE_CONTROL, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_NONE_MATCH, |
| 12 | X_CONTENT_TYPE_OPTIONS, |
| 13 | }; |
| 14 | use sha2::{Digest, Sha256}; |
| 15 | use topcoat::Result; |
| 16 | use topcoat::context::{Cx, app_context}; |
| 17 | use topcoat::router::request::headers; |
| 18 | use topcoat::router::{path_param, route}; |
| 19 | |
| 20 | use crate::pages::repo_ctx::load_repo; |
| 21 | use crate::state::AppState; |
| 22 | |
| 23 | path_param!(owner); |
| 24 | path_param!(repo); |
| 25 | |
| 26 | |
| 27 | const CACHE_REVALIDATE: &str = "no-cache"; |
| 28 | |
| 29 | |
| 30 | fn luminance(r: u8, g: u8, b: u8) -> f64 { |
| 31 | let linear = |c: u8| { |
| 32 | let c = f64::from(c) / 255.0; |
| 33 | if c <= 0.03928 { |
| 34 | c / 12.92 |
| 35 | } else { |
| 36 | ((c + 0.055) / 1.055).powf(2.4) |
| 37 | } |
| 38 | }; |
| 39 | 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) |
| 40 | } |
| 41 | |
| 42 | |
| 43 | |
| 44 | pub fn text_color(r: u8, g: u8, b: u8) -> &'static str { |
| 45 | if luminance(r, g, b) > 0.179 { |
| 46 | "#000" |
| 47 | } else { |
| 48 | "#fff" |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | |
| 53 | fn parse_hex(color: &str) -> Option<(u8, u8, u8)> { |
| 54 | let hex = color.strip_prefix('#')?; |
| 55 | if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 56 | return None; |
| 57 | } |
| 58 | let value = u32::from_str_radix(hex, 16).ok()?; |
| 59 | Some(( |
| 60 | u8::try_from(value >> 16).ok()?, |
| 61 | u8::try_from((value >> 8) & 0xff).ok()?, |
| 62 | u8::try_from(value & 0xff).ok()?, |
| 63 | )) |
| 64 | } |
| 65 | |
| 66 | |
| 67 | fn class_safe(id: &str) -> bool { |
| 68 | !id.is_empty() |
| 69 | && id |
| 70 | .bytes() |
| 71 | .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') |
| 72 | } |
| 73 | |
| 74 | |
| 75 | pub fn label_css(labels: &[Label]) -> String { |
| 76 | let mut css = String::from("/* Label colors, generated from the repository's labels. */\n"); |
| 77 | for label in labels { |
| 78 | let Some((r, g, b)) = parse_hex(&label.color) else { |
| 79 | continue; |
| 80 | }; |
| 81 | if !class_safe(&label.id) { |
| 82 | continue; |
| 83 | } |
| 84 | |
| 85 | let _ = writeln!( |
| 86 | css, |
| 87 | ".label-{} {{ background-color: #{r:02x}{g:02x}{b:02x}; color: {}; }}", |
| 88 | label.id, |
| 89 | text_color(r, g, b) |
| 90 | ); |
| 91 | } |
| 92 | css |
| 93 | } |
| 94 | |
| 95 | |
| 96 | fn etag_matches(if_none_match: &str, etag: &str) -> bool { |
| 97 | if_none_match |
| 98 | .split(',') |
| 99 | .map(str::trim) |
| 100 | .any(|token| token == "*" || token.strip_prefix("W/").unwrap_or(token) == etag) |
| 101 | } |
| 102 | |
| 103 | #[route(GET "/{owner}/{repo}/labels.css")] |
| 104 | async fn labels_css(cx: &Cx) -> Result<(StatusCode, HeaderMap, String)> { |
| 105 | let owner = path_param::<Owner>(cx); |
| 106 | let name = path_param::<Repo>(cx); |
| 107 | let ctx = load_repo(cx, owner, name).await?; |
| 108 | let if_none_match = headers(cx) |
| 109 | .get(IF_NONE_MATCH) |
| 110 | .and_then(|v| v.to_str().ok()) |
| 111 | .map(str::to_owned); |
| 112 | let state = app_context::<AppState>(cx); |
| 113 | |
| 114 | let labels = state.store.list_labels(&ctx.repo.id).await?; |
| 115 | let css = label_css(&labels); |
| 116 | let etag = format!("\"{}\"", hex::encode(Sha256::digest(css.as_bytes()))); |
| 117 | let not_modified = if_none_match |
| 118 | .as_deref() |
| 119 | .is_some_and(|value| etag_matches(value, &etag)); |
| 120 | |
| 121 | let mut h = HeaderMap::new(); |
| 122 | h.insert( |
| 123 | CONTENT_TYPE, |
| 124 | HeaderValue::from_static("text/css; charset=utf-8"), |
| 125 | ); |
| 126 | h.insert(CACHE_CONTROL, HeaderValue::from_static(CACHE_REVALIDATE)); |
| 127 | h.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); |
| 128 | h.insert(ETAG, HeaderValue::try_from(etag)?); |
| 129 | if not_modified { |
| 130 | Ok((StatusCode::NOT_MODIFIED, h, String::new())) |
| 131 | } else { |
| 132 | Ok((StatusCode::OK, h, css)) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | #[cfg(test)] |
| 137 | mod tests { |
| 138 | use forge_core::{Label, Visibility}; |
| 139 | use http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH}; |
| 140 | |
| 141 | use super::{label_css, text_color}; |
| 142 | use crate::pages::issues_list::test_util::{OWNER, REPO, fixture, send}; |
| 143 | |
| 144 | fn label(id: &str, color: &str) -> Label { |
| 145 | Label { |
| 146 | id: id.to_owned(), |
| 147 | repo_id: "repo".to_owned(), |
| 148 | name: id.to_owned(), |
| 149 | color: color.to_owned(), |
| 150 | description: String::new(), |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | #[test] |
| 155 | fn contrast_picks_black_or_white() { |
| 156 | assert_eq!(text_color(255, 255, 255), "#000"); |
| 157 | assert_eq!(text_color(255, 255, 0), "#000"); |
| 158 | assert_eq!(text_color(0, 0, 0), "#fff"); |
| 159 | assert_eq!(text_color(0, 0, 128), "#fff"); |
| 160 | assert_eq!(text_color(136, 136, 136), "#000"); |
| 161 | } |
| 162 | |
| 163 | #[test] |
| 164 | fn css_has_colors_and_contrast() { |
| 165 | let css = label_css(&[ |
| 166 | label("red1", "#ff0000"), |
| 167 | label("navy", "#000080"), |
| 168 | label("bad", "red"), |
| 169 | label("x y", "#123456"), |
| 170 | ]); |
| 171 | assert!( |
| 172 | css.contains(".label-red1 { background-color: #ff0000; color: #000; }"), |
| 173 | "{css}" |
| 174 | ); |
| 175 | assert!( |
| 176 | css.contains(".label-navy { background-color: #000080; color: #fff; }"), |
| 177 | "{css}" |
| 178 | ); |
| 179 | assert!(!css.contains(".label-bad"), "{css}"); |
| 180 | assert!(!css.contains("x y"), "{css}"); |
| 181 | } |
| 182 | |
| 183 | #[tokio::test] |
| 184 | async fn labels_css_headers_and_not_modified() { |
| 185 | let fx = fixture(Visibility::Public).await; |
| 186 | let repo = fx |
| 187 | .store |
| 188 | .repo_by_owner_and_name(OWNER, REPO) |
| 189 | .await |
| 190 | .unwrap() |
| 191 | .unwrap(); |
| 192 | fx.store |
| 193 | .create_label(&repo.id, "bug", "#b60205", "") |
| 194 | .await |
| 195 | .unwrap(); |
| 196 | |
| 197 | let url = format!("/{OWNER}/{REPO}/labels.css"); |
| 198 | let reply = send(&fx.router, "GET", &url, false, None, &[]).await; |
| 199 | assert_eq!(reply.status, 200); |
| 200 | assert_eq!( |
| 201 | reply.header(CONTENT_TYPE.as_str()).as_deref(), |
| 202 | Some("text/css; charset=utf-8") |
| 203 | ); |
| 204 | assert_eq!( |
| 205 | reply.header(CACHE_CONTROL.as_str()).as_deref(), |
| 206 | Some("no-cache") |
| 207 | ); |
| 208 | let etag = reply.header(ETAG.as_str()).expect("etag"); |
| 209 | assert!( |
| 210 | reply |
| 211 | .text() |
| 212 | .contains("background-color: #b60205; color: #fff;") |
| 213 | ); |
| 214 | |
| 215 | let reply = send( |
| 216 | &fx.router, |
| 217 | "GET", |
| 218 | &url, |
| 219 | false, |
| 220 | None, |
| 221 | &[(IF_NONE_MATCH.as_str(), etag.as_str())], |
| 222 | ) |
| 223 | .await; |
| 224 | assert_eq!(reply.status, 304); |
| 225 | assert!(reply.body.is_empty()); |
| 226 | } |
| 227 | |
| 228 | #[tokio::test] |
| 229 | async fn labels_css_is_404_for_hidden_repo() { |
| 230 | let fx = fixture(Visibility::Private).await; |
| 231 | let reply = send( |
| 232 | &fx.router, |
| 233 | "GET", |
| 234 | &format!("/{OWNER}/{REPO}/labels.css"), |
| 235 | false, |
| 236 | None, |
| 237 | &[], |
| 238 | ) |
| 239 | .await; |
| 240 | assert_eq!(reply.status, 404); |
| 241 | } |
| 242 | } |