forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
40.5 KiB
1//! Shared page chrome: the HTML shell (header, nav, user menu) and form helpers as components,
2//! the stylesheet link and the security-headers layer. `view!` escapes text and attribute values.
3
4pub mod commit_status;
5pub mod diff;
6pub mod heatmap;
7pub mod review_thread;
8
9use std::sync::OnceLock;
10
11use forge_core::{Error as CoreError, Session, User};
12use hex::encode;
13use http::header::{
14 CONTENT_SECURITY_POLICY, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
15};
16use http::{HeaderValue, Uri};
17use sha2::{Digest, Sha256};
18use topcoat::Result;
19use topcoat::context::{Cx, try_app_context};
20use topcoat::router::request::uri;
21use topcoat::router::response::{Response, response_headers};
22use topcoat::router::{Body, Next, layer};
23use topcoat::view::{Child, View, component, view};
24
25use crate::i18n::code::{self as g, K};
26use crate::i18n::community::{self as cm, K as CK};
27use crate::i18n::misc::{self as mi, K as MK};
28use crate::i18n::orgs::{self as o, K as OK};
29use crate::i18n::{self, Key, Lang, t};
30use crate::pages::assets::highlight_css_text;
31use crate::state::AppState;
32
33/// Stylesheet URL. The version is a hash of the compiled CSS (build.rs), so a changed stylesheet
34/// gets a new URL and an unchanged one keeps its cache entry.
35pub const STYLESHEET_HREF: &str = concat!("/assets/main.css?v=", env!("FORGE_MAIN_CSS_V"));
36
37/// UI font (latin subset), preloaded so first paint does not wait for the stylesheet.
38pub const FIGTREE_PRELOAD_HREF: &str = "/assets/fonts/figtree-5.3.0-latin-wght.woff2";
39
40/// Syntax highlighting stylesheet URL. The CSS is generated at runtime, so its version is a hash of
41/// those bytes, computed once on first use. Covers the light and dark classes.
42pub fn highlight_href() -> &'static str {
43 static HREF: OnceLock<String> = OnceLock::new();
44 HREF.get_or_init(|| {
45 let version = encode(Sha256::digest(highlight_css_text().as_bytes()));
46 format!("/assets/highlight.css?v={}", &version[..12])
47 })
48}
49
50/// Soft navigation script, same content-hash scheme. An external file: the CSP has no inline scripts.
51pub const NAV_SCRIPT_HREF: &str = concat!("/assets/nav.js?v=", env!("FORGE_NAV_JS_V"));
52
53const CSP: &str = "default-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'";
54
55/// The CSP header value, built once on the first request from the app's raw-content origin.
56static APP_CSP: OnceLock<HeaderValue> = OnceLock::new();
57
58/// Origin (`scheme://host[:port]`) of the user-content base URL, or `None` when it is not an
59/// http(s) URL with a plain authority (no path is kept, and userinfo is rejected).
60fn origin_of(base: &str) -> Option<String> {
61 let uri: Uri = base.trim().parse().ok()?;
62 let scheme = uri.scheme_str()?;
63 if scheme != "http" && scheme != "https" {
64 return None;
65 }
66 let authority = uri.authority()?.as_str();
67 if authority.contains('@') {
68 return None;
69 }
70 Some(format!("{scheme}://{authority}"))
71}
72
73/// The app CSP with the raw origin added to `img-src`. Falls back to `CSP` without an origin.
74fn csp_for(raw_origin: Option<&str>) -> String {
75 match raw_origin {
76 Some(origin) => format!(
77 "default-src 'self'; style-src 'self'; img-src 'self' data: {origin}; form-action 'self'; frame-ancestors 'none'"
78 ),
79 None => CSP.to_owned(),
80 }
81}
82
83/// Header value for the base URL. Falls back to `CSP` if the policy is not a valid header.
84fn csp_header(base_url: &str) -> HeaderValue {
85 let policy = csp_for(origin_of(base_url).as_deref());
86 HeaderValue::from_str(&policy).unwrap_or_else(|_| HeaderValue::from_static(CSP))
87}
88
89/// A repo link in the signed-in sidebar.
90#[derive(Clone)]
91struct SideRepo {
92 name: String,
93 href: String,
94}
95
96/// The global nav item for a request path: `dashboard` for `/`, otherwise the first segment when
97/// it is a global page. Empty for repo pages and everything else.
98fn nav_key(path: &str) -> &'static str {
99 match path.trim_matches('/').split('/').next().unwrap_or("") {
100 "" => "dashboard",
101 "explore" => "explore",
102 "notifications" => "notifications",
103 "snippets" => "snippets",
104 "new" => "new",
105 "settings" => "settings",
106 _ => "",
107 }
108}
109
110/// CSS class of a sidebar link.
111fn side_class(on: bool) -> &'static str {
112 if on { "side-link on" } else { "side-link" }
113}
114
115/// Page shell: sticky top bar (logo, page title, search, theme, bell and user menu), the signed-in
116/// sidebar and `child` as the page content. `viewer` drives the chrome: the sidebar, bell and user
117/// menu when signed in, Explore and Log in otherwise. `extra_stylesheet` adds one more stylesheet
118/// link to the head (such as a repo's generated label colors).
119#[component]
120pub async fn shell(
121 title: &str,
122 viewer: Option<&(User, Session)>,
123 #[default] extra_stylesheet: Option<&str>,
124 #[default] child: Child<'_>,
125) -> Result<impl View> {
126 let full_title = format!("{title} \u{b7} forge");
127 // The bell count and the viewer's repos for the sidebar: two queries, signed-in only.
128 let (unread, repos) = match viewer {
129 Some((user, _)) => match try_app_context::<AppState>(__cx) {
130 Some(state) => (
131 state.store.unread_count(&user.id).await?,
132 state.store.list_repos_by_owner(&user.id).await?,
133 ),
134 None => (0, Vec::new()),
135 },
136 None => (0, Vec::new()),
137 };
138 let unread_badge = (unread > 0).then(|| unread.to_string());
139 let side_repos: Vec<SideRepo> = repos
140 .iter()
141 .take(8)
142 .map(|repo| SideRepo {
143 name: repo.name.clone(),
144 href: repo_href(&repo.owner_name, &repo.name, "", ""),
145 })
146 .collect();
147 let current = nav_key(uri(__cx).path());
148 let signed_in = viewer.is_some();
149 let nav = viewer.map(|(user, session)| {
150 (
151 user.username.clone(),
152 session.csrf_token.clone(),
153 avatar_url(&user.username, user.avatar_hash.as_deref()),
154 user.is_admin,
155 )
156 });
157 // Both kinds of visitor post to `/-/theme`. Signed-in users send their CSRF token and get the
158 // stored preference saved. Anonymous visitors get the cookie. Either way the browser comes
159 // back to the page it was on.
160 let theme_csrf = viewer.map(|(_, session)| session.csrf_token.clone());
161 let back = uri(__cx).path_and_query().map(|p| p.as_str().to_owned());
162 let lang = i18n::lang(__cx, viewer.map(|(user, _)| user));
163 let theme_attr = crate::theme::data_theme(__cx, viewer.map(|(user, _)| user));
164 // The stored preference, "auto" included, drives the button: its icon, its label and the
165 // value it posts (the next preference in the cycle auto, light, dark).
166 let theme_pref = crate::theme::preference(__cx, viewer.map(|(user, _)| user));
167 let next_theme = crate::theme::next_preference(theme_pref);
168 let theme_state = match theme_pref {
169 "light" => mi::t(lang, MK::Light),
170 "dark" => mi::t(lang, MK::Dark),
171 _ => mi::t(lang, MK::MatchSystem),
172 };
173 let theme_next = match next_theme {
174 "light" => MK::SwitchToLight,
175 "dark" => MK::SwitchToDark,
176 _ => MK::SwitchToMatchSystem,
177 };
178 let switch_label = format!(
179 "{}: {}. {}",
180 mi::t(lang, MK::ThemeLabel),
181 theme_state,
182 mi::t(lang, theme_next),
183 );
184 let anonymous = viewer.is_none();
185 let search_label = o::t(lang, OK::SearchPublicRepos);
186 let notif_label = t(lang, Key::Notifications);
187 let menu_label = t(lang, Key::SiteMenu);
188 let side_repos_menu = side_repos.clone();
189 let unread_menu = unread_badge.clone();
190 let unread_aside = unread_badge.clone();
191 Ok(view! {
192 <!DOCTYPE html>
193 <html lang=(lang.code()) data-theme=(theme_attr)>
194 <head>
195 <meta charset="utf-8">
196 <meta name="viewport" content="width=device-width, initial-scale=1">
197 <meta name="referrer" content="same-origin">
198 <title>(full_title)</title>
199 <link rel="preload" as="font" type="font/woff2" crossorigin="anonymous" href=(FIGTREE_PRELOAD_HREF)>
200 <link rel="stylesheet" href=(STYLESHEET_HREF)>
201 <link rel="stylesheet" href=(highlight_href())>
202 if let Some(href) = extra_stylesheet {
203 <link rel="stylesheet" href=(href)>
204 }
205 <script src=(NAV_SCRIPT_HREF) defer="defer"></script>
206 </head>
207 <body>
208 <header class="topbar">
209 if signed_in {
210 <details class="nav-toggle">
211 <summary class="icon-btn" aria-label=(menu_label) title=(menu_label)>
212 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
213 </summary>
214 <div class="nav-panel">
215 side_nav(lang: lang, current: current, unread: unread_menu, repos: side_repos_menu)
216 </div>
217 </details>
218 }
219 <a class="wordmark" href="/">
220 <svg class="ico wordmark-mark" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 19c0-7 5-12 14-13-1 9-6 14-13 14"/><path d="M5 19l6-6"/></svg>
221 "forge"
222 </a>
223 <nav class="crumbs" aria-label=(mi::t(lang, MK::BreadcrumbNav))>
224 <ol>
225 <li><span aria-current="page">(title)</span></li>
226 </ol>
227 </nav>
228 <form class="top-search" role="search" method="get" action="/explore">
229 <svg class="ico top-search-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
230 <input type="search" name="q" placeholder=(search_label) aria-label=(search_label)>
231 </form>
232 <a class="icon-btn top-search-link" href="/explore" aria-label=(search_label) title=(search_label)>
233 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
234 </a>
235 <form class="theme-form" method="post" action="/-/theme">
236 if let Some(csrf) = theme_csrf {
237 <input type="hidden" name="csrf" value=(csrf.as_str())>
238 }
239 if let Some(back) = back {
240 <input type="hidden" name="back" value=(back.as_str())>
241 }
242 <input type="hidden" name="theme" value=(next_theme)>
243 <button type="submit" class="icon-btn" aria-label=(switch_label.as_str()) title=(switch_label.as_str())>
244 if theme_pref == "light" {
245 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
246 } else if theme_pref == "dark" {
247 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/></svg>
248 } else {
249 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
250 }
251 </button>
252 </form>
253 if let Some((username, csrf, avatar_src, is_admin)) = nav {
254 <a class="icon-btn bell" href="/notifications" aria-label=(notif_label) title=(notif_label)>
255 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 9H3c0-2 3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/></svg>
256 if let Some(badge) = unread_badge {
257 <span class="bell-count">(badge.as_str())</span>
258 }
259 </a>
260 <details class="menu">
261 <summary aria-label=(username.as_str())>
262 <img class="avatar avatar-sm" src=(avatar_src) alt="" width="28" height="28">
263 </summary>
264 <ul class="menu-list">
265 <li><a href="/settings/profile">(t(lang, Key::Profile))</a></li>
266 <li><a href="/settings/keys">(t(lang, Key::SshKeys))</a></li>
267 <li><a href="/settings/gpg">(t(lang, Key::GpgKeys))</a></li>
268 <li><a href="/settings/tokens">(t(lang, Key::AccessTokens))</a></li>
269 <li><a href="/settings/sessions">(t(lang, Key::Sessions))</a></li>
270 <li><a href="/settings/security">(t(lang, Key::Security))</a></li>
271 <li><a href="/settings/notifications">(t(lang, Key::MenuNotificationEmails))</a></li>
272 <li><a href="/settings/appearance">(mi::t(lang, MK::Appearance))</a></li>
273 if is_admin {
274 <li><a href="/admin/users">(t(lang, Key::AdminUsers))</a></li>
275 <li><a href="/admin/audit">(t(lang, Key::AdminAuditLog))</a></li>
276 <li><a href="/admin/runners">(t(lang, Key::AdminRunners))</a></li>
277 }
278 <li>
279 <form data-hard="true" method="post" action="/logout">
280 <input type="hidden" name="csrf" value=(csrf)>
281 <button type="submit" class="link-button">(t(lang, Key::LogOut))</button>
282 </form>
283 </li>
284 </ul>
285 </details>
286 } else {
287 <a class="topbar-link" href="/explore">(t(lang, Key::Explore))</a>
288 <a class="button primary" href="/login">(t(lang, Key::LogIn))</a>
289 }
290 </header>
291 <div class="shell">
292 if signed_in {
293 <aside class="shell-sidebar">
294 side_nav(lang: lang, current: current, unread: unread_aside, repos: side_repos)
295 </aside>
296 }
297 <main tabindex="-1">
298 (child)
299 </main>
300 </div>
301 <footer class="site-footer">
302 <span>"forge"</span>
303 if anonymous {
304 <form method="post" action="/-/theme" class="theme-picker">
305 <label>
306 <span>(mi::t(lang, MK::ThemeLabel))</span>
307 <select name="theme">
308 <option value="auto">(mi::t(lang, MK::MatchSystem))</option>
309 <option value="light">(mi::t(lang, MK::Light))</option>
310 <option value="dark">(mi::t(lang, MK::Dark))</option>
311 </select>
312 </label>
313 <button type="submit" class="link-button">(mi::t(lang, MK::ApplyTheme))</button>
314 </form>
315 }
316 </footer>
317 </body>
318 </html>
319 })
320}
321
322/// The signed-in global links and the viewer's repos. The sidebar shows it, and so does the phone
323/// menu. `current` is the key from `nav_key`; `unread` is the bell count shown next to Notifications.
324#[component]
325async fn side_nav(
326 lang: Lang,
327 current: &str,
328 unread: Option<String>,
329 repos: Vec<SideRepo>,
330) -> Result<impl View> {
331 let dashboard = current == "dashboard";
332 let explore = current == "explore";
333 let notifications = current == "notifications";
334 let snippets = current == "snippets";
335 let new_repo = current == "new";
336 let settings = current == "settings";
337 let empty = repos.is_empty();
338 Ok(view! {
339 <nav class="side-links">
340 <a class=(side_class(dashboard)) href="/" aria-current=(dashboard.then_some("page"))>
341 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
342 (o::t(lang, OK::Dashboard))
343 </a>
344 <a class=(side_class(explore)) href="/explore" aria-current=(explore.then_some("page"))>
345 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5z"/></svg>
346 (t(lang, Key::Explore))
347 </a>
348 <a class=(side_class(notifications)) href="/notifications" aria-current=(notifications.then_some("page"))>
349 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 9H3c0-2 3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/></svg>
350 (t(lang, Key::Notifications))
351 if let Some(count) = unread {
352 <span class="count">(count.as_str())</span>
353 }
354 </a>
355 <a class=(side_class(snippets)) href="/snippets" aria-current=(snippets.then_some("page"))>
356 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h7l5 5v13H7z"/><path d="M10 13h5M10 17h5"/></svg>
357 (cm::t(lang, CK::Snippets))
358 </a>
359 <a class=(side_class(new_repo)) href="/new" aria-current=(new_repo.then_some("page"))>
360 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/></svg>
361 (t(lang, Key::NewRepository))
362 </a>
363 <a class=(side_class(settings)) href="/settings/profile" aria-current=(settings.then_some("page"))>
364 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1"/></svg>
365 (o::t(lang, OK::Settings))
366 </a>
367 </nav>
368 <div class="side-group">
369 <h2 class="side-h">(o::t(lang, OK::YourRepositories))</h2>
370 if empty {
371 <p class="side-empty">(o::t(lang, OK::NoReposYet))</p>
372 } else {
373 <nav class="side-repos">
374 for repo in repos.into_iter() {
375 <a href=(repo.href.as_str())>(repo.name.as_str())</a>
376 }
377 </nav>
378 }
379 </div>
380 })
381}
382
383/// Name to show for a user. The reserved ghost account, which owns the content of deleted
384/// users, is shown as "Deleted user" ("Silinmiş kullanıcı"). Avatar and link lookups keep the real
385/// login name.
386pub fn display_name(lang: Lang, username: &str) -> String {
387 if forge_core::names::normalize_name(username) == forge_core::store::user_delete::GHOST_USERNAME
388 {
389 i18n::deleted_user(lang).to_owned()
390 } else {
391 username.to_owned()
392 }
393}
394
395/// URL of a user's avatar. `hash` is the avatar's content hash (`?v=` makes the response
396/// immutable); `None` gives the generated default, which is cached for a short time only.
397pub fn avatar_url(username: &str, hash: Option<&str>) -> String {
398 format!("/avatars/{}?v={}", enc(username), hash.unwrap_or("default"))
399}
400
401/// Hidden CSRF input for a POST form.
402#[component]
403pub async fn csrf_field(token: &str) -> Result<impl View> {
404 Ok(view! {
405 <input type="hidden" name="csrf" value=(token)>
406 })
407}
408
409/// Inline error box.
410#[component]
411pub async fn alert(message: &str) -> Result<impl View> {
412 Ok(view! {
413 <p class="alert" role="alert">(message)</p>
414 })
415}
416
417/// Owned copy of a user-facing error message, for the errors a form can recover from.
418/// Returns `None` for internal failures, which should propagate as 500s.
419pub fn user_facing_message(err: &CoreError) -> Option<String> {
420 match err {
421 CoreError::InvalidName(m) | CoreError::Invalid(m) | CoreError::Conflict(m) => {
422 Some(m.clone())
423 }
424 _ => None,
425 }
426}
427
428/// Adds the security headers to every response: nosniff, same-origin referrer, DENY
429/// framing, and a strict CSP. Registered on the root path so it wraps all handlers.
430#[layer("/")]
431pub async fn security_headers(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
432 let headers = response_headers(cx);
433 headers.append(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
434 headers.append(REFERRER_POLICY, HeaderValue::from_static("same-origin"));
435 headers.append(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
436 let csp = match try_app_context::<AppState>(cx) {
437 Some(state) => APP_CSP
438 .get_or_init(|| csp_header(&state.config.user_content_base_url))
439 .clone(),
440 None => HeaderValue::from_static(CSP),
441 };
442 headers.append(CONTENT_SECURITY_POLICY, csp);
443 next.run(cx, body).await
444}
445
446// Repo browsing helpers: URLs, times and sizes, and the shared breadcrumb, tabs and ref selector.
447
448/// Percent-encodes everything except unreserved characters and `/`.
449pub fn enc(s: &str) -> String {
450 let mut out = String::with_capacity(s.len());
451 for b in s.bytes() {
452 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
453 out.push(char::from(b));
454 } else {
455 out.push_str(&format!("%{b:02X}"));
456 }
457 }
458 out
459}
460
461/// `/{owner}/{repo}[/{section}][/{rest}]`. Every piece is percent-encoded, and `rest` may contain
462/// `/` (ref names such as `feature/x` and paths).
463pub fn repo_href(owner: &str, repo: &str, section: &str, rest: &str) -> String {
464 let mut href = format!("/{}/{}", enc(owner), enc(repo));
465 if !section.is_empty() {
466 href.push('/');
467 href.push_str(section);
468 }
469 let rest = rest.trim_matches('/');
470 if !rest.is_empty() {
471 href.push('/');
472 href.push_str(&enc(rest));
473 }
474 href
475}
476
477/// Current time as Unix seconds.
478pub fn now_unix() -> i64 {
479 std::time::SystemTime::now()
480 .duration_since(std::time::UNIX_EPOCH)
481 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
482 .unwrap_or(0)
483}
484
485/// ISO 8601 UTC timestamp, `YYYY-MM-DDTHH:MM:SSZ`.
486pub fn iso_time(unix: i64) -> String {
487 let days = unix.div_euclid(86_400);
488 let secs = unix.rem_euclid(86_400);
489 let (y, m, d) = civil_from_days(days);
490 format!(
491 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
492 secs / 3600,
493 secs % 3600 / 60,
494 secs % 60
495 )
496}
497
498/// Days since 1970-01-01 to (year, month, day). Howard Hinnant's civil-from-days algorithm.
499fn civil_from_days(days: i64) -> (i64, i64, i64) {
500 let z = days + 719_468;
501 let era = z.div_euclid(146_097);
502 let doe = z.rem_euclid(146_097);
503 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
504 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
505 let mp = (5 * doy + 2) / 153;
506 let d = doy - (153 * mp + 2) / 5 + 1;
507 let m = if mp < 10 { mp + 3 } else { mp - 9 };
508 let y = yoe + era * 400 + i64::from(m <= 2);
509 (y, m, d)
510}
511
512/// "just now", "5 minutes ago", "1 day ago", relative to `now`; Turkish reads "az önce" and
513/// "5 dakika önce".
514pub fn relative_time(lang: Lang, unix: i64, now: i64) -> String {
515 use i18n::TimeUnit;
516 let secs = now.saturating_sub(unix).max(0);
517 let (n, unit) = match secs {
518 0..60 => return i18n::just_now(lang).to_owned(),
519 60..3_600 => (secs / 60, TimeUnit::Minute),
520 3_600..86_400 => (secs / 3_600, TimeUnit::Hour),
521 86_400..2_592_000 => (secs / 86_400, TimeUnit::Day),
522 2_592_000..31_536_000 => (secs / 2_592_000, TimeUnit::Month),
523 _ => (secs / 31_536_000, TimeUnit::Year),
524 };
525 i18n::ago(lang, n, unit)
526}
527
528/// First seven characters of an object id.
529pub fn short_oid(oid: &str) -> &str {
530 oid.get(..7).unwrap_or(oid)
531}
532
533/// Byte count for display. Turkish uses a decimal comma ("1,5 MiB").
534pub fn human_size(lang: Lang, bytes: u64) -> String {
535 let decimal = |value: f64| {
536 let text = format!("{value:.1}");
537 match lang {
538 Lang::En => text,
539 Lang::Tr => text.replace('.', ","),
540 }
541 };
542 if bytes < 1024 {
543 match lang {
544 Lang::En => format!("{bytes} bytes"),
545 Lang::Tr => format!("{bytes} bayt"),
546 }
547 } else if bytes < 1024 * 1024 {
548 format!("{} KiB", decimal(bytes as f64 / 1024.0))
549 } else {
550 format!("{} MiB", decimal(bytes as f64 / (1024.0 * 1024.0)))
551 }
552}
553
554/// One breadcrumb segment. `href` is None for the current location.
555pub struct Crumb {
556 pub label: String,
557 pub href: Option<String>,
558}
559
560#[component]
561pub async fn breadcrumb(lang: Lang, crumbs: Vec<Crumb>) -> Result<impl View> {
562 Ok(view! {
563 <nav class="crumbs" aria-label=(mi::t(lang, MK::BreadcrumbNav))>
564 <ol>
565 for crumb in crumbs.into_iter() {
566 <li>
567 if let Some(href) = crumb.href {
568 <a href=(href)>(crumb.label)</a>
569 } else {
570 <span aria-current="page">(crumb.label)</span>
571 }
572 </li>
573 }
574 </ol>
575 </nav>
576 })
577}
578
579fn tab_class(on: bool) -> &'static str {
580 if on { "tab active" } else { "tab" }
581}
582
583/// Header tabs for a repo. `active` is one of `code`, `issues`, `commits`, `branches`, `tags`,
584/// `settings`. `open_issues` is the count shown on the Issues tab; `None` shows the tab without one.
585#[component]
586pub async fn repo_tabs(
587 owner: String,
588 repo: String,
589 default_branch: String,
590 active: String,
591 can_admin: bool,
592 lang: Lang,
593 #[default] open_issues: Option<u64>,
594 #[default] open_pulls: Option<u64>,
595 #[default] discussions: bool,
596 #[default] wiki: bool,
597 #[default] security: bool,
598) -> Result<impl View> {
599 let home = repo_href(&owner, &repo, "", "");
600 let issues = repo_href(&owner, &repo, "issues", "");
601 let issues_count = open_issues.map(|n| n.to_string());
602 let pulls = repo_href(&owner, &repo, "pulls", "");
603 let pulls_count = open_pulls.map(|n| n.to_string());
604 let pulls_on = active == "pulls";
605 let commits = repo_href(&owner, &repo, "commits", &default_branch);
606 let branches = repo_href(&owner, &repo, "branches", "");
607 let tags = repo_href(&owner, &repo, "tags", "");
608 let releases = repo_href(&owner, &repo, "releases", "");
609 let ci = repo_href(&owner, &repo, "ci", "");
610 let ci_on = active == "ci";
611 let code_on = active == "code";
612 let issues_on = active == "issues";
613 let commits_on = active == "commits";
614 let branches_on = active == "branches";
615 let tags_on = active == "tags";
616 let releases_on = active == "releases";
617 // Shown only to people who may manage the repo (owner, instance admin, or Admin collaborator).
618 let settings = repo_href(&owner, &repo, "settings", "");
619 let settings_on = active == "settings";
620 let discussions_tab = repo_href(&owner, &repo, "discussions", "");
621 let discussions_on = active == "discussions";
622 let wiki_tab = repo_href(&owner, &repo, "wiki", "");
623 let wiki_on = active == "wiki";
624 let security_tab = repo_href(&owner, &repo, "security", "");
625 let security_on = active == "security";
626 Ok(view! {
627 <nav class="repo-tabs" aria-label=(g::t(lang, K::RepositoryNav))>
628 <a class=(tab_class(code_on)) href=(home)>
629 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg></span>
630 (t(lang, Key::Code))
631 </a>
632 <a class=(tab_class(issues_on)) href=(issues)>
633 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="1"/></svg></span>
634 (g::t(lang, K::TabIssues))
635 if let Some(count) = issues_count {
636 " " <span class="tab-count">(count.as_str())</span>
637 }
638 </a>
639 <a class=(tab_class(pulls_on)) href=(pulls)>
640 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="18" r="3"/><path d="M6 9v6M18 15V9a3 3 0 0 0-3-3h-3"/></svg></span>
641 (g::t(lang, K::TabPulls))
642 if let Some(count) = pulls_count {
643 " " <span class="tab-count">(count.as_str())</span>
644 }
645 </a>
646 <a class=(tab_class(commits_on)) href=(commits)>
647 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M3 12h6M15 12h6"/></svg></span>
648 (g::t(lang, K::TabCommits))
649 </a>
650 <a class=(tab_class(branches_on)) href=(branches)>
651 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 3v12M18 9a9 9 0 0 1-9 9"/></svg></span>
652 (g::t(lang, K::BranchesTitle))
653 </a>
654 <a class=(tab_class(tags_on)) href=(tags)>
655 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z"/><circle cx="7.5" cy="7.5" r="1"/></svg></span>
656 (g::t(lang, K::TagsTitle))
657 </a>
658 <a class=(tab_class(releases_on)) href=(releases)>
659 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M21 8l-9-5-9 5v8l9 5 9-5zM3.3 7 12 12l8.7-5M12 22V12"/></svg></span>
660 (g::t(lang, K::TabReleases))
661 </a>
662 <a class=(tab_class(ci_on)) href=(ci)>
663 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M10 8.5v7l5.5-3.5z"/></svg></span>
664 (g::t(lang, K::TabCi))
665 </a>
666 if discussions {
667 <a class=(tab_class(discussions_on)) href=(discussions_tab)>
668 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
669 (g::t(lang, K::TabDiscussions))
670 </a>
671 }
672 if wiki {
673 <a class=(tab_class(wiki_on)) href=(wiki_tab)>
674 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2zM22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/></svg></span>
675 (g::t(lang, K::TabWiki))
676 </a>
677 }
678 if security {
679 <a class=(tab_class(security_on)) href=(security_tab)>
680 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
681 (t(lang, Key::Security))
682 </a>
683 }
684 if can_admin {
685 <a class=(tab_class(settings_on)) href=(settings)>
686 <span class="tab-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1"/></svg></span>
687 (g::t(lang, K::TabSettings))
688 </a>
689 }
690 </nav>
691 })
692}
693
694/// A branch or tag in the ref selector. `href` points at its root tree.
695pub struct RefLink {
696 pub name: String,
697 pub href: String,
698 pub current: bool,
699}
700
701#[component]
702async fn ref_group(title: &str, links: Vec<RefLink>) -> Result<impl View> {
703 Ok(view! {
704 <p class="ref-heading">(title)</p>
705 <ul class="ref-list">
706 for link in links.into_iter() {
707 <li>
708 if link.current {
709 <a class="current" aria-current="true" href=(link.href)>(link.name)</a>
710 } else {
711 <a href=(link.href)>(link.name)</a>
712 }
713 </li>
714 }
715 </ul>
716 })
717}
718
719/// Branch and tag selector. A plain `<details>` list, so it works without JavaScript.
720#[component]
721pub async fn ref_selector(
722 lang: Lang,
723 current: String,
724 branches: Vec<RefLink>,
725 tags: Vec<RefLink>,
726) -> Result<impl View> {
727 let has_tags = !tags.is_empty();
728 Ok(view! {
729 <details class="ref-select">
730 <summary>
731 <span class="muted small">(mi::t(lang, MK::RefLabel))</span>
732 <code>(current)</code>
733 </summary>
734 <div class="ref-menu">
735 ref_group(title: g::t(lang, K::BranchesTitle), links: branches)
736 if has_tags {
737 ref_group(title: g::t(lang, K::TagsTitle), links: tags)
738 }
739 </div>
740 </details>
741 })
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
749 fn enc_keeps_slashes_and_escapes_the_rest() {
750 assert_eq!(enc("feature/x y#1"), "feature/x%20y%231");
751 assert_eq!(enc("a~b.c_d-e"), "a~b.c_d-e");
752 }
753
754 #[test]
755 fn repo_href_builds_each_form() {
756 assert_eq!(repo_href("alice", "demo", "", ""), "/alice/demo");
757 assert_eq!(
758 repo_href("alice", "demo", "tree", "feature/x/src"),
759 "/alice/demo/tree/feature/x/src"
760 );
761 assert_eq!(
762 repo_href("alice", "demo", "tree", "main/"),
763 "/alice/demo/tree/main"
764 );
765 }
766
767 #[test]
768 fn csp_allows_the_raw_origin_for_images() {
769 let origin = origin_of("https://raw.forge.test:8443/some/path/");
770 assert_eq!(origin.as_deref(), Some("https://raw.forge.test:8443"));
771 let csp = csp_for(origin.as_deref());
772 assert!(csp.contains("img-src 'self' data: https://raw.forge.test:8443;"));
773 assert!(csp.contains("form-action 'self'; frame-ancestors 'none'"));
774 }
775
776 #[test]
777 fn csp_falls_back_when_the_base_url_is_not_an_origin() {
778 assert_eq!(origin_of("not a url"), None);
779 assert_eq!(origin_of("ftp://files.test"), None);
780 assert_eq!(origin_of("https://user:pw@files.test"), None);
781 assert_eq!(csp_for(None), CSP);
782 }
783
784 #[test]
785 fn iso_time_matches_known_instants() {
786 assert_eq!(iso_time(0), "1970-01-01T00:00:00Z");
787 assert_eq!(iso_time(1_700_000_000), "2023-11-14T22:13:20Z");
788 assert_eq!(iso_time(951_782_400), "2000-02-29T00:00:00Z");
789 }
790
791 #[test]
792 fn relative_time_picks_the_unit() {
793 assert_eq!(relative_time(Lang::En, 100, 110), "just now");
794 assert_eq!(relative_time(Lang::En, 0, 120), "2 minutes ago");
795 assert_eq!(relative_time(Lang::En, 0, 3_600), "1 hour ago");
796 assert_eq!(relative_time(Lang::En, 0, 2 * 86_400), "2 days ago");
797 assert_eq!(relative_time(Lang::En, 10, 0), "just now");
798 assert_eq!(relative_time(Lang::En, 0, 30 * 86_400), "1 month ago");
799 assert_eq!(relative_time(Lang::En, 0, 365 * 86_400), "1 year ago");
800 assert_eq!(relative_time(Lang::En, 0, 2 * 365 * 86_400), "2 years ago");
801 assert_eq!(relative_time(Lang::Tr, 100, 110), "az önce");
802 assert_eq!(relative_time(Lang::Tr, 0, 60), "1 dakika önce");
803 assert_eq!(relative_time(Lang::Tr, 0, 5 * 60), "5 dakika önce");
804 assert_eq!(relative_time(Lang::Tr, 0, 3 * 3_600), "3 saat önce");
805 assert_eq!(relative_time(Lang::Tr, 0, 3 * 86_400), "3 gün önce");
806 assert_eq!(relative_time(Lang::Tr, 0, 2 * 2_592_000), "2 ay önce");
807 assert_eq!(relative_time(Lang::Tr, 0, 365 * 86_400), "1 yıl önce");
808 }
809
810 #[test]
811 fn short_oid_and_sizes() {
812 assert_eq!(short_oid("0123456789abcdef"), "0123456");
813 assert_eq!(human_size(Lang::En, 12), "12 bytes");
814 assert_eq!(human_size(Lang::En, 2048), "2.0 KiB");
815 assert_eq!(human_size(Lang::En, 1_572_864), "1.5 MiB");
816 assert_eq!(human_size(Lang::Tr, 512), "512 bayt");
817 assert_eq!(human_size(Lang::Tr, 2048), "2,0 KiB");
818 assert_eq!(human_size(Lang::Tr, 1_572_864), "1,5 MiB");
819 }
820
821 #[test]
822 fn ghost_and_visibility_labels_follow_the_language() {
823 assert_eq!(i18n::deleted_user(Lang::En), "Deleted user");
824 assert_eq!(i18n::deleted_user(Lang::Tr), "Silinmiş kullanıcı");
825 assert_eq!(
826 i18n::visibility_label(Lang::Tr, forge_core::Visibility::Public),
827 "herkese açık"
828 );
829 assert_eq!(
830 i18n::visibility_label(Lang::En, forge_core::Visibility::Private),
831 "private"
832 );
833 }
834
835 /// Renders `/settings/sessions` (a page that uses the shell) for a freshly signed-in user.
836 async fn menu_html(is_admin: bool) -> String {
837 use crate::auth::SESSION_COOKIE;
838 use topcoat::router::request::Request;
839 use topcoat::router::to_bytes;
840
841 let dir = tempfile::tempdir().expect("tempdir");
842 let state = crate::test_support::test_state(dir.path()).await;
843 let store = state.store.clone();
844 let router = crate::test_support::app_router(state.clone());
845 let name = if is_admin { "root" } else { "alice" };
846 let user = store
847 .create_user(name, None, "x", is_admin)
848 .await
849 .expect("user");
850 crate::test_support::create_session(&store, &user.id, "menu-token", "menu-csrf").await;
851 let req = Request::builder()
852 .method("GET")
853 .uri("/settings/sessions")
854 .header("cookie", format!("__Host-{SESSION_COOKIE}=menu-token"))
855 .body(Body::empty())
856 .expect("request");
857 let res = router.handle(req).await;
858 assert_eq!(res.status().as_u16(), 200);
859 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
860 String::from_utf8_lossy(&bytes).into_owned()
861 }
862
863 #[tokio::test]
864 async fn admin_links_are_hidden_from_non_admins() {
865 let html = menu_html(false).await;
866 assert!(html.contains("Log out"), "user menu is rendered");
867 assert!(!html.contains("/admin/users"), "no admin link for a user");
868 assert!(!html.contains("/admin/audit"), "no audit link for a user");
869 }
870
871 #[tokio::test]
872 async fn admin_links_are_shown_to_admins() {
873 let html = menu_html(true).await;
874 assert!(html.contains("href=\"/admin/users\""));
875 assert!(html.contains("href=\"/admin/audit\""));
876 }
877}
878
879#[cfg(test)]
880mod shell_tests {
881 use topcoat::router::request::Request;
882 use topcoat::router::{Body, to_bytes};
883
884 use super::NAV_SCRIPT_HREF;
885
886 /// Every `<script` tag carries a `src`: an inline script could not run under the CSP.
887 fn assert_no_inline_scripts(html: &str) {
888 for (at, _) in html.match_indices("<script") {
889 let tag = &html[at..];
890 let end = tag.find('>').expect("script tag closes");
891 assert!(tag[..end].contains(" src="), "inline script: {}", &tag[..=end]);
892 }
893 }
894
895 #[tokio::test]
896 async fn shell_loads_the_deferred_nav_script_without_inline_scripts() {
897 let dir = tempfile::tempdir().expect("tempdir");
898 let state = crate::test_support::test_state(dir.path()).await;
899 let router = crate::test_support::app_router(state);
900 let req = Request::builder()
901 .method("GET")
902 .uri("/login")
903 .body(Body::empty())
904 .unwrap();
905 let res = router.handle(req).await;
906 assert_eq!(res.status(), 200);
907 let csp = res.headers()["content-security-policy"].to_str().unwrap().to_owned();
908 assert!(csp.contains("default-src 'self'"), "{csp}");
909 assert!(!csp.contains("unsafe-inline"), "{csp}");
910 let html = String::from_utf8(to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec())
911 .unwrap();
912
913 let tag_at = html
914 .find(&format!("<script src=\"{NAV_SCRIPT_HREF}\""))
915 .expect("the shell loads the nav script");
916 let tag = &html[tag_at..tag_at + html[tag_at..].find('>').unwrap()];
917 assert!(tag.contains("defer"), "{tag}");
918 assert_no_inline_scripts(&html);
919
920 assert!(html.contains("<main tabindex=\"-1\">"));
921 assert!(html.contains("data-hard=\"true\" method=\"post\" action=\"/login\""));
922 }
923}