fabrica

hanna/fabrica

89712 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The HTML page handlers of the shell: home, sign-in/out, invite completion,
6//! settings, and the theme switch.
7
8use std::collections::HashMap;
9
10use axum::Form;
11use axum::extract::{Path, State};
12use axum::http::{HeaderMap, Uri, header};
13use axum::response::{IntoResponse, Redirect, Response};
14use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
15use maud::{Markup, PreEscaped, html};
16use model::User;
17use serde::Deserialize;
18
19use crate::AppState;
20use crate::assets::Scheme;
21use crate::error::{AppError, AppResult};
22use crate::icons::{Icon, icon};
23use crate::layout::{Chrome, page};
24use crate::repo::{self, RepoEntry, repo_card};
25use crate::session::{
26 MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf,
27};
28
29/// Assemble the page chrome, ensuring the CSRF cookie and resolving the active
30/// theme from the visitor's cookie (falling back to the instance default).
31pub(crate) fn build_chrome(
32 state: &AppState,
33 jar: CookieJar,
34 user: Option<User>,
35 path: String,
36) -> (CookieJar, Chrome) {
37 let (jar, csrf) = ensure_csrf(jar, state.config.auth.cookie_secure);
38 let assets = state.assets();
39 let themes = assets.themes().to_vec();
40 let active = jar
41 .get(THEME_COOKIE)
42 .map(|c| c.value().to_string())
43 .filter(|name| assets.theme(name).is_some())
44 .unwrap_or_else(|| state.config.ui.theme.clone());
45 let scheme = assets.theme(&active).map_or(Scheme::Dark, |t| t.scheme);
46 drop(assets);
47
48 let chrome = Chrome {
49 instance_name: state.instance_name(),
50 user,
51 active_theme: active,
52 active_scheme: scheme,
53 themes,
54 allow_theme_choice: state.config.ui.allow_theme_choice,
55 allow_registration: state.allow_registration(),
56 path,
57 csrf,
58 };
59 (jar, chrome)
60}
61
62/// `GET /` — the signed-in viewer's dashboard, or the public Explore landing for
63/// anonymous visitors.
64pub async fn home(
65 State(state): State<AppState>,
66 MaybeUser(user): MaybeUser,
67 jar: CookieJar,
68 uri: Uri,
69 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
70) -> AppResult<Response> {
71 let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string());
72 if let Some(u) = user {
73 let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default(), &uri).await?;
74 Ok((jar, page(&chrome, "Dashboard", body)).into_response())
75 } else {
76 let body = explore_repos_body(&state, "", &uri).await?;
77 Ok((jar, page(&chrome, "Explore", body)).into_response())
78 }
79}
80
81/// `GET /explore` — every public repository, for anyone.
82pub async fn explore(
83 State(state): State<AppState>,
84 MaybeUser(user): MaybeUser,
85 jar: CookieJar,
86 uri: Uri,
87 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
88) -> AppResult<Response> {
89 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
90 let body = explore_repos_body(&state, &lq.q.unwrap_or_default(), &uri).await?;
91 Ok((jar, page(&chrome, "Explore", body)).into_response())
92}
93
94/// `GET /explore/users` — every user, for anyone.
95pub async fn explore_users(
96 State(state): State<AppState>,
97 MaybeUser(user): MaybeUser,
98 jar: CookieJar,
99 uri: Uri,
100 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
101) -> AppResult<Response> {
102 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
103 let body = explore_users_body(&state, &lq.q.unwrap_or_default(), &uri).await?;
104 Ok((jar, page(&chrome, "Explore", body)).into_response())
105}
106
107/// The Explore sub-navigation (Repositories / Users tabs).
108fn explore_subnav(active: &str) -> Markup {
109 html! {
110 nav class="subnav" aria-label="Explore" {
111 a href="/explore" aria-current=[(active == "repos").then_some("page")] {
112 (icon(Icon::Box)) span { "Repositories" }
113 }
114 a href="/explore/users" aria-current=[(active == "users").then_some("page")] {
115 (icon(Icon::User)) span { "Users" }
116 }
117 }
118 }
119}
120
121/// The Explore repositories tab: sub-nav, a search box, and the public repos.
122async fn explore_repos_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> {
123 let needle = q.trim().to_lowercase();
124 let all = state.store.list_repos().await?;
125 let public: Vec<_> = all
126 .into_iter()
127 .filter(|r| r.visibility == model::Visibility::Public)
128 .collect();
129 // Resolve owner names once for the cross-owner labels/links and search.
130 let mut owners: HashMap<String, String> = HashMap::new();
131 for repo in &public {
132 if !owners.contains_key(&repo.owner_id) {
133 let name = state
134 .store
135 .user_by_id(&repo.owner_id)
136 .await?
137 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
138 owners.insert(repo.owner_id.clone(), name);
139 }
140 }
141 let entries: Vec<RepoEntry> = public
142 .iter()
143 .filter_map(|r| {
144 let owner = owners.get(&r.owner_id).map_or("", String::as_str);
145 let label = format!("{owner}/{}", r.path);
146 (needle.is_empty() || label.to_lowercase().contains(&needle))
147 .then(|| RepoEntry::global(owner, r))
148 })
149 .collect();
150 let paging = repo::Pagination::from_query(uri.query());
151 let (entries, has_next) = repo::page_slice(&entries, paging);
152 Ok(html! {
153 (explore_subnav("repos"))
154 form class="search-row" method="get" action="/explore" {
155 input type="search" name="q" value=(q) placeholder="Search repositories…"
156 aria-label="Search repositories";
157 button class="btn btn-primary" type="submit" { "Search" }
158 }
159 (repo_card("", "No public repositories.", &entries))
160 @if has_next || paging.page > 1 {
161 (repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
162 }
163 })
164}
165
166/// The Explore users tab: sub-nav, a search box, and the user directory.
167async fn explore_users_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> {
168 let needle = q.trim().to_lowercase();
169 let users: Vec<User> = state
170 .store
171 .list_users()
172 .await?
173 .into_iter()
174 .filter(|u| u.disabled_at.is_none())
175 .filter(|u| {
176 needle.is_empty()
177 || u.username.to_lowercase().contains(&needle)
178 || u.display_name
179 .as_deref()
180 .is_some_and(|d| d.to_lowercase().contains(&needle))
181 })
182 .collect();
183 let paging = repo::Pagination::from_query(uri.query());
184 let (users, has_next) = repo::page_slice(&users, paging);
185 Ok(html! {
186 (explore_subnav("users"))
187 form class="search-row" method="get" action="/explore/users" {
188 input type="search" name="q" value=(q) placeholder="Search users…"
189 aria-label="Search users";
190 button class="btn btn-primary" type="submit" { "Search" }
191 }
192 (user_card(&users))
193 @if has_next || paging.page > 1 {
194 (repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
195 }
196 })
197}
198
199/// Render a directory of users as a listing card (avatar, name, join date).
200fn user_card(users: &[User]) -> Markup {
201 html! {
202 section class="listing" {
203 @if users.is_empty() {
204 div class="card" { p class="muted" { "No users." } }
205 } @else {
206 ul class="repo-list card" {
207 @for u in users {
208 li {
209 a class="user-row" href={ "/" (u.username) } {
210 img class="avatar" src={ "/avatar/" (u.username) } alt="";
211 span class="user-row-main" {
212 span class="user-row-name" {
213 (u.display_name.as_deref().unwrap_or(&u.username))
214 }
215 span class="user-row-meta muted" {
216 "@" (u.username) " · Joined on " (repo::fmt_joined(u.created_at))
217 }
218 }
219 }
220 }
221 }
222 }
223 }
224 }
225 }
226}
227
228/// The dashboard: the viewer's contribution activity and recent commits, beside
229/// a filterable list of their repositories.
230async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> AppResult<Markup> {
231 // The sidebar shows a capped slice (the dashboard's `?page` drives the
232 // activity feed); "View all" leads to the paginated profile repos tab.
233 const SIDEBAR_REPOS: usize = 12;
234 let now = now_ms();
235 let activity = crate::activity::compute(state, user, now).await?;
236 let paging = repo::Pagination::from_query(uri.query());
237
238 let mine = state.store.repos_by_owner(&user.id).await?;
239 let needle = q.trim().to_lowercase();
240 let entries: Vec<RepoEntry> = mine
241 .iter()
242 .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))
243 .map(|r| RepoEntry::owned(&user.username, r))
244 .collect();
245 let total = entries.len();
246 let shown: Vec<RepoEntry> = entries.into_iter().take(SIDEBAR_REPOS).collect();
247
248 Ok(html! {
249 div class="dashboard" {
250 div class="dashboard-main" {
251 (activity.heatmap())
252 (activity.feed_section(now, paging, uri.path(), uri.query()))
253 }
254 aside class="dashboard-side" {
255 form class="search-row" method="get" {
256 input type="search" name="q" value=(q)
257 placeholder="Search repositories…" aria-label="Search repositories";
258 button class="btn btn-primary" type="submit" { "Search" }
259 }
260 (repo_card("Repositories", "You have no repositories yet.", &shown))
261 @if total > SIDEBAR_REPOS {
262 p class="dashboard-viewall" {
263 a href={ "/" (user.username) "?tab=repos" } {
264 "View all " (total) " repositories →"
265 }
266 }
267 }
268 }
269 }
270 })
271}
272
273/// `GET /new/issue` — choose a repository to open an issue on.
274pub async fn new_issue_chooser(
275 State(state): State<AppState>,
276 RequireUser(user): RequireUser,
277 jar: CookieJar,
278 uri: Uri,
279) -> AppResult<Response> {
280 let body = repo_chooser(&state, &user, "issues", "issue").await?;
281 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
282 Ok((jar, page(&chrome, "New issue", body)).into_response())
283}
284
285/// `GET /new/pull` — choose a repository to open a pull request on.
286pub async fn new_pull_chooser(
287 State(state): State<AppState>,
288 RequireUser(user): RequireUser,
289 jar: CookieJar,
290 uri: Uri,
291) -> AppResult<Response> {
292 let body = repo_chooser(&state, &user, "pulls", "pull request").await?;
293 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
294 Ok((jar, page(&chrome, "New pull request", body)).into_response())
295}
296
297/// A "pick a repository" list linking each of the viewer's repos (with the
298/// relevant feature enabled) to its new-issue / new-pull form.
299async fn repo_chooser(state: &AppState, user: &User, seg: &str, noun: &str) -> AppResult<Markup> {
300 let repos = state.store.repos_by_owner(&user.id).await?;
301 let usable: Vec<_> = repos
302 .iter()
303 .filter(|r| {
304 if seg == "pulls" {
305 r.pulls_enabled
306 } else {
307 r.issues_enabled
308 }
309 })
310 .collect();
311 Ok(html! {
312 div class="new-repo" {
313 h1 { "New " (noun) }
314 p class="muted field-hint" { "Choose a repository." }
315 @if usable.is_empty() {
316 div class="card" {
317 p class="muted" { "You have no repositories with " (noun) "s enabled." }
318 }
319 } @else {
320 ul class="repo-list card" {
321 @for r in &usable {
322 li {
323 a class="repo-row" href={ "/" (user.username) "/" (r.path) "/-/" (seg) "/new" } {
324 span class="repo-row-name" {
325 (icon(Icon::Box)) span { (r.path) }
326 }
327 }
328 }
329 }
330 }
331 }
332 }
333 })
334}
335
336/// `GET /new` — the new-repository form (requires sign-in).
337pub async fn new_repo_form(
338 State(state): State<AppState>,
339 RequireUser(user): RequireUser,
340 jar: CookieJar,
341 uri: Uri,
342) -> Response {
343 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
344 let body = new_repo_body(&state, &chrome.csrf, None, "");
345 (jar, page(&chrome, "New repository", body)).into_response()
346}
347
348/// New-repository form fields.
349#[derive(Debug, Deserialize)]
350pub struct NewRepoForm {
351 /// The repo name (may include a group path).
352 #[serde(default)]
353 name: String,
354 /// Optional one-line description.
355 #[serde(default)]
356 description: String,
357 /// Visibility token (`public` | `internal` | `private`).
358 #[serde(default)]
359 visibility: String,
360 /// Optional default branch override.
361 #[serde(default)]
362 default_branch: String,
363 /// Object format (`sha1` | `sha256`).
364 #[serde(default)]
365 object_format: String,
366 /// CSRF token.
367 #[serde(rename = "_csrf")]
368 csrf: String,
369}
370
371/// `POST /new` — create the repository (row + bare repo on disk) and redirect.
372pub async fn new_repo_submit(
373 State(state): State<AppState>,
374 RequireUser(user): RequireUser,
375 jar: CookieJar,
376 headers: HeaderMap,
377 Form(form): Form<NewRepoForm>,
378) -> Response {
379 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
380 return AppError::Forbidden.into_response();
381 }
382 match create_repo_from_form(&state, &user, &form).await {
383 Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
384 Err(msg) => {
385 let (jar, chrome) = build_chrome(&state, jar, Some(user), "/new".to_string());
386 let body = new_repo_body(&state, &chrome.csrf, Some(&msg), form.name.trim());
387 (
388 axum::http::StatusCode::BAD_REQUEST,
389 jar,
390 page(&chrome, "New repository", body),
391 )
392 .into_response()
393 }
394 }
395}
396
397/// Create the repo described by `form`, returning its path or a user-facing error.
398async fn create_repo_from_form(
399 state: &AppState,
400 user: &User,
401 form: &NewRepoForm,
402) -> Result<String, String> {
403 let name = form.name.trim();
404 if name.is_empty() {
405 return Err("Repository name is required.".to_string());
406 }
407 let (group, leaf) = match name.rsplit_once('/') {
408 Some((g, l)) => (Some(g), l),
409 None => (None, name),
410 };
411 let group_id = match group {
412 Some(g) => Some(
413 state
414 .store
415 .ensure_group_path(&user.id, g)
416 .await
417 .map_err(|e| e.to_string())?
418 .id,
419 ),
420 None => None,
421 };
422 let branch = {
423 let b = form.default_branch.trim();
424 if b.is_empty() {
425 state.config.instance.default_branch.clone()
426 } else {
427 b.to_string()
428 }
429 };
430 let mut visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| {
431 model::Visibility::from_token(&state.default_visibility_token())
432 .unwrap_or(model::Visibility::Private)
433 });
434 // A repo may never be more visible than its group (the ceiling walks the
435 // whole ancestor chain; auto groups are public and impose nothing).
436 if let Some(gid) = &group_id {
437 let ceiling = state
438 .store
439 .group_visibility_ceiling(gid)
440 .await
441 .map_err(|e| e.to_string())?;
442 if visibility.rank() > ceiling.rank() {
443 visibility = ceiling;
444 }
445 }
446 let description = {
447 let d = form.description.trim();
448 (!d.is_empty()).then(|| d.to_string())
449 };
450 let repo = state
451 .store
452 .create_repo(store::NewRepo {
453 owner_id: user.id.clone(),
454 group_id,
455 name: leaf.to_string(),
456 path: name.to_string(),
457 description,
458 visibility,
459 default_branch: branch.clone(),
460 })
461 .await
462 .map_err(|e| match e {
463 store::StoreError::Conflict { .. } => {
464 "A repository with that name already exists.".to_string()
465 }
466 store::StoreError::Name(_) => {
467 "Invalid name: use letters, digits, '-', '_', and '/' for groups.".to_string()
468 }
469 other => other.to_string(),
470 })?;
471
472 let format = git::ObjectFormat::from_token(&form.object_format);
473 if format != git::ObjectFormat::Sha1 {
474 // Record the non-default format on the row before creating the on-disk repo.
475 let _ = state
476 .store
477 .set_object_format(&repo.id, format.as_str())
478 .await;
479 }
480 let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica"));
481 if let Err(e) = git::create_bare_with_format(
482 &state.config.storage.repo_dir,
483 &repo.id,
484 &branch,
485 &hook,
486 format,
487 ) {
488 let _ = state.store.delete_repo(&repo.id).await;
489 return Err(format!("Could not create the repository on disk: {e}"));
490 }
491 Ok(repo.path)
492}
493
494// ---- Migrate from Git ----
495
496/// `GET /new/migrate` — the import form.
497pub async fn migrate_form(
498 State(state): State<AppState>,
499 RequireUser(user): RequireUser,
500 jar: CookieJar,
501 uri: Uri,
502) -> Response {
503 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
504 let body = migrate_body(&chrome.csrf, &user.username, None, "", "");
505 (jar, page(&chrome, "New migration", body)).into_response()
506}
507
508/// Migrate-from-Git form fields.
509#[derive(Debug, Deserialize)]
510pub struct MigrateForm {
511 #[serde(default)]
512 remote_url: String,
513 #[serde(default)]
514 username: String,
515 #[serde(default)]
516 secret: String,
517 /// Keep syncing from the source (create a pull mirror).
518 #[serde(default)]
519 mirror: Option<String>,
520 /// Also fetch LFS objects (best-effort; requires git-lfs on the server).
521 #[serde(default)]
522 migrate_lfs: Option<String>,
523 #[serde(default)]
524 name: String,
525 #[serde(default)]
526 description: String,
527 #[serde(default)]
528 private: Option<String>,
529 #[serde(rename = "_csrf")]
530 csrf: String,
531}
532
533/// `POST /new/migrate` — clone the source into a new repo (and optionally mirror).
534pub async fn migrate_submit(
535 State(state): State<AppState>,
536 RequireUser(user): RequireUser,
537 jar: CookieJar,
538 headers: HeaderMap,
539 Form(form): Form<MigrateForm>,
540) -> Response {
541 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
542 return AppError::Forbidden.into_response();
543 }
544 match do_migrate(&state, &user, &form).await {
545 Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
546 Err(msg) => {
547 let (jar, chrome) =
548 build_chrome(&state, jar, Some(user.clone()), "/new/migrate".to_string());
549 let body = migrate_body(
550 &chrome.csrf,
551 &user.username,
552 Some(&msg),
553 form.remote_url.trim(),
554 form.name.trim(),
555 );
556 (
557 axum::http::StatusCode::BAD_REQUEST,
558 jar,
559 page(&chrome, "New migration", body),
560 )
561 .into_response()
562 }
563 }
564}
565
566/// Create the target repo, fetch the source into it once, and (if requested)
567/// register a pull mirror. Returns the new repo path or a user-facing error.
568async fn do_migrate(state: &AppState, user: &User, form: &MigrateForm) -> Result<String, String> {
569 let remote = form.remote_url.trim();
570 if remote.is_empty() {
571 return Err("A source URL is required.".to_string());
572 }
573 if form.name.trim().is_empty() {
574 return Err("Repository name is required.".to_string());
575 }
576 let visibility = if form.private.is_some() {
577 "private".to_string()
578 } else {
579 state.default_visibility_token()
580 };
581 // Reuse the normal create path (row + bare repo on disk with hooks).
582 let repo_form = NewRepoForm {
583 name: form.name.clone(),
584 description: form.description.clone(),
585 visibility,
586 default_branch: String::new(),
587 object_format: String::new(),
588 csrf: String::new(),
589 };
590 let path = create_repo_from_form(state, user, &repo_form).await?;
591 let repo = state
592 .store
593 .repo_by_owner_path(&user.id, &path)
594 .await
595 .ok()
596 .flatten()
597 .ok_or_else(|| "The new repository could not be found.".to_string())?;
598
599 let repo_dir =
600 git::repo_path(&state.config.storage.repo_dir, &repo.id).map_err(|e| e.to_string())?;
601 let home = state.config.storage.data_dir.join("tmp");
602 let username = (!form.username.trim().is_empty()).then(|| form.username.trim().to_string());
603 let secret = (!form.secret.trim().is_empty()).then(|| form.secret.trim().to_string());
604 let cred = git::mirror::RemoteCred {
605 username: username.as_deref(),
606 secret: secret.as_deref(),
607 };
608 if let Err(e) =
609 git::mirror::fetch(&state.config.git.binary, &repo_dir, remote, cred, &home).await
610 {
611 // Roll back the half-created repo so a failed import leaves nothing behind.
612 let _ = state.store.delete_repo(&repo.id).await;
613 return Err(format!("Could not fetch from the source: {e}"));
614 }
615 if form.migrate_lfs.is_some() {
616 tracing::info!(repo.id = %repo.id, "LFS migration requested (pointers imported; objects not fetched)");
617 }
618 let size = i64::try_from(crate::admin::dir_size(&repo_dir)).unwrap_or(0);
619 let _ = state.store.record_push(&repo.id, size).await;
620
621 if form.mirror.is_some() {
622 let _ = state
623 .store
624 .create_mirror(store::NewMirror {
625 repo_id: repo.id.clone(),
626 direction: store::MirrorDirection::Pull,
627 remote_url: remote.to_string(),
628 username,
629 secret,
630 branch_filter: None,
631 interval_secs: 8 * 3600,
632 sync_on_push: false,
633 })
634 .await;
635 // A mirror is a read-only copy: mark it, and disable issues/PRs.
636 let _ = state.store.set_mirror(&repo.id, true).await;
637 let _ = state
638 .store
639 .set_feature_enabled(&repo.id, "issues", false)
640 .await;
641 let _ = state
642 .store
643 .set_feature_enabled(&repo.id, "pulls", false)
644 .await;
645 }
646 Ok(path)
647}
648
649/// Render the migrate form with an optional error and prefilled fields.
650fn migrate_body(csrf: &str, owner: &str, error: Option<&str>, url: &str, name: &str) -> Markup {
651 html! {
652 div class="new-repo" {
653 h1 { "New migration" }
654 @if let Some(msg) = error { div class="notice notice-error" { (msg) } }
655 div class="card" {
656 form method="post" action="/new/migrate" {
657 input type="hidden" name="_csrf" value=(csrf);
658 label for="mg-url" { "Migrate / clone from URL" }
659 input type="url" id="mg-url" name="remote_url" value=(url)
660 placeholder="https://example.com/owner/repo.git" required;
661 p class="muted field-hint" { "The HTTP(S) or git clone URL of an existing repository." }
662 div class="admin-form-row" {
663 input type="text" name="username" placeholder="username (optional)" autocomplete="off";
664 input type="password" name="secret" placeholder="password / token (optional)" autocomplete="off";
665 }
666 label { "Owner" }
667 input type="text" value=(owner) disabled;
668 label for="mg-name" { "Repository name" }
669 input type="text" id="mg-name" name="name" value=(name) required;
670 label for="mg-desc" { "Description" }
671 textarea id="mg-desc" name="description" rows="3" {}
672 div class="admin-form-actions" {
673 button class="btn btn-primary inline-btn" type="submit" { "Migrate" }
674 label class="checkbox" { input type="checkbox" name="mirror" value="1"; " Mirror" }
675 label class="checkbox" { input type="checkbox" name="migrate_lfs" value="1"; " LFS Files" }
676 label class="checkbox" { input type="checkbox" name="private" value="1"; " Private" }
677 }
678 }
679 }
680 }
681 }
682}
683
684/// Render the new-repository form with an optional error and prefilled name.
685fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup {
686 let default_vis = state.default_visibility_token();
687 let vis_option = |value: &str, label: &str| {
688 html! { option value=(value) selected[value == default_vis] { (label) } }
689 };
690 html! {
691 div class="new-repo" {
692 h1 { "New repository" }
693 @if let Some(err) = error {
694 div class="notice notice-error" { (err) }
695 }
696 div class="card" {
697 form method="post" action="/new" {
698 input type="hidden" name="_csrf" value=(csrf);
699 label for="name" { "Repository name" }
700 input type="text" id="name" name="name" value=(name) required
701 placeholder="my-project (or group/my-project)" autofocus;
702 label for="description" { "Description" }
703 input type="text" id="description" name="description"
704 placeholder="Optional one-line description";
705 label for="visibility" { "Visibility" }
706 select id="visibility" name="visibility" {
707 (vis_option("public", "Public — visible to everyone"))
708 (vis_option("internal", "Internal — any signed-in user"))
709 (vis_option("private", "Private — only you and collaborators"))
710 }
711 label for="default_branch" { "Default branch" }
712 input type="text" id="default_branch" name="default_branch"
713 placeholder=(state.config.instance.default_branch);
714 label for="object_format" { "Object format" }
715 select id="object_format" name="object_format" {
716 option value="sha1" selected { "SHA-1 (legacy, widely compatible)" }
717 option value="sha256" { "SHA-256 (modern, experimental tooling support)" }
718 }
719 p class="muted field-hint" {
720 "SHA-256 repositories are collision-resistant but not yet supported by all git clients and hosts. This cannot be changed later."
721 }
722 button class="btn btn-primary" type="submit" { "Create repository" }
723 }
724 }
725 }
726 }
727}
728
729/// Sign-in form fields.
730#[derive(Debug, Deserialize)]
731pub struct LoginForm {
732 /// Username.
733 username: String,
734 /// Password.
735 password: String,
736 /// CSRF token (`_csrf`).
737 #[serde(rename = "_csrf")]
738 csrf: String,
739}
740
741/// `GET /login` — the sign-in form.
742pub async fn login_form(
743 State(state): State<AppState>,
744 MaybeUser(user): MaybeUser,
745 jar: CookieJar,
746 uri: Uri,
747) -> Response {
748 if user.is_some() {
749 return Redirect::to("/").into_response();
750 }
751 let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
752 let body = login_body(&chrome.csrf, None, chrome.allow_registration);
753 (jar, page(&chrome, "Sign in", body)).into_response()
754}
755
756/// Render the login form with an optional error notice.
757fn login_body(csrf: &str, error: Option<&str>, allow_registration: bool) -> Markup {
758 html! {
759 div class="auth-wrap" {
760 div class="auth-card" {
761 h1 { "Sign in" }
762 @if let Some(err) = error {
763 div class="notice notice-error" { (err) }
764 }
765 form method="post" action="/login" {
766 input type="hidden" name="_csrf" value=(csrf);
767 label for="username" { "Username" }
768 input type="text" id="username" name="username" autocomplete="username" required;
769 label for="password" { "Password" }
770 input type="password" id="password" name="password"
771 autocomplete="current-password" required;
772 button class="btn btn-primary" type="submit" { "Sign in" }
773 }
774 @if allow_registration {
775 p class="muted auth-alt" { "New here? " a href="/register" { "Create an account" } }
776 }
777 }
778 }
779 }
780}
781
782/// `POST /login` — verify credentials and open a session.
783pub async fn login_submit(
784 State(state): State<AppState>,
785 jar: CookieJar,
786 headers: HeaderMap,
787 Form(form): Form<LoginForm>,
788) -> Response {
789 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
790 return AppError::Forbidden.into_response();
791 }
792
793 let user = state
794 .store
795 .user_by_username(&form.username)
796 .await
797 .ok()
798 .flatten();
799 let hash = user.as_ref().and_then(|u| u.password_hash.clone());
800 // Constant-time verify; a missing user still runs a dummy hash inside.
801 let ok = auth::verify_password(&form.password, hash.as_deref())
802 && user
803 .as_ref()
804 .is_some_and(|u| u.disabled_at.is_none() && u.password_hash.is_some());
805
806 let Some(user) = user.filter(|_| ok) else {
807 // Re-render the form with a generic error (no username enumeration).
808 let (jar, chrome) = build_chrome(&state, jar, None, "/login".to_string());
809 let body = login_body(
810 &chrome.csrf,
811 Some("Incorrect username or password."),
812 chrome.allow_registration,
813 );
814 return (
815 axum::http::StatusCode::UNAUTHORIZED,
816 jar,
817 page(&chrome, "Sign in", body),
818 )
819 .into_response();
820 };
821
822 match open_session(&state, &jar, &user, &headers).await {
823 Ok(jar) => (jar, Redirect::to("/")).into_response(),
824 Err(err) => err.into_response(),
825 }
826}
827
828/// Mint a session, persist it, and return the jar with the session cookie set.
829async fn open_session(
830 state: &AppState,
831 jar: &CookieJar,
832 user: &User,
833 headers: &HeaderMap,
834) -> AppResult<CookieJar> {
835 let token = auth::new_session_token();
836 let ttl_ms = i64::from(state.config.auth.session_ttl_days).saturating_mul(86_400_000);
837 let user_agent = headers
838 .get(header::USER_AGENT)
839 .and_then(|v| v.to_str().ok())
840 .map(str::to_string);
841 state
842 .store
843 .create_session(store::NewSession {
844 id: token.id,
845 user_id: user.id.clone(),
846 user_agent,
847 ip: None,
848 expires_at: now_ms().saturating_add(ttl_ms),
849 })
850 .await?;
851 let cookie = session_cookie(
852 &state.config.auth.cookie_name,
853 token.cookie,
854 state.config.auth.session_ttl_days,
855 state.config.auth.cookie_secure,
856 );
857 Ok(jar.clone().add(cookie))
858}
859
860/// Sign-up form fields.
861#[derive(Debug, Deserialize)]
862pub struct RegisterForm {
863 /// Desired username.
864 #[serde(default)]
865 username: String,
866 /// Email address.
867 #[serde(default)]
868 email: String,
869 /// Password.
870 #[serde(default)]
871 password: String,
872 /// Confirmation.
873 #[serde(default)]
874 confirm: String,
875 /// hCaptcha solved token (populated by the hCaptcha widget).
876 #[serde(default, rename = "h-captcha-response")]
877 hcaptcha_response: String,
878 /// reCAPTCHA solved token (populated by the reCAPTCHA widget).
879 #[serde(default, rename = "g-recaptcha-response")]
880 grecaptcha_response: String,
881 /// A sign-up invite token, when registering via an invite link.
882 #[serde(default)]
883 invite: String,
884 /// CSRF token.
885 #[serde(rename = "_csrf")]
886 csrf: String,
887}
888
889/// The captcha provider and public site key when captcha is enabled, for
890/// rendering the widget on the sign-up form.
891fn captcha_view(cfg: &config::Config) -> Option<(config::CaptchaProvider, String)> {
892 cfg.captcha
893 .enabled()
894 .then(|| (cfg.captcha.provider, cfg.captcha.site_key.clone()))
895}
896
897/// `GET /register` — the sign-up form, or `404` when registration is disabled.
898pub async fn register_form(
899 State(state): State<AppState>,
900 MaybeUser(user): MaybeUser,
901 jar: CookieJar,
902 uri: Uri,
903) -> Response {
904 if !state.allow_registration() {
905 return AppError::NotFound.into_response();
906 }
907 if user.is_some() {
908 return Redirect::to("/").into_response();
909 }
910 let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
911 let body = register_body(
912 &chrome.csrf,
913 None,
914 "",
915 "",
916 captcha_view(&state.config).as_ref(),
917 None,
918 );
919 (jar, page(&chrome, "Sign up", body)).into_response()
920}
921
922/// `GET /register/{token}` — the sign-up form reached via an admin invite link.
923/// Works even when self-registration is disabled, provided the token is valid.
924pub async fn register_invite_form(
925 State(state): State<AppState>,
926 MaybeUser(user): MaybeUser,
927 jar: CookieJar,
928 uri: Uri,
929 Path(token): Path<String>,
930) -> Response {
931 if user.is_some() {
932 return Redirect::to("/").into_response();
933 }
934 let hash = auth::session_id(&token);
935 match state.store.signup_invite_valid(&hash).await {
936 Ok(Some(_)) => {}
937 Ok(None) => {
938 return AppError::BadRequest(
939 "That invite link is invalid or has been used.".to_string(),
940 )
941 .into_response();
942 }
943 Err(err) => return AppError::from(err).into_response(),
944 }
945 let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
946 let body = register_body(
947 &chrome.csrf,
948 None,
949 "",
950 "",
951 captcha_view(&state.config).as_ref(),
952 Some(&token),
953 );
954 (jar, page(&chrome, "Sign up", body)).into_response()
955}
956
957/// Render the sign-up form with an optional error, prefilled fields, and the
958/// captcha widget when one is configured.
959fn register_body(
960 csrf: &str,
961 error: Option<&str>,
962 username: &str,
963 email: &str,
964 captcha: Option<&(config::CaptchaProvider, String)>,
965 invite: Option<&str>,
966) -> Markup {
967 html! {
968 div class="auth-wrap" {
969 div class="auth-card" {
970 h1 { "Sign up" }
971 @if invite.is_some() {
972 div class="notice notice-success" { "You've been invited to create an account." }
973 }
974 @if let Some(err) = error {
975 div class="notice notice-error" { (err) }
976 }
977 form method="post" action="/register" {
978 input type="hidden" name="_csrf" value=(csrf);
979 @if let Some(token) = invite {
980 input type="hidden" name="invite" value=(token);
981 }
982 label for="username" { "Username" }
983 input type="text" id="username" name="username" value=(username)
984 autocomplete="username" required;
985 label for="email" { "Email" }
986 input type="email" id="email" name="email" value=(email)
987 autocomplete="email" required;
988 label for="password" { "Password" }
989 input type="password" id="password" name="password"
990 autocomplete="new-password" required minlength="8";
991 label for="confirm" { "Confirm password" }
992 input type="password" id="confirm" name="confirm"
993 autocomplete="new-password" required;
994 @if let Some((provider, site_key)) = captcha {
995 @if let Some(script) = provider.script_url() {
996 (PreEscaped(format!("<script src=\"{script}\" async defer></script>")))
997 div class=(provider.widget_class()) data-sitekey=(site_key) {}
998 }
999 }
1000 button class="btn btn-primary" type="submit" { "Create account" }
1001 }
1002 p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } }
1003 }
1004 }
1005 }
1006}
1007
1008/// `POST /register` — create an account and sign in.
1009#[allow(clippy::too_many_lines)] // Validation, invite redemption, and session setup.
1010pub async fn register_submit(
1011 State(state): State<AppState>,
1012 jar: CookieJar,
1013 headers: HeaderMap,
1014 Form(form): Form<RegisterForm>,
1015) -> Response {
1016 // Registration is allowed either globally or via a valid invite token.
1017 let invite_hash =
1018 (!form.invite.trim().is_empty()).then(|| auth::session_id(form.invite.trim()));
1019 let invite_ok = match &invite_hash {
1020 Some(h) => matches!(state.store.signup_invite_valid(h).await, Ok(Some(_))),
1021 None => false,
1022 };
1023 if !state.allow_registration() && !invite_ok {
1024 return AppError::NotFound.into_response();
1025 }
1026 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1027 return AppError::Forbidden.into_response();
1028 }
1029 let username = form.username.trim().to_string();
1030 let email = form.email.trim().to_string();
1031 let invite_field = (!form.invite.trim().is_empty()).then_some(form.invite.trim());
1032
1033 let captcha = captcha_view(&state.config);
1034 let reject = |msg: &str, username: &str, email: &str| {
1035 let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string());
1036 let body = register_body(
1037 &chrome.csrf,
1038 Some(msg),
1039 username,
1040 email,
1041 captcha.as_ref(),
1042 invite_field,
1043 );
1044 (
1045 axum::http::StatusCode::BAD_REQUEST,
1046 jar,
1047 page(&chrome, "Sign up", body),
1048 )
1049 .into_response()
1050 };
1051
1052 if !email.contains('@') {
1053 return reject("Enter a valid email address.", &username, &email);
1054 }
1055 if form.password != form.confirm {
1056 return reject("Passwords do not match.", &username, &email);
1057 }
1058 if form.password.len() < 8 {
1059 return reject("Password must be at least 8 characters.", &username, &email);
1060 }
1061
1062 // When captcha is enabled, the solved token must verify with the provider
1063 // before we touch the store.
1064 if state.config.captcha.enabled() {
1065 let token = match state.config.captcha.provider {
1066 config::CaptchaProvider::ReCaptcha => form.grecaptcha_response.trim(),
1067 _ => form.hcaptcha_response.trim(),
1068 };
1069 if !verify_captcha(&state.config.captcha, token).await {
1070 return reject(
1071 "Captcha verification failed. Please try again.",
1072 &username,
1073 &email,
1074 );
1075 }
1076 }
1077
1078 let params = auth::HashParams {
1079 m_cost: state.config.auth.argon2_m_cost,
1080 t_cost: state.config.auth.argon2_t_cost,
1081 p_cost: state.config.auth.argon2_p_cost,
1082 };
1083 let Ok(hash) = auth::hash_password(&form.password, params) else {
1084 return AppError::internal("password hashing failed").into_response();
1085 };
1086
1087 let created = state
1088 .store
1089 .create_user(store::NewUser {
1090 username: username.clone(),
1091 email: email.clone(),
1092 display_name: None,
1093 password_hash: Some(hash),
1094 is_admin: false,
1095 must_change_password: false,
1096 })
1097 .await;
1098 let user = match created {
1099 Ok(u) => u,
1100 Err(store::StoreError::Conflict { field, .. }) => {
1101 let msg = if field == "email" {
1102 "That email is already in use."
1103 } else {
1104 "That username is already taken."
1105 };
1106 return reject(msg, &username, &email);
1107 }
1108 Err(store::StoreError::Name(_)) => {
1109 return reject(
1110 "Invalid username: use letters, digits, '-', and '_'.",
1111 &username,
1112 &email,
1113 );
1114 }
1115 Err(err) => return AppError::from(err).into_response(),
1116 };
1117
1118 // Consume the invite (if any) now that the account exists.
1119 if let Some(h) = &invite_hash {
1120 let _ = state.store.redeem_signup_invite(h, &user.id).await;
1121 }
1122
1123 // Send a verification email for the new primary address (best-effort).
1124 if let Ok(emails) = state.store.emails_by_user(&user.id).await
1125 && let Some(primary) = emails.into_iter().find(|e| e.is_primary)
1126 {
1127 send_email_verification(&state, &primary).await;
1128 }
1129
1130 match open_session(&state, &jar, &user, &headers).await {
1131 Ok(jar) => (jar, Redirect::to("/")).into_response(),
1132 Err(err) => err.into_response(),
1133 }
1134}
1135
1136/// The relevant fields of a provider `siteverify` response.
1137#[derive(Debug, Deserialize)]
1138struct SiteVerify {
1139 /// Whether the token was valid.
1140 #[serde(default)]
1141 success: bool,
1142}
1143
1144/// Verify a solved captcha `token` with the configured provider. Returns `false`
1145/// on an empty token, a missing secret, a network error, or a rejected token — a
1146/// closed failure mode, so a broken verifier never lets a bot through.
1147async fn verify_captcha(cfg: &config::Captcha, token: &str) -> bool {
1148 if token.is_empty() {
1149 return false;
1150 }
1151 let Some(secret) = cfg.secret_key.as_ref() else {
1152 return false;
1153 };
1154 let Ok(client) = reqwest::Client::builder()
1155 .timeout(std::time::Duration::from_secs(10))
1156 .build()
1157 else {
1158 return false;
1159 };
1160 let form = [("secret", secret.expose()), ("response", token)];
1161 match client
1162 .post(cfg.provider.verify_url())
1163 .form(&form)
1164 .send()
1165 .await
1166 {
1167 Ok(resp) => resp.json::<SiteVerify>().await.is_ok_and(|v| v.success),
1168 Err(_) => false,
1169 }
1170}
1171
1172/// Logout form field.
1173#[derive(Debug, Deserialize)]
1174pub struct CsrfForm {
1175 /// CSRF token (`_csrf`).
1176 #[serde(rename = "_csrf")]
1177 csrf: String,
1178}
1179
1180/// `POST /logout` — destroy the session.
1181pub async fn logout(
1182 State(state): State<AppState>,
1183 jar: CookieJar,
1184 headers: HeaderMap,
1185 Form(form): Form<CsrfForm>,
1186) -> Response {
1187 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1188 return AppError::Forbidden.into_response();
1189 }
1190 if let Some(cookie) = jar.get(&state.config.auth.cookie_name) {
1191 let session_id = auth::session_id(cookie.value());
1192 let _ = state.store.delete_session(&session_id).await;
1193 }
1194 let jar = jar.add(clear_cookie(&state.config.auth.cookie_name));
1195 (jar, Redirect::to("/login")).into_response()
1196}
1197
1198/// Invite set-password form.
1199#[derive(Debug, Deserialize)]
1200pub struct InviteForm {
1201 /// New password.
1202 password: String,
1203 /// Confirmation.
1204 confirm: String,
1205 /// CSRF token (`_csrf`).
1206 #[serde(rename = "_csrf")]
1207 csrf: String,
1208}
1209
1210/// `GET /invite/{token}` — show the activation form, or an error if the invite is
1211/// invalid, used, or expired.
1212pub async fn invite_form(
1213 State(state): State<AppState>,
1214 jar: CookieJar,
1215 uri: Uri,
1216 Path(token): Path<String>,
1217) -> AppResult<Response> {
1218 let invite = live_invite(&state, &token).await?;
1219 let _ = invite;
1220 let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
1221 let body = html! {
1222 div class="auth-wrap" {
1223 div class="auth-card" {
1224 h1 { "Set your password" }
1225 p class="muted" { "Choose a password to activate your account." }
1226 form method="post" action={ "/invite/" (token) } {
1227 input type="hidden" name="_csrf" value=(chrome.csrf);
1228 label for="password" { "Password" }
1229 input type="password" id="password" name="password"
1230 autocomplete="new-password" required minlength="8";
1231 label for="confirm" { "Confirm password" }
1232 input type="password" id="confirm" name="confirm"
1233 autocomplete="new-password" required;
1234 button class="btn btn-primary" type="submit" { "Activate account" }
1235 }
1236 }
1237 }
1238 };
1239 Ok((jar, page(&chrome, "Activate", body)).into_response())
1240}
1241
1242/// `POST /invite/{token}` — set the password, redeem the invite, and sign in.
1243pub async fn invite_submit(
1244 State(state): State<AppState>,
1245 jar: CookieJar,
1246 headers: HeaderMap,
1247 Path(token): Path<String>,
1248 Form(form): Form<InviteForm>,
1249) -> Response {
1250 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1251 return AppError::Forbidden.into_response();
1252 }
1253 if form.password != form.confirm {
1254 return AppError::BadRequest("Passwords do not match.".to_string()).into_response();
1255 }
1256 if form.password.len() < 8 {
1257 return AppError::BadRequest("Password must be at least 8 characters.".to_string())
1258 .into_response();
1259 }
1260
1261 let result = async {
1262 let invite = live_invite(&state, &token).await?;
1263 let params = auth::HashParams {
1264 m_cost: state.config.auth.argon2_m_cost,
1265 t_cost: state.config.auth.argon2_t_cost,
1266 p_cost: state.config.auth.argon2_p_cost,
1267 };
1268 let hash = auth::hash_password(&form.password, params).map_err(AppError::internal)?;
1269 state
1270 .store
1271 .set_password(&invite.user_id, Some(&hash))
1272 .await?;
1273 state.store.mark_invite_used(&invite.id).await?;
1274 // The invite was delivered to this account's email, so completing it
1275 // proves control of that address — mark it verified.
1276 let _ = state.store.verify_primary_email(&invite.user_id).await;
1277 let user = state
1278 .store
1279 .user_by_id(&invite.user_id)
1280 .await?
1281 .ok_or(AppError::NotFound)?;
1282 open_session(&state, &jar, &user, &headers).await
1283 }
1284 .await;
1285
1286 match result {
1287 Ok(jar) => (jar, Redirect::to("/")).into_response(),
1288 Err(err) => err.into_response(),
1289 }
1290}
1291
1292/// Resolve a live (unredeemed, unexpired) invite for a raw token, or a themed
1293/// error.
1294async fn live_invite(state: &AppState, token: &str) -> AppResult<model::Invite> {
1295 let hash = auth::session_id(token);
1296 let invite = state
1297 .store
1298 .invite_by_token_hash(&hash)
1299 .await?
1300 .ok_or(AppError::NotFound)?;
1301 if invite.used_at.is_some() {
1302 return Err(AppError::BadRequest(
1303 "This invite has already been used.".to_string(),
1304 ));
1305 }
1306 if invite.expires_at <= now_ms() {
1307 return Err(AppError::BadRequest("This invite has expired.".to_string()));
1308 }
1309 Ok(invite)
1310}
1311
1312/// `GET /settings` — the Profile tab.
1313pub async fn settings(
1314 State(state): State<AppState>,
1315 RequireUser(user): RequireUser,
1316 jar: CookieJar,
1317 uri: Uri,
1318) -> AppResult<Response> {
1319 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
1320 let body = settings_shell("profile", profile_tab(&user, &chrome.csrf));
1321 Ok((jar, page(&chrome, "Settings", body)).into_response())
1322}
1323
1324/// `GET /settings/account` — the Account tab (username, emails, password).
1325pub async fn settings_account(
1326 State(state): State<AppState>,
1327 RequireUser(user): RequireUser,
1328 jar: CookieJar,
1329 uri: Uri,
1330) -> AppResult<Response> {
1331 let emails = state.store.emails_by_user(&user.id).await?;
1332 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
1333 let body = settings_shell("account", account_tab(&user, &chrome.csrf, &emails));
1334 Ok((jar, page(&chrome, "Settings", body)).into_response())
1335}
1336
1337/// `GET /settings/keys` — the SSH and GPG keys tab.
1338pub async fn settings_keys(
1339 State(state): State<AppState>,
1340 RequireUser(user): RequireUser,
1341 jar: CookieJar,
1342 uri: Uri,
1343) -> AppResult<Response> {
1344 let keys = state.store.keys_by_user(&user.id).await?;
1345 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
1346 let body = settings_shell("keys", keys_section(&chrome.csrf, &keys));
1347 Ok((jar, page(&chrome, "Settings", body)).into_response())
1348}
1349
1350/// `GET /settings/tokens` — the API tokens tab.
1351pub async fn settings_tokens(
1352 State(state): State<AppState>,
1353 RequireUser(user): RequireUser,
1354 jar: CookieJar,
1355 uri: Uri,
1356) -> AppResult<Response> {
1357 let tokens = state.store.tokens_by_user(&user.id).await?;
1358 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
1359 let body = settings_shell("tokens", tokens_section(&chrome.csrf, &tokens, None));
1360 Ok((jar, page(&chrome, "Settings", body)).into_response())
1361}
1362
1363/// Profile settings form fields. Empty strings clear the corresponding column.
1364#[derive(Debug, Deserialize)]
1365pub struct SettingsForm {
1366 /// Display name.
1367 #[serde(default)]
1368 display_name: String,
1369 /// Pronouns.
1370 #[serde(default)]
1371 pronouns: String,
1372 /// Free-text bio.
1373 #[serde(default)]
1374 bio: String,
1375 /// Location.
1376 #[serde(default)]
1377 location: String,
1378 /// Up to five arbitrary profile links (`serde_urlencoded` has no sequence
1379 /// support, so each slot is its own field).
1380 #[serde(default)]
1381 link1: String,
1382 #[serde(default)]
1383 link2: String,
1384 #[serde(default)]
1385 link3: String,
1386 #[serde(default)]
1387 link4: String,
1388 #[serde(default)]
1389 link5: String,
1390 /// CSRF token (`_csrf`).
1391 #[serde(rename = "_csrf")]
1392 csrf: String,
1393}
1394
1395/// The number of profile-link slots offered on the settings page.
1396const LINK_SLOTS: usize = 5;
1397
1398/// `POST /settings` — persist the viewer's profile fields.
1399pub async fn settings_submit(
1400 State(state): State<AppState>,
1401 RequireUser(user): RequireUser,
1402 jar: CookieJar,
1403 headers: HeaderMap,
1404 Form(form): Form<SettingsForm>,
1405) -> Response {
1406 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1407 return AppError::Forbidden.into_response();
1408 }
1409 let links: Vec<String> = [
1410 &form.link1,
1411 &form.link2,
1412 &form.link3,
1413 &form.link4,
1414 &form.link5,
1415 ]
1416 .iter()
1417 .map(|s| s.trim().to_string())
1418 .filter(|s| !s.is_empty())
1419 .take(LINK_SLOTS)
1420 .collect();
1421 let update = store::ProfileUpdate {
1422 display_name: norm(&form.display_name),
1423 pronouns: norm(&form.pronouns),
1424 bio: norm(&form.bio),
1425 location: norm(&form.location),
1426 links,
1427 };
1428 match state.store.update_profile(&user.id, update).await {
1429 Ok(_) => Redirect::to("/settings").into_response(),
1430 Err(err) => AppError::from(err).into_response(),
1431 }
1432}
1433
1434/// Trim a form string, mapping the empty result to `None` (clears the column).
1435fn norm(s: &str) -> Option<String> {
1436 let t = s.trim();
1437 (!t.is_empty()).then(|| t.to_string())
1438}
1439
1440/// The change-username form.
1441#[derive(Debug, Deserialize)]
1442pub struct AccountFieldForm {
1443 /// The new username.
1444 #[serde(default)]
1445 username: String,
1446 /// CSRF token.
1447 #[serde(rename = "_csrf")]
1448 csrf: String,
1449}
1450
1451/// `POST /settings/username` — change the viewer's username.
1452pub async fn account_username(
1453 State(state): State<AppState>,
1454 RequireUser(user): RequireUser,
1455 jar: CookieJar,
1456 headers: HeaderMap,
1457 Form(form): Form<AccountFieldForm>,
1458) -> Response {
1459 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1460 return AppError::Forbidden.into_response();
1461 }
1462 match state
1463 .store
1464 .update_username(&user.id, form.username.trim())
1465 .await
1466 {
1467 Ok(_) => Redirect::to("/settings/account").into_response(),
1468 Err(store::StoreError::Conflict { .. }) => {
1469 AppError::BadRequest("That username is already taken.".to_string()).into_response()
1470 }
1471 Err(store::StoreError::Name(_)) => {
1472 AppError::BadRequest("Invalid username.".to_string()).into_response()
1473 }
1474 Err(err) => AppError::from(err).into_response(),
1475 }
1476}
1477
1478/// The add-email form.
1479#[derive(Debug, Deserialize)]
1480pub struct EmailAddForm {
1481 /// The new address.
1482 #[serde(default)]
1483 email: String,
1484 /// CSRF token.
1485 #[serde(rename = "_csrf")]
1486 csrf: String,
1487}
1488
1489/// `POST /settings/emails` — add a secondary email address.
1490pub async fn email_add(
1491 State(state): State<AppState>,
1492 RequireUser(user): RequireUser,
1493 jar: CookieJar,
1494 headers: HeaderMap,
1495 Form(form): Form<EmailAddForm>,
1496) -> Response {
1497 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1498 return AppError::Forbidden.into_response();
1499 }
1500 let email = form.email.trim();
1501 if !email.contains('@') {
1502 return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();
1503 }
1504 match state.store.add_email(&user.id, email).await {
1505 Ok(added) => {
1506 // Send a verification link (best-effort; logged on failure).
1507 send_email_verification(&state, &added).await;
1508 Redirect::to("/settings/account").into_response()
1509 }
1510 Err(store::StoreError::Conflict { .. }) => {
1511 AppError::BadRequest("That email is already in use.".to_string()).into_response()
1512 }
1513 Err(err) => AppError::from(err).into_response(),
1514 }
1515}
1516
1517/// Issue a fresh verification token for `email` and email the link. Best-effort:
1518/// store or mail failures are logged, not surfaced (the user can resend).
1519async fn send_email_verification(state: &AppState, email: &model::UserEmail) {
1520 let token = auth::new_secret();
1521 if let Err(err) = state
1522 .store
1523 .begin_email_verification(&email.id, &token)
1524 .await
1525 {
1526 tracing::warn!(error = %err, "could not store email verification token");
1527 return;
1528 }
1529 let base = state.config.instance.url.trim_end_matches('/');
1530 let link = format!("{base}/verify-email/{token}");
1531 if let Err(err) = state
1532 .mailer
1533 .send_invite(
1534 &state.config.instance.name,
1535 &email.email,
1536 None,
1537 &link,
1538 mail::Purpose::VerifyEmail,
1539 )
1540 .await
1541 {
1542 tracing::warn!(error = %err, email = %email.email, "could not send verification email");
1543 }
1544}
1545
1546/// `GET /verify-email/{token}` — redeem an email verification link.
1547pub async fn verify_email(
1548 State(state): State<AppState>,
1549 jar: CookieJar,
1550 Path(token): Path<String>,
1551) -> Response {
1552 match state.store.verify_email_token(&token).await {
1553 Ok(Some(_)) => {
1554 let (jar, chrome) = build_chrome(&state, jar, None, "/verify-email".to_string());
1555 let body = html! {
1556 div class="auth-wrap" {
1557 div class="auth-card" {
1558 h1 { "Email verified" }
1559 p class="muted" { "Your email address has been verified." }
1560 p { a class="btn btn-primary" href="/settings/account" { "Back to settings" } }
1561 }
1562 }
1563 };
1564 (jar, page(&chrome, "Email verified", body)).into_response()
1565 }
1566 Ok(None) => {
1567 AppError::BadRequest("That verification link is invalid or has expired.".to_string())
1568 .into_response()
1569 }
1570 Err(err) => AppError::from(err).into_response(),
1571 }
1572}
1573
1574/// `POST /settings/emails/resend` — resend a verification email.
1575pub async fn email_resend(
1576 State(state): State<AppState>,
1577 RequireUser(user): RequireUser,
1578 jar: CookieJar,
1579 headers: HeaderMap,
1580 Form(form): Form<EmailIdForm>,
1581) -> Response {
1582 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1583 return AppError::Forbidden.into_response();
1584 }
1585 if let Ok(Some(email)) = state.store.email_owned_by(&user.id, &form.id).await {
1586 send_email_verification(&state, &email).await;
1587 }
1588 Redirect::to("/settings/account").into_response()
1589}
1590
1591/// An email-id form (set-primary or delete).
1592#[derive(Debug, Deserialize)]
1593pub struct EmailIdForm {
1594 /// The `user_emails` row id.
1595 #[serde(default)]
1596 id: String,
1597 /// CSRF token.
1598 #[serde(rename = "_csrf")]
1599 csrf: String,
1600}
1601
1602/// `POST /settings/emails/primary` — make one of the viewer's emails primary.
1603pub async fn email_primary(
1604 State(state): State<AppState>,
1605 RequireUser(user): RequireUser,
1606 jar: CookieJar,
1607 headers: HeaderMap,
1608 Form(form): Form<EmailIdForm>,
1609) -> Response {
1610 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1611 return AppError::Forbidden.into_response();
1612 }
1613 match state.store.set_primary_email(&user.id, &form.id).await {
1614 Ok(true) => Redirect::to("/settings/account").into_response(),
1615 Ok(false) => {
1616 AppError::BadRequest("Verify the email address before making it primary.".to_string())
1617 .into_response()
1618 }
1619 Err(err) => AppError::from(err).into_response(),
1620 }
1621}
1622
1623/// `POST /settings/emails/delete` — remove one of the viewer's secondary emails.
1624pub async fn email_delete(
1625 State(state): State<AppState>,
1626 RequireUser(user): RequireUser,
1627 jar: CookieJar,
1628 headers: HeaderMap,
1629 Form(form): Form<EmailIdForm>,
1630) -> Response {
1631 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1632 return AppError::Forbidden.into_response();
1633 }
1634 let _ = state.store.delete_email(&user.id, &form.id).await;
1635 Redirect::to("/settings/account").into_response()
1636}
1637
1638/// The email-visibility toggle form.
1639#[derive(Debug, Deserialize)]
1640pub struct EmailVisibilityForm {
1641 /// Present (any value) when the checkbox is ticked.
1642 #[serde(default)]
1643 public: Option<String>,
1644 /// CSRF token.
1645 #[serde(rename = "_csrf")]
1646 csrf: String,
1647}
1648
1649/// `POST /settings/emails/visibility` — show/hide the primary email on the profile.
1650pub async fn email_visibility(
1651 State(state): State<AppState>,
1652 RequireUser(user): RequireUser,
1653 jar: CookieJar,
1654 headers: HeaderMap,
1655 Form(form): Form<EmailVisibilityForm>,
1656) -> Response {
1657 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1658 return AppError::Forbidden.into_response();
1659 }
1660 let _ = state
1661 .store
1662 .set_email_public(&user.id, form.public.is_some())
1663 .await;
1664 Redirect::to("/settings/account").into_response()
1665}
1666
1667/// The change-password form.
1668#[derive(Debug, Deserialize)]
1669pub struct PasswordForm {
1670 /// The current password (re-verified).
1671 #[serde(default)]
1672 current: String,
1673 /// The new password.
1674 #[serde(default)]
1675 new_password: String,
1676 /// Confirmation of the new password.
1677 #[serde(default)]
1678 confirm: String,
1679 /// CSRF token.
1680 #[serde(rename = "_csrf")]
1681 csrf: String,
1682}
1683
1684/// `POST /settings/password` — change the password after re-verifying the current
1685/// one; all other sessions are invalidated and this one is re-established.
1686pub async fn account_password(
1687 State(state): State<AppState>,
1688 RequireUser(user): RequireUser,
1689 jar: CookieJar,
1690 headers: HeaderMap,
1691 Form(form): Form<PasswordForm>,
1692) -> Response {
1693 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1694 return AppError::Forbidden.into_response();
1695 }
1696 if !auth::verify_password(&form.current, user.password_hash.as_deref()) {
1697 return AppError::BadRequest("Current password is incorrect.".to_string()).into_response();
1698 }
1699 if form.new_password != form.confirm {
1700 return AppError::BadRequest("New passwords do not match.".to_string()).into_response();
1701 }
1702 if form.new_password.len() < 8 {
1703 return AppError::BadRequest("New password must be at least 8 characters.".to_string())
1704 .into_response();
1705 }
1706 let result = async {
1707 let params = auth::HashParams {
1708 m_cost: state.config.auth.argon2_m_cost,
1709 t_cost: state.config.auth.argon2_t_cost,
1710 p_cost: state.config.auth.argon2_p_cost,
1711 };
1712 let hash = auth::hash_password(&form.new_password, params).map_err(AppError::internal)?;
1713 // set_password deletes every session (including this one); re-establish it.
1714 state.store.set_password(&user.id, Some(&hash)).await?;
1715 open_session(&state, &jar, &user, &headers).await
1716 }
1717 .await;
1718 match result {
1719 Ok(jar) => (jar, Redirect::to("/settings/account")).into_response(),
1720 Err(err) => err.into_response(),
1721 }
1722}
1723
1724/// The create-token form.
1725#[derive(Debug, Deserialize)]
1726pub struct TokenCreateForm {
1727 /// The token label.
1728 #[serde(default)]
1729 name: String,
1730 /// `repo:read` scope (checkbox present when granted).
1731 #[serde(default)]
1732 repo_read: Option<String>,
1733 /// `repo:write` scope.
1734 #[serde(default)]
1735 repo_write: Option<String>,
1736 /// `admin` scope.
1737 #[serde(default)]
1738 admin: Option<String>,
1739 /// CSRF token.
1740 #[serde(rename = "_csrf")]
1741 csrf: String,
1742}
1743
1744/// `POST /settings/tokens` — mint a new API token, shown once.
1745pub async fn token_create(
1746 State(state): State<AppState>,
1747 RequireUser(user): RequireUser,
1748 jar: CookieJar,
1749 headers: HeaderMap,
1750 Form(form): Form<TokenCreateForm>,
1751) -> Response {
1752 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1753 return AppError::Forbidden.into_response();
1754 }
1755 let name = form.name.trim();
1756 if name.is_empty() {
1757 return AppError::BadRequest("Token name is required.".to_string()).into_response();
1758 }
1759 let mut scopes: Vec<&str> = Vec::new();
1760 if form.repo_read.is_some() {
1761 scopes.push("repo:read");
1762 }
1763 if form.repo_write.is_some() {
1764 scopes.push("repo:write");
1765 }
1766 if form.admin.is_some() {
1767 scopes.push("admin");
1768 }
1769 if scopes.is_empty() {
1770 return AppError::BadRequest("Select at least one scope.".to_string()).into_response();
1771 }
1772 let scope_str = scopes.join(",");
1773
1774 let result = async {
1775 let parsed = auth::parse_scopes(&scope_str).map_err(AppError::internal)?;
1776 let canonical = auth::format_scopes(&parsed);
1777 let token = state
1778 .store
1779 .create_token(store::NewToken {
1780 user_id: user.id.clone(),
1781 name: name.to_string(),
1782 scopes: canonical.clone(),
1783 expires_at: None,
1784 })
1785 .await?;
1786 // Mint the JWT with a far-future expiry (the row's revoked_at enforces
1787 // revocation regardless).
1788 let now = now_ms().div_euclid(1000);
1789 let claims = auth::Claims {
1790 sub: user.id.clone(),
1791 jti: token.id.clone(),
1792 scopes: canonical,
1793 iat: now,
1794 exp: now.saturating_add(10 * 365 * 86_400),
1795 };
1796 let jwt = auth::mint_jwt(&state.secret, &claims).map_err(AppError::internal)?;
1797 Ok::<String, AppError>(jwt)
1798 }
1799 .await;
1800
1801 match result {
1802 Ok(jwt) => {
1803 let tokens = state
1804 .store
1805 .tokens_by_user(&user.id)
1806 .await
1807 .unwrap_or_default();
1808 let (jar, chrome) = build_chrome(
1809 &state,
1810 jar,
1811 Some(user.clone()),
1812 "/settings/tokens".to_string(),
1813 );
1814 let body = settings_shell("tokens", tokens_section(&chrome.csrf, &tokens, Some(&jwt)));
1815 (jar, page(&chrome, "Settings", body)).into_response()
1816 }
1817 Err(err) => err.into_response(),
1818 }
1819}
1820
1821/// The revoke-token form.
1822#[derive(Debug, Deserialize)]
1823pub struct TokenRevokeForm {
1824 /// The token id.
1825 #[serde(default)]
1826 id: String,
1827 /// CSRF token.
1828 #[serde(rename = "_csrf")]
1829 csrf: String,
1830}
1831
1832/// `POST /settings/tokens/revoke` — revoke one of the viewer's tokens.
1833pub async fn token_revoke(
1834 State(state): State<AppState>,
1835 RequireUser(user): RequireUser,
1836 jar: CookieJar,
1837 headers: HeaderMap,
1838 Form(form): Form<TokenRevokeForm>,
1839) -> Response {
1840 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1841 return AppError::Forbidden.into_response();
1842 }
1843 // Only revoke a token that belongs to the viewer.
1844 let owned = state
1845 .store
1846 .token_by_id(&form.id)
1847 .await
1848 .ok()
1849 .flatten()
1850 .is_some_and(|t| t.user_id == user.id);
1851 if owned {
1852 let _ = state.store.revoke_token(&form.id).await;
1853 }
1854 Redirect::to("/settings/tokens").into_response()
1855}
1856
1857/// The add-key form.
1858#[derive(Debug, Deserialize)]
1859pub struct KeyAddForm {
1860 /// `ssh` or `gpg`.
1861 #[serde(default)]
1862 kind: String,
1863 /// Optional label.
1864 #[serde(default)]
1865 name: String,
1866 /// The public key material (`authorized_keys` line or armored block).
1867 #[serde(default)]
1868 key: String,
1869 /// CSRF token.
1870 #[serde(rename = "_csrf")]
1871 csrf: String,
1872}
1873
1874/// `POST /settings/keys` — register an SSH or GPG public key for the viewer.
1875pub async fn key_add(
1876 State(state): State<AppState>,
1877 RequireUser(user): RequireUser,
1878 jar: CookieJar,
1879 headers: HeaderMap,
1880 Form(form): Form<KeyAddForm>,
1881) -> Response {
1882 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1883 return AppError::Forbidden.into_response();
1884 }
1885 let Some(kind) = model::KeyKind::from_token(&form.kind) else {
1886 return AppError::BadRequest("Choose a key type.".to_string()).into_response();
1887 };
1888 let material = form.key.trim();
1889 let Ok(parsed) = git::parse_public_key(kind, material) else {
1890 return AppError::BadRequest(format!("That does not look like a valid {kind} key."))
1891 .into_response();
1892 };
1893 // Reject a duplicate fingerprint (registered to anyone).
1894 match state
1895 .store
1896 .key_by_fingerprint(kind, &parsed.fingerprint)
1897 .await
1898 {
1899 Ok(Some(_)) => {
1900 return AppError::BadRequest("That key is already registered.".to_string())
1901 .into_response();
1902 }
1903 Ok(None) => {}
1904 Err(err) => return AppError::from(err).into_response(),
1905 }
1906 let name = form.name.trim();
1907 let label = (!name.is_empty())
1908 .then(|| name.to_string())
1909 .or(parsed.label);
1910 match state
1911 .store
1912 .create_key(store::NewKey {
1913 user_id: user.id.clone(),
1914 kind,
1915 name: label,
1916 fingerprint: parsed.fingerprint,
1917 public_key: parsed.public_key,
1918 })
1919 .await
1920 {
1921 Ok(_) => Redirect::to("/settings/keys").into_response(),
1922 Err(store::StoreError::Conflict { .. }) => {
1923 AppError::BadRequest("That key is already registered.".to_string()).into_response()
1924 }
1925 Err(err) => AppError::from(err).into_response(),
1926 }
1927}
1928
1929/// The delete-key form.
1930#[derive(Debug, Deserialize)]
1931pub struct KeyDeleteForm {
1932 /// The key id.
1933 #[serde(default)]
1934 id: String,
1935 /// CSRF token.
1936 #[serde(rename = "_csrf")]
1937 csrf: String,
1938}
1939
1940/// `POST /settings/keys/delete` — delete one of the viewer's keys.
1941pub async fn key_delete(
1942 State(state): State<AppState>,
1943 RequireUser(user): RequireUser,
1944 jar: CookieJar,
1945 headers: HeaderMap,
1946 Form(form): Form<KeyDeleteForm>,
1947) -> Response {
1948 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1949 return AppError::Forbidden.into_response();
1950 }
1951 // Only delete a key that belongs to the viewer.
1952 let owned = state
1953 .store
1954 .keys_by_user(&user.id)
1955 .await
1956 .is_ok_and(|keys| keys.iter().any(|k| k.id == form.id));
1957 if owned {
1958 let _ = state.store.delete_key(&form.id).await;
1959 }
1960 Redirect::to("/settings/keys").into_response()
1961}
1962
1963/// The verify-key form: the key id and a pasted armored signature.
1964#[derive(Debug, Deserialize)]
1965pub struct KeyVerifyForm {
1966 /// The key id.
1967 #[serde(default)]
1968 id: String,
1969 /// The armored detached signature over the challenge.
1970 #[serde(default)]
1971 signature: String,
1972 /// CSRF token.
1973 #[serde(rename = "_csrf")]
1974 csrf: String,
1975}
1976
1977/// `POST /settings/keys/verify` — verify GPG key ownership from a signed
1978/// challenge. (SSH keys verify automatically on authentication.)
1979pub async fn key_verify(
1980 State(state): State<AppState>,
1981 RequireUser(user): RequireUser,
1982 jar: CookieJar,
1983 headers: HeaderMap,
1984 Form(form): Form<KeyVerifyForm>,
1985) -> Response {
1986 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1987 return AppError::Forbidden.into_response();
1988 }
1989 let Ok(keys) = state.store.keys_by_user(&user.id).await else {
1990 return AppError::internal("could not load keys").into_response();
1991 };
1992 let Some(key) = keys.into_iter().find(|k| k.id == form.id) else {
1993 return AppError::NotFound.into_response();
1994 };
1995 if key.kind != model::KeyKind::Gpg {
1996 return AppError::BadRequest("Only GPG keys are verified this way.".to_string())
1997 .into_response();
1998 }
1999 let challenge = gpg_challenge(&key);
2000 let ok = git::verify_challenge(
2001 model::KeyKind::Gpg,
2002 &key.fingerprint,
2003 &key.public_key,
2004 form.signature.trim(),
2005 challenge.as_bytes(),
2006 );
2007 if !ok {
2008 return AppError::BadRequest("That signature did not verify against this key.".to_string())
2009 .into_response();
2010 }
2011 let _ = state.store.set_key_verified(&key.id).await;
2012 Redirect::to("/settings/keys").into_response()
2013}
2014
2015/// Render the settings page: avatar, profile, account credentials, and tokens.
2016/// The settings page shell: a left tab nav and the active tab's content. Each tab
2017/// is its own route, so navigation works without JavaScript.
2018#[allow(clippy::needless_pass_by_value)] // `content` is an owned fragment embedded once.
2019fn settings_shell(active: &str, content: Markup) -> Markup {
2020 let tab = |href: &str, key: &str, label: &str| {
2021 let current = active == key;
2022 html! {
2023 a href=(href) aria-current=[current.then_some("page")] { (label) }
2024 }
2025 };
2026 html! {
2027 div class="settings-layout" {
2028 aside class="settings-nav" {
2029 h1 { "Settings" }
2030 nav {
2031 (tab("/settings", "profile", "Profile"))
2032 (tab("/settings/account", "account", "Account"))
2033 (tab("/settings/keys", "keys", "SSH and GPG keys"))
2034 (tab("/settings/tokens", "tokens", "API tokens"))
2035 }
2036 }
2037 div class="settings-content" { (content) }
2038 }
2039 }
2040}
2041
2042/// The Profile tab: avatar and profile fields.
2043fn profile_tab(user: &User, csrf: &str) -> Markup {
2044 let val = |v: &Option<String>| v.clone().unwrap_or_default();
2045 html! {
2046 section class="listing" {
2047 h2 { "Avatar" }
2048 div class="card avatar-settings" {
2049 img class="avatar avatar-lg" src={ "/avatar/" (user.username) } alt="Your avatar";
2050 form class="avatar-form" method="post" action="/settings/avatar" enctype="multipart/form-data" {
2051 input type="hidden" name="_csrf" value=(csrf);
2052 p class="muted field-hint" { "PNG, JPEG, GIF, or WebP up to 1 MiB. Leave blank for a generated identicon." }
2053 div class="avatar-upload-row" {
2054 input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp";
2055 button class="btn inline-btn" type="submit" { (icon(Icon::Upload)) span { "Upload" } }
2056 }
2057 }
2058 }
2059 }
2060 section class="listing" {
2061 h2 { "Profile" }
2062 div class="card" {
2063 form method="post" action="/settings" {
2064 input type="hidden" name="_csrf" value=(csrf);
2065 label for="display_name" { "Display name" }
2066 input type="text" id="display_name" name="display_name"
2067 value=(val(&user.display_name)) autocomplete="name";
2068 label for="pronouns" { "Pronouns" }
2069 input type="text" id="pronouns" name="pronouns"
2070 value=(val(&user.pronouns)) placeholder="they/them";
2071 label for="bio" { "Bio" }
2072 textarea id="bio" name="bio" rows="3" { (val(&user.bio)) }
2073 label for="location" { "Location" }
2074 input type="text" id="location" name="location" value=(val(&user.location));
2075 label for="link1" { "Links" }
2076 p class="muted field-hint" { "Up to five links (website, GitHub, Mastodon, …); icons are chosen from the address." }
2077 div class="link-fields" data-link-fields {
2078 @for i in 0..LINK_SLOTS {
2079 input type="url" id=(format!("link{}", i + 1)) name=(format!("link{}", i + 1))
2080 class="link-slot" value=(user.links.get(i).map_or("", String::as_str))
2081 placeholder="https://example.com";
2082 }
2083 button class="btn link-add" type="button" data-link-add { "Add" }
2084 }
2085 button class="btn btn-primary" type="submit" { "Save profile" }
2086 }
2087 }
2088 }
2089 }
2090}
2091
2092/// The Account tab: username, email(s), and password.
2093fn account_tab(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup {
2094 html! {
2095 section class="listing" {
2096 h2 { "Username" }
2097 div class="card" {
2098 form method="post" action="/settings/username" {
2099 input type="hidden" name="_csrf" value=(csrf);
2100 label for="username" { "Username" }
2101 input type="text" id="username" name="username" value=(user.username) required;
2102 button class="btn" type="submit" { "Change username" }
2103 }
2104 }
2105 }
2106 (emails_section(user, csrf, emails))
2107 section class="listing" {
2108 h2 { "Password" }
2109 div class="card" {
2110 form method="post" action="/settings/password" {
2111 input type="hidden" name="_csrf" value=(csrf);
2112 label for="current" { "Current password" }
2113 input type="password" id="current" name="current"
2114 autocomplete="current-password" required;
2115 label for="new_password" { "New password" }
2116 input type="password" id="new_password" name="new_password"
2117 autocomplete="new-password" required minlength="8";
2118 label for="confirm" { "Confirm new password" }
2119 input type="password" id="confirm" name="confirm"
2120 autocomplete="new-password" required;
2121 button class="btn btn-primary" type="submit" { "Change password" }
2122 }
2123 }
2124 }
2125 }
2126}
2127
2128/// The emails sub-section of the Account tab: the list of addresses (primary
2129/// marked, with set-primary / remove actions), the profile visibility toggle,
2130/// and the add-email form.
2131fn emails_section(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup {
2132 html! {
2133 section class="listing" {
2134 h2 { "Emails" }
2135 div class="card" {
2136 ul class="entry-list email-list" {
2137 @for e in emails {
2138 li class="entry-row" {
2139 div class="entry-row-body" {
2140 span class="entry-row-title" {
2141 (e.email)
2142 @if e.is_primary { " " span class="badge" { "primary" } }
2143 @if e.verified() {
2144 " " span class="badge badge-verified" { "verified" }
2145 } @else {
2146 " " span class="badge badge-unverified" { "unverified" }
2147 }
2148 }
2149 }
2150 div class="entry-row-actions" {
2151 @if !e.verified() {
2152 form method="post" action="/settings/emails/resend" {
2153 input type="hidden" name="_csrf" value=(csrf);
2154 input type="hidden" name="id" value=(e.id);
2155 button class="btn" type="submit" { "Resend" }
2156 }
2157 }
2158 @if !e.is_primary && e.verified() {
2159 form method="post" action="/settings/emails/primary" {
2160 input type="hidden" name="_csrf" value=(csrf);
2161 input type="hidden" name="id" value=(e.id);
2162 button class="btn" type="submit" { "Make primary" }
2163 }
2164 }
2165 @if !e.is_primary {
2166 form method="post" action="/settings/emails/delete" {
2167 input type="hidden" name="_csrf" value=(csrf);
2168 input type="hidden" name="id" value=(e.id);
2169 button class="btn btn-danger" type="submit" { "Remove" }
2170 }
2171 }
2172 }
2173 }
2174 }
2175 }
2176 form class="email-visibility" method="post" action="/settings/emails/visibility" {
2177 input type="hidden" name="_csrf" value=(csrf);
2178 label class="checkbox" {
2179 input type="checkbox" name="public" value="1" checked[user.email_public]
2180 onchange="this.form.submit()";
2181 " Show my primary email on my profile"
2182 }
2183 noscript { button class="btn" type="submit" { "Save" } }
2184 }
2185 form class="email-add" method="post" action="/settings/emails" {
2186 input type="hidden" name="_csrf" value=(csrf);
2187 label for="new_email" { "Add email address" }
2188 div class="email-add-row" {
2189 input type="email" id="new_email" name="email" required
2190 placeholder="you@example.com";
2191 button class="btn btn-primary inline-btn" type="submit" { "Add" }
2192 }
2193 }
2194 }
2195 }
2196 }
2197}
2198
2199/// The SSH and GPG keys settings section.
2200fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup {
2201 html! {
2202 section class="listing" {
2203 h2 { "SSH and GPG keys" }
2204 div class="card" {
2205 p class="muted field-hint" {
2206 "SSH keys authorize push over SSH; both SSH and GPG keys verify commit signatures."
2207 }
2208 @if keys.is_empty() {
2209 p class="muted" { "No keys yet." }
2210 } @else {
2211 ul class="entry-list key-list" {
2212 @for k in keys {
2213 li class="entry-row key-entry" {
2214 div class="entry-row-body" {
2215 span class="entry-row-title" {
2216 span class="badge" { (k.kind.as_str()) }
2217 @if k.verified() {
2218 " " span class="badge badge-verified" { "verified" }
2219 } @else {
2220 " " span class="badge badge-unverified" { "unverified" }
2221 }
2222 " " (k.name.as_deref().unwrap_or("(unnamed)"))
2223 }
2224 div class="entry-row-meta muted mono" { (k.fingerprint) }
2225 @if !k.verified() {
2226 (key_verify_hint(k, csrf))
2227 }
2228 }
2229 div class="entry-row-actions" {
2230 form method="post" action="/settings/keys/delete" {
2231 input type="hidden" name="_csrf" value=(csrf);
2232 input type="hidden" name="id" value=(k.id);
2233 button class="btn btn-danger" type="submit" { "Delete" }
2234 }
2235 }
2236 }
2237 }
2238 }
2239 }
2240 form class="key-add" method="post" action="/settings/keys" {
2241 input type="hidden" name="_csrf" value=(csrf);
2242 div class="key-add-row" {
2243 label for="key_kind" { "Type" }
2244 select id="key_kind" name="kind" {
2245 option value="ssh" { "SSH" }
2246 option value="gpg" { "GPG" }
2247 }
2248 label for="key_name" { "Title" }
2249 input type="text" id="key_name" name="name" placeholder="Optional label";
2250 }
2251 label for="key_material" { "Key" }
2252 textarea id="key_material" name="key" rows="4" required
2253 placeholder="Paste an SSH public key or an ASCII-armored GPG public key" {}
2254 button class="btn btn-primary" type="submit" { "Add key" }
2255 }
2256 }
2257 }
2258 }
2259}
2260
2261/// The deterministic message a GPG key owner signs to prove possession.
2262fn gpg_challenge(key: &model::Key) -> String {
2263 format!(
2264 "fabrica GPG key ownership verification\nfingerprint: {}\n",
2265 key.fingerprint
2266 )
2267}
2268
2269/// The verify affordance for an unverified key: a note for SSH keys (which verify
2270/// on use) and a sign-a-challenge form for GPG keys.
2271fn key_verify_hint(key: &model::Key, csrf: &str) -> Markup {
2272 match key.kind {
2273 model::KeyKind::Ssh => html! {
2274 p class="muted field-hint key-verify-note" {
2275 "Verifies automatically the next time you authenticate over SSH."
2276 }
2277 },
2278 model::KeyKind::Gpg => html! {
2279 details class="key-verify" {
2280 summary { "Verify ownership" }
2281 p class="muted field-hint" {
2282 "Sign this exact text with your GPG key and paste the armored signature:"
2283 }
2284 pre class="key-challenge" { code { (gpg_challenge(key)) } }
2285 p class="muted field-hint mono" {
2286 "gpg --local-user " (key.fingerprint) " --armor --detach-sign challenge.txt"
2287 }
2288 form method="post" action="/settings/keys/verify" {
2289 input type="hidden" name="_csrf" value=(csrf);
2290 input type="hidden" name="id" value=(key.id);
2291 textarea name="signature" rows="6" required
2292 placeholder="-----BEGIN PGP SIGNATURE-----" {}
2293 button class="btn" type="submit" { "Verify" }
2294 }
2295 }
2296 },
2297 }
2298}
2299
2300/// The API tokens settings section.
2301fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {
2302 html! {
2303 section class="listing" {
2304 h2 { "API tokens" }
2305 div class="card" {
2306 @if let Some(secret) = new_token {
2307 div class="notice notice-success" {
2308 p { strong { "New token created." } " Copy it now — it is not shown again." }
2309 pre class="token-secret" { code { (secret) } }
2310 }
2311 }
2312 @if tokens.is_empty() {
2313 p class="muted" { "No API tokens yet." }
2314 } @else {
2315 ul class="entry-list token-list" {
2316 @for t in tokens {
2317 li class="entry-row" {
2318 div class="entry-row-body" {
2319 span class="entry-row-title" { (t.name) }
2320 div class="entry-row-meta muted" {
2321 span { (t.scopes) }
2322 @if t.revoked_at.is_some() { span class="badge badge-private" { "revoked" } }
2323 }
2324 }
2325 div class="entry-row-actions" {
2326 @if t.revoked_at.is_none() {
2327 form method="post" action="/settings/tokens/revoke" {
2328 input type="hidden" name="_csrf" value=(csrf);
2329 input type="hidden" name="id" value=(t.id);
2330 button class="btn btn-danger" type="submit" { "Revoke" }
2331 }
2332 }
2333 }
2334 }
2335 }
2336 }
2337 }
2338 form class="token-add" method="post" action="/settings/tokens" {
2339 input type="hidden" name="_csrf" value=(csrf);
2340 label for="token_name" { "New token" }
2341 input type="text" id="token_name" name="name" placeholder="Token name" required;
2342 fieldset class="token-scopes" {
2343 legend { "Scopes" }
2344 label class="checkbox" { input type="checkbox" name="repo_read" value="1" checked; " repo:read" }
2345 label class="checkbox" { input type="checkbox" name="repo_write" value="1"; " repo:write" }
2346 label class="checkbox" { input type="checkbox" name="admin" value="1"; " admin" }
2347 }
2348 button class="btn btn-primary" type="submit" { "Create token" }
2349 }
2350 }
2351 }
2352 }
2353}
2354
2355/// Theme-switch query.
2356#[derive(Debug, Deserialize)]
2357pub struct ThemeQuery {
2358 /// The chosen theme name.
2359 name: String,
2360 /// Where to return afterwards (a local path).
2361 #[serde(default)]
2362 r#return: Option<String>,
2363}
2364
2365/// `GET /settings/theme` — persist the chosen theme in a cookie and return. A
2366/// preference, so a GET that sets a cookie is acceptable and works without JS.
2367pub async fn set_theme(
2368 State(state): State<AppState>,
2369 jar: CookieJar,
2370 axum::extract::Query(q): axum::extract::Query<ThemeQuery>,
2371) -> Response {
2372 // Only accept a known theme name.
2373 if state.assets().theme(&q.name).is_none() {
2374 return AppError::BadRequest("Unknown theme.".to_string()).into_response();
2375 }
2376 let cookie = Cookie::build((THEME_COOKIE, q.name))
2377 .http_only(false)
2378 .same_site(SameSite::Lax)
2379 .secure(state.config.auth.cookie_secure)
2380 .path("/")
2381 .max_age(time::Duration::days(365))
2382 .build();
2383 // Only redirect to a local path, never an absolute URL (open-redirect guard).
2384 let dest = q
2385 .r#return
2386 .filter(|r| r.starts_with('/') && !r.starts_with("//"))
2387 .unwrap_or_else(|| "/".to_string());
2388 (jar.add(cookie), Redirect::to(&dest)).into_response()
2389}
2390
2391/// The current time in Unix milliseconds.
2392fn now_ms() -> i64 {
2393 use std::time::{SystemTime, UNIX_EPOCH};
2394 SystemTime::now()
2395 .duration_since(UNIX_EPOCH)
2396 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
2397}