forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
41.3 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=(user_href(username.as_str()))>(mi::t(lang, MK::YourProfile))</a></li>
266 <li><a href="/settings/profile">(mi::t(lang, MK::EditProfile))</a></li>
267 <li><a href="/settings/keys">(t(lang, Key::SshKeys))</a></li>
268 <li><a href="/settings/gpg">(t(lang, Key::GpgKeys))</a></li>
269 <li><a href="/settings/tokens">(t(lang, Key::AccessTokens))</a></li>
270 <li><a href="/settings/sessions">(t(lang, Key::Sessions))</a></li>
271 <li><a href="/settings/security">(t(lang, Key::Security))</a></li>
272 <li><a href="/settings/notifications">(t(lang, Key::MenuNotificationEmails))</a></li>
273 <li><a href="/settings/appearance">(mi::t(lang, MK::Appearance))</a></li>
274 if is_admin {
275 <li><a href="/admin/users">(t(lang, Key::AdminUsers))</a></li>
276 <li><a href="/admin/audit">(t(lang, Key::AdminAuditLog))</a></li>
277 <li><a href="/admin/runners">(t(lang, Key::AdminRunners))</a></li>
278 }
279 <li>
280 <form data-hard="true" method="post" action="/logout">
281 <input type="hidden" name="csrf" value=(csrf)>
282 <button type="submit" class="link-button">(t(lang, Key::LogOut))</button>
283 </form>
284 </li>
285 </ul>
286 </details>
287 } else {
288 <a class="topbar-link" href="/explore">(t(lang, Key::Explore))</a>
289 <a class="button primary" href="/login">(t(lang, Key::LogIn))</a>
290 }
291 </header>
292 <div class="shell">
293 if signed_in {
294 <aside class="shell-sidebar">
295 side_nav(lang: lang, current: current, unread: unread_aside, repos: side_repos)
296 </aside>
297 }
298 <main tabindex="-1">
299 (child)
300 </main>
301 </div>
302 <footer class="site-footer">
303 <span>"forge"</span>
304 if anonymous {
305 <form method="post" action="/-/theme" class="theme-picker">
306 <label>
307 <span>(mi::t(lang, MK::ThemeLabel))</span>
308 <select name="theme">
309 <option value="auto">(mi::t(lang, MK::MatchSystem))</option>
310 <option value="light">(mi::t(lang, MK::Light))</option>
311 <option value="dark">(mi::t(lang, MK::Dark))</option>
312 </select>
313 </label>
314 <button type="submit" class="link-button">(mi::t(lang, MK::ApplyTheme))</button>
315 </form>
316 }
317 </footer>
318 </body>
319 </html>
320 })
321}
322
323/// The signed-in global links and the viewer's repos. The sidebar shows it, and so does the phone
324/// menu. `current` is the key from `nav_key`; `unread` is the bell count shown next to Notifications.
325#[component]
326async fn side_nav(
327 lang: Lang,
328 current: &str,
329 unread: Option<String>,
330 repos: Vec<SideRepo>,
331) -> Result<impl View> {
332 let dashboard = current == "dashboard";
333 let explore = current == "explore";
334 let notifications = current == "notifications";
335 let snippets = current == "snippets";
336 let new_repo = current == "new";
337 let settings = current == "settings";
338 let empty = repos.is_empty();
339 Ok(view! {
340 <nav class="side-links">
341 <a class=(side_class(dashboard)) href="/" aria-current=(dashboard.then_some("page"))>
342 <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>
343 (o::t(lang, OK::Dashboard))
344 </a>
345 <a class=(side_class(explore)) href="/explore" aria-current=(explore.then_some("page"))>
346 <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>
347 (t(lang, Key::Explore))
348 </a>
349 <a class=(side_class(notifications)) href="/notifications" aria-current=(notifications.then_some("page"))>
350 <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>
351 (t(lang, Key::Notifications))
352 if let Some(count) = unread {
353 <span class="count">(count.as_str())</span>
354 }
355 </a>
356 <a class=(side_class(snippets)) href="/snippets" aria-current=(snippets.then_some("page"))>
357 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h7l5 5v13H7z"/><path d="M10 13h5M10 17h5"/></svg>
358 (cm::t(lang, CK::Snippets))
359 </a>
360 <a class=(side_class(new_repo)) href="/new" aria-current=(new_repo.then_some("page"))>
361 <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/></svg>
362 (t(lang, Key::NewRepository))
363 </a>
364 <a class=(side_class(settings)) href="/settings/profile" aria-current=(settings.then_some("page"))>
365 <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>
366 (o::t(lang, OK::Settings))
367 </a>
368 </nav>
369 <div class="side-group">
370 <h2 class="side-h">(o::t(lang, OK::YourRepositories))</h2>
371 if empty {
372 <p class="side-empty">(o::t(lang, OK::NoReposYet))</p>
373 } else {
374 <nav class="side-repos">
375 for repo in repos.into_iter() {
376 <a href=(repo.href.as_str())>(repo.name.as_str())</a>
377 }
378 </nav>
379 }
380 </div>
381 })
382}
383
384/// Name to show for a user. The reserved ghost account, which owns the content of deleted
385/// users, is shown as "Deleted user" ("Silinmiş kullanıcı"). Avatar and link lookups keep the real
386/// login name.
387pub fn display_name(lang: Lang, username: &str) -> String {
388 if forge_core::names::normalize_name(username) == forge_core::store::user_delete::GHOST_USERNAME
389 {
390 i18n::deleted_user(lang).to_owned()
391 } else {
392 username.to_owned()
393 }
394}
395
396/// URL of a user's avatar. `hash` is the avatar's content hash (`?v=` makes the response
397/// immutable); `None` gives the generated default, which is cached for a short time only.
398pub fn avatar_url(username: &str, hash: Option<&str>) -> String {
399 format!("/avatars/{}?v={}", enc(username), hash.unwrap_or("default"))
400}
401
402/// Public profile URL of a user: `/{username}`.
403pub fn user_href(username: &str) -> String {
404 format!("/{}", enc(username))
405}
406
407/// Link to a user's public profile. `login` is the real username (the link target), `shown` is
408/// the text to display (it differs from `login` only for the ghost account). Both are escaped
409/// like any other interpolation.
410#[component]
411pub async fn user_link(shown: &str, login: &str) -> Result<impl View> {
412 let href = user_href(login);
413 Ok(view! {
414 <a href=(href.as_str())>(shown)</a>
415 })
416}
417
418/// Hidden CSRF input for a POST form.
419#[component]
420pub async fn csrf_field(token: &str) -> Result<impl View> {
421 Ok(view! {
422 <input type="hidden" name="csrf" value=(token)>
423 })
424}
425
426/// Inline error box.
427#[component]
428pub async fn alert(message: &str) -> Result<impl View> {
429 Ok(view! {
430 <p class="alert" role="alert">(message)</p>
431 })
432}
433
434/// Owned copy of a user-facing error message, for the errors a form can recover from.
435/// Returns `None` for internal failures, which should propagate as 500s.
436pub fn user_facing_message(err: &CoreError) -> Option<String> {
437 match err {
438 CoreError::InvalidName(m) | CoreError::Invalid(m) | CoreError::Conflict(m) => {
439 Some(m.clone())
440 }
441 _ => None,
442 }
443}
444
445/// Adds the security headers to every response: nosniff, same-origin referrer, DENY
446/// framing, and a strict CSP. Registered on the root path so it wraps all handlers.
447#[layer("/")]
448pub async fn security_headers(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
449 let headers = response_headers(cx);
450 headers.append(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
451 headers.append(REFERRER_POLICY, HeaderValue::from_static("same-origin"));
452 headers.append(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
453 let csp = match try_app_context::<AppState>(cx) {
454 Some(state) => APP_CSP
455 .get_or_init(|| csp_header(&state.config.user_content_base_url))
456 .clone(),
457 None => HeaderValue::from_static(CSP),
458 };
459 headers.append(CONTENT_SECURITY_POLICY, csp);
460 next.run(cx, body).await
461}
462
463// Repo browsing helpers: URLs, times and sizes, and the shared breadcrumb, tabs and ref selector.
464
465/// Percent-encodes everything except unreserved characters and `/`.
466pub fn enc(s: &str) -> String {
467 let mut out = String::with_capacity(s.len());
468 for b in s.bytes() {
469 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
470 out.push(char::from(b));
471 } else {
472 out.push_str(&format!("%{b:02X}"));
473 }
474 }
475 out
476}
477
478/// `/{owner}/{repo}[/{section}][/{rest}]`. Every piece is percent-encoded, and `rest` may contain
479/// `/` (ref names such as `feature/x` and paths).
480pub fn repo_href(owner: &str, repo: &str, section: &str, rest: &str) -> String {
481 let mut href = format!("/{}/{}", enc(owner), enc(repo));
482 if !section.is_empty() {
483 href.push('/');
484 href.push_str(section);
485 }
486 let rest = rest.trim_matches('/');
487 if !rest.is_empty() {
488 href.push('/');
489 href.push_str(&enc(rest));
490 }
491 href
492}
493
494/// Current time as Unix seconds.
495pub fn now_unix() -> i64 {
496 std::time::SystemTime::now()
497 .duration_since(std::time::UNIX_EPOCH)
498 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
499 .unwrap_or(0)
500}
501
502/// ISO 8601 UTC timestamp, `YYYY-MM-DDTHH:MM:SSZ`.
503pub fn iso_time(unix: i64) -> String {
504 let days = unix.div_euclid(86_400);
505 let secs = unix.rem_euclid(86_400);
506 let (y, m, d) = civil_from_days(days);
507 format!(
508 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
509 secs / 3600,
510 secs % 3600 / 60,
511 secs % 60
512 )
513}
514
515/// Days since 1970-01-01 to (year, month, day). Howard Hinnant's civil-from-days algorithm.
516fn civil_from_days(days: i64) -> (i64, i64, i64) {
517 let z = days + 719_468;
518 let era = z.div_euclid(146_097);
519 let doe = z.rem_euclid(146_097);
520 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
521 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
522 let mp = (5 * doy + 2) / 153;
523 let d = doy - (153 * mp + 2) / 5 + 1;
524 let m = if mp < 10 { mp + 3 } else { mp - 9 };
525 let y = yoe + era * 400 + i64::from(m <= 2);
526 (y, m, d)
527}
528
529/// "just now", "5 minutes ago", "1 day ago", relative to `now`; Turkish reads "az önce" and
530/// "5 dakika önce".
531pub fn relative_time(lang: Lang, unix: i64, now: i64) -> String {
532 use i18n::TimeUnit;
533 let secs = now.saturating_sub(unix).max(0);
534 let (n, unit) = match secs {
535 0..60 => return i18n::just_now(lang).to_owned(),
536 60..3_600 => (secs / 60, TimeUnit::Minute),
537 3_600..86_400 => (secs / 3_600, TimeUnit::Hour),
538 86_400..2_592_000 => (secs / 86_400, TimeUnit::Day),
539 2_592_000..31_536_000 => (secs / 2_592_000, TimeUnit::Month),
540 _ => (secs / 31_536_000, TimeUnit::Year),
541 };
542 i18n::ago(lang, n, unit)
543}
544
545/// First seven characters of an object id.
546pub fn short_oid(oid: &str) -> &str {
547 oid.get(..7).unwrap_or(oid)
548}
549
550/// Byte count for display. Turkish uses a decimal comma ("1,5 MiB").
551pub fn human_size(lang: Lang, bytes: u64) -> String {
552 let decimal = |value: f64| {
553 let text = format!("{value:.1}");
554 match lang {
555 Lang::En => text,
556 Lang::Tr => text.replace('.', ","),
557 }
558 };
559 if bytes < 1024 {
560 match lang {
561 Lang::En => format!("{bytes} bytes"),
562 Lang::Tr => format!("{bytes} bayt"),
563 }
564 } else if bytes < 1024 * 1024 {
565 format!("{} KiB", decimal(bytes as f64 / 1024.0))
566 } else {
567 format!("{} MiB", decimal(bytes as f64 / (1024.0 * 1024.0)))
568 }
569}
570
571/// One breadcrumb segment. `href` is None for the current location.
572pub struct Crumb {
573 pub label: String,
574 pub href: Option<String>,
575}
576
577#[component]
578pub async fn breadcrumb(lang: Lang, crumbs: Vec<Crumb>) -> Result<impl View> {
579 Ok(view! {
580 <nav class="crumbs" aria-label=(mi::t(lang, MK::BreadcrumbNav))>
581 <ol>
582 for crumb in crumbs.into_iter() {
583 <li>
584 if let Some(href) = crumb.href {
585 <a href=(href)>(crumb.label)</a>
586 } else {
587 <span aria-current="page">(crumb.label)</span>
588 }
589 </li>
590 }
591 </ol>
592 </nav>
593 })
594}
595
596fn tab_class(on: bool) -> &'static str {
597 if on { "tab active" } else { "tab" }
598}
599
600/// Header tabs for a repo. `active` is one of `code`, `issues`, `commits`, `branches`, `tags`,
601/// `settings`. `open_issues` is the count shown on the Issues tab; `None` shows the tab without one.
602#[component]
603pub async fn repo_tabs(
604 owner: String,
605 repo: String,
606 default_branch: String,
607 active: String,
608 can_admin: bool,
609 lang: Lang,
610 #[default] open_issues: Option<u64>,
611 #[default] open_pulls: Option<u64>,
612 #[default] discussions: bool,
613 #[default] wiki: bool,
614 #[default] security: bool,
615) -> Result<impl View> {
616 let home = repo_href(&owner, &repo, "", "");
617 let issues = repo_href(&owner, &repo, "issues", "");
618 let issues_count = open_issues.map(|n| n.to_string());
619 let pulls = repo_href(&owner, &repo, "pulls", "");
620 let pulls_count = open_pulls.map(|n| n.to_string());
621 let pulls_on = active == "pulls";
622 let commits = repo_href(&owner, &repo, "commits", &default_branch);
623 let branches = repo_href(&owner, &repo, "branches", "");
624 let tags = repo_href(&owner, &repo, "tags", "");
625 let releases = repo_href(&owner, &repo, "releases", "");
626 let ci = repo_href(&owner, &repo, "ci", "");
627 let ci_on = active == "ci";
628 let code_on = active == "code";
629 let issues_on = active == "issues";
630 let commits_on = active == "commits";
631 let branches_on = active == "branches";
632 let tags_on = active == "tags";
633 let releases_on = active == "releases";
634 // Shown only to people who may manage the repo (owner, instance admin, or Admin collaborator).
635 let settings = repo_href(&owner, &repo, "settings", "");
636 let settings_on = active == "settings";
637 let discussions_tab = repo_href(&owner, &repo, "discussions", "");
638 let discussions_on = active == "discussions";
639 let wiki_tab = repo_href(&owner, &repo, "wiki", "");
640 let wiki_on = active == "wiki";
641 let security_tab = repo_href(&owner, &repo, "security", "");
642 let security_on = active == "security";
643 Ok(view! {
644 <nav class="repo-tabs" aria-label=(g::t(lang, K::RepositoryNav))>
645 <a class=(tab_class(code_on)) href=(home)>
646 <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>
647 (t(lang, Key::Code))
648 </a>
649 <a class=(tab_class(issues_on)) href=(issues)>
650 <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>
651 (g::t(lang, K::TabIssues))
652 if let Some(count) = issues_count {
653 " " <span class="tab-count">(count.as_str())</span>
654 }
655 </a>
656 <a class=(tab_class(pulls_on)) href=(pulls)>
657 <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>
658 (g::t(lang, K::TabPulls))
659 if let Some(count) = pulls_count {
660 " " <span class="tab-count">(count.as_str())</span>
661 }
662 </a>
663 <a class=(tab_class(commits_on)) href=(commits)>
664 <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>
665 (g::t(lang, K::TabCommits))
666 </a>
667 <a class=(tab_class(branches_on)) href=(branches)>
668 <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>
669 (g::t(lang, K::BranchesTitle))
670 </a>
671 <a class=(tab_class(tags_on)) href=(tags)>
672 <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>
673 (g::t(lang, K::TagsTitle))
674 </a>
675 <a class=(tab_class(releases_on)) href=(releases)>
676 <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>
677 (g::t(lang, K::TabReleases))
678 </a>
679 <a class=(tab_class(ci_on)) href=(ci)>
680 <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>
681 (g::t(lang, K::TabCi))
682 </a>
683 if discussions {
684 <a class=(tab_class(discussions_on)) href=(discussions_tab)>
685 <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>
686 (g::t(lang, K::TabDiscussions))
687 </a>
688 }
689 if wiki {
690 <a class=(tab_class(wiki_on)) href=(wiki_tab)>
691 <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>
692 (g::t(lang, K::TabWiki))
693 </a>
694 }
695 if security {
696 <a class=(tab_class(security_on)) href=(security_tab)>
697 <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>
698 (t(lang, Key::Security))
699 </a>
700 }
701 if can_admin {
702 <a class=(tab_class(settings_on)) href=(settings)>
703 <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>
704 (g::t(lang, K::TabSettings))
705 </a>
706 }
707 </nav>
708 })
709}
710
711/// A branch or tag in the ref selector. `href` points at its root tree.
712pub struct RefLink {
713 pub name: String,
714 pub href: String,
715 pub current: bool,
716}
717
718#[component]
719async fn ref_group(title: &str, links: Vec<RefLink>) -> Result<impl View> {
720 Ok(view! {
721 <p class="ref-heading">(title)</p>
722 <ul class="ref-list">
723 for link in links.into_iter() {
724 <li>
725 if link.current {
726 <a class="current" aria-current="true" href=(link.href)>(link.name)</a>
727 } else {
728 <a href=(link.href)>(link.name)</a>
729 }
730 </li>
731 }
732 </ul>
733 })
734}
735
736/// Branch and tag selector. A plain `<details>` list, so it works without JavaScript.
737#[component]
738pub async fn ref_selector(
739 lang: Lang,
740 current: String,
741 branches: Vec<RefLink>,
742 tags: Vec<RefLink>,
743) -> Result<impl View> {
744 let has_tags = !tags.is_empty();
745 Ok(view! {
746 <details class="ref-select">
747 <summary>
748 <span class="muted small">(mi::t(lang, MK::RefLabel))</span>
749 <code>(current)</code>
750 </summary>
751 <div class="ref-menu">
752 ref_group(title: g::t(lang, K::BranchesTitle), links: branches)
753 if has_tags {
754 ref_group(title: g::t(lang, K::TagsTitle), links: tags)
755 }
756 </div>
757 </details>
758 })
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn enc_keeps_slashes_and_escapes_the_rest() {
767 assert_eq!(enc("feature/x y#1"), "feature/x%20y%231");
768 assert_eq!(enc("a~b.c_d-e"), "a~b.c_d-e");
769 }
770
771 #[test]
772 fn repo_href_builds_each_form() {
773 assert_eq!(repo_href("alice", "demo", "", ""), "/alice/demo");
774 assert_eq!(
775 repo_href("alice", "demo", "tree", "feature/x/src"),
776 "/alice/demo/tree/feature/x/src"
777 );
778 assert_eq!(
779 repo_href("alice", "demo", "tree", "main/"),
780 "/alice/demo/tree/main"
781 );
782 }
783
784 #[test]
785 fn csp_allows_the_raw_origin_for_images() {
786 let origin = origin_of("https://raw.forge.test:8443/some/path/");
787 assert_eq!(origin.as_deref(), Some("https://raw.forge.test:8443"));
788 let csp = csp_for(origin.as_deref());
789 assert!(csp.contains("img-src 'self' data: https://raw.forge.test:8443;"));
790 assert!(csp.contains("form-action 'self'; frame-ancestors 'none'"));
791 }
792
793 #[test]
794 fn csp_falls_back_when_the_base_url_is_not_an_origin() {
795 assert_eq!(origin_of("not a url"), None);
796 assert_eq!(origin_of("ftp://files.test"), None);
797 assert_eq!(origin_of("https://user:pw@files.test"), None);
798 assert_eq!(csp_for(None), CSP);
799 }
800
801 #[test]
802 fn iso_time_matches_known_instants() {
803 assert_eq!(iso_time(0), "1970-01-01T00:00:00Z");
804 assert_eq!(iso_time(1_700_000_000), "2023-11-14T22:13:20Z");
805 assert_eq!(iso_time(951_782_400), "2000-02-29T00:00:00Z");
806 }
807
808 #[test]
809 fn relative_time_picks_the_unit() {
810 assert_eq!(relative_time(Lang::En, 100, 110), "just now");
811 assert_eq!(relative_time(Lang::En, 0, 120), "2 minutes ago");
812 assert_eq!(relative_time(Lang::En, 0, 3_600), "1 hour ago");
813 assert_eq!(relative_time(Lang::En, 0, 2 * 86_400), "2 days ago");
814 assert_eq!(relative_time(Lang::En, 10, 0), "just now");
815 assert_eq!(relative_time(Lang::En, 0, 30 * 86_400), "1 month ago");
816 assert_eq!(relative_time(Lang::En, 0, 365 * 86_400), "1 year ago");
817 assert_eq!(relative_time(Lang::En, 0, 2 * 365 * 86_400), "2 years ago");
818 assert_eq!(relative_time(Lang::Tr, 100, 110), "az önce");
819 assert_eq!(relative_time(Lang::Tr, 0, 60), "1 dakika önce");
820 assert_eq!(relative_time(Lang::Tr, 0, 5 * 60), "5 dakika önce");
821 assert_eq!(relative_time(Lang::Tr, 0, 3 * 3_600), "3 saat önce");
822 assert_eq!(relative_time(Lang::Tr, 0, 3 * 86_400), "3 gün önce");
823 assert_eq!(relative_time(Lang::Tr, 0, 2 * 2_592_000), "2 ay önce");
824 assert_eq!(relative_time(Lang::Tr, 0, 365 * 86_400), "1 yıl önce");
825 }
826
827 #[test]
828 fn short_oid_and_sizes() {
829 assert_eq!(short_oid("0123456789abcdef"), "0123456");
830 assert_eq!(human_size(Lang::En, 12), "12 bytes");
831 assert_eq!(human_size(Lang::En, 2048), "2.0 KiB");
832 assert_eq!(human_size(Lang::En, 1_572_864), "1.5 MiB");
833 assert_eq!(human_size(Lang::Tr, 512), "512 bayt");
834 assert_eq!(human_size(Lang::Tr, 2048), "2,0 KiB");
835 assert_eq!(human_size(Lang::Tr, 1_572_864), "1,5 MiB");
836 }
837
838 #[test]
839 fn ghost_and_visibility_labels_follow_the_language() {
840 assert_eq!(i18n::deleted_user(Lang::En), "Deleted user");
841 assert_eq!(i18n::deleted_user(Lang::Tr), "Silinmiş kullanıcı");
842 assert_eq!(
843 i18n::visibility_label(Lang::Tr, forge_core::Visibility::Public),
844 "herkese açık"
845 );
846 assert_eq!(
847 i18n::visibility_label(Lang::En, forge_core::Visibility::Private),
848 "private"
849 );
850 }
851
852 /// Renders `/settings/sessions` (a page that uses the shell) for a freshly signed-in user.
853 async fn menu_html(is_admin: bool) -> String {
854 use crate::auth::SESSION_COOKIE;
855 use topcoat::router::request::Request;
856 use topcoat::router::to_bytes;
857
858 let dir = tempfile::tempdir().expect("tempdir");
859 let state = crate::test_support::test_state(dir.path()).await;
860 let store = state.store.clone();
861 let router = crate::test_support::app_router(state.clone());
862 let name = if is_admin { "root" } else { "alice" };
863 let user = store
864 .create_user(name, None, "x", is_admin)
865 .await
866 .expect("user");
867 crate::test_support::create_session(&store, &user.id, "menu-token", "menu-csrf").await;
868 let req = Request::builder()
869 .method("GET")
870 .uri("/settings/sessions")
871 .header("cookie", format!("__Host-{SESSION_COOKIE}=menu-token"))
872 .body(Body::empty())
873 .expect("request");
874 let res = router.handle(req).await;
875 assert_eq!(res.status().as_u16(), 200);
876 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
877 String::from_utf8_lossy(&bytes).into_owned()
878 }
879
880 #[tokio::test]
881 async fn admin_links_are_hidden_from_non_admins() {
882 let html = menu_html(false).await;
883 assert!(html.contains("Log out"), "user menu is rendered");
884 assert!(!html.contains("/admin/users"), "no admin link for a user");
885 assert!(!html.contains("/admin/audit"), "no audit link for a user");
886 }
887
888 #[tokio::test]
889 async fn admin_links_are_shown_to_admins() {
890 let html = menu_html(true).await;
891 assert!(html.contains("href=\"/admin/users\""));
892 assert!(html.contains("href=\"/admin/audit\""));
893 }
894}
895
896#[cfg(test)]
897mod shell_tests {
898 use topcoat::router::request::Request;
899 use topcoat::router::{Body, to_bytes};
900
901 use super::NAV_SCRIPT_HREF;
902
903 /// Every `<script` tag carries a `src`: an inline script could not run under the CSP.
904 fn assert_no_inline_scripts(html: &str) {
905 for (at, _) in html.match_indices("<script") {
906 let tag = &html[at..];
907 let end = tag.find('>').expect("script tag closes");
908 assert!(
909 tag[..end].contains(" src="),
910 "inline script: {}",
911 &tag[..=end]
912 );
913 }
914 }
915
916 #[tokio::test]
917 async fn shell_loads_the_deferred_nav_script_without_inline_scripts() {
918 let dir = tempfile::tempdir().expect("tempdir");
919 let state = crate::test_support::test_state(dir.path()).await;
920 let router = crate::test_support::app_router(state);
921 let req = Request::builder()
922 .method("GET")
923 .uri("/login")
924 .body(Body::empty())
925 .unwrap();
926 let res = router.handle(req).await;
927 assert_eq!(res.status(), 200);
928 let csp = res.headers()["content-security-policy"]
929 .to_str()
930 .unwrap()
931 .to_owned();
932 assert!(csp.contains("default-src 'self'"), "{csp}");
933 assert!(!csp.contains("unsafe-inline"), "{csp}");
934 let html = String::from_utf8(
935 to_bytes(res.into_body(), usize::MAX)
936 .await
937 .unwrap()
938 .to_vec(),
939 )
940 .unwrap();
941
942 let tag_at = html
943 .find(&format!("<script src=\"{NAV_SCRIPT_HREF}\""))
944 .expect("the shell loads the nav script");
945 let tag = &html[tag_at..tag_at + html[tag_at..].find('>').unwrap()];
946 assert!(tag.contains("defer"), "{tag}");
947 assert_no_inline_scripts(&html);
948
949 assert!(html.contains("<main tabindex=\"-1\">"));
950 assert!(html.contains("data-hard=\"true\" method=\"post\" action=\"/login\""));
951 }
952}