//! Shared page chrome: the HTML shell (header, nav, user menu) and form helpers as components, //! the stylesheet link and the security-headers layer. `view!` escapes text and attribute values. pub mod commit_status; pub mod diff; pub mod heatmap; pub mod review_thread; use std::sync::OnceLock; use forge_core::{Error as CoreError, Session, User}; use hex::encode; use http::header::{ CONTENT_SECURITY_POLICY, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS, }; use http::{HeaderValue, Uri}; use sha2::{Digest, Sha256}; use topcoat::Result; use topcoat::context::{Cx, try_app_context}; use topcoat::router::request::uri; use topcoat::router::response::{Response, response_headers}; use topcoat::router::{Body, Next, layer}; use topcoat::view::{Child, View, component, view}; use crate::i18n::code::{self as g, K}; use crate::i18n::community::{self as cm, K as CK}; use crate::i18n::misc::{self as mi, K as MK}; use crate::i18n::orgs::{self as o, K as OK}; use crate::i18n::{self, Key, Lang, t}; use crate::pages::assets::highlight_css_text; use crate::state::AppState; /// Stylesheet URL. The version is a hash of the compiled CSS (build.rs), so a changed stylesheet /// gets a new URL and an unchanged one keeps its cache entry. pub const STYLESHEET_HREF: &str = concat!("/assets/main.css?v=", env!("FORGE_MAIN_CSS_V")); /// UI font (latin subset), preloaded so first paint does not wait for the stylesheet. pub const FIGTREE_PRELOAD_HREF: &str = "/assets/fonts/figtree-5.3.0-latin-wght.woff2"; /// Syntax highlighting stylesheet URL. The CSS is generated at runtime, so its version is a hash of /// those bytes, computed once on first use. Covers the light and dark classes. pub fn highlight_href() -> &'static str { static HREF: OnceLock = OnceLock::new(); HREF.get_or_init(|| { let version = encode(Sha256::digest(highlight_css_text().as_bytes())); format!("/assets/highlight.css?v={}", &version[..12]) }) } /// Soft navigation script, same content-hash scheme. An external file: the CSP has no inline scripts. pub const NAV_SCRIPT_HREF: &str = concat!("/assets/nav.js?v=", env!("FORGE_NAV_JS_V")); const CSP: &str = "default-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'"; /// The CSP header value, built once on the first request from the app's raw-content origin. static APP_CSP: OnceLock = OnceLock::new(); /// Origin (`scheme://host[:port]`) of the user-content base URL, or `None` when it is not an /// http(s) URL with a plain authority (no path is kept, and userinfo is rejected). fn origin_of(base: &str) -> Option { let uri: Uri = base.trim().parse().ok()?; let scheme = uri.scheme_str()?; if scheme != "http" && scheme != "https" { return None; } let authority = uri.authority()?.as_str(); if authority.contains('@') { return None; } Some(format!("{scheme}://{authority}")) } /// The app CSP with the raw origin added to `img-src`. Falls back to `CSP` without an origin. fn csp_for(raw_origin: Option<&str>) -> String { match raw_origin { Some(origin) => format!( "default-src 'self'; style-src 'self'; img-src 'self' data: {origin}; form-action 'self'; frame-ancestors 'none'" ), None => CSP.to_owned(), } } /// Header value for the base URL. Falls back to `CSP` if the policy is not a valid header. fn csp_header(base_url: &str) -> HeaderValue { let policy = csp_for(origin_of(base_url).as_deref()); HeaderValue::from_str(&policy).unwrap_or_else(|_| HeaderValue::from_static(CSP)) } /// A repo link in the signed-in sidebar. #[derive(Clone)] struct SideRepo { name: String, href: String, } /// The global nav item for a request path: `dashboard` for `/`, otherwise the first segment when /// it is a global page. Empty for repo pages and everything else. fn nav_key(path: &str) -> &'static str { match path.trim_matches('/').split('/').next().unwrap_or("") { "" => "dashboard", "explore" => "explore", "notifications" => "notifications", "snippets" => "snippets", "new" => "new", "settings" => "settings", _ => "", } } /// CSS class of a sidebar link. fn side_class(on: bool) -> &'static str { if on { "side-link on" } else { "side-link" } } /// Page shell: sticky top bar (logo, page title, search, theme, bell and user menu), the signed-in /// sidebar and `child` as the page content. `viewer` drives the chrome: the sidebar, bell and user /// menu when signed in, Explore and Log in otherwise. `extra_stylesheet` adds one more stylesheet /// link to the head (such as a repo's generated label colors). #[component] pub async fn shell( title: &str, viewer: Option<&(User, Session)>, #[default] extra_stylesheet: Option<&str>, #[default] child: Child<'_>, ) -> Result { let full_title = format!("{title} \u{b7} forge"); // The bell count and the viewer's repos for the sidebar: two queries, signed-in only. let (unread, repos) = match viewer { Some((user, _)) => match try_app_context::(__cx) { Some(state) => ( state.store.unread_count(&user.id).await?, state.store.list_repos_by_owner(&user.id).await?, ), None => (0, Vec::new()), }, None => (0, Vec::new()), }; let unread_badge = (unread > 0).then(|| unread.to_string()); let side_repos: Vec = repos .iter() .take(8) .map(|repo| SideRepo { name: repo.name.clone(), href: repo_href(&repo.owner_name, &repo.name, "", ""), }) .collect(); let current = nav_key(uri(__cx).path()); let signed_in = viewer.is_some(); let nav = viewer.map(|(user, session)| { ( user.username.clone(), session.csrf_token.clone(), avatar_url(&user.username, user.avatar_hash.as_deref()), user.is_admin, ) }); // Both kinds of visitor post to `/-/theme`. Signed-in users send their CSRF token and get the // stored preference saved. Anonymous visitors get the cookie. Either way the browser comes // back to the page it was on. let theme_csrf = viewer.map(|(_, session)| session.csrf_token.clone()); let back = uri(__cx).path_and_query().map(|p| p.as_str().to_owned()); let lang = i18n::lang(__cx, viewer.map(|(user, _)| user)); let theme_attr = crate::theme::data_theme(__cx, viewer.map(|(user, _)| user)); // The stored preference, "auto" included, drives the button: its icon, its label and the // value it posts (the next preference in the cycle auto, light, dark). let theme_pref = crate::theme::preference(__cx, viewer.map(|(user, _)| user)); let next_theme = crate::theme::next_preference(theme_pref); let theme_state = match theme_pref { "light" => mi::t(lang, MK::Light), "dark" => mi::t(lang, MK::Dark), _ => mi::t(lang, MK::MatchSystem), }; let theme_next = match next_theme { "light" => MK::SwitchToLight, "dark" => MK::SwitchToDark, _ => MK::SwitchToMatchSystem, }; let switch_label = format!( "{}: {}. {}", mi::t(lang, MK::ThemeLabel), theme_state, mi::t(lang, theme_next), ); let anonymous = viewer.is_none(); let search_label = o::t(lang, OK::SearchPublicRepos); let notif_label = t(lang, Key::Notifications); let menu_label = t(lang, Key::SiteMenu); let side_repos_menu = side_repos.clone(); let unread_menu = unread_badge.clone(); let unread_aside = unread_badge.clone(); Ok(view! { (full_title) if let Some(href) = extra_stylesheet { }
if signed_in { } "forge"
if let Some(csrf) = theme_csrf { } if let Some(back) = back { }
if let Some((username, csrf, avatar_src, is_admin)) = nav { if let Some(badge) = unread_badge { (badge.as_str()) } } else { (t(lang, Key::Explore)) (t(lang, Key::LogIn)) }
if signed_in { }
(child)
"forge" if anonymous {
}
}) } /// The signed-in global links and the viewer's repos. The sidebar shows it, and so does the phone /// menu. `current` is the key from `nav_key`; `unread` is the bell count shown next to Notifications. #[component] async fn side_nav( lang: Lang, current: &str, unread: Option, repos: Vec, ) -> Result { let dashboard = current == "dashboard"; let explore = current == "explore"; let notifications = current == "notifications"; let snippets = current == "snippets"; let new_repo = current == "new"; let settings = current == "settings"; let empty = repos.is_empty(); Ok(view! {

(o::t(lang, OK::YourRepositories))

if empty {

(o::t(lang, OK::NoReposYet))

} else { }
}) } /// Name to show for a user. The reserved ghost account, which owns the content of deleted /// users, is shown as "Deleted user" ("Silinmiş kullanıcı"). Avatar and link lookups keep the real /// login name. pub fn display_name(lang: Lang, username: &str) -> String { if forge_core::names::normalize_name(username) == forge_core::store::user_delete::GHOST_USERNAME { i18n::deleted_user(lang).to_owned() } else { username.to_owned() } } /// URL of a user's avatar. `hash` is the avatar's content hash (`?v=` makes the response /// immutable); `None` gives the generated default, which is cached for a short time only. pub fn avatar_url(username: &str, hash: Option<&str>) -> String { format!("/avatars/{}?v={}", enc(username), hash.unwrap_or("default")) } /// Public profile URL of a user: `/{username}`. pub fn user_href(username: &str) -> String { format!("/{}", enc(username)) } /// Link to a user's public profile. `login` is the real username (the link target), `shown` is /// the text to display (it differs from `login` only for the ghost account). Both are escaped /// like any other interpolation. #[component] pub async fn user_link(shown: &str, login: &str) -> Result { let href = user_href(login); Ok(view! { (shown) }) } /// Hidden CSRF input for a POST form. #[component] pub async fn csrf_field(token: &str) -> Result { Ok(view! { }) } /// Inline error box. #[component] pub async fn alert(message: &str) -> Result { Ok(view! { }) } /// Owned copy of a user-facing error message, for the errors a form can recover from. /// Returns `None` for internal failures, which should propagate as 500s. pub fn user_facing_message(err: &CoreError) -> Option { match err { CoreError::InvalidName(m) | CoreError::Invalid(m) | CoreError::Conflict(m) => { Some(m.clone()) } _ => None, } } /// Adds the security headers to every response: nosniff, same-origin referrer, DENY /// framing, and a strict CSP. Registered on the root path so it wraps all handlers. #[layer("/")] pub async fn security_headers(cx: &Cx, body: Body, next: Next<'_>) -> Result { let headers = response_headers(cx); headers.append(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); headers.append(REFERRER_POLICY, HeaderValue::from_static("same-origin")); headers.append(X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); let csp = match try_app_context::(cx) { Some(state) => APP_CSP .get_or_init(|| csp_header(&state.config.user_content_base_url)) .clone(), None => HeaderValue::from_static(CSP), }; headers.append(CONTENT_SECURITY_POLICY, csp); next.run(cx, body).await } // Repo browsing helpers: URLs, times and sizes, and the shared breadcrumb, tabs and ref selector. /// Percent-encodes everything except unreserved characters and `/`. pub fn enc(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') { out.push(char::from(b)); } else { out.push_str(&format!("%{b:02X}")); } } out } /// `/{owner}/{repo}[/{section}][/{rest}]`. Every piece is percent-encoded, and `rest` may contain /// `/` (ref names such as `feature/x` and paths). pub fn repo_href(owner: &str, repo: &str, section: &str, rest: &str) -> String { let mut href = format!("/{}/{}", enc(owner), enc(repo)); if !section.is_empty() { href.push('/'); href.push_str(section); } let rest = rest.trim_matches('/'); if !rest.is_empty() { href.push('/'); href.push_str(&enc(rest)); } href } /// Current time as Unix seconds. pub fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) .unwrap_or(0) } /// ISO 8601 UTC timestamp, `YYYY-MM-DDTHH:MM:SSZ`. pub fn iso_time(unix: i64) -> String { let days = unix.div_euclid(86_400); let secs = unix.rem_euclid(86_400); let (y, m, d) = civil_from_days(days); format!( "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60 ) } /// Days since 1970-01-01 to (year, month, day). Howard Hinnant's civil-from-days algorithm. fn civil_from_days(days: i64) -> (i64, i64, i64) { let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + i64::from(m <= 2); (y, m, d) } /// "just now", "5 minutes ago", "1 day ago", relative to `now`; Turkish reads "az önce" and /// "5 dakika önce". pub fn relative_time(lang: Lang, unix: i64, now: i64) -> String { use i18n::TimeUnit; let secs = now.saturating_sub(unix).max(0); let (n, unit) = match secs { 0..60 => return i18n::just_now(lang).to_owned(), 60..3_600 => (secs / 60, TimeUnit::Minute), 3_600..86_400 => (secs / 3_600, TimeUnit::Hour), 86_400..2_592_000 => (secs / 86_400, TimeUnit::Day), 2_592_000..31_536_000 => (secs / 2_592_000, TimeUnit::Month), _ => (secs / 31_536_000, TimeUnit::Year), }; i18n::ago(lang, n, unit) } /// First seven characters of an object id. pub fn short_oid(oid: &str) -> &str { oid.get(..7).unwrap_or(oid) } /// Byte count for display. Turkish uses a decimal comma ("1,5 MiB"). pub fn human_size(lang: Lang, bytes: u64) -> String { let decimal = |value: f64| { let text = format!("{value:.1}"); match lang { Lang::En => text, Lang::Tr => text.replace('.', ","), } }; if bytes < 1024 { match lang { Lang::En => format!("{bytes} bytes"), Lang::Tr => format!("{bytes} bayt"), } } else if bytes < 1024 * 1024 { format!("{} KiB", decimal(bytes as f64 / 1024.0)) } else { format!("{} MiB", decimal(bytes as f64 / (1024.0 * 1024.0))) } } /// One breadcrumb segment. `href` is None for the current location. pub struct Crumb { pub label: String, pub href: Option, } #[component] pub async fn breadcrumb(lang: Lang, crumbs: Vec) -> Result { Ok(view! { }) } fn tab_class(on: bool) -> &'static str { if on { "tab active" } else { "tab" } } /// Header tabs for a repo. `active` is one of `code`, `issues`, `commits`, `branches`, `tags`, /// `settings`. `open_issues` is the count shown on the Issues tab; `None` shows the tab without one. #[component] pub async fn repo_tabs( owner: String, repo: String, default_branch: String, active: String, can_admin: bool, lang: Lang, #[default] open_issues: Option, #[default] open_pulls: Option, #[default] discussions: bool, #[default] wiki: bool, #[default] security: bool, ) -> Result { let home = repo_href(&owner, &repo, "", ""); let issues = repo_href(&owner, &repo, "issues", ""); let issues_count = open_issues.map(|n| n.to_string()); let pulls = repo_href(&owner, &repo, "pulls", ""); let pulls_count = open_pulls.map(|n| n.to_string()); let pulls_on = active == "pulls"; let commits = repo_href(&owner, &repo, "commits", &default_branch); let branches = repo_href(&owner, &repo, "branches", ""); let tags = repo_href(&owner, &repo, "tags", ""); let releases = repo_href(&owner, &repo, "releases", ""); let ci = repo_href(&owner, &repo, "ci", ""); let ci_on = active == "ci"; let code_on = active == "code"; let issues_on = active == "issues"; let commits_on = active == "commits"; let branches_on = active == "branches"; let tags_on = active == "tags"; let releases_on = active == "releases"; // Shown only to people who may manage the repo (owner, instance admin, or Admin collaborator). let settings = repo_href(&owner, &repo, "settings", ""); let settings_on = active == "settings"; let discussions_tab = repo_href(&owner, &repo, "discussions", ""); let discussions_on = active == "discussions"; let wiki_tab = repo_href(&owner, &repo, "wiki", ""); let wiki_on = active == "wiki"; let security_tab = repo_href(&owner, &repo, "security", ""); let security_on = active == "security"; Ok(view! { }) } /// A branch or tag in the ref selector. `href` points at its root tree. pub struct RefLink { pub name: String, pub href: String, pub current: bool, } #[component] async fn ref_group(title: &str, links: Vec) -> Result { Ok(view! {

(title)

}) } /// Branch and tag selector. A plain `
` list, so it works without JavaScript. #[component] pub async fn ref_selector( lang: Lang, current: String, branches: Vec, tags: Vec, ) -> Result { let has_tags = !tags.is_empty(); Ok(view! {
(mi::t(lang, MK::RefLabel)) (current)
ref_group(title: g::t(lang, K::BranchesTitle), links: branches) if has_tags { ref_group(title: g::t(lang, K::TagsTitle), links: tags) }
}) } #[cfg(test)] mod tests { use super::*; #[test] fn enc_keeps_slashes_and_escapes_the_rest() { assert_eq!(enc("feature/x y#1"), "feature/x%20y%231"); assert_eq!(enc("a~b.c_d-e"), "a~b.c_d-e"); } #[test] fn repo_href_builds_each_form() { assert_eq!(repo_href("alice", "demo", "", ""), "/alice/demo"); assert_eq!( repo_href("alice", "demo", "tree", "feature/x/src"), "/alice/demo/tree/feature/x/src" ); assert_eq!( repo_href("alice", "demo", "tree", "main/"), "/alice/demo/tree/main" ); } #[test] fn csp_allows_the_raw_origin_for_images() { let origin = origin_of("https://raw.forge.test:8443/some/path/"); assert_eq!(origin.as_deref(), Some("https://raw.forge.test:8443")); let csp = csp_for(origin.as_deref()); assert!(csp.contains("img-src 'self' data: https://raw.forge.test:8443;")); assert!(csp.contains("form-action 'self'; frame-ancestors 'none'")); } #[test] fn csp_falls_back_when_the_base_url_is_not_an_origin() { assert_eq!(origin_of("not a url"), None); assert_eq!(origin_of("ftp://files.test"), None); assert_eq!(origin_of("https://user:pw@files.test"), None); assert_eq!(csp_for(None), CSP); } #[test] fn iso_time_matches_known_instants() { assert_eq!(iso_time(0), "1970-01-01T00:00:00Z"); assert_eq!(iso_time(1_700_000_000), "2023-11-14T22:13:20Z"); assert_eq!(iso_time(951_782_400), "2000-02-29T00:00:00Z"); } #[test] fn relative_time_picks_the_unit() { assert_eq!(relative_time(Lang::En, 100, 110), "just now"); assert_eq!(relative_time(Lang::En, 0, 120), "2 minutes ago"); assert_eq!(relative_time(Lang::En, 0, 3_600), "1 hour ago"); assert_eq!(relative_time(Lang::En, 0, 2 * 86_400), "2 days ago"); assert_eq!(relative_time(Lang::En, 10, 0), "just now"); assert_eq!(relative_time(Lang::En, 0, 30 * 86_400), "1 month ago"); assert_eq!(relative_time(Lang::En, 0, 365 * 86_400), "1 year ago"); assert_eq!(relative_time(Lang::En, 0, 2 * 365 * 86_400), "2 years ago"); assert_eq!(relative_time(Lang::Tr, 100, 110), "az önce"); assert_eq!(relative_time(Lang::Tr, 0, 60), "1 dakika önce"); assert_eq!(relative_time(Lang::Tr, 0, 5 * 60), "5 dakika önce"); assert_eq!(relative_time(Lang::Tr, 0, 3 * 3_600), "3 saat önce"); assert_eq!(relative_time(Lang::Tr, 0, 3 * 86_400), "3 gün önce"); assert_eq!(relative_time(Lang::Tr, 0, 2 * 2_592_000), "2 ay önce"); assert_eq!(relative_time(Lang::Tr, 0, 365 * 86_400), "1 yıl önce"); } #[test] fn short_oid_and_sizes() { assert_eq!(short_oid("0123456789abcdef"), "0123456"); assert_eq!(human_size(Lang::En, 12), "12 bytes"); assert_eq!(human_size(Lang::En, 2048), "2.0 KiB"); assert_eq!(human_size(Lang::En, 1_572_864), "1.5 MiB"); assert_eq!(human_size(Lang::Tr, 512), "512 bayt"); assert_eq!(human_size(Lang::Tr, 2048), "2,0 KiB"); assert_eq!(human_size(Lang::Tr, 1_572_864), "1,5 MiB"); } #[test] fn ghost_and_visibility_labels_follow_the_language() { assert_eq!(i18n::deleted_user(Lang::En), "Deleted user"); assert_eq!(i18n::deleted_user(Lang::Tr), "Silinmiş kullanıcı"); assert_eq!( i18n::visibility_label(Lang::Tr, forge_core::Visibility::Public), "herkese açık" ); assert_eq!( i18n::visibility_label(Lang::En, forge_core::Visibility::Private), "private" ); } /// Renders `/settings/sessions` (a page that uses the shell) for a freshly signed-in user. async fn menu_html(is_admin: bool) -> String { use crate::auth::SESSION_COOKIE; use topcoat::router::request::Request; use topcoat::router::to_bytes; let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let store = state.store.clone(); let router = crate::test_support::app_router(state.clone()); let name = if is_admin { "root" } else { "alice" }; let user = store .create_user(name, None, "x", is_admin) .await .expect("user"); crate::test_support::create_session(&store, &user.id, "menu-token", "menu-csrf").await; let req = Request::builder() .method("GET") .uri("/settings/sessions") .header("cookie", format!("__Host-{SESSION_COOKIE}=menu-token")) .body(Body::empty()) .expect("request"); let res = router.handle(req).await; assert_eq!(res.status().as_u16(), 200); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); String::from_utf8_lossy(&bytes).into_owned() } #[tokio::test] async fn admin_links_are_hidden_from_non_admins() { let html = menu_html(false).await; assert!(html.contains("Log out"), "user menu is rendered"); assert!(!html.contains("/admin/users"), "no admin link for a user"); assert!(!html.contains("/admin/audit"), "no audit link for a user"); } #[tokio::test] async fn admin_links_are_shown_to_admins() { let html = menu_html(true).await; assert!(html.contains("href=\"/admin/users\"")); assert!(html.contains("href=\"/admin/audit\"")); } } #[cfg(test)] mod shell_tests { use topcoat::router::request::Request; use topcoat::router::{Body, to_bytes}; use super::NAV_SCRIPT_HREF; /// Every `').expect("script tag closes"); assert!( tag[..end].contains(" src="), "inline script: {}", &tag[..=end] ); } } #[tokio::test] async fn shell_loads_the_deferred_nav_script_without_inline_scripts() { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let router = crate::test_support::app_router(state); let req = Request::builder() .method("GET") .uri("/login") .body(Body::empty()) .unwrap(); let res = router.handle(req).await; assert_eq!(res.status(), 200); let csp = res.headers()["content-security-policy"] .to_str() .unwrap() .to_owned(); assert!(csp.contains("default-src 'self'"), "{csp}"); assert!(!csp.contains("unsafe-inline"), "{csp}"); let html = String::from_utf8( to_bytes(res.into_body(), usize::MAX) .await .unwrap() .to_vec(), ) .unwrap(); let tag_at = html .find(&format!("