| author | |
| committer | |
| log | ae3420908b83c1e9384bb93c7da8cba4efffa636 |
| tree | 6ce7782803819811b7763d847e5d409b6015e5c0 |
| parent | 92445a1ff69766728a676fabd182092161c589bb |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Assisted-by: gpt-65 files changed, 76 insertions(+), 118 deletions(-)
dashboard/src/guest.rs+13-70| ... | @@ -291,20 +291,6 @@ fn signed_claims( | ... | @@ -291,20 +291,6 @@ fn signed_claims( |
| 291 | Ok(claims) | 291 | Ok(claims) |
| 292 | } | 292 | } |
| 293 | 293 | ||
| 294 | pub(crate) fn astheno_profile(value: &str) -> Option<String> { | ||
| 295 | let url = url::Url::parse(value).ok()?; | ||
| 296 | (url.origin().ascii_serialization() == ASTHENO | ||
| 297 | && url.username().is_empty() | ||
| 298 | && url.password().is_none() | ||
| 299 | && url.query().is_none() | ||
| 300 | && url.fragment().is_none() | ||
| 301 | && url | ||
| 302 | .path() | ||
| 303 | .strip_prefix("/user/") | ||
| 304 | .is_some_and(|id| !id.is_empty() && !id.contains('/'))) | ||
| 305 | .then(|| url.into()) | ||
| 306 | } | ||
| 307 | |||
| 308 | async fn exchange( | 294 | async fn exchange( |
| 309 | http: &reqwest::Client, | 295 | http: &reqwest::Client, |
| 310 | provider: &str, | 296 | provider: &str, |
| ... | @@ -313,7 +299,7 @@ async fn exchange( | ... | @@ -313,7 +299,7 @@ async fn exchange( |
| 313 | code: &str, | 299 | code: &str, |
| 314 | callback: &str, | 300 | callback: &str, |
| 315 | flow: &Value, | 301 | flow: &Value, |
| 316 | ) -> Result<(String, String, Option<String>, Option<String>)> { | 302 | ) -> Result<(String, String, Option<String>)> { |
| 317 | let form = [ | 303 | let form = [ |
| 318 | ("client_id", client), | 304 | ("client_id", client), |
| 319 | ("client_secret", secret), | 305 | ("client_secret", secret), |
| ... | @@ -370,7 +356,7 @@ async fn exchange( | ... | @@ -370,7 +356,7 @@ async fn exchange( |
| 370 | "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.", |
| 371 | ) | 357 | ) |
| 372 | })?; | 358 | })?; |
| 373 | return Ok((id.to_string(), login.to_owned(), None, None)); | 359 | return Ok((id.to_string(), login.to_owned(), None)); |
| 374 | } | 360 | } |
| 375 | let mut form = form.to_vec(); | 361 | let mut form = form.to_vec(); |
| 376 | form.push(("state", "none")); | 362 | form.push(("state", "none")); |
| ... | @@ -455,13 +441,7 @@ async fn exchange( | ... | @@ -455,13 +441,7 @@ async fn exchange( |
| 455 | && url.password().is_none() | 441 | && url.password().is_none() |
| 456 | }) | 442 | }) |
| 457 | .map(String::from); | 443 | .map(String::from); |
| 458 | let public_profile = profile["profile"].as_str().and_then(astheno_profile); | 444 | Ok((string(&profile["sub"]).to_owned(), name, picture)) |
| 459 | Ok(( | ||
| 460 | string(&profile["sub"]).to_owned(), | ||
| 461 | name, | ||
| 462 | picture, | ||
| 463 | public_profile, | ||
| 464 | )) | ||
| 465 | } | 445 | } |
| 466 | 446 | ||
| 467 | fn account( | 447 | fn account( |
| ... | @@ -470,12 +450,7 @@ fn account( | ... | @@ -470,12 +450,7 @@ fn account( |
| 470 | subject: &str, | 450 | subject: &str, |
| 471 | name: &str, | 451 | name: &str, |
| 472 | picture: Option<&str>, | 452 | picture: Option<&str>, |
| 473 | public_profile: Option<&str>, | ||
| 474 | ) -> Result<String> { | 453 | ) -> Result<String> { |
| 475 | let mut attributes = json!({"picture":picture.map(|picture| vec![picture])}); | ||
| 476 | if let Some(profile) = public_profile { | ||
| 477 | attributes["profile"] = json!([profile]); | ||
| 478 | } | ||
| 479 | let mut db = auth.db.lock().unwrap(); | 454 | let mut db = auth.db.lock().unwrap(); |
| 480 | let tx = db.transaction()?; | 455 | let tx = db.transaction()?; |
| 481 | let existing: Option<String> = tx | 456 | let existing: Option<String> = tx |
| ... | @@ -495,10 +470,7 @@ fn account( | ... | @@ -495,10 +470,7 @@ fn account( |
| 495 | } | 470 | } |
| 496 | tx.execute( | 471 | tx.execute( |
| 497 | "UPDATE users SET profile=json_patch(profile,?) WHERE id=?", | 472 | "UPDATE users SET profile=json_patch(profile,?) WHERE id=?", |
| 498 | sql![ | 473 | sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id], |
| 499 | json!({"firstName":name,"attributes":attributes}).to_string(), | ||
| 500 | id | ||
| 501 | ], | ||
| 502 | )?; | 474 | )?; |
| 503 | tx.commit()?; | 475 | tx.commit()?; |
| 504 | return Ok(id); | 476 | return Ok(id); |
| ... | @@ -509,11 +481,10 @@ fn account( | ... | @@ -509,11 +481,10 @@ fn account( |
| 509 | } else { | 481 | } else { |
| 510 | mcp::hash(subject)[..24].to_owned() | 482 | mcp::hash(subject)[..24].to_owned() |
| 511 | }; | 483 | }; |
| 512 | let mut profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"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}); |
| 513 | if picture.is_none() { | 485 | if let Some(picture) = picture { |
| 514 | attributes.as_object_mut().unwrap().remove("picture"); | 486 | profile["attributes"]["picture"] = json!([picture]); |
| 515 | } | 487 | } |
| 516 | profile["attributes"] = attributes; | ||
| 517 | tx.execute( | 488 | tx.execute( |
| 518 | "INSERT INTO users(id,profile) VALUES (?,?)", | 489 | "INSERT INTO users(id,profile) VALUES (?,?)", |
| 519 | sql![id, profile.to_string()], | 490 | sql![id, profile.to_string()], |
| ... | @@ -652,16 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> { | ... | @@ -652,16 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> { |
| 652 | headers | 623 | headers |
| 653 | }) | 624 | }) |
| 654 | .build()?; | 625 | .build()?; |
| 655 | let (subject, name, picture, public_profile) = | 626 | let (subject, name, picture) = |
| 656 | exchange(&http, provider, &client, &secret, code, &callback, &flow).await?; | 627 | exchange(&http, provider, &client, &secret, code, &callback, &flow).await?; |
| 657 | let id = account( | 628 | let id = account(auth, provider, &subject, &name, picture.as_deref())?; |
| 658 | auth, | ||
| 659 | provider, | ||
| 660 | &subject, | ||
| 661 | &name, | ||
| 662 | picture.as_deref(), | ||
| 663 | public_profile.as_deref(), | ||
| 664 | )?; | ||
| 665 | auth.create_session(&id, "dashboard", headers, None) | 629 | auth.create_session(&id, "dashboard", headers, None) |
| 666 | } | 630 | } |
| 667 | .await; | 631 | .await; |
| ... | @@ -826,22 +790,6 @@ mod tests { | ... | @@ -826,22 +790,6 @@ mod tests { |
| 826 | } | 790 | } |
| 827 | } | 791 | } |
| 828 | 792 | ||
| 829 | #[test] | ||
| 830 | fn public_astheno_profiles_stay_on_the_identity_origin() { | ||
| 831 | let profile = "https://identity.astheno.software/user/00653PKPFWTHWVX7K6NZ06ZW79"; | ||
| 832 | assert_eq!(astheno_profile(profile).as_deref(), Some(profile)); | ||
| 833 | for value in [ | ||
| 834 | "http://identity.astheno.software/user/id", | ||
| 835 | "https://other.test/user/id", | ||
| 836 | "https://identity.astheno.software/user/", | ||
| 837 | "https://identity.astheno.software/user/id/extra", | ||
| 838 | "https://identity.astheno.software/user/id?query=yes", | ||
| 839 | "https://user@identity.astheno.software/user/id", | ||
| 840 | ] { | ||
| 841 | assert!(astheno_profile(value).is_none()); | ||
| 842 | } | ||
| 843 | } | ||
| 844 | |||
| 845 | #[test] | 793 | #[test] |
| 846 | fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() { | 794 | fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() { |
| 847 | let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4())); | 795 | let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4())); |
| ... | @@ -858,15 +806,14 @@ mod tests { | ... | @@ -858,15 +806,14 @@ mod tests { |
| 858 | db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", []) | 806 | db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", []) |
| 859 | .unwrap(); | 807 | .unwrap(); |
| 860 | } | 808 | } |
| 861 | let first = account(&auth, "github", "123", "clover", None, None).unwrap(); | 809 | let first = account(&auth, "github", "123", "clover", None).unwrap(); |
| 862 | let repeat = account(&auth, "github", "123", "renamed", None, None).unwrap(); | 810 | let repeat = account(&auth, "github", "123", "renamed", None).unwrap(); |
| 863 | let other = account( | 811 | let other = account( |
| 864 | &auth, | 812 | &auth, |
| 865 | "astheno", | 813 | "astheno", |
| 866 | "123", | 814 | "123", |
| 867 | "clover", | 815 | "clover", |
| 868 | Some("https://identity.astheno.software/avatar/123"), | 816 | Some("https://identity.astheno.software/avatar/123"), |
| 869 | Some("https://identity.astheno.software/user/public-id"), | ||
| 870 | ) | 817 | ) |
| 871 | .unwrap(); | 818 | .unwrap(); |
| 872 | assert_eq!(first, repeat); | 819 | assert_eq!(first, repeat); |
| ... | @@ -876,11 +823,7 @@ mod tests { | ... | @@ -876,11 +823,7 @@ mod tests { |
| 876 | auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0], | 823 | auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0], |
| 877 | "https://identity.astheno.software/avatar/123" | 824 | "https://identity.astheno.software/avatar/123" |
| 878 | ); | 825 | ); |
| 879 | account(&auth, "astheno", "123", "clover", None, None).unwrap(); | 826 | account(&auth, "astheno", "123", "clover", None).unwrap(); |
| 880 | assert_eq!( | ||
| 881 | auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["profile"][0], | ||
| 882 | "https://identity.astheno.software/user/public-id" | ||
| 883 | ); | ||
| 884 | assert!( | 827 | assert!( |
| 885 | auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"] | 828 | auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"] |
| 886 | .get("picture") | 829 | .get("picture") |
| ... | @@ -911,7 +854,7 @@ mod tests { | ... | @@ -911,7 +854,7 @@ mod tests { |
| 911 | ) | 854 | ) |
| 912 | .unwrap(); | 855 | .unwrap(); |
| 913 | } | 856 | } |
| 914 | assert!(account(&auth, "github", "123", "clover", None, None).is_err()); | 857 | assert!(account(&auth, "github", "123", "clover", None).is_err()); |
| 915 | assert!( | 858 | assert!( |
| 916 | auth.create_session(&other, "file", &HeaderMap::new(), None) | 859 | auth.create_session(&other, "file", &HeaderMap::new(), None) |
| 917 | .is_err() | 860 | .is_err() |
dashboard/src/shale.rs+2-2| ... | @@ -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^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a[href^='https://identity.astheno.software/user/']").unwrap()) | 329 | .select(&Selector::parse(".n-card__header a[href^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a:not([href]).va-middle-childs").unwrap()) |
| 330 | .next() | 330 | .next() |
| 331 | .map(text); | 331 | .map(text); |
| 332 | let time = comment | 332 | let time = comment |
| ... | @@ -937,7 +937,7 @@ mod tests { | ... | @@ -937,7 +937,7 @@ mod tests { |
| 937 | } | 937 | } |
| 938 | #[test] | 938 | #[test] |
| 939 | fn issue_comment_authors_include_external_guest_profiles() { | 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>"; | 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 class='usa-link va-middle-childs'>Astheno user</a></div><div class=markdown>Comment</div></li></ul></body>"; |
| 941 | let parsed = issue(page, "owned", Some(3)).unwrap(); | 941 | let parsed = issue(page, "owned", Some(3)).unwrap(); |
| 942 | assert_eq!(parsed["comments"][0]["author"], "paperclover"); | 942 | assert_eq!(parsed["comments"][0]["author"], "paperclover"); |
| 943 | assert_eq!(parsed["comments"][1]["author"], "Astheno user"); | 943 | assert_eq!(parsed["comments"][1]["author"], "Astheno user"); |
dashboard/src/shale_page.rs+33-25| ... | @@ -4,14 +4,14 @@ use lol_html::{RewriteStrSettings, element, html_content::ContentType, text}; | ... | @@ -4,14 +4,14 @@ use lol_html::{RewriteStrSettings, element, html_content::ContentType, text}; |
| 4 | 4 | ||
| 5 | struct Profile { | 5 | struct Profile { |
| 6 | name: String, | 6 | name: String, |
| 7 | url: String, | 7 | url: Option<String>, |
| 8 | icon: &'static str, | 8 | icon: &'static str, |
| 9 | picture: Option<String>, | 9 | picture: Option<String>, |
| 10 | } | 10 | } |
| 11 | 11 | ||
| 12 | fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> { | 12 | fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> { |
| 13 | let db = auth.db.lock().unwrap(); | 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]'),json_extract(profile,'$.attributes.profile[0]') FROM users JOIN external_identities ON user_id=users.id WHERE json_extract(profile,'$.kind')='guest'")?; | 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| { | 15 | let rows = query.query_map([], |row| { |
| 16 | Ok(( | 16 | Ok(( |
| 17 | row.get::<_, String>(0)?, | 17 | row.get::<_, String>(0)?, |
| ... | @@ -19,24 +19,20 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> { | ... | @@ -19,24 +19,20 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> { |
| 19 | row.get::<_, String>(2)?, | 19 | row.get::<_, String>(2)?, |
| 20 | row.get::<_, String>(3)?, | 20 | row.get::<_, String>(3)?, |
| 21 | row.get::<_, Option<String>>(4)?, | 21 | row.get::<_, Option<String>>(4)?, |
| 22 | row.get::<_, Option<String>>(5)?, | ||
| 23 | )) | 22 | )) |
| 24 | })?; | 23 | })?; |
| 25 | let mut profiles = HashMap::new(); | 24 | let mut profiles = HashMap::new(); |
| 26 | for row in rows { | 25 | for row in rows { |
| 27 | let (username, name, provider, subject, picture, public_profile) = row?; | 26 | let (username, name, provider, subject, picture) = row?; |
| 28 | if name.is_empty() { continue; } | 27 | if name.is_empty() { continue; } |
| 29 | let (url, icon) = match provider.as_str() { | 28 | let (url, icon) = match provider.as_str() { |
| 30 | "github" => { | 29 | "github" => { |
| 31 | if matches!(name.as_str(), "." | "..") { continue; } | 30 | if matches!(name.as_str(), "." | "..") { continue; } |
| 32 | let mut url = url::Url::parse("https://github.com/")?; | 31 | let mut url = url::Url::parse("https://github.com/")?; |
| 33 | url.path_segments_mut().unwrap().pop_if_empty().push(&name); | 32 | url.path_segments_mut().unwrap().pop_if_empty().push(&name); |
| 34 | (String::from(url), include_str!("../web/sso/github.svg")) | 33 | (Some(String::from(url)), include_str!("../web/sso/github.svg")) |
| 35 | } | ||
| 36 | "astheno" => { | ||
| 37 | let Some(url) = public_profile.as_deref().and_then(guest::astheno_profile) else { continue; }; | ||
| 38 | (url, include_str!("astheno.svg")) | ||
| 39 | } | 34 | } |
| 35 | "astheno" => (None, include_str!("astheno.svg")), | ||
| 40 | _ => continue, | 36 | _ => continue, |
| 41 | }; | 37 | }; |
| 42 | profiles.insert( | 38 | profiles.insert( |
| ... | @@ -79,19 +75,28 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) - | ... | @@ -79,19 +75,28 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) - |
| 79 | let Some(profile) = profile else { return Ok(()); }; | 75 | let Some(profile) = profile else { return Ok(()); }; |
| 80 | let closing = active.clone(); | 76 | let closing = active.clone(); |
| 81 | element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?; | 77 | element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?; |
| 82 | element.set_attribute("href", &profile.url)?; | 78 | if let Some(url) = &profile.url { |
| 83 | element.set_attribute("target", "_blank")?; | 79 | element.set_attribute("href", url)?; |
| 84 | element.set_attribute("rel", "noreferrer")?; | 80 | element.set_attribute("target", "_blank")?; |
| 81 | element.set_attribute("rel", "noreferrer")?; | ||
| 82 | } else { | ||
| 83 | for attribute in ["href", "target", "rel", "tabindex"] { element.remove_attribute(attribute); } | ||
| 84 | } | ||
| 85 | let style = element.get_attribute("style").unwrap_or_default(); | 85 | let style = element.get_attribute("style").unwrap_or_default(); |
| 86 | element.set_attribute("style", &format!("{style};text-decoration-line:underline;text-decoration-color:currentColor"))?; | 86 | let cursor = if profile.url.is_some() { "" } else { ";cursor:default" }; |
| 87 | element.set_attribute("style", &format!("{style};text-decoration:none{cursor}"))?; | ||
| 87 | if let Some(picture) = &profile.picture { | 88 | if let Some(picture) = &profile.picture { |
| 88 | let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;"); | 89 | 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 | 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 | } |
| 91 | let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;stroke:currentColor;width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em\" "); | 92 | let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;stroke:currentColor;width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em\" "); |
| 92 | let icon = if profile.url.starts_with("https://github.com/") { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon }; | 93 | let icon = if profile.url.is_some() { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon }; |
| 93 | element.append(&icon, ContentType::Html); | 94 | element.append(&icon, ContentType::Html); |
| 95 | if profile.url.is_some() { | ||
| 96 | element.append("<span style=\"text-decoration:underline;text-decoration-color:currentColor\">", ContentType::Html); | ||
| 97 | } | ||
| 94 | element.append(&profile.name, ContentType::Text); | 98 | element.append(&profile.name, ContentType::Text); |
| 99 | if profile.url.is_some() { element.append("</span>", ContentType::Html); } | ||
| 95 | Ok(()) | 100 | Ok(()) |
| 96 | })) | 101 | })) |
| 97 | .append_element_content_handler(element!("a[href] img, a[href] span", move |child| { | 102 | .append_element_content_handler(element!("a[href] img, a[href] span", move |child| { |
| ... | @@ -170,12 +175,12 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> { | ... | @@ -170,12 +175,12 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> { |
| 170 | let origin = url::Url::parse(&format!("https://{host}{uri}"))?; | 175 | let origin = url::Url::parse(&format!("https://{host}{uri}"))?; |
| 171 | let profiles = profiles(&app.auth)?; | 176 | let profiles = profiles(&app.auth)?; |
| 172 | if matches!(parts.method, Method::GET | Method::HEAD) | 177 | if matches!(parts.method, Method::GET | Method::HEAD) |
| 173 | && let Some(profile) = account_path(uri.path()).and_then(|name| profiles.get(name)) | 178 | && let Some(url) = account_path(uri.path()).and_then(|name| profiles.get(name)).and_then(|profile| profile.url.as_deref()) |
| 174 | { | 179 | { |
| 175 | return Ok(( | 180 | return Ok(( |
| 176 | StatusCode::FOUND, | 181 | StatusCode::FOUND, |
| 177 | [ | 182 | [ |
| 178 | ("location", profile.url.as_str()), | 183 | ("location", url), |
| 179 | ("cache-control", "no-store"), | 184 | ("cache-control", "no-store"), |
| 180 | ("referrer-policy", "no-referrer"), | 185 | ("referrer-policy", "no-referrer"), |
| 181 | ], | 186 | ], |
| ... | @@ -275,7 +280,7 @@ mod tests { | ... | @@ -275,7 +280,7 @@ mod tests { |
| 275 | "guest-github-123".into(), | 280 | "guest-github-123".into(), |
| 276 | Profile { | 281 | Profile { |
| 277 | name: "<&\"clover".into(), | 282 | name: "<&\"clover".into(), |
| 278 | url: "https://github.com/clover".into(), | 283 | url: Some("https://github.com/clover".into()), |
| 279 | icon: include_str!("../web/sso/github.svg"), | 284 | icon: include_str!("../web/sso/github.svg"), |
| 280 | picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()), | 285 | picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()), |
| 281 | }, | 286 | }, |
| ... | @@ -284,7 +289,7 @@ mod tests { | ... | @@ -284,7 +289,7 @@ mod tests { |
| 284 | "guest-astheno-abc".into(), | 289 | "guest-astheno-abc".into(), |
| 285 | Profile { | 290 | Profile { |
| 286 | name: "Astheno user".into(), | 291 | name: "Astheno user".into(), |
| 287 | url: "https://identity.astheno.software/user/123".into(), | 292 | url: None, |
| 288 | icon: include_str!("astheno.svg"), | 293 | icon: include_str!("astheno.svg"), |
| 289 | picture: None, | 294 | picture: None, |
| 290 | }, | 295 | }, |
| ... | @@ -293,10 +298,16 @@ mod tests { | ... | @@ -293,10 +298,16 @@ mod tests { |
| 293 | 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>"#; | 298 | 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>"#; |
| 294 | let rewritten = rewrite(html, &origin, &profiles).unwrap(); | 299 | let rewritten = rewrite(html, &origin, &profiles).unwrap(); |
| 295 | let page = Html::parse_document(&rewritten); | 300 | let page = Html::parse_document(&rewritten); |
| 296 | let links: Vec<_> = page.select(&Selector::parse("a").unwrap()).collect(); | 301 | let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect(); |
| 302 | assert_eq!(links[0].attr("target"), Some("_blank")); | ||
| 303 | assert_eq!(links[0].attr("rel"), Some("noreferrer")); | ||
| 304 | assert!(links[0].attr("style").unwrap().contains("text-decoration:none")); | ||
| 305 | assert!(links[0].select(&Selector::parse("span[style]").unwrap()).next().unwrap().attr("style").unwrap().contains("text-decoration:underline")); | ||
| 306 | assert_eq!(links[1].value().name(), "a"); | ||
| 307 | assert_eq!(links[1].attr("target"), None); | ||
| 308 | assert_eq!(links[1].attr("rel"), None); | ||
| 309 | assert_eq!(links[1].text().collect::<String>().trim(), "Astheno user"); | ||
| 297 | for link in &links[..2] { | 310 | for link in &links[..2] { |
| 298 | assert_eq!(link.attr("target"), Some("_blank")); | ||
| 299 | assert_eq!(link.attr("rel"), Some("noreferrer")); | ||
| 300 | assert_eq!( | 311 | assert_eq!( |
| 301 | link.select(&Selector::parse("svg[aria-hidden=true]").unwrap()) | 312 | link.select(&Selector::parse("svg[aria-hidden=true]").unwrap()) |
| 302 | .count(), | 313 | .count(), |
| ... | @@ -322,10 +333,7 @@ mod tests { | ... | @@ -322,10 +333,7 @@ mod tests { |
| 322 | ); | 333 | ); |
| 323 | assert_eq!(links[0].text().collect::<String>().trim(), "<&\"clover"); | 334 | assert_eq!(links[0].text().collect::<String>().trim(), "<&\"clover"); |
| 324 | assert_eq!(links[0].attr("class"), Some("usa-nav-link")); | 335 | assert_eq!(links[0].attr("class"), Some("usa-nav-link")); |
| 325 | assert_eq!( | 336 | assert_eq!(links[1].attr("href"), None); |
| 326 | links[1].attr("href"), | ||
| 327 | Some("https://identity.astheno.software/user/123") | ||
| 328 | ); | ||
| 329 | for link in &links[2..] { | 337 | for link in &links[2..] { |
| 330 | assert_eq!(link.attr("target"), None); | 338 | assert_eq!(link.attr("target"), None); |
| 331 | } | 339 | } |
tools/dashboard-shale-page-test.py+22-16| ... | @@ -57,6 +57,9 @@ def main(): | ... | @@ -57,6 +57,9 @@ def main(): |
| 57 | return self.respond(blob, 'application/octet-stream') | 57 | return self.respond(blob, 'application/octet-stream') |
| 58 | if self.path == '/large': | 58 | if self.path == '/large': |
| 59 | return self.respond(b' ' * (9 * 1024 * 1024), 'text/html') | 59 | return self.respond(b' ' * (9 * 1024 * 1024), 'text/html') |
| 60 | if self.path == '/-/login': | ||
| 61 | callback = 'https://' + self.headers['Host'] + '/-/callback' | ||
| 62 | return self.respond(b'', 'text/plain', 302, [('Location', 'https://snowglobe.studio.test/auth/oidc/authorize?redirect_uri=' + urllib.parse.quote(callback, safe=''))]) | ||
| 60 | if self.path == '/redirect': | 63 | 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=/')]) | 64 | 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: | 65 | if self.path.startswith('/-/') and args.page: |
| ... | @@ -122,11 +125,9 @@ def main(): | ... | @@ -122,11 +125,9 @@ def main(): |
| 122 | for provider, subject, suffix, name in [('github', '24465214', '24465214', 'paperclover'), ('astheno', 'pairwise-astheno-subject', 'abc', 'Astheno user'), ('github', '777', '777', None), ('astheno', 'unmapped-pairwise-subject', 'unknown', 'Unmapped guest')]: | 125 | for provider, subject, suffix, name in [('github', '24465214', '24465214', 'paperclover'), ('astheno', 'pairwise-astheno-subject', 'abc', 'Astheno user'), ('github', '777', '777', None), ('astheno', 'unmapped-pairwise-subject', 'unknown', 'Unmapped guest')]: |
| 123 | username = f'guest-{provider}-{suffix}' | 126 | username = f'guest-{provider}-{suffix}' |
| 124 | profile = {'kind': 'guest', 'enabled': True, 'username': username, 'guestProvider': provider, 'firstName': name} | 127 | profile = {'kind': 'guest', 'enabled': True, 'username': username, 'guestProvider': provider, 'firstName': name} |
| 125 | if provider == 'astheno' and suffix == 'abc': | ||
| 126 | profile['attributes'] = {'profile': ['https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB']} | ||
| 127 | db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)]) | 128 | db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)]) |
| 128 | db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username]) | 129 | db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username]) |
| 129 | os.environ.update(STUDIO_DOMAIN='studio.test', STUDIO_DASHBOARD_PORT=str(dashboard_port), STUDIO_PROXY_TOKEN_FILE=str(token)) | 130 | os.environ.update(STUDIO_DOMAIN='studio.test', STUDIO_DASHBOARD_PORT=str(dashboard_port), STUDIO_PROXY_TOKEN_FILE=str(token), STUDIO_INTERNAL_PORT=str(internal_port)) |
| 130 | router.ROUTE_DIR = str(root / 'routes') | 131 | router.ROUTE_DIR = str(root / 'routes') |
| 131 | Path(router.ROUTE_DIR).mkdir() | 132 | Path(router.ROUTE_DIR).mkdir() |
| 132 | (Path(router.ROUTE_DIR) / 'shale.json').write_text(json.dumps({'headHtml': {'shale.studio.test': {'/snowbound/*': '<meta name="rewrite-test" content="kept">'}}})) | 133 | (Path(router.ROUTE_DIR) / 'shale.json').write_text(json.dumps({'headHtml': {'shale.studio.test': {'/snowbound/*': '<meta name="rewrite-test" content="kept">'}}})) |
| ... | @@ -153,10 +154,9 @@ def main(): | ... | @@ -153,10 +154,9 @@ def main(): |
| 153 | 154 | ||
| 154 | preview_port = port() | 155 | preview_port = port() |
| 155 | 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) | 156 | 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) |
| 156 | config += f'\nhttps://localhost:{internal_port} {{\n tls {certificate} {gateway_key}\n @trusted header Studio-Proxy-Token {proof}\n handle @trusted {{\n request_header -Studio-Proxy-Token\n' | 157 | internal_host = f'dashboard.internal.studio.test:{internal_port}' |
| 157 | for service in ['shale', 'shale-preview-12345678']: | 158 | start = rendered.index(internal_host + ' {') |
| 158 | config += f' handle_path /services/{service}/* {{\n reverse_proxy 127.0.0.1:{fixture.server_port}\n }}\n' | 159 | config += '\n' + rendered[start:].replace(internal_host + ' {', f'https://localhost:{internal_port} {{', 1).replace(' tls internal\n', f' tls {certificate} {gateway_key}\n', 1) |
| 159 | config += ' }\n handle {\n respond 403\n }\n}\n' | ||
| 160 | config_path = root / 'Caddyfile' | 160 | config_path = root / 'Caddyfile' |
| 161 | config_path.write_text(config) | 161 | config_path.write_text(config) |
| 162 | subprocess.run([str(args.caddy), 'validate', '--config', str(config_path), '--adapter', 'caddyfile'], check=True, capture_output=True, env=caddy_env) | 162 | subprocess.run([str(args.caddy), 'validate', '--config', str(config_path), '--adapter', 'caddyfile'], check=True, capture_output=True, env=caddy_env) |
| ... | @@ -186,26 +186,32 @@ def main(): | ... | @@ -186,26 +186,32 @@ def main(): |
| 186 | time.sleep(.05) | 186 | time.sleep(.05) |
| 187 | assert status == 200, (status, body, (root / 'dashboard.log').read_text(), (root / 'caddy.log').read_text()) | 187 | assert status == 200, (status, body, (root / 'dashboard.log').read_text(), (root / 'caddy.log').read_text()) |
| 188 | links = Links(body.decode()).links | 188 | links = Links(body.decode()).links |
| 189 | for url in ['https://github.com/paperclover', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB']: | 189 | link = next(link for link in links if link.get('href') == 'https://github.com/paperclover') |
| 190 | link = next(link for link in links if link['href'] == url) | 190 | assert link['target'] == '_blank' and link['rel'] == 'noreferrer', link |
| 191 | assert link['target'] == '_blank' and link['rel'] == 'noreferrer', link | 191 | assert any(link.get('href') == '/~guest-github-unknown' for link in links) |
| 192 | assert any(link['href'] == '/~guest-github-unknown' for link in links) | 192 | assert any(link.get('href') == '/~guest-github-24465214/repo' for link in links) |
| 193 | assert any(link['href'] == '/~guest-github-24465214/repo' for link in links) | 193 | assert b'Astheno user' in body and not any(link.get('href') == '/~guest-astheno-abc' for link in links) |
| 194 | assert b'identity.astheno.software/user/' not in body and b'pairwise-astheno-subject' not in body | ||
| 194 | assert b'https://avatars.githubusercontent.com/u/24465214?s=64' in body | 195 | assert b'https://avatars.githubusercontent.com/u/24465214?s=64' in body |
| 195 | assert b'rewrite-test' in body | 196 | assert b'rewrite-test' in body |
| 196 | assert headers['Cache-Control'] == 'no-store' and headers.get('ETag') is None and headers.get('Last-Modified') is None | 197 | assert headers['Cache-Control'] == 'no-store' and headers.get('ETag') is None and headers.get('Last-Modified') is None |
| 197 | assert observations[-1][1] == '/snowbound/issues/26?query=kept', observations[-1] | 198 | assert observations[-1][1] == '/snowbound/issues/26?query=kept', observations[-1] |
| 199 | assert observations[-1][2]['host'] == 'shale.studio.test', observations[-1] | ||
| 198 | assert observations[-1][2]['cookie'] == 'SessionID=preserved', observations[-1] | 200 | assert observations[-1][2]['cookie'] == 'SessionID=preserved', observations[-1] |
| 199 | assert not any(key.lower().startswith('studio-') for key in observations[-1][2]), observations[-1] | 201 | assert not any(key.lower().startswith('studio-') for key in observations[-1][2]), observations[-1] |
| 200 | for path, target in [('/~guest-github-24465214', 'https://github.com/paperclover'), ('/~guest-astheno-abc/', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB')]: | 202 | status, headers, body = request('/~guest-github-24465214') |
| 201 | status, headers, body = request(path) | 203 | assert status == 302 and headers['Location'] == 'https://github.com/paperclover' and headers['Referrer-Policy'] == 'no-referrer' |
| 202 | assert status == 302 and headers['Location'] == target and headers['Referrer-Policy'] == 'no-referrer', (path, status, dict(headers), observations[-1][:2]) | 204 | assert request('/~guest-astheno-abc/')[0] == 200 |
| 205 | assert observations[-1][1] == '/~guest-astheno-abc/' | ||
| 203 | assert request('/~guest-astheno-unknown')[0] == 200 | 206 | assert request('/~guest-astheno-unknown')[0] == 200 |
| 207 | status, headers, body = request('/-/login') | ||
| 208 | assert status == 302 and urllib.parse.parse_qs(urllib.parse.urlparse(headers['Location']).query)['redirect_uri'] == ['https://shale.studio.test/-/callback'] | ||
| 204 | status, headers, body = request('/redirect') | 209 | status, headers, body = request('/redirect') |
| 205 | assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2 | 210 | assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2 |
| 206 | with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response: | 211 | with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response: |
| 207 | assert response.status == 200 | 212 | assert response.status == 200 |
| 208 | assert any(link['href'] == 'https://github.com/paperclover' for link in Links(response.read().decode()).links) | 213 | assert observations[-1][2]['host'] == 'shale-preview-12345678.studio.test' |
| 214 | assert any(link.get('href') == 'https://github.com/paperclover' for link in Links(response.read().decode()).links) | ||
| 209 | assert b'/~guest-github-24465214' in request('/repo/info/refs')[2] | 215 | assert b'/~guest-github-24465214' in request('/repo/info/refs')[2] |
| 210 | assert request('/binary')[2] == blob | 216 | assert request('/binary')[2] == blob |
| 211 | assert len(request('/large')[2]) == 9 * 1024 * 1024 | 217 | assert len(request('/large')[2]) == 9 * 1024 * 1024 |
tools/router.py+6-5| ... | @@ -24,13 +24,13 @@ def nomad(path, token): | ... | @@ -24,13 +24,13 @@ def nomad(path, token): |
| 24 | return json.load(response) | 24 | return json.load(response) |
| 25 | 25 | ||
| 26 | 26 | ||
| 27 | def proxy(upstreams, indent, uncompressed=False, upstream_host=False): | 27 | def proxy(upstreams, indent, uncompressed=False, host=None): |
| 28 | lines = [f"{indent}reverse_proxy {upstreams} {{", f"{indent} lb_try_duration 5s", | 28 | lines = [f"{indent}reverse_proxy {upstreams} {{", f"{indent} lb_try_duration 5s", |
| 29 | f"{indent} fail_duration 30s"] | 29 | f"{indent} fail_duration 30s"] |
| 30 | if uncompressed: | 30 | if uncompressed: |
| 31 | lines.append(f"{indent} header_up Accept-Encoding identity") | 31 | lines.append(f"{indent} header_up Accept-Encoding identity") |
| 32 | if upstream_host: | 32 | if host: |
| 33 | lines.append(f"{indent} header_up Host {{upstream_hostport}}") | 33 | lines.append(f"{indent} header_up Host {host}") |
| 34 | return [*lines, f"{indent}}}"] | 34 | return [*lines, f"{indent}}}"] |
| 35 | 35 | ||
| 36 | 36 | ||
| ... | @@ -357,10 +357,11 @@ def render(token): | ... | @@ -357,10 +357,11 @@ def render(token): |
| 357 | f" @dashboard header Studio-Proxy-Token {dashboard_proof}", | 357 | f" @dashboard header Studio-Proxy-Token {dashboard_proof}", |
| 358 | " handle @dashboard {", " request_header -Studio-Proxy-Token", | 358 | " handle @dashboard {", " request_header -Studio-Proxy-Token", |
| 359 | " request_header -User-Name", " request_header -User-Groups", | 359 | " request_header -User-Name", " request_header -User-Groups", |
| 360 | " handle_path /nomad/* {", *proxy("127.0.0.1:4646", " ", upstream_host=True), " }"] | 360 | " handle_path /nomad/* {", *proxy("127.0.0.1:4646", " ", host="{upstream_hostport}"), " }"] |
| 361 | for service, upstreams in sorted(internal_services.items()): | 361 | for service, upstreams in sorted(internal_services.items()): |
| 362 | host = f"{service}.{os.environ['STUDIO_DOMAIN']}" if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service) else "{upstream_hostport}" | ||
| 362 | lines += [f" handle_path /services/{service}/* {{", | 363 | lines += [f" handle_path /services/{service}/* {{", |
| 363 | *proxy(" ".join(sorted(upstreams)), " ", upstream_host=True), " }"] | 364 | *proxy(" ".join(sorted(upstreams)), " ", host=host), " }"] |
| 364 | lines += [" handle {", " respond 404", " }", " }", " handle {", " respond 403", " }", "}"] | 365 | lines += [" handle {", " respond 404", " }", " }", " handle {", " respond 403", " }", "}"] |
| 365 | return "\n".join(lines) + "\n" | 366 | return "\n".join(lines) + "\n" |
| 366 | 367 |