| author | |
| committer | |
| log | fed95315bf3a060a272a75be193773cde19900f9 |
| tree | 91537bcc0d371f8d34f2d81653d98a0c273b95d4 |
| parent | 71f9be2ae0fb92155e6bbd66e6207ec4278115ba |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Rewrite guest links to external profiles and redirect direct profile visits.
Assisted-by: gpt-611 files changed, 793 insertions(+), 21 deletions(-)
dashboard/Cargo.lock+99-5| ... | ... | @@ -11,6 +11,12 @@ dependencies = [ |
| 11 | 11 | "memchr", |
| 12 | 12 | ] |
| 13 | 13 | |
| 14 | [[package]] | |
| 15 | name = "allocator-api2" | |
| 16 | version = "0.2.21" | |
| 17 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 19 | ||
| 14 | 20 | [[package]] |
| 15 | 21 | name = "android_system_properties" |
| 16 | 22 | version = "0.1.6" |
| ... | ... | @@ -324,19 +330,42 @@ dependencies = [ |
| 324 | 330 | "typenum", |
| 325 | 331 | ] |
| 326 | 332 | |
| 333 | [[package]] | |
| 334 | name = "cssparser" | |
| 335 | version = "0.36.0" | |
| 336 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 337 | checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" | |
| 338 | dependencies = [ | |
| 339 | "cssparser-macros 0.6.1", | |
| 340 | "dtoa-short", | |
| 341 | "itoa", | |
| 342 | "phf", | |
| 343 | "smallvec", | |
| 344 | ] | |
| 345 | ||
| 327 | 346 | [[package]] |
| 328 | 347 | name = "cssparser" |
| 329 | 348 | version = "0.37.0" |
| 330 | 349 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 331 | 350 | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" |
| 332 | 351 | dependencies = [ |
| 333 | "cssparser-macros", | |
| 352 | "cssparser-macros 0.7.1", | |
| 334 | 353 | "dtoa-short", |
| 335 | 354 | "itoa", |
| 336 | 355 | "phf", |
| 337 | 356 | "smallvec", |
| 338 | 357 | ] |
| 339 | 358 | |
| 359 | [[package]] | |
| 360 | name = "cssparser-macros" | |
| 361 | version = "0.6.1" | |
| 362 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 363 | checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" | |
| 364 | dependencies = [ | |
| 365 | "quote", | |
| 366 | "syn 2.0.119", | |
| 367 | ] | |
| 368 | ||
| 340 | 369 | [[package]] |
| 341 | 370 | name = "cssparser-macros" |
| 342 | 371 | version = "0.7.1" |
| ... | ... | @@ -503,6 +532,12 @@ version = "0.1.5" |
| 503 | 532 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 504 | 533 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" |
| 505 | 534 | |
| 535 | [[package]] | |
| 536 | name = "foldhash" | |
| 537 | version = "0.2.0" | |
| 538 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 539 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 540 | ||
| 506 | 541 | [[package]] |
| 507 | 542 | name = "foreign-types" |
| 508 | 543 | version = "0.3.2" |
| ... | ... | @@ -694,7 +729,7 @@ version = "0.15.5" |
| 694 | 729 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 695 | 730 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" |
| 696 | 731 | dependencies = [ |
| 697 | "foldhash", | |
| 732 | "foldhash 0.1.5", | |
| 698 | 733 | ] |
| 699 | 734 | |
| 700 | 735 | [[package]] |
| ... | ... | @@ -702,6 +737,11 @@ name = "hashbrown" |
| 702 | 737 | version = "0.17.1" |
| 703 | 738 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 704 | 739 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" |
| 740 | dependencies = [ | |
| 741 | "allocator-api2", | |
| 742 | "equivalent", | |
| 743 | "foldhash 0.2.0", | |
| 744 | ] | |
| 705 | 745 | |
| 706 | 746 | [[package]] |
| 707 | 747 | name = "hashlink" |
| ... | ... | @@ -729,6 +769,7 @@ dependencies = [ |
| 729 | 769 | "chrono", |
| 730 | 770 | "futures", |
| 731 | 771 | "globset", |
| 772 | "lol_html", | |
| 732 | 773 | "openssl", |
| 733 | 774 | "rand 0.9.5", |
| 734 | 775 | "regex", |
| ... | ... | @@ -1080,6 +1121,25 @@ version = "0.4.34" |
| 1080 | 1121 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1081 | 1122 | checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" |
| 1082 | 1123 | |
| 1124 | [[package]] | |
| 1125 | name = "lol_html" | |
| 1126 | version = "3.0.1" | |
| 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1128 | checksum = "5adbb62638edf7e6bc88835cd3ea388bdd53382af42045da0e414ea78aa0c91a" | |
| 1129 | dependencies = [ | |
| 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 | 1143 | [[package]] |
| 1084 | 1144 | name = "lru-slab" |
| 1085 | 1145 | version = "0.1.3" |
| ... | ... | @@ -1649,12 +1709,14 @@ dependencies = [ |
| 1649 | 1709 | "sync_wrapper", |
| 1650 | 1710 | "tokio", |
| 1651 | 1711 | "tokio-rustls", |
| 1712 | "tokio-util", | |
| 1652 | 1713 | "tower", |
| 1653 | 1714 | "tower-http", |
| 1654 | 1715 | "tower-service", |
| 1655 | 1716 | "url", |
| 1656 | 1717 | "wasm-bindgen", |
| 1657 | 1718 | "wasm-bindgen-futures", |
| 1719 | "wasm-streams", | |
| 1658 | 1720 | "web-sys", |
| 1659 | 1721 | "webpki-roots", |
| 1660 | 1722 | ] |
| ... | ... | @@ -1836,14 +1898,33 @@ version = "0.27.0" |
| 1836 | 1898 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1837 | 1899 | checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" |
| 1838 | 1900 | dependencies = [ |
| 1839 | "cssparser", | |
| 1901 | "cssparser 0.37.0", | |
| 1840 | 1902 | "ego-tree", |
| 1841 | 1903 | "html5ever", |
| 1842 | 1904 | "precomputed-hash", |
| 1843 | "selectors", | |
| 1905 | "selectors 0.38.0", | |
| 1844 | 1906 | "tendril", |
| 1845 | 1907 | ] |
| 1846 | 1908 | |
| 1909 | [[package]] | |
| 1910 | name = "selectors" | |
| 1911 | version = "0.37.0" | |
| 1912 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1913 | checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" | |
| 1914 | dependencies = [ | |
| 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 | 1928 | [[package]] |
| 1848 | 1929 | name = "selectors" |
| 1849 | 1930 | version = "0.38.0" |
| ... | ... | @@ -1851,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1851 | 1932 | checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" |
| 1852 | 1933 | dependencies = [ |
| 1853 | 1934 | "bitflags", |
| 1854 | "cssparser", | |
| 1935 | "cssparser 0.37.0", | |
| 1855 | 1936 | "derive_more", |
| 1856 | 1937 | "log", |
| 1857 | 1938 | "new_debug_unreachable", |
| ... | ... | @@ -2582,6 +2663,19 @@ dependencies = [ |
| 2582 | 2663 | "unicode-ident", |
| 2583 | 2664 | ] |
| 2584 | 2665 | |
| 2666 | [[package]] | |
| 2667 | name = "wasm-streams" | |
| 2668 | version = "0.4.2" | |
| 2669 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2670 | checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" | |
| 2671 | dependencies = [ | |
| 2672 | "futures-util", | |
| 2673 | "js-sys", | |
| 2674 | "wasm-bindgen", | |
| 2675 | "wasm-bindgen-futures", | |
| 2676 | "web-sys", | |
| 2677 | ] | |
| 2678 | ||
| 2585 | 2679 | [[package]] |
| 2586 | 2680 | name = "web-sys" |
| 2587 | 2681 | version = "0.3.106" |
dashboard/Cargo.toml+2-1| ... | ... | @@ -13,8 +13,9 @@ futures = "0.3" |
| 13 | 13 | globset = "0.4" |
| 14 | 14 | openssl = "0.10" |
| 15 | 15 | rand = "0.9" |
| 16 | lol_html = "3" | |
| 16 | 17 | regex = "1" |
| 17 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } | |
| 18 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart", "stream"] } | |
| 18 | 19 | rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } |
| 19 | 20 | rusqlite = { version = "0.37", features = ["bundled"] } |
| 20 | 21 | serde = { version = "1", features = ["derive"] } |
dashboard/src/astheno.svg created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | <!-- | |
| 2 | ISC License | |
| 3 | ||
| 4 | Copyright (c) 2026 Lucide Icons and Contributors | |
| 5 | ||
| 6 | Permission to use, copy, modify, and/or distribute this software for any | |
| 7 | purpose with or without fee is hereby granted, provided that the above | |
| 8 | copyright notice and this permission notice appear in all copies. | |
| 9 | ||
| 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
| 11 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |
| 12 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | |
| 13 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |
| 14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | |
| 15 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | |
| 16 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| 17 | ||
| 18 | --- | |
| 19 | ||
| 20 | The following Lucide icons are derived from the Feather project: | |
| 21 | ||
| 22 | airplay, 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 | ||
| 24 | The MIT License (MIT) (for the icons listed above) | |
| 25 | ||
| 26 | Copyright (c) 2013-present Cole Bemis | |
| 27 | ||
| 28 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 29 | of this software and associated documentation files (the "Software"), to deal | |
| 30 | in the Software without restriction, including without limitation the rights | |
| 31 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 32 | copies of the Software, and to permit persons to whom the Software is | |
| 33 | furnished to do so, subject to the following conditions: | |
| 34 | ||
| 35 | The above copyright notice and this permission notice shall be included in all | |
| 36 | copies or substantial portions of the Software. | |
| 37 | ||
| 38 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 39 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 40 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 41 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 42 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 43 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 44 | SOFTWARE. | |
| 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 | 560 | } |
| 561 | 561 | |
| 562 | 562 | pub 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 | 566 | if request.uri().path().starts_with("/auth/guest/") { |
| 564 | 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 | 299 | code: &str, |
| 300 | 300 | callback: &str, |
| 301 | 301 | flow: &Value, |
| 302 | ) -> Result<(String, String)> { | |
| 302 | ) -> Result<(String, String, Option<String>)> { | |
| 303 | 303 | let form = [ |
| 304 | 304 | ("client_id", client), |
| 305 | 305 | ("client_secret", secret), |
| ... | ... | @@ -356,7 +356,7 @@ async fn exchange( |
| 356 | 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 | 361 | let mut form = form.to_vec(); |
| 362 | 362 | form.push(("state", "none")); |
| ... | ... | @@ -430,10 +430,27 @@ async fn exchange( |
| 430 | 430 | .chars() |
| 431 | 431 | .take(128) |
| 432 | 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 | } |
| 435 | 446 | |
| 436 | fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Result<String> { | |
| 447 | fn account( | |
| 448 | auth: &auth::Store, | |
| 449 | provider: &str, | |
| 450 | subject: &str, | |
| 451 | name: &str, | |
| 452 | picture: Option<&str>, | |
| 453 | ) -> Result<String> { | |
| 437 | 454 | let mut db = auth.db.lock().unwrap(); |
| 438 | 455 | let tx = db.transaction()?; |
| 439 | 456 | let existing: Option<String> = tx |
| ... | ... | @@ -451,6 +468,11 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res |
| 451 | 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 | 476 | return Ok(id); |
| 455 | 477 | } |
| 456 | 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 | 481 | } else { |
| 460 | 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 | 488 | tx.execute( |
| 464 | 489 | "INSERT INTO users(id,profile) VALUES (?,?)", |
| 465 | 490 | sql![id, profile.to_string()], |
| ... | ... | @@ -598,9 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> { |
| 598 | 623 | headers |
| 599 | 624 | }) |
| 600 | 625 | .build()?; |
| 601 | let (subject, name) = | |
| 626 | let (subject, name, picture) = | |
| 602 | 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 | 629 | auth.create_session(&id, "dashboard", headers, None) |
| 605 | 630 | } |
| 606 | 631 | .await; |
| ... | ... | @@ -781,16 +806,34 @@ mod tests { |
| 781 | 806 | db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", []) |
| 782 | 807 | .unwrap(); |
| 783 | 808 | } |
| 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(); | |
| 787 | 819 | assert_eq!(first, repeat); |
| 788 | 820 | assert_ne!(first, "owner"); |
| 789 | 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 | 833 | let db = auth.db.lock().unwrap(); |
| 792 | 834 | let profile = auth::user(&db, &first).unwrap(); |
| 793 | 835 | assert!(is_guest(&profile)); |
| 836 | assert_eq!(profile["firstName"], "renamed"); | |
| 794 | 837 | assert!(profile["email"].is_null()); |
| 795 | 838 | assert_eq!(profile["groups"], json!([])); |
| 796 | 839 | assert!( |
| ... | ... | @@ -811,7 +854,7 @@ mod tests { |
| 811 | 854 | ) |
| 812 | 855 | .unwrap(); |
| 813 | 856 | } |
| 814 | assert!(account(&auth, "github", "123", "clover").is_err()); | |
| 857 | assert!(account(&auth, "github", "123", "clover", None).is_err()); | |
| 815 | 858 | assert!( |
| 816 | 859 | auth.create_session(&other, "file", &HeaderMap::new(), None) |
| 817 | 860 | .is_err() |
dashboard/src/main.rs+4| ... | ... | @@ -12,6 +12,7 @@ mod observability; |
| 12 | 12 | mod oidc; |
| 13 | 13 | mod relay; |
| 14 | 14 | mod shale; |
| 15 | mod shale_page; | |
| 15 | 16 | mod storage; |
| 16 | 17 | mod telemetry; |
| 17 | 18 | mod users; |
| ... | ... | @@ -527,6 +528,9 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 527 | 528 | } |
| 528 | 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 | 534 | let path = request.uri().path().to_owned(); |
| 531 | 535 | let asset = path.starts_with("/assets/") |
| 532 | 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 | 326 | ) |
| 327 | 327 | })?; |
| 328 | 328 | 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 | 330 | .next() |
| 331 | 331 | .map(text); |
| 332 | 332 | let time = comment |
| ... | ... | @@ -935,6 +935,14 @@ mod tests { |
| 935 | 935 | assert!(issue(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), "owned", Some(3)).is_err()); |
| 936 | 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 | 946 | #[test] |
| 939 | 947 | fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() { |
| 940 | 948 | assert_eq!( |
dashboard/src/shale_page.rs created+336| ... | ... | @@ -0,0 +1,336 @@ |
| 1 | use crate::*; | |
| 2 | use axum::body::Body; | |
| 3 | use lol_html::{RewriteStrSettings, element, html_content::ContentType, text}; | |
| 4 | ||
| 5 | struct Profile { | |
| 6 | name: String, | |
| 7 | url: String, | |
| 8 | icon: &'static str, | |
| 9 | picture: Option<String>, | |
| 10 | } | |
| 11 | ||
| 12 | fn 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 | ||
| 63 | fn 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 | ||
| 69 | fn 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 | ||
| 114 | fn 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 | ||
| 139 | pub 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)] | |
| 267 | mod 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 | 38 | src = lib.fileset.toSource { |
| 39 | 39 | root = ../dashboard; |
| 40 | 40 | 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 | |
| 42 | 42 | ../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 | |
| 2 | import argparse | |
| 3 | from contextlib import ExitStack | |
| 4 | from html.parser import HTMLParser | |
| 5 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| 6 | import json | |
| 7 | import os | |
| 8 | from pathlib import Path | |
| 9 | import socket | |
| 10 | import sqlite3 | |
| 11 | import subprocess | |
| 12 | import tempfile | |
| 13 | import threading | |
| 14 | import time | |
| 15 | import urllib.error | |
| 16 | import urllib.request | |
| 17 | ||
| 18 | import router | |
| 19 | ||
| 20 | ||
| 21 | class 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 | ||
| 32 | def port(): | |
| 33 | with socket.socket() as sock: | |
| 34 | sock.bind(('127.0.0.1', 0)) | |
| 35 | return sock.getsockname()[1] | |
| 36 | ||
| 37 | ||
| 38 | def 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 | ||
| 219 | if __name__ == '__main__': | |
| 220 | main() |
tools/router.py+15-2| ... | ... | @@ -297,14 +297,27 @@ def render(token): |
| 297 | 297 | f" request_header -{scrub[0]}", " }", " }", |
| 298 | 298 | *proxy(upstreams, " "), " }", "}"] |
| 299 | 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 | 309 | for index, (request, markup) in enumerate(head_html.items()): |
| 301 | 310 | name = f"@studio_head_{index}" |
| 302 | 311 | lines += [f" {name} path {request}", f" handle {name} {{", " route {", |
| 303 | 312 | f" replace </head> {json.dumps(markup + '</head>')} {{", |
| 304 | 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 | 319 | lines += [" handle {", *(f" request_header -{name}" for name in scrub), |
| 307 | *proxy(upstreams, " "), " }", "}"] | |
| 320 | *page_handler, " }", "}"] | |
| 308 | 321 | if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service): |
| 309 | 322 | lines.insert(len(lines) - 1, " }") |
| 310 | 323 | else: |