| 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,6 +11,12 @@ dependencies = [ |
| 11 | "memchr", | 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 | [[package]] | 20 | [[package]] |
| 15 | name = "android_system_properties" | 21 | name = "android_system_properties" |
| 16 | version = "0.1.6" | 22 | version = "0.1.6" |
| ... | @@ -324,19 +330,42 @@ dependencies = [ | ... | @@ -324,19 +330,42 @@ dependencies = [ |
| 324 | "typenum", | 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 | [[package]] | 346 | [[package]] |
| 328 | name = "cssparser" | 347 | name = "cssparser" |
| 329 | version = "0.37.0" | 348 | version = "0.37.0" |
| 330 | source = "registry+https://github.com/rust-lang/crates.io-index" | 349 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 331 | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" | 350 | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" |
| 332 | dependencies = [ | 351 | dependencies = [ |
| 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 | ] |
| 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 | [[package]] | 369 | [[package]] |
| 341 | name = "cssparser-macros" | 370 | name = "cssparser-macros" |
| 342 | version = "0.7.1" | 371 | version = "0.7.1" |
| ... | @@ -503,6 +532,12 @@ version = "0.1.5" | ... | @@ -503,6 +532,12 @@ version = "0.1.5" |
| 503 | source = "registry+https://github.com/rust-lang/crates.io-index" | 532 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 504 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | 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 | [[package]] | 541 | [[package]] |
| 507 | name = "foreign-types" | 542 | name = "foreign-types" |
| 508 | version = "0.3.2" | 543 | version = "0.3.2" |
| ... | @@ -694,7 +729,7 @@ version = "0.15.5" | ... | @@ -694,7 +729,7 @@ version = "0.15.5" |
| 694 | source = "registry+https://github.com/rust-lang/crates.io-index" | 729 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 695 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | 730 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" |
| 696 | dependencies = [ | 731 | dependencies = [ |
| 697 | "foldhash", | 732 | "foldhash 0.1.5", |
| 698 | ] | 733 | ] |
| 699 | 734 | ||
| 700 | [[package]] | 735 | [[package]] |
| ... | @@ -702,6 +737,11 @@ name = "hashbrown" | ... | @@ -702,6 +737,11 @@ name = "hashbrown" |
| 702 | version = "0.17.1" | 737 | version = "0.17.1" |
| 703 | source = "registry+https://github.com/rust-lang/crates.io-index" | 738 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 704 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | 739 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" |
| 740 | dependencies = [ | ||
| 741 | "allocator-api2", | ||
| 742 | "equivalent", | ||
| 743 | "foldhash 0.2.0", | ||
| 744 | ] | ||
| 705 | 745 | ||
| 706 | [[package]] | 746 | [[package]] |
| 707 | name = "hashlink" | 747 | name = "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" |
| 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1121 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1081 | checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" | 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 | [[package]] | 1143 | [[package]] |
| 1084 | name = "lru-slab" | 1144 | name = "lru-slab" |
| 1085 | version = "0.1.3" | 1145 | version = "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" |
| 1836 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1898 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1837 | checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" | 1899 | checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" |
| 1838 | dependencies = [ | 1900 | dependencies = [ |
| 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 | ] |
| 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 | [[package]] | 1928 | [[package]] |
| 1848 | name = "selectors" | 1929 | name = "selectors" |
| 1849 | version = "0.38.0" | 1930 | version = "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" |
| 1851 | checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" | 1932 | checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" |
| 1852 | dependencies = [ | 1933 | dependencies = [ |
| 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 | ] |
| 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 | [[package]] | 2679 | [[package]] |
| 2586 | name = "web-sys" | 2680 | name = "web-sys" |
| 2587 | version = "0.3.106" | 2681 | version = "0.3.106" |
dashboard/Cargo.toml+2-1| ... | @@ -13,8 +13,9 @@ futures = "0.3" | ... | @@ -13,8 +13,9 @@ futures = "0.3" |
| 13 | globset = "0.4" | 13 | globset = "0.4" |
| 14 | openssl = "0.10" | 14 | openssl = "0.10" |
| 15 | rand = "0.9" | 15 | rand = "0.9" |
| 16 | lol_html = "3" | ||
| 16 | regex = "1" | 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 | rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } | 19 | rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } |
| 19 | rusqlite = { version = "0.37", features = ["bundled"] } | 20 | rusqlite = { version = "0.37", features = ["bundled"] } |
| 20 | serde = { version = "1", features = ["derive"] } | 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,6 +560,9 @@ impl Store { |
| 560 | } | 560 | } |
| 561 | 561 | ||
| 562 | pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> { | 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 | 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 | } |
| 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 | 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> = tx | 456 | 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 | headers | 623 | 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; |
| 12 | mod oidc; | 12 | mod oidc; |
| 13 | mod relay; | 13 | mod relay; |
| 14 | mod shale; | 14 | mod shale; |
| 15 | mod shale_page; | ||
| 15 | mod storage; | 16 | mod storage; |
| 16 | mod telemetry; | 17 | mod telemetry; |
| 17 | mod users; | 18 | mod 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 = comment | 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 | .next() | 330 | .next() |
| 331 | .map(text); | 331 | .map(text); |
| 332 | let time = comment | 332 | 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 @@ | ||
| 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,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.lock | 41 | ../dashboard/src ../dashboard/web/sso/github.svg ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock |
| 42 | ../dashboard/agent/install.sh ../dashboard/agent/install.ps1 | 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,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: |