authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 00:19:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
logfed95315bf3a060a272a75be193773cde19900f9
tree91537bcc0d371f8d34f2d81653d98a0c273b95d4
parent71f9be2ae0fb92155e6bbd66e6207ec4278115ba
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Show Shale guest identities with provider icons and profile pictures

Rewrite guest links to external profiles and redirect direct profile visits. Assisted-by: gpt-6

11 files changed, 793 insertions(+), 21 deletions(-)

dashboard/Cargo.lock+99-5
......@@ -11,6 +11,12 @@ dependencies = [
1111 "memchr",
1212]
1313
14[[package]]
15name = "allocator-api2"
16version = "0.2.21"
17source = "registry+https://github.com/rust-lang/crates.io-index"
18checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19
1420[[package]]
1521name = "android_system_properties"
1622version = "0.1.6"
......@@ -324,19 +330,42 @@ dependencies = [
324330 "typenum",
325331]
326332
333[[package]]
334name = "cssparser"
335version = "0.36.0"
336source = "registry+https://github.com/rust-lang/crates.io-index"
337checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
338dependencies = [
339 "cssparser-macros 0.6.1",
340 "dtoa-short",
341 "itoa",
342 "phf",
343 "smallvec",
344]
345
327346[[package]]
328347name = "cssparser"
329348version = "0.37.0"
330349source = "registry+https://github.com/rust-lang/crates.io-index"
331350checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
332351dependencies = [
333 "cssparser-macros",
352 "cssparser-macros 0.7.1",
334353 "dtoa-short",
335354 "itoa",
336355 "phf",
337356 "smallvec",
338357]
339358
359[[package]]
360name = "cssparser-macros"
361version = "0.6.1"
362source = "registry+https://github.com/rust-lang/crates.io-index"
363checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
364dependencies = [
365 "quote",
366 "syn 2.0.119",
367]
368
340369[[package]]
341370name = "cssparser-macros"
342371version = "0.7.1"
......@@ -503,6 +532,12 @@ version = "0.1.5"
503532source = "registry+https://github.com/rust-lang/crates.io-index"
504533checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
505534
535[[package]]
536name = "foldhash"
537version = "0.2.0"
538source = "registry+https://github.com/rust-lang/crates.io-index"
539checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
540
506541[[package]]
507542name = "foreign-types"
508543version = "0.3.2"
......@@ -694,7 +729,7 @@ version = "0.15.5"
694729source = "registry+https://github.com/rust-lang/crates.io-index"
695730checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
696731dependencies = [
697 "foldhash",
732 "foldhash 0.1.5",
698733]
699734
700735[[package]]
......@@ -702,6 +737,11 @@ name = "hashbrown"
702737version = "0.17.1"
703738source = "registry+https://github.com/rust-lang/crates.io-index"
704739checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
740dependencies = [
741 "allocator-api2",
742 "equivalent",
743 "foldhash 0.2.0",
744]
705745
706746[[package]]
707747name = "hashlink"
......@@ -729,6 +769,7 @@ dependencies = [
729769 "chrono",
730770 "futures",
731771 "globset",
772 "lol_html",
732773 "openssl",
733774 "rand 0.9.5",
734775 "regex",
......@@ -1080,6 +1121,25 @@ version = "0.4.34"
10801121source = "registry+https://github.com/rust-lang/crates.io-index"
10811122checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
10821123
1124[[package]]
1125name = "lol_html"
1126version = "3.0.1"
1127source = "registry+https://github.com/rust-lang/crates.io-index"
1128checksum = "5adbb62638edf7e6bc88835cd3ea388bdd53382af42045da0e414ea78aa0c91a"
1129dependencies = [
1130 "bitflags",
1131 "cfg-if",
1132 "cssparser 0.36.0",
1133 "encoding_rs",
1134 "foldhash 0.2.0",
1135 "hashbrown 0.17.1",
1136 "memchr",
1137 "mime",
1138 "precomputed-hash",
1139 "selectors 0.37.0",
1140 "thiserror 2.0.21",
1141]
1142
10831143[[package]]
10841144name = "lru-slab"
10851145version = "0.1.3"
......@@ -1649,12 +1709,14 @@ dependencies = [
16491709 "sync_wrapper",
16501710 "tokio",
16511711 "tokio-rustls",
1712 "tokio-util",
16521713 "tower",
16531714 "tower-http",
16541715 "tower-service",
16551716 "url",
16561717 "wasm-bindgen",
16571718 "wasm-bindgen-futures",
1719 "wasm-streams",
16581720 "web-sys",
16591721 "webpki-roots",
16601722]
......@@ -1836,14 +1898,33 @@ version = "0.27.0"
18361898source = "registry+https://github.com/rust-lang/crates.io-index"
18371899checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
18381900dependencies = [
1839 "cssparser",
1901 "cssparser 0.37.0",
18401902 "ego-tree",
18411903 "html5ever",
18421904 "precomputed-hash",
1843 "selectors",
1905 "selectors 0.38.0",
18441906 "tendril",
18451907]
18461908
1909[[package]]
1910name = "selectors"
1911version = "0.37.0"
1912source = "registry+https://github.com/rust-lang/crates.io-index"
1913checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9"
1914dependencies = [
1915 "bitflags",
1916 "cssparser 0.36.0",
1917 "derive_more",
1918 "log",
1919 "new_debug_unreachable",
1920 "phf",
1921 "phf_codegen",
1922 "precomputed-hash",
1923 "rustc-hash",
1924 "servo_arc",
1925 "smallvec",
1926]
1927
18471928[[package]]
18481929name = "selectors"
18491930version = "0.38.0"
......@@ -1851,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
18511932checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
18521933dependencies = [
18531934 "bitflags",
1854 "cssparser",
1935 "cssparser 0.37.0",
18551936 "derive_more",
18561937 "log",
18571938 "new_debug_unreachable",
......@@ -2582,6 +2663,19 @@ dependencies = [
25822663 "unicode-ident",
25832664]
25842665
2666[[package]]
2667name = "wasm-streams"
2668version = "0.4.2"
2669source = "registry+https://github.com/rust-lang/crates.io-index"
2670checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
2671dependencies = [
2672 "futures-util",
2673 "js-sys",
2674 "wasm-bindgen",
2675 "wasm-bindgen-futures",
2676 "web-sys",
2677]
2678
25852679[[package]]
25862680name = "web-sys"
25872681version = "0.3.106"
dashboard/Cargo.toml+2-1
......@@ -13,8 +13,9 @@ futures = "0.3"
1313globset = "0.4"
1414openssl = "0.10"
1515rand = "0.9"
16lol_html = "3"
1617regex = "1"
17reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
18reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart", "stream"] }
1819rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
1920rusqlite = { version = "0.37", features = ["bundled"] }
2021serde = { version = "1", features = ["derive"] }
dashboard/src/astheno.svg created+50
......@@ -0,0 +1,50 @@
1<!--
2ISC License
3
4Copyright (c) 2026 Lucide Icons and Contributors
5
6Permission to use, copy, modify, and/or distribute this software for any
7purpose with or without fee is hereby granted, provided that the above
8copyright notice and this permission notice appear in all copies.
9
10THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
11WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
12MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
13ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
15ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
16OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17
18---
19
20The following Lucide icons are derived from the Feather project:
21
22airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
23
24The MIT License (MIT) (for the icons listed above)
25
26Copyright (c) 2013-present Cole Bemis
27
28Permission is hereby granted, free of charge, to any person obtaining a copy
29of this software and associated documentation files (the "Software"), to deal
30in the Software without restriction, including without limitation the rights
31to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
32copies of the Software, and to permit persons to whom the Software is
33furnished to do so, subject to the following conditions:
34
35The above copyright notice and this permission notice shall be included in all
36copies or substantial portions of the Software.
37
38THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
39IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
40FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
41AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
42LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
43OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
44SOFTWARE.
45-->
46<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
47 <path d="M11.264 2.205A4 4 0 0 0 6.42 4.211l-4 8a4 4 0 0 0 1.359 5.117l6 4a4 4 0 0 0 4.438 0l6-4a4 4 0 0 0 1.576-4.592l-2-6a4 4 0 0 0-2.53-2.53z" />
48 <path d="M11.99 22 14 12l7.822 3.184" />
49 <path d="M14 12 8.47 2.302" />
50 </svg>
dashboard/src/auth.rs+3
......@@ -560,6 +560,9 @@ impl Store {
560560}
561561
562562pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
563 if request.uri().path() == "/auth/shale/page" {
564 return shale_page::proxy(app, request).await;
565 }
563566 if request.uri().path().starts_with("/auth/guest/") {
564567 return Ok(guest::route(State(app), request).await);
565568 }
dashboard/src/guest.rs+54-11
......@@ -299,7 +299,7 @@ async fn exchange(
299299 code: &str,
300300 callback: &str,
301301 flow: &Value,
302) -> Result<(String, String)> {
302) -> Result<(String, String, Option<String>)> {
303303 let form = [
304304 ("client_id", client),
305305 ("client_secret", secret),
......@@ -356,7 +356,7 @@ async fn exchange(
356356 "GitHub couldn't verify your account. Return to Shale and try again.",
357357 )
358358 })?;
359 return Ok((id.to_string(), login.to_owned()));
359 return Ok((id.to_string(), login.to_owned(), None));
360360 }
361361 let mut form = form.to_vec();
362362 form.push(("state", "none"));
......@@ -430,10 +430,27 @@ async fn exchange(
430430 .chars()
431431 .take(128)
432432 .collect();
433 Ok((string(&profile["sub"]).to_owned(), name))
433 let picture = profile["picture"]
434 .as_str()
435 .filter(|value| value.len() <= 2048)
436 .and_then(|value| url::Url::parse(value).ok())
437 .filter(|url| {
438 url.scheme() == "https"
439 && url.host_str().is_some()
440 && url.username().is_empty()
441 && url.password().is_none()
442 })
443 .map(String::from);
444 Ok((string(&profile["sub"]).to_owned(), name, picture))
434445}
435446
436fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Result<String> {
447fn account(
448 auth: &auth::Store,
449 provider: &str,
450 subject: &str,
451 name: &str,
452 picture: Option<&str>,
453) -> Result<String> {
437454 let mut db = auth.db.lock().unwrap();
438455 let tx = db.transaction()?;
439456 let existing: Option<String> = tx
......@@ -451,6 +468,11 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res
451468 "This guest account is disabled. Contact Clover.",
452469 ));
453470 }
471 tx.execute(
472 "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",
473 sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id],
474 )?;
475 tx.commit()?;
454476 return Ok(id);
455477 }
456478 let id = uuid::Uuid::new_v4().to_string();
......@@ -459,7 +481,10 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res
459481 } else {
460482 mcp::hash(subject)[..24].to_owned()
461483 };
462 let profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"attributes":{},"createdTimestamp":(now()*1000.0) as i64});
484 let mut profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"attributes":{},"createdTimestamp":(now()*1000.0) as i64});
485 if let Some(picture) = picture {
486 profile["attributes"]["picture"] = json!([picture]);
487 }
463488 tx.execute(
464489 "INSERT INTO users(id,profile) VALUES (?,?)",
465490 sql![id, profile.to_string()],
......@@ -598,9 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> {
598623 headers
599624 })
600625 .build()?;
601 let (subject, name) =
626 let (subject, name, picture) =
602627 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;
603 let id = account(auth, provider, &subject, &name)?;
628 let id = account(auth, provider, &subject, &name, picture.as_deref())?;
604629 auth.create_session(&id, "dashboard", headers, None)
605630 }
606631 .await;
......@@ -781,16 +806,34 @@ mod tests {
781806 db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", [])
782807 .unwrap();
783808 }
784 let first = account(&auth, "github", "123", "clover").unwrap();
785 let repeat = account(&auth, "github", "123", "renamed").unwrap();
786 let other = account(&auth, "astheno", "123", "clover").unwrap();
809 let first = account(&auth, "github", "123", "clover", None).unwrap();
810 let repeat = account(&auth, "github", "123", "renamed", None).unwrap();
811 let other = account(
812 &auth,
813 "astheno",
814 "123",
815 "clover",
816 Some("https://identity.astheno.software/avatar/123"),
817 )
818 .unwrap();
787819 assert_eq!(first, repeat);
788820 assert_ne!(first, "owner");
789821 assert_ne!(first, other);
822 assert_eq!(
823 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],
824 "https://identity.astheno.software/avatar/123"
825 );
826 account(&auth, "astheno", "123", "clover", None).unwrap();
827 assert!(
828 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]
829 .get("picture")
830 .is_none()
831 );
790832 {
791833 let db = auth.db.lock().unwrap();
792834 let profile = auth::user(&db, &first).unwrap();
793835 assert!(is_guest(&profile));
836 assert_eq!(profile["firstName"], "renamed");
794837 assert!(profile["email"].is_null());
795838 assert_eq!(profile["groups"], json!([]));
796839 assert!(
......@@ -811,7 +854,7 @@ mod tests {
811854 )
812855 .unwrap();
813856 }
814 assert!(account(&auth, "github", "123", "clover").is_err());
857 assert!(account(&auth, "github", "123", "clover", None).is_err());
815858 assert!(
816859 auth.create_session(&other, "file", &HeaderMap::new(), None)
817860 .is_err()
dashboard/src/main.rs+4
......@@ -12,6 +12,7 @@ mod observability;
1212mod oidc;
1313mod relay;
1414mod shale;
15mod shale_page;
1516mod storage;
1617mod telemetry;
1718mod users;
......@@ -527,6 +528,9 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
527528 }
528529 request.headers_mut().remove("Studio-Proxy-Token");
529530 }
531 if request.uri().path() == "/auth/shale/page" {
532 return next.run(request).await;
533 }
530534 let path = request.uri().path().to_owned();
531535 let asset = path.starts_with("/assets/")
532536 || path.starts_with("/fonts/")
dashboard/src/shale.rs+9-1
......@@ -326,7 +326,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
326326 )
327327 })?;
328328 let author = comment
329 .select(&Selector::parse(".n-card__header a[href^='/~']").unwrap())
329 .select(&Selector::parse(".n-card__header a[href^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a[href^='https://identity.astheno.software/user/']").unwrap())
330330 .next()
331331 .map(text);
332332 let time = comment
......@@ -935,6 +935,14 @@ mod tests {
935935 assert!(issue(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), "owned", Some(3)).is_err());
936936 assert!(issue(&page.replace("Issue #3", "Issue #0"), "owned", None).is_err());
937937 }
938 #[test]
939 fn issue_comment_authors_include_external_guest_profiles() {
940 let page = "<head><meta name='astheno.shale.repo.name' content='owned'></head><body id=page-issue><h1><span>#3</span><span>Title</span></h1><dl class=sidebar><dd><span class=issuestatus-done>Done</span></dd></dl><ul><li class=comment id=c1><div class=n-card__header><a href='https://github.com/paperclover'>paperclover</a></div><div class=markdown>Comment</div></li><li class=comment id=c2><div class=n-card__header><a href='https://identity.astheno.software/user/123'>Astheno user</a></div><div class=markdown>Comment</div></li></ul></body>";
941 let parsed = issue(page, "owned", Some(3)).unwrap();
942 assert_eq!(parsed["comments"][0]["author"], "paperclover");
943 assert_eq!(parsed["comments"][1]["author"], "Astheno user");
944 }
945
938946 #[test]
939947 fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() {
940948 assert_eq!(
dashboard/src/shale_page.rs created+336
......@@ -0,0 +1,336 @@
1use crate::*;
2use axum::body::Body;
3use lol_html::{RewriteStrSettings, element, html_content::ContentType, text};
4
5struct Profile {
6 name: String,
7 url: String,
8 icon: &'static str,
9 picture: Option<String>,
10}
11
12fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
13 let db = auth.db.lock().unwrap();
14 let mut query = db.prepare("SELECT json_extract(profile,'$.username'),coalesce(json_extract(profile,'$.firstName'),''),provider,subject,json_extract(profile,'$.attributes.picture[0]') FROM users JOIN external_identities ON user_id=users.id WHERE json_extract(profile,'$.kind')='guest'")?;
15 let rows = query.query_map([], |row| {
16 Ok((
17 row.get::<_, String>(0)?,
18 row.get::<_, String>(1)?,
19 row.get::<_, String>(2)?,
20 row.get::<_, String>(3)?,
21 row.get::<_, Option<String>>(4)?,
22 ))
23 })?;
24 let mut profiles = HashMap::new();
25 for row in rows {
26 let (username, name, provider, subject, picture) = row?;
27 let (mut url, id, icon) = match provider.as_str() {
28 "github" => (
29 url::Url::parse("https://github.com/")?,
30 name.as_str(),
31 include_str!("../web/sso/github.svg"),
32 ),
33 "astheno" => (
34 url::Url::parse("https://identity.astheno.software/user/")?,
35 subject.as_str(),
36 include_str!("astheno.svg"),
37 ),
38 _ => continue,
39 };
40 if name.is_empty() || id.is_empty() || matches!(id, "." | "..") {
41 continue;
42 }
43 url.path_segments_mut().unwrap().pop_if_empty().push(id);
44 profiles.insert(
45 username,
46 Profile {
47 name,
48 url: url.into(),
49 icon,
50 picture: if provider == "github" {
51 Some(format!(
52 "https://avatars.githubusercontent.com/u/{subject}?s=64"
53 ))
54 } else {
55 picture
56 },
57 },
58 );
59 }
60 Ok(profiles)
61}
62
63fn account_path(path: &str) -> Option<&str> {
64 let name = path.strip_prefix("/~")?;
65 let name = name.strip_suffix('/').unwrap_or(name);
66 (name.starts_with("guest-") && !name.contains('/')).then_some(name)
67}
68
69fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -> Result<String> {
70 use std::{cell::Cell, rc::Rc};
71 let active = Rc::new(Cell::new(None::<bool>));
72 let images = active.clone();
73 let labels = active.clone();
74 Ok(lol_html::rewrite_str(html, RewriteStrSettings::new()
75 .append_element_content_handler(element!("a[href]", |element| {
76 let profile = element.get_attribute("href")
77 .and_then(|href| origin.join(&href).ok())
78 .filter(|target| target.origin() == origin.origin())
79 .and_then(|target| account_path(target.path()).and_then(|name| profiles.get(name)));
80 active.set(profile.map(|profile| profile.picture.is_some()));
81 let Some(profile) = profile else { return Ok(()); };
82 let closing = active.clone();
83 element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?;
84 element.set_attribute("href", &profile.url)?;
85 element.set_attribute("target", "_blank")?;
86 element.set_attribute("rel", "noreferrer")?;
87 if let Some(picture) = &profile.picture {
88 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
89 element.prepend(&format!("<img src=\"{picture}\" alt=\"\" width=\"16\" height=\"16\" referrerpolicy=\"no-referrer\" style=\"width:1em;height:1em;object-fit:cover;vertical-align:-.125em;margin-right:.3em\">"), ContentType::Html);
90 }
91 let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"width:1em;height:1em;vertical-align:-.125em;margin-right:.3em\" ");
92 let icon = if profile.url.starts_with("https://github.com/") { icon.replace("<path ", "<path fill=\"currentColor\" ") } else { icon };
93 element.append(&icon, ContentType::Html);
94 element.append(&profile.name, ContentType::Text);
95 Ok(())
96 }))
97 .append_element_content_handler(element!("a[href] img, a[href] span", move |child| {
98 if let Some(replace_picture) = images.get() {
99 if child.tag_name() == "span" { child.remove_and_keep_content(); }
100 else if replace_picture { child.remove(); }
101 else {
102 child.set_attribute("alt", "")?;
103 child.set_attribute("referrerpolicy", "no-referrer")?;
104 }
105 }
106 Ok(())
107 }))
108 .append_element_content_handler(text!("a[href]", move |text| {
109 if labels.get().is_some() { text.remove(); }
110 Ok(())
111 })))?)
112}
113
114fn strip_hop_headers(headers: &mut HeaderMap) {
115 let named: Vec<_> = headers
116 .get_all("connection")
117 .iter()
118 .filter_map(|v| v.to_str().ok())
119 .flat_map(|v| v.split(','))
120 .map(|v| v.trim().to_owned())
121 .collect();
122 for name in named {
123 headers.remove(name);
124 }
125 for name in [
126 "connection",
127 "keep-alive",
128 "proxy-authenticate",
129 "proxy-authorization",
130 "te",
131 "trailer",
132 "transfer-encoding",
133 "upgrade",
134 ] {
135 headers.remove(name);
136 }
137}
138
139pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {
140 let (mut parts, body) = request.into_parts();
141 let host = parts
142 .headers
143 .get("host")
144 .and_then(|v| v.to_str().ok())
145 .unwrap_or_default();
146 let domain = env("STUDIO_DOMAIN", "studio.test");
147 let service = host.strip_suffix(&format!(".{domain}")).unwrap_or_default();
148 if service != "shale"
149 && !(service
150 .strip_prefix("shale-preview-")
151 .is_some_and(|id| id.len() == 8 && id.bytes().all(|b| b.is_ascii_hexdigit())))
152 {
153 return Err(Error::new(403, "Open Shale to continue."));
154 }
155 let upstream: std::net::SocketAddr = parts
156 .headers
157 .get("studio-shale-upstream")
158 .and_then(|v| v.to_str().ok())
159 .ok_or_else(|| Error::new(403, "Open Shale to continue."))?
160 .parse()?;
161 let uri: axum::http::Uri = parts
162 .headers
163 .get("studio-shale-uri")
164 .and_then(|v| v.to_str().ok())
165 .ok_or_else(|| Error::new(403, "Open Shale to continue."))?
166 .parse()?;
167 if uri.scheme().is_some() || uri.authority().is_some() || !uri.path().starts_with('/') {
168 return Err(Error::new(403, "Open Shale to continue."));
169 }
170 let origin = url::Url::parse(&format!("https://{host}{uri}"))?;
171 let profiles = profiles(&app.auth)?;
172 if matches!(parts.method, Method::GET | Method::HEAD)
173 && let Some(profile) = account_path(uri.path()).and_then(|name| profiles.get(name))
174 {
175 return Ok((
176 StatusCode::FOUND,
177 [
178 ("location", profile.url.as_str()),
179 ("cache-control", "no-store"),
180 ("referrer-policy", "no-referrer"),
181 ],
182 )
183 .into_response());
184 }
185 strip_hop_headers(&mut parts.headers);
186 for name in [
187 "studio-shale-upstream",
188 "studio-shale-uri",
189 "studio-proxy-token",
190 "user-name",
191 "user-groups",
192 "user-id",
193 "if-none-match",
194 "if-modified-since",
195 "accept-encoding",
196 ] {
197 parts.headers.remove(name);
198 }
199 parts
200 .headers
201 .insert("accept-encoding", "identity".parse().unwrap());
202 static HTTP: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
203 reqwest::Client::builder()
204 .connect_timeout(Duration::from_secs(5))
205 .read_timeout(Duration::from_secs(30))
206 .redirect(reqwest::redirect::Policy::none())
207 .retry(reqwest::retry::never())
208 .build()
209 .unwrap()
210 });
211 let mut upstream_response = HTTP
212 .request(parts.method.clone(), format!("http://{upstream}{uri}"))
213 .headers(parts.headers)
214 .body(reqwest::Body::wrap_stream(body.into_data_stream()))
215 .send()
216 .await?;
217 let status = upstream_response.status();
218 let mut headers = upstream_response.headers().clone();
219 strip_hop_headers(&mut headers);
220 let html = parts.method != Method::HEAD
221 && headers
222 .get("content-type")
223 .and_then(|v| v.to_str().ok())
224 .is_some_and(|v| {
225 v.split(';')
226 .next()
227 .unwrap_or_default()
228 .trim()
229 .eq_ignore_ascii_case("text/html")
230 })
231 && headers
232 .get("content-encoding")
233 .is_none_or(|v| v == "identity");
234 let mut prefix = Vec::new();
235 if html {
236 while let Some(chunk) = upstream_response.chunk().await? {
237 prefix.extend_from_slice(&chunk);
238 if prefix.len() > 8 * 1024 * 1024 {
239 break;
240 }
241 }
242 if prefix.len() <= 8 * 1024 * 1024
243 && let Ok(text) = std::str::from_utf8(&prefix)
244 {
245 let rewritten = rewrite(text, &origin, &profiles)?;
246 for name in [
247 "content-length",
248 "etag",
249 "last-modified",
250 "content-md5",
251 "digest",
252 "content-digest",
253 "accept-ranges",
254 ] {
255 headers.remove(name);
256 }
257 headers.insert("cache-control", "no-store".parse().unwrap());
258 return Ok((status, headers, rewritten).into_response());
259 }
260 }
261 let stream = futures::stream::once(async move { Ok::<_, reqwest::Error>(Bytes::from(prefix)) })
262 .chain(upstream_response.bytes_stream());
263 Ok((status, headers, Body::from_stream(stream)).into_response())
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use scraper::{Html, Selector};
270
271 #[test]
272 fn rewrites_only_known_local_account_links_and_escapes_provider_names() {
273 let origin = url::Url::parse("https://shale.paperclover.net/").unwrap();
274 let profiles = HashMap::from([
275 (
276 "guest-github-123".into(),
277 Profile {
278 name: "<&\"clover".into(),
279 url: "https://github.com/clover".into(),
280 icon: include_str!("../web/sso/github.svg"),
281 picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),
282 },
283 ),
284 (
285 "guest-astheno-abc".into(),
286 Profile {
287 name: "Astheno user".into(),
288 url: "https://identity.astheno.software/user/123".into(),
289 icon: include_str!("astheno.svg"),
290 picture: None,
291 },
292 ),
293 ]);
294 let html = r#"<a class="usa-nav-link" href="/~guest-github-123"><img src="avatar"><span>~guest-github-123</span></a><a href="https://shale.paperclover.net/~guest-astheno-abc/"><img src="astheno-avatar" alt="avatar">guest</a><a href="/~guest-github-unknown">unknown</a><a href="https://other.example/~guest-github-123">external</a><a href="/~guest-github-123/repo">repo</a><pre>~guest-github-123</pre>"#;
295 let rewritten = rewrite(html, &origin, &profiles).unwrap();
296 let page = Html::parse_document(&rewritten);
297 let links: Vec<_> = page.select(&Selector::parse("a").unwrap()).collect();
298 for link in &links[..2] {
299 assert_eq!(link.attr("target"), Some("_blank"));
300 assert_eq!(link.attr("rel"), Some("noreferrer"));
301 assert_eq!(
302 link.select(&Selector::parse("svg[aria-hidden=true]").unwrap())
303 .count(),
304 1
305 );
306 }
307 assert_eq!(
308 links[1]
309 .select(&Selector::parse("img").unwrap())
310 .next()
311 .unwrap()
312 .attr("src"),
313 Some("astheno-avatar")
314 );
315 assert_eq!(links[0].select(&Selector::parse("img").unwrap()).count(), 1);
316 assert_eq!(
317 links[0]
318 .select(&Selector::parse("img").unwrap())
319 .next()
320 .unwrap()
321 .attr("src"),
322 Some("https://avatars.githubusercontent.com/u/123?s=64")
323 );
324 assert_eq!(links[0].text().collect::<String>().trim(), "<&\"clover");
325 assert_eq!(links[0].attr("class"), Some("usa-nav-link"));
326 assert_eq!(
327 links[1].attr("href"),
328 Some("https://identity.astheno.software/user/123")
329 );
330 for link in &links[2..] {
331 assert_eq!(link.attr("target"), None);
332 }
333 assert!(rewritten.contains("<pre>~guest-github-123</pre>"));
334 assert_eq!(account_path("/~guest-github-123//"), None);
335 }
336}
nixos/dashboard.nix+1-1
......@@ -38,7 +38,7 @@ let
3838 src = lib.fileset.toSource {
3939 root = ../dashboard;
4040 fileset = lib.fileset.unions [
41 ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock
41 ../dashboard/src ../dashboard/web/sso/github.svg ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock
4242 ../dashboard/agent/install.sh ../dashboard/agent/install.ps1
4343 ];
4444 };
tools/dashboard-shale-page-test.py created+220
......@@ -0,0 +1,220 @@
1#!/usr/bin/env python3
2import argparse
3from contextlib import ExitStack
4from html.parser import HTMLParser
5from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6import json
7import os
8from pathlib import Path
9import socket
10import sqlite3
11import subprocess
12import tempfile
13import threading
14import time
15import urllib.error
16import urllib.request
17
18import router
19
20
21class Links(HTMLParser):
22 def __init__(self, html):
23 super().__init__()
24 self.links = []
25 self.feed(html)
26
27 def handle_starttag(self, tag, attrs):
28 if tag == 'a':
29 self.links.append(dict(attrs))
30
31
32def port():
33 with socket.socket() as sock:
34 sock.bind(('127.0.0.1', 0))
35 return sock.getsockname()[1]
36
37
38def main():
39 parser = argparse.ArgumentParser()
40 parser.add_argument('--caddy', required=True, type=Path)
41 parser.add_argument('--binary', type=Path, default=Path(__file__).resolve().parent.parent / 'dashboard/target/debug/home-dashboard')
42 parser.add_argument('--page', type=Path)
43 parser.add_argument('--browser-ready-file', type=Path)
44 args = parser.parse_args()
45 repo = Path(__file__).resolve().parent.parent
46 observations = []
47 issue = args.page.read_bytes() if args.page else b'<html><head></head><body><a href="/~guest-github-24465214"><img src="avatar"><span>~guest-github-24465214</span></a></body></html>'
48 sample = b'<a href="/~guest-astheno-abc">guest</a><a href="/~guest-github-unknown">unknown</a><a href="/~guest-github-24465214/repo">repo</a>'
49 blob = bytes(range(256)) * 65536
50
51 class Fixture(BaseHTTPRequestHandler):
52 protocol_version = 'HTTP/1.1'
53
54 def do_GET(self):
55 observations.append((self.command, self.path, dict(self.headers)))
56 if self.path == '/binary':
57 return self.respond(blob, 'application/octet-stream')
58 if self.path == '/large':
59 return self.respond(b' ' * (9 * 1024 * 1024), 'text/html')
60 if self.path == '/redirect':
61 return self.respond(b'', 'text/plain', 302, [('Location', '/snowbound/issues/26'), ('Set-Cookie', 'SessionID=new; Path=/; HttpOnly'), ('Set-Cookie', 'other=kept; Path=/')])
62 if self.path.startswith('/-/') and args.page:
63 with urllib.request.urlopen('https://shale.paperclover.net' + self.path, timeout=15) as response:
64 return self.respond(response.read(), response.headers['Content-Type'])
65 return self.respond(issue + sample, 'text/html; charset=utf-8', headers=[('ETag', '"stale"'), ('Last-Modified', 'Mon, 05 Oct 2026 00:00:00 GMT')])
66
67 def do_HEAD(self):
68 self.respond(b'', 'text/html')
69
70 def do_POST(self):
71 body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
72 observations.append((self.command, self.path, dict(self.headers), body))
73 self.respond(body, 'application/octet-stream', 201)
74
75 def respond(self, body, content_type, status=200, headers=()):
76 self.send_response(status)
77 self.send_header('Content-Type', content_type)
78 self.send_header('Content-Length', str(len(body)))
79 for key, value in headers:
80 self.send_header(key, value)
81 self.end_headers()
82 self.wfile.write(body)
83
84 def log_message(self, *args):
85 pass
86
87 class NoRedirect(urllib.request.HTTPRedirectHandler):
88 def redirect_request(self, *args):
89 return None
90
91 opener = urllib.request.build_opener(NoRedirect)
92 with tempfile.TemporaryDirectory(prefix='shale-page-') as temporary, ExitStack() as stack:
93 root = Path(temporary).resolve()
94 fixture = ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
95 stack.callback(fixture.server_close)
96 stack.callback(fixture.shutdown)
97 threading.Thread(target=fixture.serve_forever, daemon=True).start()
98 dashboard_port, gateway_port = port(), port()
99 proof = 'a' * 64
100 token = root / 'proxy.token'
101 token.write_text(proof)
102 data = root / 'data'
103 environment = {**os.environ, 'PORT': str(dashboard_port), 'STUDIO_DOMAIN': 'studio.test', 'STUDIO_DATA_DIR': str(data), 'STUDIO_PROXY_TOKEN_FILE': str(token), 'STUDIO_REPO': str(repo), 'STUDIO_WEB_DIR': str(repo / 'dashboard/dist')}
104 for key in ['STUDIO_INTERNAL_URL', 'STUDIO_INDEX_POOL', 'STUDIO_AUTH_REQUIRED', 'STUDIO_YT_STATE']:
105 environment.pop(key, None)
106 log = stack.enter_context((root / 'dashboard.log').open('w'))
107 dashboard = subprocess.Popen([str(args.binary)], env=environment, stdout=log, stderr=log)
108 stack.callback(lambda: dashboard.wait(timeout=10))
109 stack.callback(dashboard.terminate)
110 deadline = time.monotonic() + 15
111 while True:
112 assert dashboard.poll() is None, (root / 'dashboard.log').read_text()
113 try:
114 with socket.create_connection(('127.0.0.1', dashboard_port), timeout=.1):
115 break
116 except OSError:
117 assert time.monotonic() < deadline
118 time.sleep(.05)
119 with sqlite3.connect(data / 'accounts.sqlite') as db:
120 for provider, subject, suffix, name in [('github', '24465214', '24465214', 'paperclover'), ('astheno', '00653DG7HZ7MCGTW2BPG7RMGQB', 'abc', 'Astheno user'), ('github', '777', '777', None)]:
121 username = f'guest-{provider}-{suffix}'
122 profile = {'kind': 'guest', 'enabled': True, 'username': username, 'guestProvider': provider, 'firstName': name}
123 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)])
124 db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username])
125 os.environ.update(STUDIO_DOMAIN='studio.test', STUDIO_DASHBOARD_PORT=str(dashboard_port), STUDIO_PROXY_TOKEN_FILE=str(token))
126 router.ROUTE_DIR = str(root / 'routes')
127 Path(router.ROUTE_DIR).mkdir()
128 (Path(router.ROUTE_DIR) / 'shale.json').write_text(json.dumps({'headHtml': {'shale.studio.test': {'/snowbound/*': '<meta name="rewrite-test" content="kept">'}}}))
129
130 def nomad(path, token):
131 if path == '/v1/services':
132 return [{'Namespace': 'default', 'Services': [{'ServiceName': 'shale'}, {'ServiceName': 'shale-preview-12345678'}]}]
133 if path.startswith('/v1/service/'):
134 service = path.rsplit('/', 1)[1]
135 host = 'shale.studio.test' if service == 'shale' else service + '.studio.test'
136 return [{'Address': '127.0.0.1', 'Port': fixture.server_port, 'ServiceName': service, 'AllocID': service, 'JobID': service, 'Tags': ['caddy-host=' + host]}]
137 return {'ready': {'Status': 'success'}}
138
139 router.nomad = nomad
140 rendered = router.render('fixture')
141 full_config = root / 'routes.caddy'
142 full_config.write_text('{\n admin off\n auto_https off\n}\n' + rendered)
143 caddy_env = {**os.environ, 'XDG_DATA_HOME': str(root / 'caddy-data'), 'XDG_CONFIG_HOME': str(root / 'caddy-config')}
144 subprocess.run([str(args.caddy), 'validate', '--config', str(full_config), '--adapter', 'caddyfile'], check=True, capture_output=True, env=caddy_env)
145 def local_site(host, listener):
146 start = rendered.index(host + ' {')
147 end = rendered.index('\n}', start) + 2
148 return rendered[start:end].replace(host + ' {', f'http://127.0.0.1:{listener} {{', 1).replace(' tls internal\n', '').replace(' route {\n', f' route {{\n request_header Host {host}\n', 1)
149
150 preview_port = port()
151 config = '{\n admin off\n auto_https off\n}\n' + local_site('shale.studio.test', gateway_port) + '\n' + local_site('shale-preview-12345678.studio.test', preview_port)
152 config_path = root / 'Caddyfile'
153 config_path.write_text(config)
154 subprocess.run([str(args.caddy), 'validate', '--config', str(config_path), '--adapter', 'caddyfile'], check=True, capture_output=True, env=caddy_env)
155 caddy_log = stack.enter_context((root / 'caddy.log').open('w'))
156 caddy = subprocess.Popen([str(args.caddy), 'run', '--config', str(config_path), '--adapter', 'caddyfile'], stdout=caddy_log, stderr=caddy_log, env=caddy_env)
157 stack.callback(lambda: caddy.wait(timeout=10))
158 stack.callback(caddy.terminate)
159 origin = f'http://127.0.0.1:{gateway_port}'
160
161 def request(path, *, headers=None, body=None, direct=False):
162 supplied = {'Cookie': 'SessionID=preserved', 'Origin': 'https://shale.studio.test', **(headers or {})}
163 url = (f'http://127.0.0.1:{dashboard_port}' if direct else origin) + path
164 try:
165 response = opener.open(urllib.request.Request(url, headers=supplied, data=body), timeout=15)
166 except urllib.error.HTTPError as error:
167 response = error
168 with response:
169 return response.status, response.headers, response.read()
170
171 deadline = time.monotonic() + 15
172 while True:
173 try:
174 status, headers, body = request('/snowbound/issues/26?query=kept')
175 break
176 except urllib.error.URLError:
177 assert time.monotonic() < deadline and caddy.poll() is None
178 time.sleep(.05)
179 assert status == 200, (status, body, (root / 'dashboard.log').read_text(), (root / 'caddy.log').read_text())
180 links = Links(body.decode()).links
181 for url in ['https://github.com/paperclover', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB']:
182 link = next(link for link in links if link['href'] == url)
183 assert link['target'] == '_blank' and link['rel'] == 'noreferrer', link
184 assert any(link['href'] == '/~guest-github-unknown' for link in links)
185 assert any(link['href'] == '/~guest-github-24465214/repo' for link in links)
186 assert b'https://avatars.githubusercontent.com/u/24465214?s=64' in body
187 assert b'rewrite-test' in body
188 assert headers['Cache-Control'] == 'no-store' and headers.get('ETag') is None and headers.get('Last-Modified') is None
189 assert observations[-1][1] == '/snowbound/issues/26?query=kept', observations[-1]
190 assert observations[-1][2]['cookie'] == 'SessionID=preserved', observations[-1]
191 assert not any(key.lower().startswith('studio-') for key in observations[-1][2]), observations[-1]
192 for path, target in [('/~guest-github-24465214', 'https://github.com/paperclover'), ('/~guest-astheno-abc/', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB')]:
193 status, headers, body = request(path)
194 assert status == 302 and headers['Location'] == target and headers['Referrer-Policy'] == 'no-referrer', (path, status, dict(headers), observations[-1][:2])
195 status, headers, body = request('/redirect')
196 assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2
197 with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response:
198 assert response.status == 200
199 assert any(link['href'] == 'https://github.com/paperclover' for link in Links(response.read().decode()).links)
200 assert b'/~guest-github-24465214' in request('/repo/info/refs')[2]
201 assert request('/binary')[2] == blob
202 assert len(request('/large')[2]) == 9 * 1024 * 1024
203 status, headers, body = request('/submit?keep=yes', body=b'field=unchanged')
204 assert status == 201 and body == b'field=unchanged' and observations[-1][1] == '/submit?keep=yes'
205 assert request('/submit', body=b'blocked', headers={'Origin': 'https://other.example'})[0] == 403
206 assert request('/auth/shale/page', direct=True)[0] == 403
207 assert request('/auth/shale/page', direct=True, headers={'Studio-Proxy-Token': proof, 'Studio-Shale-Upstream': f'127.0.0.1:{fixture.server_port}', 'Studio-Shale-Uri': '/', 'Host': 'snowglobe.studio.test'})[0] == 403
208 print(json.dumps({'html_links': 'passed', 'provider_redirects': 'passed', 'cookies_forms_and_binary': 'passed', 'proxy_boundary': 'passed', 'head_injection': 'passed', 'origin': origin}), flush=True)
209 if args.browser_ready_file:
210 args.browser_ready_file.write_text(json.dumps({'origin': origin}))
211 stop = args.browser_ready_file.with_suffix('.stop')
212 deadline = time.monotonic() + 600
213 while not stop.exists() and time.monotonic() < deadline:
214 time.sleep(.2)
215 args.browser_ready_file.unlink(missing_ok=True)
216 stop.unlink(missing_ok=True)
217
218
219if __name__ == '__main__':
220 main()
tools/router.py+15-2
......@@ -297,14 +297,27 @@ def render(token):
297297 f" request_header -{scrub[0]}", " }", " }",
298298 *proxy(upstreams, " "), " }", "}"]
299299 else:
300 page_upstream = upstreams
301 shale_page = service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service)
302 page_headers = []
303 if shale_page:
304 page_upstream = f"127.0.0.1:{int(os.environ['STUDIO_DASHBOARD_PORT'])}"
305 page_headers = [f" request_header Studio-Proxy-Token {dashboard_proof}",
306 f" request_header Studio-Shale-Upstream {json.dumps(sorted(route['upstreams'])[0])}",
307 " request_header Studio-Shale-Uri {uri}",
308 " rewrite * /auth/shale/page"]
300309 for index, (request, markup) in enumerate(head_html.items()):
301310 name = f"@studio_head_{index}"
302311 lines += [f" {name} path {request}", f" handle {name} {{", " route {",
303312 f" replace </head> {json.dumps(markup + '</head>')} {{",
304313 " match {", " header Content-Type text/html*", " }", " }",
305 *proxy(upstreams, " ", uncompressed=True), " }", " }"]
314 *[" " + line for line in page_headers],
315 *proxy(page_upstream, " ", uncompressed=True), " }", " }"]
316 page_handler = [*page_headers, *proxy(page_upstream, " ")]
317 if shale_page:
318 page_handler = [" route {", *[" " + line for line in page_handler], " }"]
306319 lines += [" handle {", *(f" request_header -{name}" for name in scrub),
307 *proxy(upstreams, " "), " }", "}"]
320 *page_handler, " }", "}"]
308321 if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service):
309322 lines.insert(len(lines) - 1, " }")
310323 else: