//! 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 {
})
}
/// 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! {
(message)
})
}
/// 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! {
})
}
/// 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)