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 = [...@@ -11,6 +11,12 @@ dependencies = [
11 "memchr",11 "memchr",
12]12]
1313
14[[package]]
15name = "allocator-api2"
16version = "0.2.21"
17source = "registry+https://github.com/rust-lang/crates.io-index"
18checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19
14[[package]]20[[package]]
15name = "android_system_properties"21name = "android_system_properties"
16version = "0.1.6"22version = "0.1.6"
...@@ -324,19 +330,42 @@ dependencies = [...@@ -324,19 +330,42 @@ dependencies = [
324 "typenum",330 "typenum",
325]331]
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
327[[package]]346[[package]]
328name = "cssparser"347name = "cssparser"
329version = "0.37.0"348version = "0.37.0"
330source = "registry+https://github.com/rust-lang/crates.io-index"349source = "registry+https://github.com/rust-lang/crates.io-index"
331checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"350checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
332dependencies = [351dependencies = [
333 "cssparser-macros",352 "cssparser-macros 0.7.1",
334 "dtoa-short",353 "dtoa-short",
335 "itoa",354 "itoa",
336 "phf",355 "phf",
337 "smallvec",356 "smallvec",
338]357]
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
340[[package]]369[[package]]
341name = "cssparser-macros"370name = "cssparser-macros"
342version = "0.7.1"371version = "0.7.1"
...@@ -503,6 +532,12 @@ version = "0.1.5"...@@ -503,6 +532,12 @@ version = "0.1.5"
503source = "registry+https://github.com/rust-lang/crates.io-index"532source = "registry+https://github.com/rust-lang/crates.io-index"
504checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"533checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
505534
535[[package]]
536name = "foldhash"
537version = "0.2.0"
538source = "registry+https://github.com/rust-lang/crates.io-index"
539checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
540
506[[package]]541[[package]]
507name = "foreign-types"542name = "foreign-types"
508version = "0.3.2"543version = "0.3.2"
...@@ -694,7 +729,7 @@ version = "0.15.5"...@@ -694,7 +729,7 @@ version = "0.15.5"
694source = "registry+https://github.com/rust-lang/crates.io-index"729source = "registry+https://github.com/rust-lang/crates.io-index"
695checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"730checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
696dependencies = [731dependencies = [
697 "foldhash",732 "foldhash 0.1.5",
698]733]
699734
700[[package]]735[[package]]
...@@ -702,6 +737,11 @@ name = "hashbrown"...@@ -702,6 +737,11 @@ name = "hashbrown"
702version = "0.17.1"737version = "0.17.1"
703source = "registry+https://github.com/rust-lang/crates.io-index"738source = "registry+https://github.com/rust-lang/crates.io-index"
704checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"739checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
740dependencies = [
741 "allocator-api2",
742 "equivalent",
743 "foldhash 0.2.0",
744]
705745
706[[package]]746[[package]]
707name = "hashlink"747name = "hashlink"
...@@ -729,6 +769,7 @@ dependencies = [...@@ -729,6 +769,7 @@ dependencies = [
729 "chrono",769 "chrono",
730 "futures",770 "futures",
731 "globset",771 "globset",
772 "lol_html",
732 "openssl",773 "openssl",
733 "rand 0.9.5",774 "rand 0.9.5",
734 "regex",775 "regex",
...@@ -1080,6 +1121,25 @@ version = "0.4.34"...@@ -1080,6 +1121,25 @@ version = "0.4.34"
1080source = "registry+https://github.com/rust-lang/crates.io-index"1121source = "registry+https://github.com/rust-lang/crates.io-index"
1081checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"1122checksum = "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
1083[[package]]1143[[package]]
1084name = "lru-slab"1144name = "lru-slab"
1085version = "0.1.3"1145version = "0.1.3"
...@@ -1649,12 +1709,14 @@ dependencies = [...@@ -1649,12 +1709,14 @@ dependencies = [
1649 "sync_wrapper",1709 "sync_wrapper",
1650 "tokio",1710 "tokio",
1651 "tokio-rustls",1711 "tokio-rustls",
1712 "tokio-util",
1652 "tower",1713 "tower",
1653 "tower-http",1714 "tower-http",
1654 "tower-service",1715 "tower-service",
1655 "url",1716 "url",
1656 "wasm-bindgen",1717 "wasm-bindgen",
1657 "wasm-bindgen-futures",1718 "wasm-bindgen-futures",
1719 "wasm-streams",
1658 "web-sys",1720 "web-sys",
1659 "webpki-roots",1721 "webpki-roots",
1660]1722]
...@@ -1836,14 +1898,33 @@ version = "0.27.0"...@@ -1836,14 +1898,33 @@ version = "0.27.0"
1836source = "registry+https://github.com/rust-lang/crates.io-index"1898source = "registry+https://github.com/rust-lang/crates.io-index"
1837checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"1899checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
1838dependencies = [1900dependencies = [
1839 "cssparser",1901 "cssparser 0.37.0",
1840 "ego-tree",1902 "ego-tree",
1841 "html5ever",1903 "html5ever",
1842 "precomputed-hash",1904 "precomputed-hash",
1843 "selectors",1905 "selectors 0.38.0",
1844 "tendril",1906 "tendril",
1845]1907]
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
1847[[package]]1928[[package]]
1848name = "selectors"1929name = "selectors"
1849version = "0.38.0"1930version = "0.38.0"
...@@ -1851,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -1851,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1851checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"1932checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
1852dependencies = [1933dependencies = [
1853 "bitflags",1934 "bitflags",
1854 "cssparser",1935 "cssparser 0.37.0",
1855 "derive_more",1936 "derive_more",
1856 "log",1937 "log",
1857 "new_debug_unreachable",1938 "new_debug_unreachable",
...@@ -2582,6 +2663,19 @@ dependencies = [...@@ -2582,6 +2663,19 @@ dependencies = [
2582 "unicode-ident",2663 "unicode-ident",
2583]2664]
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
2585[[package]]2679[[package]]
2586name = "web-sys"2680name = "web-sys"
2587version = "0.3.106"2681version = "0.3.106"
dashboard/Cargo.toml+2-1
...@@ -13,8 +13,9 @@ futures = "0.3"...@@ -13,8 +13,9 @@ futures = "0.3"
13globset = "0.4"13globset = "0.4"
14openssl = "0.10"14openssl = "0.10"
15rand = "0.9"15rand = "0.9"
16lol_html = "3"
16regex = "1"17regex = "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"] }
18rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }19rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
19rusqlite = { version = "0.37", features = ["bundled"] }20rusqlite = { version = "0.37", features = ["bundled"] }
20serde = { version = "1", features = ["derive"] }21serde = { 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 {...@@ -560,6 +560,9 @@ impl Store {
560}560}
561561
562pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {562pub 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 }
563 if request.uri().path().starts_with("/auth/guest/") {566 if request.uri().path().starts_with("/auth/guest/") {
564 return Ok(guest::route(State(app), request).await);567 return Ok(guest::route(State(app), request).await);
565 }568 }
dashboard/src/guest.rs+54-11
...@@ -299,7 +299,7 @@ async fn exchange(...@@ -299,7 +299,7 @@ async fn exchange(
299 code: &str,299 code: &str,
300 callback: &str,300 callback: &str,
301 flow: &Value,301 flow: &Value,
302) -> Result<(String, String)> {302) -> Result<(String, String, Option<String>)> {
303 let form = [303 let form = [
304 ("client_id", client),304 ("client_id", client),
305 ("client_secret", secret),305 ("client_secret", secret),
...@@ -356,7 +356,7 @@ async fn exchange(...@@ -356,7 +356,7 @@ async fn exchange(
356 "GitHub couldn't verify your account. Return to Shale and try again.",356 "GitHub couldn't verify your account. Return to Shale and try again.",
357 )357 )
358 })?;358 })?;
359 return Ok((id.to_string(), login.to_owned()));359 return Ok((id.to_string(), login.to_owned(), None));
360 }360 }
361 let mut form = form.to_vec();361 let mut form = form.to_vec();
362 form.push(("state", "none"));362 form.push(("state", "none"));
...@@ -430,10 +430,27 @@ async fn exchange(...@@ -430,10 +430,27 @@ async fn exchange(
430 .chars()430 .chars()
431 .take(128)431 .take(128)
432 .collect();432 .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))
434}445}
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> {
437 let mut db = auth.db.lock().unwrap();454 let mut db = auth.db.lock().unwrap();
438 let tx = db.transaction()?;455 let tx = db.transaction()?;
439 let existing: Option<String> = tx456 let existing: Option<String> = tx
...@@ -451,6 +468,11 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res...@@ -451,6 +468,11 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res
451 "This guest account is disabled. Contact Clover.",468 "This guest account is disabled. Contact Clover.",
452 ));469 ));
453 }470 }
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()?;
454 return Ok(id);476 return Ok(id);
455 }477 }
456 let id = uuid::Uuid::new_v4().to_string();478 let id = uuid::Uuid::new_v4().to_string();
...@@ -459,7 +481,10 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res...@@ -459,7 +481,10 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res
459 } else {481 } else {
460 mcp::hash(subject)[..24].to_owned()482 mcp::hash(subject)[..24].to_owned()
461 };483 };
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 }
463 tx.execute(488 tx.execute(
464 "INSERT INTO users(id,profile) VALUES (?,?)",489 "INSERT INTO users(id,profile) VALUES (?,?)",
465 sql![id, profile.to_string()],490 sql![id, profile.to_string()],
...@@ -598,9 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> {...@@ -598,9 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> {
598 headers623 headers
599 })624 })
600 .build()?;625 .build()?;
601 let (subject, name) =626 let (subject, name, picture) =
602 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;627 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())?;
604 auth.create_session(&id, "dashboard", headers, None)629 auth.create_session(&id, "dashboard", headers, None)
605 }630 }
606 .await;631 .await;
...@@ -781,16 +806,34 @@ mod tests {...@@ -781,16 +806,34 @@ mod tests {
781 db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", [])806 db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", [])
782 .unwrap();807 .unwrap();
783 }808 }
784 let first = account(&auth, "github", "123", "clover").unwrap();809 let first = account(&auth, "github", "123", "clover", None).unwrap();
785 let repeat = account(&auth, "github", "123", "renamed").unwrap();810 let repeat = account(&auth, "github", "123", "renamed", None).unwrap();
786 let other = account(&auth, "astheno", "123", "clover").unwrap();811 let other = account(
812 &auth,
813 "astheno",
814 "123",
815 "clover",
816 Some("https://identity.astheno.software/avatar/123"),
817 )
818 .unwrap();
787 assert_eq!(first, repeat);819 assert_eq!(first, repeat);
788 assert_ne!(first, "owner");820 assert_ne!(first, "owner");
789 assert_ne!(first, other);821 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 );
790 {832 {
791 let db = auth.db.lock().unwrap();833 let db = auth.db.lock().unwrap();
792 let profile = auth::user(&db, &first).unwrap();834 let profile = auth::user(&db, &first).unwrap();
793 assert!(is_guest(&profile));835 assert!(is_guest(&profile));
836 assert_eq!(profile["firstName"], "renamed");
794 assert!(profile["email"].is_null());837 assert!(profile["email"].is_null());
795 assert_eq!(profile["groups"], json!([]));838 assert_eq!(profile["groups"], json!([]));
796 assert!(839 assert!(
...@@ -811,7 +854,7 @@ mod tests {...@@ -811,7 +854,7 @@ mod tests {
811 )854 )
812 .unwrap();855 .unwrap();
813 }856 }
814 assert!(account(&auth, "github", "123", "clover").is_err());857 assert!(account(&auth, "github", "123", "clover", None).is_err());
815 assert!(858 assert!(
816 auth.create_session(&other, "file", &HeaderMap::new(), None)859 auth.create_session(&other, "file", &HeaderMap::new(), None)
817 .is_err()860 .is_err()
dashboard/src/main.rs+4
...@@ -12,6 +12,7 @@ mod observability;...@@ -12,6 +12,7 @@ mod observability;
12mod oidc;12mod oidc;
13mod relay;13mod relay;
14mod shale;14mod shale;
15mod shale_page;
15mod storage;16mod storage;
16mod telemetry;17mod telemetry;
17mod users;18mod users;
...@@ -527,6 +528,9 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {...@@ -527,6 +528,9 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
527 }528 }
528 request.headers_mut().remove("Studio-Proxy-Token");529 request.headers_mut().remove("Studio-Proxy-Token");
529 }530 }
531 if request.uri().path() == "/auth/shale/page" {
532 return next.run(request).await;
533 }
530 let path = request.uri().path().to_owned();534 let path = request.uri().path().to_owned();
531 let asset = path.starts_with("/assets/")535 let asset = path.starts_with("/assets/")
532 || path.starts_with("/fonts/")536 || 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> {...@@ -326,7 +326,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
326 )326 )
327 })?;327 })?;
328 let author = comment328 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())
330 .next()330 .next()
331 .map(text);331 .map(text);
332 let time = comment332 let time = comment
...@@ -935,6 +935,14 @@ mod tests {...@@ -935,6 +935,14 @@ mod tests {
935 assert!(issue(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), "owned", Some(3)).is_err());935 assert!(issue(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), "owned", Some(3)).is_err());
936 assert!(issue(&page.replace("Issue #3", "Issue #0"), "owned", None).is_err());936 assert!(issue(&page.replace("Issue #3", "Issue #0"), "owned", None).is_err());
937 }937 }
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
938 #[test]946 #[test]
939 fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() {947 fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() {
940 assert_eq!(948 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...@@ -38,7 +38,7 @@ let
38 src = lib.fileset.toSource {38 src = lib.fileset.toSource {
39 root = ../dashboard;39 root = ../dashboard;
40 fileset = lib.fileset.unions [40 fileset = lib.fileset.unions [
41 ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock41 ../dashboard/src ../dashboard/web/sso/github.svg ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock
42 ../dashboard/agent/install.sh ../dashboard/agent/install.ps142 ../dashboard/agent/install.sh ../dashboard/agent/install.ps1
43 ];43 ];
44 };44 };
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):...@@ -297,14 +297,27 @@ def render(token):
297 f" request_header -{scrub[0]}", " }", " }",297 f" request_header -{scrub[0]}", " }", " }",
298 *proxy(upstreams, " "), " }", "}"]298 *proxy(upstreams, " "), " }", "}"]
299 else:299 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"]
300 for index, (request, markup) in enumerate(head_html.items()):309 for index, (request, markup) in enumerate(head_html.items()):
301 name = f"@studio_head_{index}"310 name = f"@studio_head_{index}"
302 lines += [f" {name} path {request}", f" handle {name} {{", " route {",311 lines += [f" {name} path {request}", f" handle {name} {{", " route {",
303 f" replace </head> {json.dumps(markup + '</head>')} {{",312 f" replace </head> {json.dumps(markup + '</head>')} {{",
304 " match {", " header Content-Type text/html*", " }", " }",313 " 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], " }"]
306 lines += [" handle {", *(f" request_header -{name}" for name in scrub),319 lines += [" handle {", *(f" request_header -{name}" for name in scrub),
307 *proxy(upstreams, " "), " }", "}"]320 *page_handler, " }", "}"]
308 if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service):321 if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service):
309 lines.insert(len(lines) - 1, " }")322 lines.insert(len(lines) - 1, " }")
310 else:323 else: