// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The HTML page handlers of the shell: home, sign-in/out, invite completion, //! settings, and the theme switch. use std::collections::HashMap; use axum::Form; use axum::extract::{Path, State}; use axum::http::{HeaderMap, Uri, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use maud::{Markup, PreEscaped, html}; use model::User; use serde::Deserialize; use crate::AppState; use crate::assets::Scheme; use crate::error::{AppError, AppResult}; use crate::icons::{Icon, icon}; use crate::layout::{Chrome, page}; use crate::repo::{self, RepoEntry, repo_card}; use crate::session::{ MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf, }; /// Assemble the page chrome, ensuring the CSRF cookie and resolving the active /// theme from the visitor's cookie (falling back to the instance default). pub(crate) fn build_chrome( state: &AppState, jar: CookieJar, user: Option, path: String, ) -> (CookieJar, Chrome) { let (jar, csrf) = ensure_csrf(jar, state.config.auth.cookie_secure); let assets = state.assets(); let themes = assets.themes().to_vec(); let active = jar .get(THEME_COOKIE) .map(|c| c.value().to_string()) .filter(|name| assets.theme(name).is_some()) .unwrap_or_else(|| state.config.ui.theme.clone()); let scheme = assets.theme(&active).map_or(Scheme::Dark, |t| t.scheme); drop(assets); let chrome = Chrome { instance_name: state.instance_name(), user, active_theme: active, active_scheme: scheme, themes, allow_theme_choice: state.config.ui.allow_theme_choice, allow_registration: state.allow_registration(), path, csrf, }; (jar, chrome) } /// `GET /` — the signed-in viewer's dashboard, or the public Explore landing for /// anonymous visitors. pub async fn home( State(state): State, MaybeUser(user): MaybeUser, jar: CookieJar, uri: Uri, axum::extract::Query(lq): axum::extract::Query, ) -> AppResult { let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string()); if let Some(u) = user { let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default(), &uri).await?; Ok((jar, page(&chrome, "Dashboard", body)).into_response()) } else { let body = explore_repos_body(&state, "", &uri).await?; Ok((jar, page(&chrome, "Explore", body)).into_response()) } } /// `GET /explore` — every public repository, for anyone. pub async fn explore( State(state): State, MaybeUser(user): MaybeUser, jar: CookieJar, uri: Uri, axum::extract::Query(lq): axum::extract::Query, ) -> AppResult { let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string()); let body = explore_repos_body(&state, &lq.q.unwrap_or_default(), &uri).await?; Ok((jar, page(&chrome, "Explore", body)).into_response()) } /// `GET /explore/users` — every user, for anyone. pub async fn explore_users( State(state): State, MaybeUser(user): MaybeUser, jar: CookieJar, uri: Uri, axum::extract::Query(lq): axum::extract::Query, ) -> AppResult { let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string()); let body = explore_users_body(&state, &lq.q.unwrap_or_default(), &uri).await?; Ok((jar, page(&chrome, "Explore", body)).into_response()) } /// The Explore sub-navigation (Repositories / Users tabs). fn explore_subnav(active: &str) -> Markup { html! { nav class="subnav" aria-label="Explore" { a href="/explore" aria-current=[(active == "repos").then_some("page")] { (icon(Icon::Box)) span { "Repositories" } } a href="/explore/users" aria-current=[(active == "users").then_some("page")] { (icon(Icon::User)) span { "Users" } } } } } /// The Explore repositories tab: sub-nav, a search box, and the public repos. async fn explore_repos_body(state: &AppState, q: &str, uri: &Uri) -> AppResult { let needle = q.trim().to_lowercase(); let all = state.store.list_repos().await?; let public: Vec<_> = all .into_iter() .filter(|r| r.visibility == model::Visibility::Public) .collect(); // Resolve owner names once for the cross-owner labels/links and search. let mut owners: HashMap = HashMap::new(); for repo in &public { if !owners.contains_key(&repo.owner_id) { let name = state .store .user_by_id(&repo.owner_id) .await? .map_or_else(|| repo.owner_id.clone(), |u| u.username); owners.insert(repo.owner_id.clone(), name); } } let entries: Vec = public .iter() .filter_map(|r| { let owner = owners.get(&r.owner_id).map_or("", String::as_str); let label = format!("{owner}/{}", r.path); (needle.is_empty() || label.to_lowercase().contains(&needle)) .then(|| RepoEntry::global(owner, r)) }) .collect(); let paging = repo::Pagination::from_query(uri.query()); let (entries, has_next) = repo::page_slice(&entries, paging); Ok(html! { (explore_subnav("repos")) form class="search-row" method="get" action="/explore" { input type="search" name="q" value=(q) placeholder="Search repositories…" aria-label="Search repositories"; button class="btn btn-primary" type="submit" { "Search" } } (repo_card("", "No public repositories.", &entries)) @if has_next || paging.page > 1 { (repo::pagination_nav(uri.path(), uri.query(), paging, has_next)) } }) } /// The Explore users tab: sub-nav, a search box, and the user directory. async fn explore_users_body(state: &AppState, q: &str, uri: &Uri) -> AppResult { let needle = q.trim().to_lowercase(); let users: Vec = state .store .list_users() .await? .into_iter() .filter(|u| u.disabled_at.is_none()) .filter(|u| { needle.is_empty() || u.username.to_lowercase().contains(&needle) || u.display_name .as_deref() .is_some_and(|d| d.to_lowercase().contains(&needle)) }) .collect(); let paging = repo::Pagination::from_query(uri.query()); let (users, has_next) = repo::page_slice(&users, paging); Ok(html! { (explore_subnav("users")) form class="search-row" method="get" action="/explore/users" { input type="search" name="q" value=(q) placeholder="Search users…" aria-label="Search users"; button class="btn btn-primary" type="submit" { "Search" } } (user_card(&users)) @if has_next || paging.page > 1 { (repo::pagination_nav(uri.path(), uri.query(), paging, has_next)) } }) } /// Render a directory of users as a listing card (avatar, name, join date). fn user_card(users: &[User]) -> Markup { html! { section class="listing" { @if users.is_empty() { div class="card" { p class="muted" { "No users." } } } @else { ul class="repo-list card" { @for u in users { li { a class="user-row" href={ "/" (u.username) } { img class="avatar" src={ "/avatar/" (u.username) } alt=""; span class="user-row-main" { span class="user-row-name" { (u.display_name.as_deref().unwrap_or(&u.username)) } span class="user-row-meta muted" { "@" (u.username) " · Joined on " (repo::fmt_joined(u.created_at)) } } } } } } } } } } /// The dashboard: the viewer's contribution activity and recent commits, beside /// a filterable list of their repositories. async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> AppResult { // The sidebar shows a capped slice (the dashboard's `?page` drives the // activity feed); "View all" leads to the paginated profile repos tab. const SIDEBAR_REPOS: usize = 12; let now = now_ms(); let activity = crate::activity::compute(state, user, now).await?; let paging = repo::Pagination::from_query(uri.query()); let mine = state.store.repos_by_owner(&user.id).await?; let needle = q.trim().to_lowercase(); let entries: Vec = mine .iter() .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle)) .map(|r| RepoEntry::owned(&user.username, r)) .collect(); let total = entries.len(); let shown: Vec = entries.into_iter().take(SIDEBAR_REPOS).collect(); Ok(html! { div class="dashboard" { div class="dashboard-main" { (activity.heatmap()) (activity.feed_section(now, paging, uri.path(), uri.query())) } aside class="dashboard-side" { form class="search-row" method="get" { input type="search" name="q" value=(q) placeholder="Search repositories…" aria-label="Search repositories"; button class="btn btn-primary" type="submit" { "Search" } } (repo_card("Repositories", "You have no repositories yet.", &shown)) @if total > SIDEBAR_REPOS { p class="dashboard-viewall" { a href={ "/" (user.username) "?tab=repos" } { "View all " (total) " repositories →" } } } } } }) } /// `GET /new/issue` — choose a repository to open an issue on. pub async fn new_issue_chooser( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> AppResult { let body = repo_chooser(&state, &user, "issues", "issue").await?; let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); Ok((jar, page(&chrome, "New issue", body)).into_response()) } /// `GET /new/pull` — choose a repository to open a pull request on. pub async fn new_pull_chooser( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> AppResult { let body = repo_chooser(&state, &user, "pulls", "pull request").await?; let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); Ok((jar, page(&chrome, "New pull request", body)).into_response()) } /// A "pick a repository" list linking each of the viewer's repos (with the /// relevant feature enabled) to its new-issue / new-pull form. async fn repo_chooser(state: &AppState, user: &User, seg: &str, noun: &str) -> AppResult { let repos = state.store.repos_by_owner(&user.id).await?; let usable: Vec<_> = repos .iter() .filter(|r| { if seg == "pulls" { r.pulls_enabled } else { r.issues_enabled } }) .collect(); Ok(html! { div class="new-repo" { h1 { "New " (noun) } p class="muted field-hint" { "Choose a repository." } @if usable.is_empty() { div class="card" { p class="muted" { "You have no repositories with " (noun) "s enabled." } } } @else { ul class="repo-list card" { @for r in &usable { li { a class="repo-row" href={ "/" (user.username) "/" (r.path) "/-/" (seg) "/new" } { span class="repo-row-name" { (icon(Icon::Box)) span { (r.path) } } } } } } } } }) } /// `GET /new` — the new-repository form (requires sign-in). pub async fn new_repo_form( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> Response { let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); let body = new_repo_body(&state, &chrome.csrf, None, ""); (jar, page(&chrome, "New repository", body)).into_response() } /// New-repository form fields. #[derive(Debug, Deserialize)] pub struct NewRepoForm { /// The repo name (may include a group path). #[serde(default)] name: String, /// Optional one-line description. #[serde(default)] description: String, /// Visibility token (`public` | `internal` | `private`). #[serde(default)] visibility: String, /// Optional default branch override. #[serde(default)] default_branch: String, /// Object format (`sha1` | `sha256`). #[serde(default)] object_format: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /new` — create the repository (row + bare repo on disk) and redirect. pub async fn new_repo_submit( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } match create_repo_from_form(&state, &user, &form).await { Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(), Err(msg) => { let (jar, chrome) = build_chrome(&state, jar, Some(user), "/new".to_string()); let body = new_repo_body(&state, &chrome.csrf, Some(&msg), form.name.trim()); ( axum::http::StatusCode::BAD_REQUEST, jar, page(&chrome, "New repository", body), ) .into_response() } } } /// Create the repo described by `form`, returning its path or a user-facing error. async fn create_repo_from_form( state: &AppState, user: &User, form: &NewRepoForm, ) -> Result { let name = form.name.trim(); if name.is_empty() { return Err("Repository name is required.".to_string()); } let (group, leaf) = match name.rsplit_once('/') { Some((g, l)) => (Some(g), l), None => (None, name), }; let group_id = match group { Some(g) => Some( state .store .ensure_group_path(&user.id, g) .await .map_err(|e| e.to_string())? .id, ), None => None, }; let branch = { let b = form.default_branch.trim(); if b.is_empty() { state.config.instance.default_branch.clone() } else { b.to_string() } }; let mut visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| { model::Visibility::from_token(&state.default_visibility_token()) .unwrap_or(model::Visibility::Private) }); // A repo may never be more visible than its group (the ceiling walks the // whole ancestor chain; auto groups are public and impose nothing). if let Some(gid) = &group_id { let ceiling = state .store .group_visibility_ceiling(gid) .await .map_err(|e| e.to_string())?; if visibility.rank() > ceiling.rank() { visibility = ceiling; } } let description = { let d = form.description.trim(); (!d.is_empty()).then(|| d.to_string()) }; let repo = state .store .create_repo(store::NewRepo { owner_id: user.id.clone(), group_id, name: leaf.to_string(), path: name.to_string(), description, visibility, default_branch: branch.clone(), }) .await .map_err(|e| match e { store::StoreError::Conflict { .. } => { "A repository with that name already exists.".to_string() } store::StoreError::Name(_) => { "Invalid name: use letters, digits, '-', '_', and '/' for groups.".to_string() } other => other.to_string(), })?; let format = git::ObjectFormat::from_token(&form.object_format); if format != git::ObjectFormat::Sha1 { // Record the non-default format on the row before creating the on-disk repo. let _ = state .store .set_object_format(&repo.id, format.as_str()) .await; } let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); if let Err(e) = git::create_bare_with_format( &state.config.storage.repo_dir, &repo.id, &branch, &hook, format, ) { let _ = state.store.delete_repo(&repo.id).await; return Err(format!("Could not create the repository on disk: {e}")); } Ok(repo.path) } // ---- Migrate from Git ---- /// `GET /new/migrate` — the import form. pub async fn migrate_form( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> Response { let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); let body = migrate_body(&chrome.csrf, &user.username, None, "", ""); (jar, page(&chrome, "New migration", body)).into_response() } /// Migrate-from-Git form fields. #[derive(Debug, Deserialize)] pub struct MigrateForm { #[serde(default)] remote_url: String, #[serde(default)] username: String, #[serde(default)] secret: String, /// Keep syncing from the source (create a pull mirror). #[serde(default)] mirror: Option, /// Also fetch LFS objects (best-effort; requires git-lfs on the server). #[serde(default)] migrate_lfs: Option, #[serde(default)] name: String, #[serde(default)] description: String, #[serde(default)] private: Option, #[serde(rename = "_csrf")] csrf: String, } /// `POST /new/migrate` — clone the source into a new repo (and optionally mirror). pub async fn migrate_submit( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } match do_migrate(&state, &user, &form).await { Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(), Err(msg) => { let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), "/new/migrate".to_string()); let body = migrate_body( &chrome.csrf, &user.username, Some(&msg), form.remote_url.trim(), form.name.trim(), ); ( axum::http::StatusCode::BAD_REQUEST, jar, page(&chrome, "New migration", body), ) .into_response() } } } /// Create the target repo, fetch the source into it once, and (if requested) /// register a pull mirror. Returns the new repo path or a user-facing error. async fn do_migrate(state: &AppState, user: &User, form: &MigrateForm) -> Result { let remote = form.remote_url.trim(); if remote.is_empty() { return Err("A source URL is required.".to_string()); } if form.name.trim().is_empty() { return Err("Repository name is required.".to_string()); } let visibility = if form.private.is_some() { "private".to_string() } else { state.default_visibility_token() }; // Reuse the normal create path (row + bare repo on disk with hooks). let repo_form = NewRepoForm { name: form.name.clone(), description: form.description.clone(), visibility, default_branch: String::new(), object_format: String::new(), csrf: String::new(), }; let path = create_repo_from_form(state, user, &repo_form).await?; let repo = state .store .repo_by_owner_path(&user.id, &path) .await .ok() .flatten() .ok_or_else(|| "The new repository could not be found.".to_string())?; let repo_dir = git::repo_path(&state.config.storage.repo_dir, &repo.id).map_err(|e| e.to_string())?; let home = state.config.storage.data_dir.join("tmp"); let username = (!form.username.trim().is_empty()).then(|| form.username.trim().to_string()); let secret = (!form.secret.trim().is_empty()).then(|| form.secret.trim().to_string()); let cred = git::mirror::RemoteCred { username: username.as_deref(), secret: secret.as_deref(), }; if let Err(e) = git::mirror::fetch(&state.config.git.binary, &repo_dir, remote, cred, &home).await { // Roll back the half-created repo so a failed import leaves nothing behind. let _ = state.store.delete_repo(&repo.id).await; return Err(format!("Could not fetch from the source: {e}")); } if form.migrate_lfs.is_some() { tracing::info!(repo.id = %repo.id, "LFS migration requested (pointers imported; objects not fetched)"); } let size = i64::try_from(crate::admin::dir_size(&repo_dir)).unwrap_or(0); let _ = state.store.record_push(&repo.id, size).await; if form.mirror.is_some() { let _ = state .store .create_mirror(store::NewMirror { repo_id: repo.id.clone(), direction: store::MirrorDirection::Pull, remote_url: remote.to_string(), username, secret, branch_filter: None, interval_secs: 8 * 3600, sync_on_push: false, }) .await; // A mirror is a read-only copy: mark it, and disable issues/PRs. let _ = state.store.set_mirror(&repo.id, true).await; let _ = state .store .set_feature_enabled(&repo.id, "issues", false) .await; let _ = state .store .set_feature_enabled(&repo.id, "pulls", false) .await; } Ok(path) } /// Render the migrate form with an optional error and prefilled fields. fn migrate_body(csrf: &str, owner: &str, error: Option<&str>, url: &str, name: &str) -> Markup { html! { div class="new-repo" { h1 { "New migration" } @if let Some(msg) = error { div class="notice notice-error" { (msg) } } div class="card" { form method="post" action="/new/migrate" { input type="hidden" name="_csrf" value=(csrf); label for="mg-url" { "Migrate / clone from URL" } input type="url" id="mg-url" name="remote_url" value=(url) placeholder="https://example.com/owner/repo.git" required; p class="muted field-hint" { "The HTTP(S) or git clone URL of an existing repository." } div class="admin-form-row" { input type="text" name="username" placeholder="username (optional)" autocomplete="off"; input type="password" name="secret" placeholder="password / token (optional)" autocomplete="off"; } label { "Owner" } input type="text" value=(owner) disabled; label for="mg-name" { "Repository name" } input type="text" id="mg-name" name="name" value=(name) required; label for="mg-desc" { "Description" } textarea id="mg-desc" name="description" rows="3" {} div class="admin-form-actions" { button class="btn btn-primary inline-btn" type="submit" { "Migrate" } label class="checkbox" { input type="checkbox" name="mirror" value="1"; " Mirror" } label class="checkbox" { input type="checkbox" name="migrate_lfs" value="1"; " LFS Files" } label class="checkbox" { input type="checkbox" name="private" value="1"; " Private" } } } } } } } /// Render the new-repository form with an optional error and prefilled name. fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup { let default_vis = state.default_visibility_token(); let vis_option = |value: &str, label: &str| { html! { option value=(value) selected[value == default_vis] { (label) } } }; html! { div class="new-repo" { h1 { "New repository" } @if let Some(err) = error { div class="notice notice-error" { (err) } } div class="card" { form method="post" action="/new" { input type="hidden" name="_csrf" value=(csrf); label for="name" { "Repository name" } input type="text" id="name" name="name" value=(name) required placeholder="my-project (or group/my-project)" autofocus; label for="description" { "Description" } input type="text" id="description" name="description" placeholder="Optional one-line description"; label for="visibility" { "Visibility" } select id="visibility" name="visibility" { (vis_option("public", "Public — visible to everyone")) (vis_option("internal", "Internal — any signed-in user")) (vis_option("private", "Private — only you and collaborators")) } label for="default_branch" { "Default branch" } input type="text" id="default_branch" name="default_branch" placeholder=(state.config.instance.default_branch); label for="object_format" { "Object format" } select id="object_format" name="object_format" { option value="sha1" selected { "SHA-1 (legacy, widely compatible)" } option value="sha256" { "SHA-256 (modern, experimental tooling support)" } } p class="muted field-hint" { "SHA-256 repositories are collision-resistant but not yet supported by all git clients and hosts. This cannot be changed later." } button class="btn btn-primary" type="submit" { "Create repository" } } } } } } /// Sign-in form fields. #[derive(Debug, Deserialize)] pub struct LoginForm { /// Username. username: String, /// Password. password: String, /// CSRF token (`_csrf`). #[serde(rename = "_csrf")] csrf: String, } /// `GET /login` — the sign-in form. pub async fn login_form( State(state): State, MaybeUser(user): MaybeUser, jar: CookieJar, uri: Uri, ) -> Response { if user.is_some() { return Redirect::to("/").into_response(); } let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); let body = login_body(&chrome.csrf, None, chrome.allow_registration); (jar, page(&chrome, "Sign in", body)).into_response() } /// Render the login form with an optional error notice. fn login_body(csrf: &str, error: Option<&str>, allow_registration: bool) -> Markup { html! { div class="auth-wrap" { div class="auth-card" { h1 { "Sign in" } @if let Some(err) = error { div class="notice notice-error" { (err) } } form method="post" action="/login" { input type="hidden" name="_csrf" value=(csrf); label for="username" { "Username" } input type="text" id="username" name="username" autocomplete="username" required; label for="password" { "Password" } input type="password" id="password" name="password" autocomplete="current-password" required; button class="btn btn-primary" type="submit" { "Sign in" } } @if allow_registration { p class="muted auth-alt" { "New here? " a href="/register" { "Create an account" } } } } } } } /// `POST /login` — verify credentials and open a session. pub async fn login_submit( State(state): State, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let user = state .store .user_by_username(&form.username) .await .ok() .flatten(); let hash = user.as_ref().and_then(|u| u.password_hash.clone()); // Constant-time verify; a missing user still runs a dummy hash inside. let ok = auth::verify_password(&form.password, hash.as_deref()) && user .as_ref() .is_some_and(|u| u.disabled_at.is_none() && u.password_hash.is_some()); let Some(user) = user.filter(|_| ok) else { // Re-render the form with a generic error (no username enumeration). let (jar, chrome) = build_chrome(&state, jar, None, "/login".to_string()); let body = login_body( &chrome.csrf, Some("Incorrect username or password."), chrome.allow_registration, ); return ( axum::http::StatusCode::UNAUTHORIZED, jar, page(&chrome, "Sign in", body), ) .into_response(); }; match open_session(&state, &jar, &user, &headers).await { Ok(jar) => (jar, Redirect::to("/")).into_response(), Err(err) => err.into_response(), } } /// Mint a session, persist it, and return the jar with the session cookie set. async fn open_session( state: &AppState, jar: &CookieJar, user: &User, headers: &HeaderMap, ) -> AppResult { let token = auth::new_session_token(); let ttl_ms = i64::from(state.config.auth.session_ttl_days).saturating_mul(86_400_000); let user_agent = headers .get(header::USER_AGENT) .and_then(|v| v.to_str().ok()) .map(str::to_string); state .store .create_session(store::NewSession { id: token.id, user_id: user.id.clone(), user_agent, ip: None, expires_at: now_ms().saturating_add(ttl_ms), }) .await?; let cookie = session_cookie( &state.config.auth.cookie_name, token.cookie, state.config.auth.session_ttl_days, state.config.auth.cookie_secure, ); Ok(jar.clone().add(cookie)) } /// Sign-up form fields. #[derive(Debug, Deserialize)] pub struct RegisterForm { /// Desired username. #[serde(default)] username: String, /// Email address. #[serde(default)] email: String, /// Password. #[serde(default)] password: String, /// Confirmation. #[serde(default)] confirm: String, /// hCaptcha solved token (populated by the hCaptcha widget). #[serde(default, rename = "h-captcha-response")] hcaptcha_response: String, /// reCAPTCHA solved token (populated by the reCAPTCHA widget). #[serde(default, rename = "g-recaptcha-response")] grecaptcha_response: String, /// A sign-up invite token, when registering via an invite link. #[serde(default)] invite: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// The captcha provider and public site key when captcha is enabled, for /// rendering the widget on the sign-up form. fn captcha_view(cfg: &config::Config) -> Option<(config::CaptchaProvider, String)> { cfg.captcha .enabled() .then(|| (cfg.captcha.provider, cfg.captcha.site_key.clone())) } /// `GET /register` — the sign-up form, or `404` when registration is disabled. pub async fn register_form( State(state): State, MaybeUser(user): MaybeUser, jar: CookieJar, uri: Uri, ) -> Response { if !state.allow_registration() { return AppError::NotFound.into_response(); } if user.is_some() { return Redirect::to("/").into_response(); } let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); let body = register_body( &chrome.csrf, None, "", "", captcha_view(&state.config).as_ref(), None, ); (jar, page(&chrome, "Sign up", body)).into_response() } /// `GET /register/{token}` — the sign-up form reached via an admin invite link. /// Works even when self-registration is disabled, provided the token is valid. pub async fn register_invite_form( State(state): State, MaybeUser(user): MaybeUser, jar: CookieJar, uri: Uri, Path(token): Path, ) -> Response { if user.is_some() { return Redirect::to("/").into_response(); } let hash = auth::session_id(&token); match state.store.signup_invite_valid(&hash).await { Ok(Some(_)) => {} Ok(None) => { return AppError::BadRequest( "That invite link is invalid or has been used.".to_string(), ) .into_response(); } Err(err) => return AppError::from(err).into_response(), } let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); let body = register_body( &chrome.csrf, None, "", "", captcha_view(&state.config).as_ref(), Some(&token), ); (jar, page(&chrome, "Sign up", body)).into_response() } /// Render the sign-up form with an optional error, prefilled fields, and the /// captcha widget when one is configured. fn register_body( csrf: &str, error: Option<&str>, username: &str, email: &str, captcha: Option<&(config::CaptchaProvider, String)>, invite: Option<&str>, ) -> Markup { html! { div class="auth-wrap" { div class="auth-card" { h1 { "Sign up" } @if invite.is_some() { div class="notice notice-success" { "You've been invited to create an account." } } @if let Some(err) = error { div class="notice notice-error" { (err) } } form method="post" action="/register" { input type="hidden" name="_csrf" value=(csrf); @if let Some(token) = invite { input type="hidden" name="invite" value=(token); } label for="username" { "Username" } input type="text" id="username" name="username" value=(username) autocomplete="username" required; label for="email" { "Email" } input type="email" id="email" name="email" value=(email) autocomplete="email" required; label for="password" { "Password" } input type="password" id="password" name="password" autocomplete="new-password" required minlength="8"; label for="confirm" { "Confirm password" } input type="password" id="confirm" name="confirm" autocomplete="new-password" required; @if let Some((provider, site_key)) = captcha { @if let Some(script) = provider.script_url() { (PreEscaped(format!(""))) div class=(provider.widget_class()) data-sitekey=(site_key) {} } } button class="btn btn-primary" type="submit" { "Create account" } } p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } } } } } } /// `POST /register` — create an account and sign in. #[allow(clippy::too_many_lines)] // Validation, invite redemption, and session setup. pub async fn register_submit( State(state): State, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { // Registration is allowed either globally or via a valid invite token. let invite_hash = (!form.invite.trim().is_empty()).then(|| auth::session_id(form.invite.trim())); let invite_ok = match &invite_hash { Some(h) => matches!(state.store.signup_invite_valid(h).await, Ok(Some(_))), None => false, }; if !state.allow_registration() && !invite_ok { return AppError::NotFound.into_response(); } if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let username = form.username.trim().to_string(); let email = form.email.trim().to_string(); let invite_field = (!form.invite.trim().is_empty()).then_some(form.invite.trim()); let captcha = captcha_view(&state.config); let reject = |msg: &str, username: &str, email: &str| { let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string()); let body = register_body( &chrome.csrf, Some(msg), username, email, captcha.as_ref(), invite_field, ); ( axum::http::StatusCode::BAD_REQUEST, jar, page(&chrome, "Sign up", body), ) .into_response() }; if !email.contains('@') { return reject("Enter a valid email address.", &username, &email); } if form.password != form.confirm { return reject("Passwords do not match.", &username, &email); } if form.password.len() < 8 { return reject("Password must be at least 8 characters.", &username, &email); } // When captcha is enabled, the solved token must verify with the provider // before we touch the store. if state.config.captcha.enabled() { let token = match state.config.captcha.provider { config::CaptchaProvider::ReCaptcha => form.grecaptcha_response.trim(), _ => form.hcaptcha_response.trim(), }; if !verify_captcha(&state.config.captcha, token).await { return reject( "Captcha verification failed. Please try again.", &username, &email, ); } } let params = auth::HashParams { m_cost: state.config.auth.argon2_m_cost, t_cost: state.config.auth.argon2_t_cost, p_cost: state.config.auth.argon2_p_cost, }; let Ok(hash) = auth::hash_password(&form.password, params) else { return AppError::internal("password hashing failed").into_response(); }; let created = state .store .create_user(store::NewUser { username: username.clone(), email: email.clone(), display_name: None, password_hash: Some(hash), is_admin: false, must_change_password: false, }) .await; let user = match created { Ok(u) => u, Err(store::StoreError::Conflict { field, .. }) => { let msg = if field == "email" { "That email is already in use." } else { "That username is already taken." }; return reject(msg, &username, &email); } Err(store::StoreError::Name(_)) => { return reject( "Invalid username: use letters, digits, '-', and '_'.", &username, &email, ); } Err(err) => return AppError::from(err).into_response(), }; // Consume the invite (if any) now that the account exists. if let Some(h) = &invite_hash { let _ = state.store.redeem_signup_invite(h, &user.id).await; } // Send a verification email for the new primary address (best-effort). if let Ok(emails) = state.store.emails_by_user(&user.id).await && let Some(primary) = emails.into_iter().find(|e| e.is_primary) { send_email_verification(&state, &primary).await; } match open_session(&state, &jar, &user, &headers).await { Ok(jar) => (jar, Redirect::to("/")).into_response(), Err(err) => err.into_response(), } } /// The relevant fields of a provider `siteverify` response. #[derive(Debug, Deserialize)] struct SiteVerify { /// Whether the token was valid. #[serde(default)] success: bool, } /// Verify a solved captcha `token` with the configured provider. Returns `false` /// on an empty token, a missing secret, a network error, or a rejected token — a /// closed failure mode, so a broken verifier never lets a bot through. async fn verify_captcha(cfg: &config::Captcha, token: &str) -> bool { if token.is_empty() { return false; } let Some(secret) = cfg.secret_key.as_ref() else { return false; }; let Ok(client) = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() else { return false; }; let form = [("secret", secret.expose()), ("response", token)]; match client .post(cfg.provider.verify_url()) .form(&form) .send() .await { Ok(resp) => resp.json::().await.is_ok_and(|v| v.success), Err(_) => false, } } /// Logout form field. #[derive(Debug, Deserialize)] pub struct CsrfForm { /// CSRF token (`_csrf`). #[serde(rename = "_csrf")] csrf: String, } /// `POST /logout` — destroy the session. pub async fn logout( State(state): State, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } if let Some(cookie) = jar.get(&state.config.auth.cookie_name) { let session_id = auth::session_id(cookie.value()); let _ = state.store.delete_session(&session_id).await; } let jar = jar.add(clear_cookie(&state.config.auth.cookie_name)); (jar, Redirect::to("/login")).into_response() } /// Invite set-password form. #[derive(Debug, Deserialize)] pub struct InviteForm { /// New password. password: String, /// Confirmation. confirm: String, /// CSRF token (`_csrf`). #[serde(rename = "_csrf")] csrf: String, } /// `GET /invite/{token}` — show the activation form, or an error if the invite is /// invalid, used, or expired. pub async fn invite_form( State(state): State, jar: CookieJar, uri: Uri, Path(token): Path, ) -> AppResult { let invite = live_invite(&state, &token).await?; let _ = invite; let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); let body = html! { div class="auth-wrap" { div class="auth-card" { h1 { "Set your password" } p class="muted" { "Choose a password to activate your account." } form method="post" action={ "/invite/" (token) } { input type="hidden" name="_csrf" value=(chrome.csrf); label for="password" { "Password" } input type="password" id="password" name="password" autocomplete="new-password" required minlength="8"; label for="confirm" { "Confirm password" } input type="password" id="confirm" name="confirm" autocomplete="new-password" required; button class="btn btn-primary" type="submit" { "Activate account" } } } } }; Ok((jar, page(&chrome, "Activate", body)).into_response()) } /// `POST /invite/{token}` — set the password, redeem the invite, and sign in. pub async fn invite_submit( State(state): State, jar: CookieJar, headers: HeaderMap, Path(token): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } if form.password != form.confirm { return AppError::BadRequest("Passwords do not match.".to_string()).into_response(); } if form.password.len() < 8 { return AppError::BadRequest("Password must be at least 8 characters.".to_string()) .into_response(); } let result = async { let invite = live_invite(&state, &token).await?; let params = auth::HashParams { m_cost: state.config.auth.argon2_m_cost, t_cost: state.config.auth.argon2_t_cost, p_cost: state.config.auth.argon2_p_cost, }; let hash = auth::hash_password(&form.password, params).map_err(AppError::internal)?; state .store .set_password(&invite.user_id, Some(&hash)) .await?; state.store.mark_invite_used(&invite.id).await?; // The invite was delivered to this account's email, so completing it // proves control of that address — mark it verified. let _ = state.store.verify_primary_email(&invite.user_id).await; let user = state .store .user_by_id(&invite.user_id) .await? .ok_or(AppError::NotFound)?; open_session(&state, &jar, &user, &headers).await } .await; match result { Ok(jar) => (jar, Redirect::to("/")).into_response(), Err(err) => err.into_response(), } } /// Resolve a live (unredeemed, unexpired) invite for a raw token, or a themed /// error. async fn live_invite(state: &AppState, token: &str) -> AppResult { let hash = auth::session_id(token); let invite = state .store .invite_by_token_hash(&hash) .await? .ok_or(AppError::NotFound)?; if invite.used_at.is_some() { return Err(AppError::BadRequest( "This invite has already been used.".to_string(), )); } if invite.expires_at <= now_ms() { return Err(AppError::BadRequest("This invite has expired.".to_string())); } Ok(invite) } /// `GET /settings` — the Profile tab. pub async fn settings( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> AppResult { let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); let body = settings_shell("profile", profile_tab(&user, &chrome.csrf)); Ok((jar, page(&chrome, "Settings", body)).into_response()) } /// `GET /settings/account` — the Account tab (username, emails, password). pub async fn settings_account( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> AppResult { let emails = state.store.emails_by_user(&user.id).await?; let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); let body = settings_shell("account", account_tab(&user, &chrome.csrf, &emails)); Ok((jar, page(&chrome, "Settings", body)).into_response()) } /// `GET /settings/keys` — the SSH and GPG keys tab. pub async fn settings_keys( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> AppResult { let keys = state.store.keys_by_user(&user.id).await?; let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); let body = settings_shell("keys", keys_section(&chrome.csrf, &keys)); Ok((jar, page(&chrome, "Settings", body)).into_response()) } /// `GET /settings/tokens` — the API tokens tab. pub async fn settings_tokens( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> AppResult { let tokens = state.store.tokens_by_user(&user.id).await?; let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); let body = settings_shell("tokens", tokens_section(&chrome.csrf, &tokens, None)); Ok((jar, page(&chrome, "Settings", body)).into_response()) } /// Profile settings form fields. Empty strings clear the corresponding column. #[derive(Debug, Deserialize)] pub struct SettingsForm { /// Display name. #[serde(default)] display_name: String, /// Pronouns. #[serde(default)] pronouns: String, /// Free-text bio. #[serde(default)] bio: String, /// Location. #[serde(default)] location: String, /// Up to five arbitrary profile links (`serde_urlencoded` has no sequence /// support, so each slot is its own field). #[serde(default)] link1: String, #[serde(default)] link2: String, #[serde(default)] link3: String, #[serde(default)] link4: String, #[serde(default)] link5: String, /// CSRF token (`_csrf`). #[serde(rename = "_csrf")] csrf: String, } /// The number of profile-link slots offered on the settings page. const LINK_SLOTS: usize = 5; /// `POST /settings` — persist the viewer's profile fields. pub async fn settings_submit( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let links: Vec = [ &form.link1, &form.link2, &form.link3, &form.link4, &form.link5, ] .iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .take(LINK_SLOTS) .collect(); let update = store::ProfileUpdate { display_name: norm(&form.display_name), pronouns: norm(&form.pronouns), bio: norm(&form.bio), location: norm(&form.location), links, }; match state.store.update_profile(&user.id, update).await { Ok(_) => Redirect::to("/settings").into_response(), Err(err) => AppError::from(err).into_response(), } } /// Trim a form string, mapping the empty result to `None` (clears the column). fn norm(s: &str) -> Option { let t = s.trim(); (!t.is_empty()).then(|| t.to_string()) } /// The change-username form. #[derive(Debug, Deserialize)] pub struct AccountFieldForm { /// The new username. #[serde(default)] username: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/username` — change the viewer's username. pub async fn account_username( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } match state .store .update_username(&user.id, form.username.trim()) .await { Ok(_) => Redirect::to("/settings/account").into_response(), Err(store::StoreError::Conflict { .. }) => { AppError::BadRequest("That username is already taken.".to_string()).into_response() } Err(store::StoreError::Name(_)) => { AppError::BadRequest("Invalid username.".to_string()).into_response() } Err(err) => AppError::from(err).into_response(), } } /// The add-email form. #[derive(Debug, Deserialize)] pub struct EmailAddForm { /// The new address. #[serde(default)] email: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/emails` — add a secondary email address. pub async fn email_add( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let email = form.email.trim(); if !email.contains('@') { return AppError::BadRequest("Enter a valid email address.".to_string()).into_response(); } match state.store.add_email(&user.id, email).await { Ok(added) => { // Send a verification link (best-effort; logged on failure). send_email_verification(&state, &added).await; Redirect::to("/settings/account").into_response() } Err(store::StoreError::Conflict { .. }) => { AppError::BadRequest("That email is already in use.".to_string()).into_response() } Err(err) => AppError::from(err).into_response(), } } /// Issue a fresh verification token for `email` and email the link. Best-effort: /// store or mail failures are logged, not surfaced (the user can resend). async fn send_email_verification(state: &AppState, email: &model::UserEmail) { let token = auth::new_secret(); if let Err(err) = state .store .begin_email_verification(&email.id, &token) .await { tracing::warn!(error = %err, "could not store email verification token"); return; } let base = state.config.instance.url.trim_end_matches('/'); let link = format!("{base}/verify-email/{token}"); if let Err(err) = state .mailer .send_invite( &state.config.instance.name, &email.email, None, &link, mail::Purpose::VerifyEmail, ) .await { tracing::warn!(error = %err, email = %email.email, "could not send verification email"); } } /// `GET /verify-email/{token}` — redeem an email verification link. pub async fn verify_email( State(state): State, jar: CookieJar, Path(token): Path, ) -> Response { match state.store.verify_email_token(&token).await { Ok(Some(_)) => { let (jar, chrome) = build_chrome(&state, jar, None, "/verify-email".to_string()); let body = html! { div class="auth-wrap" { div class="auth-card" { h1 { "Email verified" } p class="muted" { "Your email address has been verified." } p { a class="btn btn-primary" href="/settings/account" { "Back to settings" } } } } }; (jar, page(&chrome, "Email verified", body)).into_response() } Ok(None) => { AppError::BadRequest("That verification link is invalid or has expired.".to_string()) .into_response() } Err(err) => AppError::from(err).into_response(), } } /// `POST /settings/emails/resend` — resend a verification email. pub async fn email_resend( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } if let Ok(Some(email)) = state.store.email_owned_by(&user.id, &form.id).await { send_email_verification(&state, &email).await; } Redirect::to("/settings/account").into_response() } /// An email-id form (set-primary or delete). #[derive(Debug, Deserialize)] pub struct EmailIdForm { /// The `user_emails` row id. #[serde(default)] id: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/emails/primary` — make one of the viewer's emails primary. pub async fn email_primary( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } match state.store.set_primary_email(&user.id, &form.id).await { Ok(true) => Redirect::to("/settings/account").into_response(), Ok(false) => { AppError::BadRequest("Verify the email address before making it primary.".to_string()) .into_response() } Err(err) => AppError::from(err).into_response(), } } /// `POST /settings/emails/delete` — remove one of the viewer's secondary emails. pub async fn email_delete( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.delete_email(&user.id, &form.id).await; Redirect::to("/settings/account").into_response() } /// The email-visibility toggle form. #[derive(Debug, Deserialize)] pub struct EmailVisibilityForm { /// Present (any value) when the checkbox is ticked. #[serde(default)] public: Option, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/emails/visibility` — show/hide the primary email on the profile. pub async fn email_visibility( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state .store .set_email_public(&user.id, form.public.is_some()) .await; Redirect::to("/settings/account").into_response() } /// The change-password form. #[derive(Debug, Deserialize)] pub struct PasswordForm { /// The current password (re-verified). #[serde(default)] current: String, /// The new password. #[serde(default)] new_password: String, /// Confirmation of the new password. #[serde(default)] confirm: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/password` — change the password after re-verifying the current /// one; all other sessions are invalidated and this one is re-established. pub async fn account_password( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } if !auth::verify_password(&form.current, user.password_hash.as_deref()) { return AppError::BadRequest("Current password is incorrect.".to_string()).into_response(); } if form.new_password != form.confirm { return AppError::BadRequest("New passwords do not match.".to_string()).into_response(); } if form.new_password.len() < 8 { return AppError::BadRequest("New password must be at least 8 characters.".to_string()) .into_response(); } let result = async { let params = auth::HashParams { m_cost: state.config.auth.argon2_m_cost, t_cost: state.config.auth.argon2_t_cost, p_cost: state.config.auth.argon2_p_cost, }; let hash = auth::hash_password(&form.new_password, params).map_err(AppError::internal)?; // set_password deletes every session (including this one); re-establish it. state.store.set_password(&user.id, Some(&hash)).await?; open_session(&state, &jar, &user, &headers).await } .await; match result { Ok(jar) => (jar, Redirect::to("/settings/account")).into_response(), Err(err) => err.into_response(), } } /// The create-token form. #[derive(Debug, Deserialize)] pub struct TokenCreateForm { /// The token label. #[serde(default)] name: String, /// `repo:read` scope (checkbox present when granted). #[serde(default)] repo_read: Option, /// `repo:write` scope. #[serde(default)] repo_write: Option, /// `admin` scope. #[serde(default)] admin: Option, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/tokens` — mint a new API token, shown once. pub async fn token_create( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let name = form.name.trim(); if name.is_empty() { return AppError::BadRequest("Token name is required.".to_string()).into_response(); } let mut scopes: Vec<&str> = Vec::new(); if form.repo_read.is_some() { scopes.push("repo:read"); } if form.repo_write.is_some() { scopes.push("repo:write"); } if form.admin.is_some() { scopes.push("admin"); } if scopes.is_empty() { return AppError::BadRequest("Select at least one scope.".to_string()).into_response(); } let scope_str = scopes.join(","); let result = async { let parsed = auth::parse_scopes(&scope_str).map_err(AppError::internal)?; let canonical = auth::format_scopes(&parsed); let token = state .store .create_token(store::NewToken { user_id: user.id.clone(), name: name.to_string(), scopes: canonical.clone(), expires_at: None, }) .await?; // Mint the JWT with a far-future expiry (the row's revoked_at enforces // revocation regardless). let now = now_ms().div_euclid(1000); let claims = auth::Claims { sub: user.id.clone(), jti: token.id.clone(), scopes: canonical, iat: now, exp: now.saturating_add(10 * 365 * 86_400), }; let jwt = auth::mint_jwt(&state.secret, &claims).map_err(AppError::internal)?; Ok::(jwt) } .await; match result { Ok(jwt) => { let tokens = state .store .tokens_by_user(&user.id) .await .unwrap_or_default(); let (jar, chrome) = build_chrome( &state, jar, Some(user.clone()), "/settings/tokens".to_string(), ); let body = settings_shell("tokens", tokens_section(&chrome.csrf, &tokens, Some(&jwt))); (jar, page(&chrome, "Settings", body)).into_response() } Err(err) => err.into_response(), } } /// The revoke-token form. #[derive(Debug, Deserialize)] pub struct TokenRevokeForm { /// The token id. #[serde(default)] id: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/tokens/revoke` — revoke one of the viewer's tokens. pub async fn token_revoke( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } // Only revoke a token that belongs to the viewer. let owned = state .store .token_by_id(&form.id) .await .ok() .flatten() .is_some_and(|t| t.user_id == user.id); if owned { let _ = state.store.revoke_token(&form.id).await; } Redirect::to("/settings/tokens").into_response() } /// The add-key form. #[derive(Debug, Deserialize)] pub struct KeyAddForm { /// `ssh` or `gpg`. #[serde(default)] kind: String, /// Optional label. #[serde(default)] name: String, /// The public key material (`authorized_keys` line or armored block). #[serde(default)] key: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/keys` — register an SSH or GPG public key for the viewer. pub async fn key_add( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let Some(kind) = model::KeyKind::from_token(&form.kind) else { return AppError::BadRequest("Choose a key type.".to_string()).into_response(); }; let material = form.key.trim(); let Ok(parsed) = git::parse_public_key(kind, material) else { return AppError::BadRequest(format!("That does not look like a valid {kind} key.")) .into_response(); }; // Reject a duplicate fingerprint (registered to anyone). match state .store .key_by_fingerprint(kind, &parsed.fingerprint) .await { Ok(Some(_)) => { return AppError::BadRequest("That key is already registered.".to_string()) .into_response(); } Ok(None) => {} Err(err) => return AppError::from(err).into_response(), } let name = form.name.trim(); let label = (!name.is_empty()) .then(|| name.to_string()) .or(parsed.label); match state .store .create_key(store::NewKey { user_id: user.id.clone(), kind, name: label, fingerprint: parsed.fingerprint, public_key: parsed.public_key, }) .await { Ok(_) => Redirect::to("/settings/keys").into_response(), Err(store::StoreError::Conflict { .. }) => { AppError::BadRequest("That key is already registered.".to_string()).into_response() } Err(err) => AppError::from(err).into_response(), } } /// The delete-key form. #[derive(Debug, Deserialize)] pub struct KeyDeleteForm { /// The key id. #[serde(default)] id: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/keys/delete` — delete one of the viewer's keys. pub async fn key_delete( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } // Only delete a key that belongs to the viewer. let owned = state .store .keys_by_user(&user.id) .await .is_ok_and(|keys| keys.iter().any(|k| k.id == form.id)); if owned { let _ = state.store.delete_key(&form.id).await; } Redirect::to("/settings/keys").into_response() } /// The verify-key form: the key id and a pasted armored signature. #[derive(Debug, Deserialize)] pub struct KeyVerifyForm { /// The key id. #[serde(default)] id: String, /// The armored detached signature over the challenge. #[serde(default)] signature: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /settings/keys/verify` — verify GPG key ownership from a signed /// challenge. (SSH keys verify automatically on authentication.) pub async fn key_verify( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let Ok(keys) = state.store.keys_by_user(&user.id).await else { return AppError::internal("could not load keys").into_response(); }; let Some(key) = keys.into_iter().find(|k| k.id == form.id) else { return AppError::NotFound.into_response(); }; if key.kind != model::KeyKind::Gpg { return AppError::BadRequest("Only GPG keys are verified this way.".to_string()) .into_response(); } let challenge = gpg_challenge(&key); let ok = git::verify_challenge( model::KeyKind::Gpg, &key.fingerprint, &key.public_key, form.signature.trim(), challenge.as_bytes(), ); if !ok { return AppError::BadRequest("That signature did not verify against this key.".to_string()) .into_response(); } let _ = state.store.set_key_verified(&key.id).await; Redirect::to("/settings/keys").into_response() } /// Render the settings page: avatar, profile, account credentials, and tokens. /// The settings page shell: a left tab nav and the active tab's content. Each tab /// is its own route, so navigation works without JavaScript. #[allow(clippy::needless_pass_by_value)] // `content` is an owned fragment embedded once. fn settings_shell(active: &str, content: Markup) -> Markup { let tab = |href: &str, key: &str, label: &str| { let current = active == key; html! { a href=(href) aria-current=[current.then_some("page")] { (label) } } }; html! { div class="settings-layout" { aside class="settings-nav" { h1 { "Settings" } nav { (tab("/settings", "profile", "Profile")) (tab("/settings/account", "account", "Account")) (tab("/settings/keys", "keys", "SSH and GPG keys")) (tab("/settings/tokens", "tokens", "API tokens")) } } div class="settings-content" { (content) } } } } /// The Profile tab: avatar and profile fields. fn profile_tab(user: &User, csrf: &str) -> Markup { let val = |v: &Option| v.clone().unwrap_or_default(); html! { section class="listing" { h2 { "Avatar" } div class="card avatar-settings" { img class="avatar avatar-lg" src={ "/avatar/" (user.username) } alt="Your avatar"; form class="avatar-form" method="post" action="/settings/avatar" enctype="multipart/form-data" { input type="hidden" name="_csrf" value=(csrf); p class="muted field-hint" { "PNG, JPEG, GIF, or WebP up to 1 MiB. Leave blank for a generated identicon." } div class="avatar-upload-row" { input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp"; button class="btn inline-btn" type="submit" { (icon(Icon::Upload)) span { "Upload" } } } } } } section class="listing" { h2 { "Profile" } div class="card" { form method="post" action="/settings" { input type="hidden" name="_csrf" value=(csrf); label for="display_name" { "Display name" } input type="text" id="display_name" name="display_name" value=(val(&user.display_name)) autocomplete="name"; label for="pronouns" { "Pronouns" } input type="text" id="pronouns" name="pronouns" value=(val(&user.pronouns)) placeholder="they/them"; label for="bio" { "Bio" } textarea id="bio" name="bio" rows="3" { (val(&user.bio)) } label for="location" { "Location" } input type="text" id="location" name="location" value=(val(&user.location)); label for="link1" { "Links" } p class="muted field-hint" { "Up to five links (website, GitHub, Mastodon, …); icons are chosen from the address." } div class="link-fields" data-link-fields { @for i in 0..LINK_SLOTS { input type="url" id=(format!("link{}", i + 1)) name=(format!("link{}", i + 1)) class="link-slot" value=(user.links.get(i).map_or("", String::as_str)) placeholder="https://example.com"; } button class="btn link-add" type="button" data-link-add { "Add" } } button class="btn btn-primary" type="submit" { "Save profile" } } } } } } /// The Account tab: username, email(s), and password. fn account_tab(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup { html! { section class="listing" { h2 { "Username" } div class="card" { form method="post" action="/settings/username" { input type="hidden" name="_csrf" value=(csrf); label for="username" { "Username" } input type="text" id="username" name="username" value=(user.username) required; button class="btn" type="submit" { "Change username" } } } } (emails_section(user, csrf, emails)) section class="listing" { h2 { "Password" } div class="card" { form method="post" action="/settings/password" { input type="hidden" name="_csrf" value=(csrf); label for="current" { "Current password" } input type="password" id="current" name="current" autocomplete="current-password" required; label for="new_password" { "New password" } input type="password" id="new_password" name="new_password" autocomplete="new-password" required minlength="8"; label for="confirm" { "Confirm new password" } input type="password" id="confirm" name="confirm" autocomplete="new-password" required; button class="btn btn-primary" type="submit" { "Change password" } } } } } } /// The emails sub-section of the Account tab: the list of addresses (primary /// marked, with set-primary / remove actions), the profile visibility toggle, /// and the add-email form. fn emails_section(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup { html! { section class="listing" { h2 { "Emails" } div class="card" { ul class="entry-list email-list" { @for e in emails { li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { (e.email) @if e.is_primary { " " span class="badge" { "primary" } } @if e.verified() { " " span class="badge badge-verified" { "verified" } } @else { " " span class="badge badge-unverified" { "unverified" } } } } div class="entry-row-actions" { @if !e.verified() { form method="post" action="/settings/emails/resend" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="id" value=(e.id); button class="btn" type="submit" { "Resend" } } } @if !e.is_primary && e.verified() { form method="post" action="/settings/emails/primary" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="id" value=(e.id); button class="btn" type="submit" { "Make primary" } } } @if !e.is_primary { form method="post" action="/settings/emails/delete" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="id" value=(e.id); button class="btn btn-danger" type="submit" { "Remove" } } } } } } } form class="email-visibility" method="post" action="/settings/emails/visibility" { input type="hidden" name="_csrf" value=(csrf); label class="checkbox" { input type="checkbox" name="public" value="1" checked[user.email_public] onchange="this.form.submit()"; " Show my primary email on my profile" } noscript { button class="btn" type="submit" { "Save" } } } form class="email-add" method="post" action="/settings/emails" { input type="hidden" name="_csrf" value=(csrf); label for="new_email" { "Add email address" } div class="email-add-row" { input type="email" id="new_email" name="email" required placeholder="you@example.com"; button class="btn btn-primary inline-btn" type="submit" { "Add" } } } } } } } /// The SSH and GPG keys settings section. fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup { html! { section class="listing" { h2 { "SSH and GPG keys" } div class="card" { p class="muted field-hint" { "SSH keys authorize push over SSH; both SSH and GPG keys verify commit signatures." } @if keys.is_empty() { p class="muted" { "No keys yet." } } @else { ul class="entry-list key-list" { @for k in keys { li class="entry-row key-entry" { div class="entry-row-body" { span class="entry-row-title" { span class="badge" { (k.kind.as_str()) } @if k.verified() { " " span class="badge badge-verified" { "verified" } } @else { " " span class="badge badge-unverified" { "unverified" } } " " (k.name.as_deref().unwrap_or("(unnamed)")) } div class="entry-row-meta muted mono" { (k.fingerprint) } @if !k.verified() { (key_verify_hint(k, csrf)) } } div class="entry-row-actions" { form method="post" action="/settings/keys/delete" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="id" value=(k.id); button class="btn btn-danger" type="submit" { "Delete" } } } } } } } form class="key-add" method="post" action="/settings/keys" { input type="hidden" name="_csrf" value=(csrf); div class="key-add-row" { label for="key_kind" { "Type" } select id="key_kind" name="kind" { option value="ssh" { "SSH" } option value="gpg" { "GPG" } } label for="key_name" { "Title" } input type="text" id="key_name" name="name" placeholder="Optional label"; } label for="key_material" { "Key" } textarea id="key_material" name="key" rows="4" required placeholder="Paste an SSH public key or an ASCII-armored GPG public key" {} button class="btn btn-primary" type="submit" { "Add key" } } } } } } /// The deterministic message a GPG key owner signs to prove possession. fn gpg_challenge(key: &model::Key) -> String { format!( "fabrica GPG key ownership verification\nfingerprint: {}\n", key.fingerprint ) } /// The verify affordance for an unverified key: a note for SSH keys (which verify /// on use) and a sign-a-challenge form for GPG keys. fn key_verify_hint(key: &model::Key, csrf: &str) -> Markup { match key.kind { model::KeyKind::Ssh => html! { p class="muted field-hint key-verify-note" { "Verifies automatically the next time you authenticate over SSH." } }, model::KeyKind::Gpg => html! { details class="key-verify" { summary { "Verify ownership" } p class="muted field-hint" { "Sign this exact text with your GPG key and paste the armored signature:" } pre class="key-challenge" { code { (gpg_challenge(key)) } } p class="muted field-hint mono" { "gpg --local-user " (key.fingerprint) " --armor --detach-sign challenge.txt" } form method="post" action="/settings/keys/verify" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="id" value=(key.id); textarea name="signature" rows="6" required placeholder="-----BEGIN PGP SIGNATURE-----" {} button class="btn" type="submit" { "Verify" } } } }, } } /// The API tokens settings section. fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup { html! { section class="listing" { h2 { "API tokens" } div class="card" { @if let Some(secret) = new_token { div class="notice notice-success" { p { strong { "New token created." } " Copy it now — it is not shown again." } pre class="token-secret" { code { (secret) } } } } @if tokens.is_empty() { p class="muted" { "No API tokens yet." } } @else { ul class="entry-list token-list" { @for t in tokens { li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { (t.name) } div class="entry-row-meta muted" { span { (t.scopes) } @if t.revoked_at.is_some() { span class="badge badge-private" { "revoked" } } } } div class="entry-row-actions" { @if t.revoked_at.is_none() { form method="post" action="/settings/tokens/revoke" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="id" value=(t.id); button class="btn btn-danger" type="submit" { "Revoke" } } } } } } } } form class="token-add" method="post" action="/settings/tokens" { input type="hidden" name="_csrf" value=(csrf); label for="token_name" { "New token" } input type="text" id="token_name" name="name" placeholder="Token name" required; fieldset class="token-scopes" { legend { "Scopes" } label class="checkbox" { input type="checkbox" name="repo_read" value="1" checked; " repo:read" } label class="checkbox" { input type="checkbox" name="repo_write" value="1"; " repo:write" } label class="checkbox" { input type="checkbox" name="admin" value="1"; " admin" } } button class="btn btn-primary" type="submit" { "Create token" } } } } } } /// Theme-switch query. #[derive(Debug, Deserialize)] pub struct ThemeQuery { /// The chosen theme name. name: String, /// Where to return afterwards (a local path). #[serde(default)] r#return: Option, } /// `GET /settings/theme` — persist the chosen theme in a cookie and return. A /// preference, so a GET that sets a cookie is acceptable and works without JS. pub async fn set_theme( State(state): State, jar: CookieJar, axum::extract::Query(q): axum::extract::Query, ) -> Response { // Only accept a known theme name. if state.assets().theme(&q.name).is_none() { return AppError::BadRequest("Unknown theme.".to_string()).into_response(); } let cookie = Cookie::build((THEME_COOKIE, q.name)) .http_only(false) .same_site(SameSite::Lax) .secure(state.config.auth.cookie_secure) .path("/") .max_age(time::Duration::days(365)) .build(); // Only redirect to a local path, never an absolute URL (open-redirect guard). let dest = q .r#return .filter(|r| r.starts_with('/') && !r.starts_with("//")) .unwrap_or_else(|| "/".to_string()); (jar.add(cookie), Redirect::to(&dest)).into_response() } /// The current time in Unix milliseconds. fn now_ms() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) }