forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.5 KiB
1//! Serves a repo's label colors at `/{owner}/{repo}/labels.css`. Each label becomes
2//! `.label-<id>` with its background color and a black or white text color chosen for contrast.
3//! The stylesheet is generated from the repo's labels, so no page needs an inline style. Same
4//! visibility as the repo: a hidden repo is 404.
5
6use std::fmt::Write as _;
7
8use forge_core::Label;
9use http::StatusCode;
10use http::header::{
11 CACHE_CONTROL, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_NONE_MATCH,
12 X_CONTENT_TYPE_OPTIONS,
13};
14use sha2::{Digest, Sha256};
15use topcoat::Result;
16use topcoat::context::{Cx, app_context};
17use topcoat::router::request::headers;
18use topcoat::router::{path_param, route};
19
20use crate::pages::repo_ctx::load_repo;
21use crate::state::AppState;
22
23path_param!(owner);
24path_param!(repo);
25
26/// Revalidate on every use: the ETag makes an unchanged stylesheet a cheap 304.
27const CACHE_REVALIDATE: &str = "no-cache";
28
29/// Relative luminance of an sRGB color, as defined by WCAG 2.x.
30fn 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/// Black or white text, whichever gives the higher contrast ratio against the background. The
43/// two ratios are equal at a luminance of about 0.179, so black wins above that.
44pub 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/// `#rrggbb` to its channels. Only six hex digits with a leading `#` are accepted.
53fn 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/// Label ids go into a CSS class name, so only plain identifier characters are kept.
67fn 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/// The stylesheet for a set of labels. Labels with a malformed color or an unsafe id are skipped.
75pub 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 // Writing to a String cannot fail.
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/// True when an `If-None-Match` header lists `etag`, `*`, or its weak form.
96fn 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")]
104async 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)]
137mod 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}