Git hosting and a container registry in one Rust binary (axum + Astro)
Web UI: sign-in with Google, settings, profiles with heatmap, orgs, dashboard, explore, repository browser
18 files changed, +4365 -8
+93-0.playwright-mcp/page-2026-10-08T18-01-57-486Z.yml
| @@ -0,0 +1,93 @@ | ||
| 1 | +- generic [active] [ref=f11e1]: | |
| 2 | + - banner [ref=f11e2]: | |
| 3 | + - generic [ref=f11e3]: | |
| 4 | + - link "irongit" [ref=f11e4] [cursor=pointer]: | |
| 5 | + - /url: / | |
| 6 | + - navigation [ref=f11e11]: | |
| 7 | + - link "Explore" [ref=f11e12] [cursor=pointer]: | |
| 8 | + - /url: /explore | |
| 9 | + - link "Docs" [ref=f11e13] [cursor=pointer]: | |
| 10 | + - /url: /docs | |
| 11 | + - generic [ref=f11e14]: | |
| 12 | + - link "Sign in" [ref=f11e15] [cursor=pointer]: | |
| 13 | + - /url: /login?next=%2Falice%2F-%2Fpackages%2Falpine | |
| 14 | + - link "Register" [ref=f11e16] [cursor=pointer]: | |
| 15 | + - /url: /register | |
| 16 | + - main [ref=f11e17]: | |
| 17 | + - generic [ref=f11e18]: | |
| 18 | + - generic [ref=f11e19]: | |
| 19 | + - img "alice" [ref=f11e20] | |
| 20 | + - link "alice" [ref=f11e21] [cursor=pointer]: | |
| 21 | + - /url: /alice | |
| 22 | + - generic [ref=f11e22]: / | |
| 23 | + - link "images" [ref=f11e23] [cursor=pointer]: | |
| 24 | + - /url: /alice/-/packages | |
| 25 | + - generic [ref=f11e24]: / | |
| 26 | + - link "alpine" [ref=f11e25] [cursor=pointer]: | |
| 27 | + - /url: /alice/-/packages/alpine | |
| 28 | + - generic [ref=f11e26]: Public | |
| 29 | + - navigation [ref=f11e27]: | |
| 30 | + - link "Tags 1" [ref=f11e28] [cursor=pointer]: | |
| 31 | + - /url: /alice/-/packages/alpine | |
| 32 | + - text: Tags | |
| 33 | + - generic [ref=f11e29]: "1" | |
| 34 | + - generic [ref=f11e30]: | |
| 35 | + - generic [ref=f11e31]: | |
| 36 | + - generic [ref=f11e32]: | |
| 37 | + - generic [ref=f11e33]: Pull | |
| 38 | + - generic [ref=f11e35]: | |
| 39 | + - textbox [ref=f11e36]: docker pull localhost:7881/alice/alpine:test | |
| 40 | + - button "Copy" [ref=f11e37] [cursor=pointer] | |
| 41 | + - table [ref=f11e39]: | |
| 42 | + - rowgroup [ref=f11e40]: | |
| 43 | + - row [ref=f11e41]: | |
| 44 | + - columnheader "Tag" [ref=f11e42] | |
| 45 | + - columnheader "Digest" [ref=f11e43] | |
| 46 | + - columnheader "Platforms" [ref=f11e44] | |
| 47 | + - columnheader "Size" [ref=f11e45] | |
| 48 | + - columnheader "Updated" [ref=f11e46] | |
| 49 | + - rowgroup [ref=f11e47]: | |
| 50 | + - row [ref=f11e48]: | |
| 51 | + - cell [ref=f11e49]: | |
| 52 | + - button "test" [ref=f11e50] [cursor=pointer] | |
| 53 | + - cell [ref=f11e51]: | |
| 54 | + - button "caa86b62b1e0" [ref=f11e52] [cursor=pointer] | |
| 55 | + - cell "single" [ref=f11e53] | |
| 56 | + - cell "3.8 MB" [ref=f11e54] | |
| 57 | + - cell [ref=f11e55]: | |
| 58 | + - time [ref=f11e56]: 5 minutes ago | |
| 59 | + - complementary [ref=f11e57]: | |
| 60 | + - generic [ref=f11e58]: | |
| 61 | + - generic [ref=f11e59]: About | |
| 62 | + - generic [ref=f11e60]: | |
| 63 | + - term [ref=f11e61]: Owner | |
| 64 | + - definition [ref=f11e62]: | |
| 65 | + - img "alice" [ref=f11e63] | |
| 66 | + - link "alice" [ref=f11e64] [cursor=pointer]: | |
| 67 | + - /url: /alice | |
| 68 | + - term [ref=f11e65]: Visibility | |
| 69 | + - definition [ref=f11e66]: Public | |
| 70 | + - term [ref=f11e67]: Repository | |
| 71 | + - definition [ref=f11e68]: Not linked | |
| 72 | + - term [ref=f11e69]: Pulls | |
| 73 | + - definition [ref=f11e70]: "1" | |
| 74 | + - term [ref=f11e71]: Created | |
| 75 | + - definition [ref=f11e72]: | |
| 76 | + - time [ref=f11e73]: 5 minutes ago | |
| 77 | + - term [ref=f11e74]: Updated | |
| 78 | + - definition [ref=f11e75]: | |
| 79 | + - time [ref=f11e76]: 4 minutes ago | |
| 80 | + - paragraph [ref=f11e77]: | |
| 81 | + - text: Image visibility is independent of the repository's. | |
| 82 | + - link "Registry docs" [ref=f11e78] [cursor=pointer]: | |
| 83 | + - /url: /docs/registry | |
| 84 | + - contentinfo [ref=f11e79]: | |
| 85 | + - generic [ref=f11e80]: | |
| 86 | + - generic [ref=f11e81]: irongit | |
| 87 | + - link "Docs" [ref=f11e82] [cursor=pointer]: | |
| 88 | + - /url: /docs | |
| 89 | + - link "CLI" [ref=f11e83] [cursor=pointer]: | |
| 90 | + - /url: /docs/cli | |
| 91 | + - link "Explore" [ref=f11e84] [cursor=pointer]: | |
| 92 | + - /url: /explore | |
| 93 | + - button "Toggle theme" [ref=f11e85] [cursor=pointer] |
+1-0Cargo.lock
| @@ -3166,6 +3166,7 @@ dependencies = [ | ||
| 3166 | 3166 | "rustls-platform-verifier", |
| 3167 | 3167 | "serde", |
| 3168 | 3168 | "serde_json", |
| 3169 | + "serde_urlencoded", | |
| 3169 | 3170 | "sync_wrapper", |
| 3170 | 3171 | "tokio", |
| 3171 | 3172 | "tokio-rustls", |
+1-1backend/Cargo.toml
| @@ -42,7 +42,7 @@ once_cell = "1.21.4" | ||
| 42 | 42 | time = "0.3.55" |
| 43 | 43 | tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } |
| 44 | 44 | rusty-s3 = "0.10.2" |
| 45 | -reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "stream", "json", "http2"] } | |
| 45 | +reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "stream", "json", "http2", "form", "query"] } | |
| 46 | 46 | russh = "0.64.1" |
| 47 | 47 | comrak = "0.56.0" |
| 48 | 48 | syntect = "5.3.0" |
+18-1backend/src/routes.rs
| @@ -11,7 +11,13 @@ use axum::{ | ||
| 11 | 11 | routing::get, |
| 12 | 12 | }; |
| 13 | 13 | use serde_json::json; |
| 14 | -use tower_http::trace::TraceLayer; | |
| 14 | +use tower_http::{ | |
| 15 | + compression::{ | |
| 16 | + CompressionLayer, | |
| 17 | + predicate::{DefaultPredicate, NotForContentType, Predicate}, | |
| 18 | + }, | |
| 19 | + trace::TraceLayer, | |
| 20 | +}; | |
| 15 | 21 | use tracing::Span; |
| 16 | 22 | |
| 17 | 23 | use crate::{api, clientlog, frontend, git_http, lfs, registry, state::AppState, web}; |
| @@ -32,6 +38,17 @@ pub fn router(state: AppState) -> Router { | ||
| 32 | 38 | |
| 33 | 39 | app.fallback(frontend::serve) |
| 34 | 40 | .layer(middleware::from_fn(security_headers)) |
| 41 | + // Pages and JSON compress well (highlighted source especially); git | |
| 42 | + // packs, archives and binaries are already compressed. | |
| 43 | + .layer(CompressionLayer::new().compress_when( | |
| 44 | + DefaultPredicate::new() | |
| 45 | + .and(NotForContentType::new("application/x-git")) | |
| 46 | + .and(NotForContentType::new("application/gzip")) | |
| 47 | + .and(NotForContentType::new("application/zip")) | |
| 48 | + .and(NotForContentType::new("application/octet-stream")) | |
| 49 | + .and(NotForContentType::new("application/vnd.oci")) | |
| 50 | + .and(NotForContentType::new("application/vnd.docker")), | |
| 51 | + )) | |
| 35 | 52 | .layer( |
| 36 | 53 | TraceLayer::new_for_http() |
| 37 | 54 | .make_span_with(|request: &Request| { |
+689-0backend/src/web/account.rs
| @@ -0,0 +1,689 @@ | ||
| 1 | +//! Sign in, register, sign out, Google sign-in and email verification. | |
| 2 | + | |
| 3 | +use std::{ | |
| 4 | + collections::HashMap, | |
| 5 | + sync::{LazyLock, Mutex}, | |
| 6 | + time::{Duration, Instant}, | |
| 7 | +}; | |
| 8 | + | |
| 9 | +use axum::{ | |
| 10 | + Form, Router, | |
| 11 | + extract::{Query, State}, | |
| 12 | + http::HeaderMap, | |
| 13 | + response::{IntoResponse, Redirect, Response}, | |
| 14 | + routing::get, | |
| 15 | +}; | |
| 16 | +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | |
| 17 | +use maud::{Markup, html}; | |
| 18 | +use serde::{Deserialize, Serialize}; | |
| 19 | +use serde_json::json; | |
| 20 | + | |
| 21 | +use crate::{ | |
| 22 | + analytics, | |
| 23 | + auth::{self, RequireViewer}, | |
| 24 | + error::{AppError, AppResult}, | |
| 25 | + models::audit, | |
| 26 | + ops::{self, NewUser}, | |
| 27 | + signed, | |
| 28 | + state::AppState, | |
| 29 | + web::{ | |
| 30 | + avatars, | |
| 31 | + layout::{Ctx, Page}, | |
| 32 | + ui, | |
| 33 | + }, | |
| 34 | +}; | |
| 35 | + | |
| 36 | +pub fn router() -> Router<AppState> { | |
| 37 | + Router::new() | |
| 38 | + .route("/login", get(login_page).post(login_submit)) | |
| 39 | + .route("/register", get(register_page).post(register_submit)) | |
| 40 | + .route("/logout", axum::routing::post(logout)) | |
| 41 | + .route("/login/google", get(google_start)) | |
| 42 | + .route("/login/google/callback", get(google_callback)) | |
| 43 | + .route("/login/google/finish", get(google_finish_page).post(google_finish_submit)) | |
| 44 | + .route("/settings/emails/verify", get(verify_email)) | |
| 45 | +} | |
| 46 | + | |
| 47 | +// --------------------------------------------------------------------------- | |
| 48 | +// Throttling: 10 failed sign-ins per IP and username per 15 minutes. | |
| 49 | + | |
| 50 | +static FAILURES: LazyLock<Mutex<HashMap<String, (u32, Instant)>>> = LazyLock::new(|| Mutex::new(HashMap::new())); | |
| 51 | +const FAILURE_LIMIT: u32 = 10; | |
| 52 | +const FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60); | |
| 53 | + | |
| 54 | +fn throttled(key: &str) -> bool { | |
| 55 | + let mut map = FAILURES.lock().unwrap(); | |
| 56 | + map.retain(|_, (_, at)| at.elapsed() < FAILURE_WINDOW); | |
| 57 | + map.get(key).is_some_and(|(count, _)| *count >= FAILURE_LIMIT) | |
| 58 | +} | |
| 59 | + | |
| 60 | +fn record_failure(key: &str) { | |
| 61 | + let mut map = FAILURES.lock().unwrap(); | |
| 62 | + let entry = map.entry(key.to_string()).or_insert((0, Instant::now())); | |
| 63 | + entry.0 += 1; | |
| 64 | +} | |
| 65 | + | |
| 66 | +fn clear_failures(key: &str) { | |
| 67 | + FAILURES.lock().unwrap().remove(key); | |
| 68 | +} | |
| 69 | + | |
| 70 | +// --------------------------------------------------------------------------- | |
| 71 | +// Shared pieces | |
| 72 | + | |
| 73 | +fn auth_card(title: &str, body: Markup) -> Markup { | |
| 74 | + html! { | |
| 75 | + div class="mx-auto w-full max-w-[380px] px-4 py-10" { | |
| 76 | + div class="mb-5 flex flex-col items-center gap-2" { | |
| 77 | + (ui::logo(36)) | |
| 78 | + h1 class="text-lg font-semibold" { (title) } | |
| 79 | + } | |
| 80 | + (body) | |
| 81 | + } | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +fn google_button(state: &AppState, next: &str, label: &str) -> Markup { | |
| 86 | + html! { | |
| 87 | + @if state.config.google.is_some() { | |
| 88 | + a href={ "/login/google?next=" (auth::urlencode(next)) } class="btn w-full py-1.5" data-track="google_signin_clicked" { | |
| 89 | + svg width="16" height="16" viewBox="0 0 48 48" aria-hidden="true" { | |
| 90 | + path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.4-.4-3.5z" {} | |
| 91 | + path fill="#FF3D00" d="m6.3 14.7 6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z" {} | |
| 92 | + path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z" {} | |
| 93 | + path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.1-2.4-.4-3.5z" {} | |
| 94 | + } | |
| 95 | + (label) | |
| 96 | + } | |
| 97 | + div class="my-3 flex items-center gap-2 text-xs text-ink-faint" { | |
| 98 | + div class="h-px flex-1 bg-edge" {} | |
| 99 | + "or" | |
| 100 | + div class="h-px flex-1 bg-edge" {} | |
| 101 | + } | |
| 102 | + } | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +fn set_session(jar: CookieJar, state: &AppState, token: String) -> CookieJar { | |
| 107 | + jar.add(auth::session_cookie(token, state.config.secure_cookies())) | |
| 108 | +} | |
| 109 | + | |
| 110 | +// --------------------------------------------------------------------------- | |
| 111 | +// Sign in | |
| 112 | + | |
| 113 | +#[derive(Deserialize, Default)] | |
| 114 | +pub struct NextQuery { | |
| 115 | + next: Option<String>, | |
| 116 | + error: Option<String>, | |
| 117 | +} | |
| 118 | + | |
| 119 | +pub async fn login_page(ctx: Ctx, Query(query): Query<NextQuery>) -> Response { | |
| 120 | + let next = auth::safe_next(query.next.as_deref()); | |
| 121 | + if ctx.viewer.is_some() { | |
| 122 | + return Redirect::to(&next).into_response(); | |
| 123 | + } | |
| 124 | + let error = query.error.as_deref().map(|code| match code { | |
| 125 | + "google" => "Google sign-in failed. Try again.", | |
| 126 | + "google_state" => "That Google sign-in link expired. Try again.", | |
| 127 | + "google_unverified" => "Google did not confirm that email address.", | |
| 128 | + "email_in_use" => "An account already uses that email. Sign in with your password, then connect Google in Settings.", | |
| 129 | + "closed" => "Registration is closed on this site.", | |
| 130 | + "suspended" => "This account is suspended.", | |
| 131 | + _ => "Sign-in failed.", | |
| 132 | + }); | |
| 133 | + ctx.render(login_view(&ctx, &next, "", error)) | |
| 134 | +} | |
| 135 | + | |
| 136 | +fn login_view(ctx: &Ctx, next: &str, login: &str, error: Option<&str>) -> Page { | |
| 137 | + Page::new( | |
| 138 | + "Sign in", | |
| 139 | + auth_card( | |
| 140 | + "Sign in to irongit", | |
| 141 | + html! { | |
| 142 | + (ui::alert_error(error)) | |
| 143 | + (google_button(&ctx.state, next, "Continue with Google")) | |
| 144 | + form method="post" action="/login" class="box space-y-3 p-4" data-track-submit="login_submitted" { | |
| 145 | + input type="hidden" name="next" value=(next); | |
| 146 | + div { | |
| 147 | + label class="label" for="login" { "Username or email" } | |
| 148 | + input class="input" id="login" name="login" value=(login) autocomplete="username" autofocus required; | |
| 149 | + } | |
| 150 | + div { | |
| 151 | + label class="label" for="password" { "Password" } | |
| 152 | + input class="input" id="password" name="password" type="password" autocomplete="current-password" required; | |
| 153 | + } | |
| 154 | + button type="submit" class="btn btn-primary w-full py-1.5" { "Sign in" } | |
| 155 | + } | |
| 156 | + p class="mt-3 text-center text-[13px] text-ink-dim" { | |
| 157 | + "New here? " a href={ "/register?next=" (auth::urlencode(next)) } { "Create an account" } | |
| 158 | + } | |
| 159 | + }, | |
| 160 | + ), | |
| 161 | + ) | |
| 162 | + .noindex() | |
| 163 | +} | |
| 164 | + | |
| 165 | +#[derive(Deserialize)] | |
| 166 | +pub struct LoginForm { | |
| 167 | + login: String, | |
| 168 | + password: String, | |
| 169 | + next: Option<String>, | |
| 170 | +} | |
| 171 | + | |
| 172 | +pub async fn login_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Form(form): Form<LoginForm>) -> AppResult<Response> { | |
| 173 | + let next = auth::safe_next(form.next.as_deref()); | |
| 174 | + let login = form.login.trim().to_string(); | |
| 175 | + let ip = auth::client_ip(&headers).unwrap_or_else(|| "local".into()); | |
| 176 | + let throttle_key = format!("{ip}|{}", login.to_lowercase()); | |
| 177 | + if throttled(&throttle_key) { | |
| 178 | + tracing::warn!(%ip, login, "sign-in throttled"); | |
| 179 | + return Ok(ctx.render(login_view(&ctx, &next, &login, Some("Too many failed attempts. Wait 15 minutes and try again.")))); | |
| 180 | + } | |
| 181 | + | |
| 182 | + let row: Option<(i64, String, Option<String>, bool)> = sqlx::query_as( | |
| 183 | + "select a.id, a.name::text, u.password_hash, u.suspended_at is not null | |
| 184 | + from accounts a join users u on u.account_id = a.id | |
| 185 | + where a.name = $1 or a.id = (select user_id from emails where email = $1)", | |
| 186 | + ) | |
| 187 | + .bind(&login) | |
| 188 | + .fetch_optional(&ctx.state.db) | |
| 189 | + .await?; | |
| 190 | + | |
| 191 | + // Always spend the hashing time, so timing does not reveal which | |
| 192 | + // usernames exist. | |
| 193 | + static DUMMY: LazyLock<String> = LazyLock::new(|| auth::hash_password("timing-equalizer").unwrap_or_default()); | |
| 194 | + let hash = row.as_ref().and_then(|r| r.2.clone()).unwrap_or_else(|| DUMMY.clone()); | |
| 195 | + let valid = auth::verify_password_async(form.password.clone(), hash).await && row.as_ref().is_some_and(|r| r.2.is_some()); | |
| 196 | + | |
| 197 | + let Some((user_id, username, _, suspended)) = row.filter(|_| valid) else { | |
| 198 | + record_failure(&throttle_key); | |
| 199 | + tracing::info!(%ip, login, "sign-in failed"); | |
| 200 | + analytics::track(&ctx.state, "login_failed", None, "/login", json!({})); | |
| 201 | + return Ok(ctx.render(login_view(&ctx, &next, &login, Some("Incorrect username or password.")))); | |
| 202 | + }; | |
| 203 | + if suspended { | |
| 204 | + return Ok(ctx.render(login_view(&ctx, &next, &login, Some("This account is suspended.")))); | |
| 205 | + } | |
| 206 | + clear_failures(&throttle_key); | |
| 207 | + let token = auth::create_session(&ctx.state.db, user_id, Some(&ip), auth::user_agent(&headers)).await?; | |
| 208 | + audit(&ctx.state.db, Some(user_id), "user.login", &username, json!({ "method": "password" }), Some(&ip)).await; | |
| 209 | + analytics::track(&ctx.state, "login", Some(&username), "/login", json!({ "method": "password" })); | |
| 210 | + tracing::info!(user = %username, %ip, "signed in"); | |
| 211 | + Ok((set_session(jar, &ctx.state, token), Redirect::to(&next)).into_response()) | |
| 212 | +} | |
| 213 | + | |
| 214 | +pub async fn logout(State(state): State<AppState>, headers: HeaderMap, jar: CookieJar) -> AppResult<Response> { | |
| 215 | + if let Some(token) = auth::session_token(&headers) { | |
| 216 | + auth::delete_session(&state.db, token).await?; | |
| 217 | + } | |
| 218 | + analytics::track(&state, "logout", None, "/logout", json!({})); | |
| 219 | + Ok((jar.add(auth::clear_session_cookie()), Redirect::to("/")).into_response()) | |
| 220 | +} | |
| 221 | + | |
| 222 | +// --------------------------------------------------------------------------- | |
| 223 | +// Register | |
| 224 | + | |
| 225 | +pub async fn register_page(ctx: Ctx, Query(query): Query<NextQuery>) -> Response { | |
| 226 | + let next = auth::safe_next(query.next.as_deref()); | |
| 227 | + if ctx.viewer.is_some() { | |
| 228 | + return Redirect::to(&next).into_response(); | |
| 229 | + } | |
| 230 | + ctx.render(register_view(&ctx, &next, &RegisterForm::default(), None)) | |
| 231 | +} | |
| 232 | + | |
| 233 | +#[derive(Deserialize, Default)] | |
| 234 | +pub struct RegisterForm { | |
| 235 | + username: String, | |
| 236 | + email: String, | |
| 237 | + password: String, | |
| 238 | + next: Option<String>, | |
| 239 | +} | |
| 240 | + | |
| 241 | +fn register_view(ctx: &Ctx, next: &str, form: &RegisterForm, error: Option<&str>) -> Page { | |
| 242 | + let body = if !ctx.state.config.registration_open { | |
| 243 | + html! { (ui::alert_error(Some("Registration is closed on this site. Ask an administrator for an account."))) } | |
| 244 | + } else { | |
| 245 | + html! { | |
| 246 | + (ui::alert_error(error)) | |
| 247 | + (google_button(&ctx.state, next, "Sign up with Google")) | |
| 248 | + form method="post" action="/register" class="box space-y-3 p-4" data-track-submit="register_submitted" { | |
| 249 | + input type="hidden" name="next" value=(next); | |
| 250 | + div { | |
| 251 | + label class="label" for="username" { "Username" } | |
| 252 | + input class="input" id="username" name="username" value=(form.username) autocomplete="username" pattern="[a-z0-9-]{1,39}" maxlength="39" autofocus required; | |
| 253 | + p class="hint" { "Lowercase letters, digits and hyphens. This is your URL: /" span class="font-mono" { "name" } } | |
| 254 | + } | |
| 255 | + div { | |
| 256 | + label class="label" for="email" { "Email" } | |
| 257 | + input class="input" id="email" name="email" type="email" value=(form.email) autocomplete="email" required; | |
| 258 | + p class="hint" { "Commits made with this address count on your profile once it is verified." } | |
| 259 | + } | |
| 260 | + div { | |
| 261 | + label class="label" for="password" { "Password" } | |
| 262 | + input class="input" id="password" name="password" type="password" minlength="10" autocomplete="new-password" required; | |
| 263 | + p class="hint" { "At least 10 characters." } | |
| 264 | + } | |
| 265 | + button type="submit" class="btn btn-primary w-full py-1.5" { "Create account" } | |
| 266 | + } | |
| 267 | + } | |
| 268 | + }; | |
| 269 | + Page::new( | |
| 270 | + "Create an account", | |
| 271 | + auth_card( | |
| 272 | + "Create your account", | |
| 273 | + html! { | |
| 274 | + (body) | |
| 275 | + p class="mt-3 text-center text-[13px] text-ink-dim" { | |
| 276 | + "Already have an account? " a href={ "/login?next=" (auth::urlencode(next)) } { "Sign in" } | |
| 277 | + } | |
| 278 | + }, | |
| 279 | + ), | |
| 280 | + ) | |
| 281 | + .noindex() | |
| 282 | +} | |
| 283 | + | |
| 284 | +pub async fn register_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Form(form): Form<RegisterForm>) -> AppResult<Response> { | |
| 285 | + let next = auth::safe_next(form.next.as_deref()); | |
| 286 | + if !ctx.state.config.registration_open { | |
| 287 | + return Ok(ctx.render(register_view(&ctx, &next, &form, None))); | |
| 288 | + } | |
| 289 | + let created = ops::register_user( | |
| 290 | + &ctx.state, | |
| 291 | + NewUser { username: &form.username, email: &form.email, password: Some(&form.password), display_name: "", email_verified: false }, | |
| 292 | + ) | |
| 293 | + .await; | |
| 294 | + let user_id = match created { | |
| 295 | + Ok(id) => id, | |
| 296 | + Err(AppError::BadRequest(message) | AppError::Conflict(message)) => { | |
| 297 | + return Ok(ctx.render(register_view(&ctx, &next, &form, Some(&message)))); | |
| 298 | + } | |
| 299 | + Err(other) => return Err(other), | |
| 300 | + }; | |
| 301 | + let ip = auth::client_ip(&headers); | |
| 302 | + let token = auth::create_session(&ctx.state.db, user_id, ip.as_deref(), auth::user_agent(&headers)).await?; | |
| 303 | + let destination = if next == "/" { format!("/{}?welcome=1", form.username.trim().to_ascii_lowercase()) } else { next }; | |
| 304 | + Ok((set_session(jar, &ctx.state, token), Redirect::to(&destination)).into_response()) | |
| 305 | +} | |
| 306 | + | |
| 307 | +// --------------------------------------------------------------------------- | |
| 308 | +// Email verification | |
| 309 | + | |
| 310 | +#[derive(Deserialize)] | |
| 311 | +pub struct VerifyQuery { | |
| 312 | + token: String, | |
| 313 | +} | |
| 314 | + | |
| 315 | +pub async fn verify_email(ctx: Ctx, Query(query): Query<VerifyQuery>) -> AppResult<Response> { | |
| 316 | + let verified: Option<(i64, String)> = sqlx::query_as( | |
| 317 | + "update emails set verified_at = now(), verify_token_hash = null | |
| 318 | + where verify_token_hash = $1 and verify_sent_at > now() - interval '7 days' | |
| 319 | + returning user_id, email::text", | |
| 320 | + ) | |
| 321 | + .bind(auth::sha256_hex(&query.token)) | |
| 322 | + .fetch_optional(&ctx.state.db) | |
| 323 | + .await?; | |
| 324 | + let body = match &verified { | |
| 325 | + Some((user_id, email)) => { | |
| 326 | + audit(&ctx.state.db, Some(*user_id), "email.verify", email, json!({}), None).await; | |
| 327 | + analytics::track(&ctx.state, "email_verified", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/emails/verify", json!({})); | |
| 328 | + html! { (ui::alert_ok(Some(&format!("{email} is verified. Commits made with it now count on your profile.")))) a href="/settings/emails" { "Back to email settings" } } | |
| 329 | + } | |
| 330 | + None => html! { (ui::alert_error(Some("That verification link is invalid or has expired. Send a new one from Settings > Emails."))) a href="/settings/emails" { "Email settings" } }, | |
| 331 | + }; | |
| 332 | + Ok(ctx.render(Page::new("Verify email", auth_card("Email verification", body)).noindex())) | |
| 333 | +} | |
| 334 | + | |
| 335 | +// --------------------------------------------------------------------------- | |
| 336 | +// Google sign-in (OpenID Connect, authorization code + PKCE) | |
| 337 | + | |
| 338 | +const OAUTH_COOKIE: &str = "ig_oauth"; | |
| 339 | +const SIGNUP_COOKIE: &str = "ig_google_signup"; | |
| 340 | + | |
| 341 | +#[derive(Serialize, Deserialize)] | |
| 342 | +struct OAuthState { | |
| 343 | + state: String, | |
| 344 | + verifier: String, | |
| 345 | + next: String, | |
| 346 | + /// Set when a signed-in user is connecting Google from settings. | |
| 347 | + link_user: Option<i64>, | |
| 348 | +} | |
| 349 | + | |
| 350 | +#[derive(Serialize, Deserialize, Clone)] | |
| 351 | +struct PendingSignup { | |
| 352 | + sub: String, | |
| 353 | + email: String, | |
| 354 | + name: String, | |
| 355 | + picture: Option<String>, | |
| 356 | + next: String, | |
| 357 | +} | |
| 358 | + | |
| 359 | +fn redirect_uri(state: &AppState) -> String { | |
| 360 | + format!("{}/login/google/callback", state.config.base_url()) | |
| 361 | +} | |
| 362 | + | |
| 363 | +fn short_cookie(name: &'static str, value: String, state: &AppState, minutes: i64) -> Cookie<'static> { | |
| 364 | + Cookie::build((name, value)) | |
| 365 | + .path("/") | |
| 366 | + .http_only(true) | |
| 367 | + .secure(state.config.secure_cookies()) | |
| 368 | + .same_site(SameSite::Lax) | |
| 369 | + .max_age(time::Duration::minutes(minutes)) | |
| 370 | + .build() | |
| 371 | +} | |
| 372 | + | |
| 373 | +#[derive(Deserialize)] | |
| 374 | +pub struct GoogleStart { | |
| 375 | + next: Option<String>, | |
| 376 | + link: Option<String>, | |
| 377 | +} | |
| 378 | + | |
| 379 | +pub async fn google_start(ctx: Ctx, jar: CookieJar, Query(query): Query<GoogleStart>) -> AppResult<Response> { | |
| 380 | + let Some(google) = ctx.state.config.google.clone() else { | |
| 381 | + return Err(AppError::NotFound); | |
| 382 | + }; | |
| 383 | + let link_user = if query.link.is_some() { Some(ctx.viewer.as_ref().ok_or(AppError::Unauthorized)?.id) } else { None }; | |
| 384 | + let verifier = auth::random_token(48); | |
| 385 | + let challenge = { | |
| 386 | + use base64::Engine; | |
| 387 | + use sha2::Digest; | |
| 388 | + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest(verifier.as_bytes())) | |
| 389 | + }; | |
| 390 | + let oauth = OAuthState { state: auth::random_token(24), verifier, next: auth::safe_next(query.next.as_deref()), link_user }; | |
| 391 | + let mut url = url::Url::parse("https://accounts.google.com/o/oauth2/v2/auth").expect("static url"); | |
| 392 | + url.query_pairs_mut() | |
| 393 | + .append_pair("client_id", &google.client_id) | |
| 394 | + .append_pair("redirect_uri", &redirect_uri(&ctx.state)) | |
| 395 | + .append_pair("response_type", "code") | |
| 396 | + .append_pair("scope", "openid email profile") | |
| 397 | + .append_pair("state", &oauth.state) | |
| 398 | + .append_pair("code_challenge", &challenge) | |
| 399 | + .append_pair("code_challenge_method", "S256") | |
| 400 | + .append_pair("prompt", "select_account"); | |
| 401 | + let cookie = signed::sign(&ctx.state.config.secret_key, "oauth-google", &oauth, 600); | |
| 402 | + analytics::track(&ctx.state, "google_oauth_started", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/login/google", json!({ "link": oauth.link_user.is_some() })); | |
| 403 | + Ok((jar.add(short_cookie(OAUTH_COOKIE, cookie, &ctx.state, 10)), Redirect::to(url.as_str())).into_response()) | |
| 404 | +} | |
| 405 | + | |
| 406 | +#[derive(Deserialize)] | |
| 407 | +pub struct GoogleCallback { | |
| 408 | + code: Option<String>, | |
| 409 | + state: Option<String>, | |
| 410 | + error: Option<String>, | |
| 411 | +} | |
| 412 | + | |
| 413 | +#[derive(Deserialize)] | |
| 414 | +struct TokenResponse { | |
| 415 | + access_token: String, | |
| 416 | +} | |
| 417 | + | |
| 418 | +#[derive(Deserialize)] | |
| 419 | +struct UserInfo { | |
| 420 | + sub: String, | |
| 421 | + email: Option<String>, | |
| 422 | + email_verified: Option<bool>, | |
| 423 | + name: Option<String>, | |
| 424 | + picture: Option<String>, | |
| 425 | +} | |
| 426 | + | |
| 427 | +pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query(query): Query<GoogleCallback>) -> AppResult<Response> { | |
| 428 | + let Some(google) = ctx.state.config.google.clone() else { return Err(AppError::NotFound) }; | |
| 429 | + let fail = |code: &str| Redirect::to(&format!("/login?error={code}")).into_response(); | |
| 430 | + let oauth: Option<OAuthState> = jar.get(OAUTH_COOKIE).and_then(|c| signed::verify(&ctx.state.config.secret_key, "oauth-google", c.value())); | |
| 431 | + let jar = jar.remove(Cookie::build(OAUTH_COOKIE).path("/").build()); | |
| 432 | + let Some(oauth) = oauth.filter(|o| query.state.as_deref() == Some(o.state.as_str())) else { | |
| 433 | + tracing::warn!("google callback with missing or mismatched state"); | |
| 434 | + return Ok((jar, fail("google_state")).into_response()); | |
| 435 | + }; | |
| 436 | + if let Some(error) = &query.error { | |
| 437 | + tracing::info!(error, "google sign-in cancelled or refused"); | |
| 438 | + return Ok((jar, Redirect::to(&format!("/login?next={}", auth::urlencode(&oauth.next)))).into_response()); | |
| 439 | + } | |
| 440 | + let Some(code) = query.code.as_deref() else { return Ok((jar, fail("google")).into_response()) }; | |
| 441 | + | |
| 442 | + let info = match exchange_code(&ctx.state, &google, code, &oauth.verifier).await { | |
| 443 | + Ok(info) => info, | |
| 444 | + Err(error) => { | |
| 445 | + tracing::error!(?error, "google token exchange failed"); | |
| 446 | + return Ok((jar, fail("google")).into_response()); | |
| 447 | + } | |
| 448 | + }; | |
| 449 | + let ip = auth::client_ip(&headers); | |
| 450 | + let db = &ctx.state.db; | |
| 451 | + | |
| 452 | + // Already linked: sign in. | |
| 453 | + let linked: Option<(i64, String, bool)> = sqlx::query_as( | |
| 454 | + "select a.id, a.name::text, u.suspended_at is not null from oauth_identities o | |
| 455 | + join users u on u.account_id = o.user_id join accounts a on a.id = u.account_id | |
| 456 | + where o.provider = 'google' and o.subject = $1", | |
| 457 | + ) | |
| 458 | + .bind(&info.sub) | |
| 459 | + .fetch_optional(db) | |
| 460 | + .await?; | |
| 461 | + | |
| 462 | + if let Some(link_user) = oauth.link_user { | |
| 463 | + // Connecting from settings: only for the same signed-in user. | |
| 464 | + if ctx.viewer.as_ref().map(|v| v.id) != Some(link_user) { | |
| 465 | + return Ok((jar, Redirect::to("/login")).into_response()); | |
| 466 | + } | |
| 467 | + if let Some((owner, _, _)) = linked { | |
| 468 | + let message = if owner == link_user { "already" } else { "taken" }; | |
| 469 | + return Ok((jar, Redirect::to(&format!("/settings/security?google={message}"))).into_response()); | |
| 470 | + } | |
| 471 | + sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ('google', $1, $2, $3) on conflict (provider, user_id) do update set subject = excluded.subject, email = excluded.email") | |
| 472 | + .bind(&info.sub) | |
| 473 | + .bind(link_user) | |
| 474 | + .bind(&info.email) | |
| 475 | + .execute(db) | |
| 476 | + .await?; | |
| 477 | + audit(db, Some(link_user), "google.link", info.email.as_deref().unwrap_or(""), json!({}), ip.as_deref()).await; | |
| 478 | + analytics::track(&ctx.state, "google_linked", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/security", json!({})); | |
| 479 | + return Ok((jar, Redirect::to("/settings/security?google=linked")).into_response()); | |
| 480 | + } | |
| 481 | + | |
| 482 | + if let Some((user_id, username, suspended)) = linked { | |
| 483 | + if suspended { | |
| 484 | + return Ok((jar, fail("suspended")).into_response()); | |
| 485 | + } | |
| 486 | + sqlx::query("update oauth_identities set last_used_at = now() where provider = 'google' and subject = $1").bind(&info.sub).execute(db).await?; | |
| 487 | + return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await; | |
| 488 | + } | |
| 489 | + | |
| 490 | + let Some(email) = info.email.clone().filter(|_| info.email_verified == Some(true)) else { | |
| 491 | + return Ok((jar, fail("google_unverified")).into_response()); | |
| 492 | + }; | |
| 493 | + | |
| 494 | + // Same verified email on an existing account: link and sign in. | |
| 495 | + let existing: Option<(i64, String, bool, bool)> = sqlx::query_as( | |
| 496 | + "select a.id, a.name::text, e.verified_at is not null, u.suspended_at is not null from emails e | |
| 497 | + join users u on u.account_id = e.user_id join accounts a on a.id = u.account_id where e.email = $1", | |
| 498 | + ) | |
| 499 | + .bind(&email) | |
| 500 | + .fetch_optional(db) | |
| 501 | + .await?; | |
| 502 | + if let Some((user_id, username, verified, suspended)) = existing { | |
| 503 | + if !verified { | |
| 504 | + return Ok((jar, fail("email_in_use")).into_response()); | |
| 505 | + } | |
| 506 | + if suspended { | |
| 507 | + return Ok((jar, fail("suspended")).into_response()); | |
| 508 | + } | |
| 509 | + sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ('google', $1, $2, $3) on conflict do nothing") | |
| 510 | + .bind(&info.sub) | |
| 511 | + .bind(user_id) | |
| 512 | + .bind(&email) | |
| 513 | + .execute(db) | |
| 514 | + .await?; | |
| 515 | + audit(db, Some(user_id), "google.link", &email, json!({ "auto": true }), ip.as_deref()).await; | |
| 516 | + return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await; | |
| 517 | + } | |
| 518 | + | |
| 519 | + if !ctx.state.config.registration_open { | |
| 520 | + return Ok((jar, fail("closed")).into_response()); | |
| 521 | + } | |
| 522 | + // New person: pick a username first. | |
| 523 | + let pending = PendingSignup { sub: info.sub, email, name: info.name.unwrap_or_default(), picture: info.picture, next: oauth.next }; | |
| 524 | + let cookie = signed::sign(&ctx.state.config.secret_key, "google-signup", &pending, 900); | |
| 525 | + Ok((jar.add(short_cookie(SIGNUP_COOKIE, cookie, &ctx.state, 15)), Redirect::to("/login/google/finish")).into_response()) | |
| 526 | +} | |
| 527 | + | |
| 528 | +async fn exchange_code(state: &AppState, google: &crate::config::GoogleConfig, code: &str, verifier: &str) -> anyhow::Result<UserInfo> { | |
| 529 | + let token: TokenResponse = state | |
| 530 | + .http | |
| 531 | + .post("https://oauth2.googleapis.com/token") | |
| 532 | + .form(&[ | |
| 533 | + ("code", code), | |
| 534 | + ("client_id", google.client_id.as_str()), | |
| 535 | + ("client_secret", google.client_secret.as_str()), | |
| 536 | + ("redirect_uri", redirect_uri(state).as_str()), | |
| 537 | + ("grant_type", "authorization_code"), | |
| 538 | + ("code_verifier", verifier), | |
| 539 | + ]) | |
| 540 | + .send() | |
| 541 | + .await? | |
| 542 | + .error_for_status()? | |
| 543 | + .json() | |
| 544 | + .await?; | |
| 545 | + // Fetched from Google over TLS with the fresh access token, so the | |
| 546 | + // claims need no separate id_token signature check. | |
| 547 | + Ok(state | |
| 548 | + .http | |
| 549 | + .get("https://openidconnect.googleapis.com/v1/userinfo") | |
| 550 | + .bearer_auth(token.access_token) | |
| 551 | + .send() | |
| 552 | + .await? | |
| 553 | + .error_for_status()? | |
| 554 | + .json() | |
| 555 | + .await?) | |
| 556 | +} | |
| 557 | + | |
| 558 | +async fn sign_in(state: &AppState, jar: CookieJar, headers: &HeaderMap, user_id: i64, username: &str, next: &str) -> AppResult<Response> { | |
| 559 | + let ip = auth::client_ip(headers); | |
| 560 | + let token = auth::create_session(&state.db, user_id, ip.as_deref(), auth::user_agent(headers)).await?; | |
| 561 | + audit(&state.db, Some(user_id), "user.login", username, json!({ "method": "google" }), ip.as_deref()).await; | |
| 562 | + analytics::track(state, "login", Some(username), "/login/google/callback", json!({ "method": "google" })); | |
| 563 | + tracing::info!(user = %username, "signed in with google"); | |
| 564 | + Ok((set_session(jar, state, token), Redirect::to(next)).into_response()) | |
| 565 | +} | |
| 566 | + | |
| 567 | +fn pending_signup(state: &AppState, jar: &CookieJar) -> Option<PendingSignup> { | |
| 568 | + jar.get(SIGNUP_COOKIE).and_then(|c| signed::verify(&state.config.secret_key, "google-signup", c.value())) | |
| 569 | +} | |
| 570 | + | |
| 571 | +fn suggest_username(email: &str, name: &str) -> String { | |
| 572 | + let base = email.split('@').next().unwrap_or(name); | |
| 573 | + let mut out: String = base | |
| 574 | + .to_ascii_lowercase() | |
| 575 | + .chars() | |
| 576 | + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) | |
| 577 | + .collect(); | |
| 578 | + while out.contains("--") { | |
| 579 | + out = out.replace("--", "-"); | |
| 580 | + } | |
| 581 | + out.trim_matches('-').chars().take(39).collect() | |
| 582 | +} | |
| 583 | + | |
| 584 | +fn finish_view(pending: &PendingSignup, username: &str, error: Option<&str>) -> Page { | |
| 585 | + Page::new( | |
| 586 | + "Choose a username", | |
| 587 | + auth_card( | |
| 588 | + "One more step", | |
| 589 | + html! { | |
| 590 | + (ui::alert_error(error)) | |
| 591 | + div class="box mb-3 flex items-center gap-3 p-3" { | |
| 592 | + @if let Some(picture) = &pending.picture { | |
| 593 | + img src=(picture) width="40" height="40" class="rounded-[4px]" alt="" referrerpolicy="no-referrer"; | |
| 594 | + } | |
| 595 | + div class="min-w-0" { | |
| 596 | + div class="truncate font-semibold" { (pending.name) } | |
| 597 | + div class="truncate text-xs text-ink-dim" { (pending.email) } | |
| 598 | + } | |
| 599 | + } | |
| 600 | + form method="post" action="/login/google/finish" class="box space-y-3 p-4" data-track-submit="google_signup_submitted" { | |
| 601 | + div { | |
| 602 | + label class="label" for="username" { "Pick a username" } | |
| 603 | + input class="input" id="username" name="username" value=(username) pattern="[a-z0-9-]{1,39}" maxlength="39" autofocus required; | |
| 604 | + p class="hint" { "Your profile will live at /" span class="font-mono" { (username) } ". Lowercase letters, digits and hyphens." } | |
| 605 | + } | |
| 606 | + button type="submit" class="btn btn-primary w-full py-1.5" { "Create account" } | |
| 607 | + } | |
| 608 | + }, | |
| 609 | + ), | |
| 610 | + ) | |
| 611 | + .noindex() | |
| 612 | +} | |
| 613 | + | |
| 614 | +pub async fn google_finish_page(ctx: Ctx, jar: CookieJar) -> Response { | |
| 615 | + match pending_signup(&ctx.state, &jar) { | |
| 616 | + Some(pending) => { | |
| 617 | + let suggestion = suggest_username(&pending.email, &pending.name); | |
| 618 | + ctx.render(finish_view(&pending, &suggestion, None)) | |
| 619 | + } | |
| 620 | + None => Redirect::to("/login?error=google_state").into_response(), | |
| 621 | + } | |
| 622 | +} | |
| 623 | + | |
| 624 | +#[derive(Deserialize)] | |
| 625 | +pub struct FinishForm { | |
| 626 | + username: String, | |
| 627 | +} | |
| 628 | + | |
| 629 | +pub async fn google_finish_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Form(form): Form<FinishForm>) -> AppResult<Response> { | |
| 630 | + let Some(pending) = pending_signup(&ctx.state, &jar) else { | |
| 631 | + return Ok(Redirect::to("/login?error=google_state").into_response()); | |
| 632 | + }; | |
| 633 | + let created = ops::register_user( | |
| 634 | + &ctx.state, | |
| 635 | + NewUser { username: &form.username, email: &pending.email, password: None, display_name: &pending.name, email_verified: true }, | |
| 636 | + ) | |
| 637 | + .await; | |
| 638 | + let user_id = match created { | |
| 639 | + Ok(id) => id, | |
| 640 | + Err(AppError::BadRequest(message) | AppError::Conflict(message)) => { | |
| 641 | + return Ok(ctx.render(finish_view(&pending, &form.username, Some(&message)))); | |
| 642 | + } | |
| 643 | + Err(other) => return Err(other), | |
| 644 | + }; | |
| 645 | + sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ('google', $1, $2, $3)") | |
| 646 | + .bind(&pending.sub) | |
| 647 | + .bind(user_id) | |
| 648 | + .bind(&pending.email) | |
| 649 | + .execute(&ctx.state.db) | |
| 650 | + .await?; | |
| 651 | + // Bring the Google profile photo along; people like their avatars. | |
| 652 | + if let Some(picture) = pending.picture.clone() { | |
| 653 | + let state = ctx.state.clone(); | |
| 654 | + tokio::spawn(async move { | |
| 655 | + if let Err(error) = avatars::import_from_url(&state, user_id, &picture).await { | |
| 656 | + tracing::warn!(?error, "could not import google avatar"); | |
| 657 | + } | |
| 658 | + }); | |
| 659 | + } | |
| 660 | + let username = form.username.trim().to_ascii_lowercase(); | |
| 661 | + let jar = jar.remove(Cookie::build(SIGNUP_COOKIE).path("/").build()); | |
| 662 | + let next = if pending.next == "/" { format!("/{username}?welcome=1") } else { pending.next.clone() }; | |
| 663 | + sign_in(&ctx.state, jar, &headers, user_id, &username, &next).await | |
| 664 | +} | |
| 665 | + | |
| 666 | +/// Used by settings to unlink Google; only allowed when a password exists. | |
| 667 | +pub async fn unlink_google(state: &AppState, viewer: &RequireViewer) -> AppResult<()> { | |
| 668 | + let has_password: bool = sqlx::query_scalar("select password_hash is not null from users where account_id = $1") | |
| 669 | + .bind(viewer.0.id) | |
| 670 | + .fetch_one(&state.db) | |
| 671 | + .await?; | |
| 672 | + if !has_password { | |
| 673 | + return Err(AppError::bad("Set a password before disconnecting Google, or you would be locked out.")); | |
| 674 | + } | |
| 675 | + sqlx::query("delete from oauth_identities where provider = 'google' and user_id = $1").bind(viewer.0.id).execute(&state.db).await?; | |
| 676 | + audit(&state.db, Some(viewer.0.id), "google.unlink", "", json!({}), None).await; | |
| 677 | + Ok(()) | |
| 678 | +} | |
| 679 | + | |
| 680 | +#[cfg(test)] | |
| 681 | +mod tests { | |
| 682 | + use super::*; | |
| 683 | + | |
| 684 | + #[test] | |
| 685 | + fn username_suggestions_are_valid() { | |
| 686 | + assert_eq!(suggest_username("Jane.Doe+git@gmail.com", ""), "jane-doe-git"); | |
| 687 | + assert!(crate::models::valid_account_name(&suggest_username("__x__@y.z", "")).is_ok()); | |
| 688 | + } | |
| 689 | +} |
+73-3backend/src/web/avatars.rs
| @@ -1,7 +1,5 @@ | ||
| 1 | 1 | //! /avatars/{name}: the uploaded picture from R2, or a generated pattern. |
| 2 | 2 | |
| 3 | -use std::time::Duration; | |
| 4 | - | |
| 5 | 3 | use axum::{ |
| 6 | 4 | extract::{Path, State}, |
| 7 | 5 | http::{StatusCode, header}, |
| @@ -43,7 +41,6 @@ pub async fn serve(State(state): State<AppState>, Path(name): Path<String>) -> R | ||
| 43 | 41 | Err(error) => tracing::error!(%error, key, "avatar fetch failed"), |
| 44 | 42 | } |
| 45 | 43 | } |
| 46 | - let _ = Duration::ZERO; | |
| 47 | 44 | ( |
| 48 | 45 | StatusCode::OK, |
| 49 | 46 | [(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=3600")], |
| @@ -51,3 +48,76 @@ pub async fn serve(State(state): State<AppState>, Path(name): Path<String>) -> R | ||
| 51 | 48 | ) |
| 52 | 49 | .into_response() |
| 53 | 50 | } |
| 51 | + | |
| 52 | +const MAX_AVATAR_BYTES: usize = 2 * 1024 * 1024; | |
| 53 | + | |
| 54 | +/// Image type by magic bytes; anything else is refused. | |
| 55 | +pub fn sniff_image(bytes: &[u8]) -> Option<&'static str> { | |
| 56 | + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { | |
| 57 | + Some("image/png") | |
| 58 | + } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { | |
| 59 | + Some("image/jpeg") | |
| 60 | + } else if bytes.len() > 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { | |
| 61 | + Some("image/webp") | |
| 62 | + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { | |
| 63 | + Some("image/gif") | |
| 64 | + } else { | |
| 65 | + None | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +/// Stores a new avatar in R2 and points the account at it. The key carries | |
| 70 | +/// a content hash, which doubles as the cache-busting version in URLs. | |
| 71 | +pub async fn store(state: &AppState, account_id: i64, bytes: bytes::Bytes) -> crate::error::AppResult<()> { | |
| 72 | + use crate::error::AppError; | |
| 73 | + if bytes.len() > MAX_AVATAR_BYTES { | |
| 74 | + return Err(AppError::bad("Avatars must be 2 MB or smaller.")); | |
| 75 | + } | |
| 76 | + let Some(mime) = sniff_image(&bytes) else { | |
| 77 | + return Err(AppError::bad("Upload a PNG, JPEG, WebP or GIF image.")); | |
| 78 | + }; | |
| 79 | + let hash = crate::auth::sha256_hex(&bytes); | |
| 80 | + let key = crate::storage::keys::avatar(account_id, &hash[..16]); | |
| 81 | + state.storage.put_bytes(&key, bytes, mime).await?; | |
| 82 | + let old: Option<Option<String>> = sqlx::query_scalar( | |
| 83 | + "update accounts a set avatar_key = $2, updated_at = now() from (select avatar_key from accounts where id = $1) old | |
| 84 | + where a.id = $1 returning old.avatar_key", | |
| 85 | + ) | |
| 86 | + .bind(account_id) | |
| 87 | + .bind(&key) | |
| 88 | + .fetch_optional(&state.db) | |
| 89 | + .await?; | |
| 90 | + if let Some(Some(old)) = old.filter(|o| o.as_deref() != Some(key.as_str())) { | |
| 91 | + let _ = state.storage.delete(&old).await; | |
| 92 | + } | |
| 93 | + tracing::info!(account_id, key, "avatar updated"); | |
| 94 | + Ok(()) | |
| 95 | +} | |
| 96 | + | |
| 97 | +pub async fn remove(state: &AppState, account_id: i64) -> crate::error::AppResult<()> { | |
| 98 | + let old: Option<Option<String>> = sqlx::query_scalar( | |
| 99 | + "update accounts a set avatar_key = null, updated_at = now() from (select avatar_key from accounts where id = $1) old | |
| 100 | + where a.id = $1 returning old.avatar_key", | |
| 101 | + ) | |
| 102 | + .bind(account_id) | |
| 103 | + .fetch_optional(&state.db) | |
| 104 | + .await?; | |
| 105 | + if let Some(Some(old)) = old { | |
| 106 | + let _ = state.storage.delete(&old).await; | |
| 107 | + } | |
| 108 | + Ok(()) | |
| 109 | +} | |
| 110 | + | |
| 111 | +/// Copies a profile photo from a provider (Google) into R2. | |
| 112 | +pub async fn import_from_url(state: &AppState, account_id: i64, url: &str) -> anyhow::Result<()> { | |
| 113 | + let parsed = url::Url::parse(url)?; | |
| 114 | + anyhow::ensure!(parsed.scheme() == "https", "avatar url must be https"); | |
| 115 | + anyhow::ensure!(parsed.host_str().is_some_and(|h| h.ends_with(".googleusercontent.com")), "unexpected avatar host"); | |
| 116 | + // Google serves a larger rendition when the size suffix is changed. | |
| 117 | + let url = match url.rsplit_once("=s") { | |
| 118 | + Some((base, _)) => format!("{base}=s400-c"), | |
| 119 | + None => url.to_string(), | |
| 120 | + }; | |
| 121 | + let bytes = state.http.get(url).send().await?.error_for_status()?.bytes().await?; | |
| 122 | + store(state, account_id, bytes).await.map_err(|e| anyhow::anyhow!("{e}")) | |
| 123 | +} |
+72-0backend/src/web/components.rs
| @@ -0,0 +1,72 @@ | ||
| 1 | +//! Rows and lists shared by profiles, explore and the dashboard. | |
| 2 | + | |
| 3 | +use maud::{Markup, html}; | |
| 4 | + | |
| 5 | +use crate::{ | |
| 6 | + models::{Package, Repo}, | |
| 7 | + web::ui, | |
| 8 | +}; | |
| 9 | + | |
| 10 | +pub fn repo_row(repo: &Repo, show_owner: bool) -> Markup { | |
| 11 | + html! { | |
| 12 | + div class="flex gap-3 px-3 py-2.5" { | |
| 13 | + @if show_owner { | |
| 14 | + a href={ "/" (repo.owner_name) } class="shrink-0" { (ui::avatar(&repo.owner_name, None, 32)) } | |
| 15 | + } | |
| 16 | + div class="min-w-0 flex-1" { | |
| 17 | + div class="flex flex-wrap items-center gap-2" { | |
| 18 | + a href=(repo.url()) class="font-semibold" data-track="repo_link_clicked" { | |
| 19 | + @if show_owner { span class="font-normal" { (repo.owner_name) "/" } } | |
| 20 | + (repo.name) | |
| 21 | + } | |
| 22 | + (ui::visibility_tag(&repo.visibility)) | |
| 23 | + @if repo.archived { span class="tag" { "Archived" } } | |
| 24 | + } | |
| 25 | + @if !repo.description.is_empty() { | |
| 26 | + p class="mt-0.5 line-clamp-2 text-[13px] text-ink-dim" { (repo.description) } | |
| 27 | + } | |
| 28 | + div class="mt-1 flex flex-wrap gap-x-3 text-xs text-ink-faint" { | |
| 29 | + @if let Some(pushed) = repo.pushed_at { span { "Updated " (ui::time(pushed)) } } @else { span { "Empty" } } | |
| 30 | + @if repo.size_bytes > 0 { span { (ui::bytes(repo.size_bytes as u64)) } } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + } | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +pub fn repo_list(repos: &[Repo], show_owner: bool, empty: Markup) -> Markup { | |
| 38 | + html! { | |
| 39 | + @if repos.is_empty() { | |
| 40 | + (empty) | |
| 41 | + } @else { | |
| 42 | + div class="box divide-y divide-edge" { | |
| 43 | + @for repo in repos { (repo_row(repo, show_owner)) } | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +pub fn package_row(package: &Package, show_owner: bool, tags: i64) -> Markup { | |
| 50 | + html! { | |
| 51 | + div class="flex gap-3 px-3 py-2.5" { | |
| 52 | + div class="pt-0.5" { (ui::icon_package()) } | |
| 53 | + div class="min-w-0 flex-1" { | |
| 54 | + div class="flex flex-wrap items-center gap-2" { | |
| 55 | + a href=(package.url()) class="font-semibold" { | |
| 56 | + @if show_owner { span class="font-normal" { (package.owner_name) "/" } } | |
| 57 | + (package.name) | |
| 58 | + } | |
| 59 | + (ui::visibility_tag(&package.visibility)) | |
| 60 | + } | |
| 61 | + @if !package.description.is_empty() { | |
| 62 | + p class="mt-0.5 text-[13px] text-ink-dim" { (package.description) } | |
| 63 | + } | |
| 64 | + div class="mt-1 flex flex-wrap gap-x-3 text-xs text-ink-faint" { | |
| 65 | + span { (ui::plural(tags, "tag", "tags")) } | |
| 66 | + span { (ui::plural(package.pull_count, "pull", "pulls")) } | |
| 67 | + span { "Updated " (ui::time(package.updated_at)) } | |
| 68 | + } | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} |
+171-0backend/src/web/heatmap.rs
| @@ -0,0 +1,171 @@ | ||
| 1 | +//! The contribution heatmap: 53 weeks by 7 days of commits that landed on | |
| 2 | +//! default branches, drawn as SVG with forge-heat colors. | |
| 3 | + | |
| 4 | +use std::collections::HashMap; | |
| 5 | + | |
| 6 | +use chrono::{Datelike, Duration, NaiveDate, Weekday}; | |
| 7 | +use maud::{Markup, html}; | |
| 8 | + | |
| 9 | +const CELL: i64 = 11; | |
| 10 | +const GAP: i64 = 3; | |
| 11 | +const LEFT: i64 = 28; | |
| 12 | +const TOP: i64 = 16; | |
| 13 | + | |
| 14 | +/// Per-day counts for the year ending today (UTC). | |
| 15 | +pub struct Contributions { | |
| 16 | + pub days: HashMap<NaiveDate, i64>, | |
| 17 | + pub today: NaiveDate, | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl Contributions { | |
| 21 | + pub fn start(today: NaiveDate) -> NaiveDate { | |
| 22 | + // Start on the Sunday 52 full weeks back so columns are whole weeks. | |
| 23 | + let back = today - Duration::days(364); | |
| 24 | + back - Duration::days(back.weekday().num_days_from_sunday() as i64) | |
| 25 | + } | |
| 26 | + | |
| 27 | + pub fn total(&self) -> i64 { | |
| 28 | + self.days.values().sum() | |
| 29 | + } | |
| 30 | + | |
| 31 | + /// Thresholds for levels 1..4 relative to the busiest day, so a light | |
| 32 | + /// committer still gets a readable range of colors. | |
| 33 | + fn level(&self, count: i64, max: i64) -> u8 { | |
| 34 | + if count <= 0 { | |
| 35 | + return 0; | |
| 36 | + } | |
| 37 | + let ratio = count as f64 / max.max(1) as f64; | |
| 38 | + match ratio { | |
| 39 | + r if r > 0.75 => 4, | |
| 40 | + r if r > 0.5 => 3, | |
| 41 | + r if r > 0.25 => 2, | |
| 42 | + _ => 1, | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + pub fn longest_streak(&self) -> i64 { | |
| 47 | + let mut best = 0; | |
| 48 | + let mut run = 0; | |
| 49 | + let mut day = Self::start(self.today); | |
| 50 | + while day <= self.today { | |
| 51 | + if self.days.get(&day).copied().unwrap_or(0) > 0 { | |
| 52 | + run += 1; | |
| 53 | + best = best.max(run); | |
| 54 | + } else { | |
| 55 | + run = 0; | |
| 56 | + } | |
| 57 | + day += Duration::days(1); | |
| 58 | + } | |
| 59 | + best | |
| 60 | + } | |
| 61 | + | |
| 62 | + pub fn current_streak(&self) -> i64 { | |
| 63 | + let mut run = 0; | |
| 64 | + let mut day = self.today; | |
| 65 | + // Today without commits yet does not break a streak. | |
| 66 | + if self.days.get(&day).copied().unwrap_or(0) == 0 { | |
| 67 | + day -= Duration::days(1); | |
| 68 | + } | |
| 69 | + while self.days.get(&day).copied().unwrap_or(0) > 0 { | |
| 70 | + run += 1; | |
| 71 | + day -= Duration::days(1); | |
| 72 | + } | |
| 73 | + run | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +pub fn render(data: &Contributions) -> Markup { | |
| 78 | + let start = Contributions::start(data.today); | |
| 79 | + let max = data.days.iter().filter(|(d, _)| **d >= start).map(|(_, c)| *c).max().unwrap_or(0); | |
| 80 | + let weeks = ((data.today - start).num_days() / 7) + 1; | |
| 81 | + let width = LEFT + weeks * (CELL + GAP); | |
| 82 | + let height = TOP + 7 * (CELL + GAP); | |
| 83 | + | |
| 84 | + let mut cells = Vec::new(); | |
| 85 | + let mut months = Vec::new(); | |
| 86 | + let mut last_month = None; | |
| 87 | + for week in 0..weeks { | |
| 88 | + for weekday in 0..7 { | |
| 89 | + let day = start + Duration::days(week * 7 + weekday); | |
| 90 | + if day > data.today { | |
| 91 | + break; | |
| 92 | + } | |
| 93 | + if weekday == 0 && last_month != Some(day.month()) && day.day() <= 7 { | |
| 94 | + if week < weeks - 2 { | |
| 95 | + months.push((week, day.format("%b").to_string())); | |
| 96 | + } | |
| 97 | + last_month = Some(day.month()); | |
| 98 | + } | |
| 99 | + let count = data.days.get(&day).copied().unwrap_or(0); | |
| 100 | + cells.push((week, weekday, day, count, data.level(count, max))); | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + html! { | |
| 105 | + div class="overflow-x-auto" data-scroll-end { | |
| 106 | + // Fills the box on wide screens, scrolls (from the newest end) on phones. | |
| 107 | + svg viewBox={ "0 0 " (width) " " (height) } class="block h-auto w-full" style={ "min-width:" (width) "px;max-width:" (width * 3 / 2) "px" } role="img" | |
| 108 | + aria-label={ (data.total()) " contributions in the last year" } { | |
| 109 | + @for (week, label) in &months { | |
| 110 | + text x=(LEFT + week * (CELL + GAP)) y="10" class="fill-ink-dim" font-size="10" { (label) } | |
| 111 | + } | |
| 112 | + @for (row, label) in [(1, "Mon"), (3, "Wed"), (5, "Fri")] { | |
| 113 | + text x="0" y=(TOP + row * (CELL + GAP) + 9) class="fill-ink-faint" font-size="9" { (label) } | |
| 114 | + } | |
| 115 | + @for (week, weekday, day, count, level) in &cells { | |
| 116 | + rect x=(LEFT + week * (CELL + GAP)) y=(TOP + weekday * (CELL + GAP)) width=(CELL) height=(CELL) rx="2" | |
| 117 | + class={ "fill-heat-" (level) } data-count=(count) data-day=(day.to_string()) { | |
| 118 | + title { (tooltip(*count, *day)) } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + } | |
| 122 | + } | |
| 123 | + div class="mt-1 flex items-center justify-end gap-1 text-[11px] text-ink-faint" { | |
| 124 | + "Less" | |
| 125 | + @for level in 0..5 { | |
| 126 | + svg width="10" height="10" { rect width="10" height="10" rx="2" class={ "fill-heat-" (level) } {} } | |
| 127 | + } | |
| 128 | + "More" | |
| 129 | + } | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +fn tooltip(count: i64, day: NaiveDate) -> String { | |
| 134 | + let when = day.format("%a, %b %-d, %Y"); | |
| 135 | + match count { | |
| 136 | + 0 => format!("No contributions on {when}"), | |
| 137 | + 1 => format!("1 contribution on {when}"), | |
| 138 | + n => format!("{n} contributions on {when}"), | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +pub fn weekday_name(day: Weekday) -> &'static str { | |
| 143 | + match day { | |
| 144 | + Weekday::Mon => "Monday", | |
| 145 | + Weekday::Tue => "Tuesday", | |
| 146 | + Weekday::Wed => "Wednesday", | |
| 147 | + Weekday::Thu => "Thursday", | |
| 148 | + Weekday::Fri => "Friday", | |
| 149 | + Weekday::Sat => "Saturday", | |
| 150 | + Weekday::Sun => "Sunday", | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +#[cfg(test)] | |
| 155 | +mod tests { | |
| 156 | + use super::*; | |
| 157 | + | |
| 158 | + #[test] | |
| 159 | + fn grid_starts_on_sunday_and_streaks_count() { | |
| 160 | + let today = NaiveDate::from_ymd_opt(2026, 10, 8).unwrap(); | |
| 161 | + assert_eq!(Contributions::start(today).weekday(), Weekday::Sun); | |
| 162 | + let days = HashMap::from([(today, 2), (today - Duration::days(1), 1), (today - Duration::days(3), 5)]); | |
| 163 | + let data = Contributions { days, today }; | |
| 164 | + assert_eq!(data.current_streak(), 2); | |
| 165 | + assert_eq!(data.longest_streak(), 2); | |
| 166 | + assert_eq!(data.total(), 8); | |
| 167 | + let svg = render(&data).0; | |
| 168 | + assert!(svg.contains("fill-heat-4")); | |
| 169 | + assert!(svg.contains("5 contributions on")); | |
| 170 | + } | |
| 171 | +} |
+167-0backend/src/web/highlight.rs
| @@ -0,0 +1,167 @@ | ||
| 1 | +//! Syntax highlighting with syntect, emitted as classes (`hl-*`) so light | |
| 2 | +//! and dark themes are plain CSS, served at /assets/syntax.css. | |
| 3 | + | |
| 4 | +use std::sync::LazyLock; | |
| 5 | + | |
| 6 | +use syntect::{ | |
| 7 | + highlighting::ThemeSet, | |
| 8 | + html::{ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style}, | |
| 9 | + parsing::{SyntaxReference, SyntaxSet}, | |
| 10 | + util::LinesWithEndings, | |
| 11 | +}; | |
| 12 | + | |
| 13 | +const STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "hl-" }; | |
| 14 | + | |
| 15 | +static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines); | |
| 16 | + | |
| 17 | +/// CSS for both themes: dark by default, light under the same conditions | |
| 18 | +/// the site theme uses (system preference or an explicit choice). | |
| 19 | +pub static CSS: LazyLock<String> = LazyLock::new(|| { | |
| 20 | + let themes = ThemeSet::load_defaults(); | |
| 21 | + let dark = css_for_theme_with_class_style(&themes.themes["base16-ocean.dark"], STYLE).unwrap_or_default(); | |
| 22 | + let light = css_for_theme_with_class_style(&themes.themes["InspiredGitHub"], STYLE).unwrap_or_default(); | |
| 23 | + // Backgrounds come from the page, not the theme. | |
| 24 | + let strip = |css: &str| { | |
| 25 | + css.lines() | |
| 26 | + .filter(|line| !line.trim_start().starts_with("background-color")) | |
| 27 | + .collect::<Vec<_>>() | |
| 28 | + .join("\n") | |
| 29 | + }; | |
| 30 | + format!( | |
| 31 | + "{}\n@media (prefers-color-scheme: light) {{ :root:not([data-theme=\"dark\"]) {{ {} }} }}\n:root[data-theme=\"light\"] {{ {} }}\n", | |
| 32 | + strip(&dark), | |
| 33 | + strip(&light), | |
| 34 | + strip(&light) | |
| 35 | + ) | |
| 36 | +}); | |
| 37 | + | |
| 38 | +fn syntax_for(path: &str, first_line: &str) -> &'static SyntaxReference { | |
| 39 | + let name = path.rsplit('/').next().unwrap_or(path); | |
| 40 | + let extension = name.rsplit_once('.').map(|(_, e)| e).unwrap_or(name); | |
| 41 | + let by_name = match name { | |
| 42 | + "Dockerfile" | "Containerfile" => SYNTAXES.find_syntax_by_extension("Dockerfile"), | |
| 43 | + "Makefile" | "makefile" | "GNUmakefile" => SYNTAXES.find_syntax_by_extension("make"), | |
| 44 | + "Cargo.lock" => SYNTAXES.find_syntax_by_extension("toml"), | |
| 45 | + _ => None, | |
| 46 | + }; | |
| 47 | + by_name | |
| 48 | + .or_else(|| SYNTAXES.find_syntax_by_extension(extension)) | |
| 49 | + .or_else(|| match extension { | |
| 50 | + "ts" | "tsx" | "mts" | "cts" => SYNTAXES.find_syntax_by_extension("js"), | |
| 51 | + "jsx" | "mjs" | "cjs" => SYNTAXES.find_syntax_by_extension("js"), | |
| 52 | + "toml" => SYNTAXES.find_syntax_by_name("TOML").or_else(|| SYNTAXES.find_syntax_by_extension("ini")), | |
| 53 | + "astro" | "svelte" | "vue" => SYNTAXES.find_syntax_by_extension("html"), | |
| 54 | + "zsh" | "fish" => SYNTAXES.find_syntax_by_extension("sh"), | |
| 55 | + "kt" | "kts" => SYNTAXES.find_syntax_by_extension("java"), | |
| 56 | + _ => None, | |
| 57 | + }) | |
| 58 | + .or_else(|| SYNTAXES.find_syntax_by_first_line(first_line)) | |
| 59 | + .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()) | |
| 60 | +} | |
| 61 | + | |
| 62 | +pub fn language_name(path: &str, first_line: &str) -> &'static str { | |
| 63 | + &syntax_for(path, first_line).name | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Highlights `source` and returns one balanced HTML fragment per line, so | |
| 67 | +/// each line can sit in its own table row with a line number. | |
| 68 | +pub fn highlight_lines(path: &str, source: &str) -> Vec<String> { | |
| 69 | + let first_line = source.lines().next().unwrap_or(""); | |
| 70 | + let syntax = syntax_for(path, first_line); | |
| 71 | + let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, &SYNTAXES, STYLE); | |
| 72 | + for line in LinesWithEndings::from(source) { | |
| 73 | + if generator.parse_html_for_line_which_includes_newline(line).is_err() { | |
| 74 | + return plain_lines(source); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + split_balanced(&generator.finalize()) | |
| 78 | +} | |
| 79 | + | |
| 80 | +pub fn plain_lines(source: &str) -> Vec<String> { | |
| 81 | + source.lines().map(|line| maud::html! { (line) }.0).collect() | |
| 82 | +} | |
| 83 | + | |
| 84 | +/// syntect's spans can cross newlines. Close every open span at the end of | |
| 85 | +/// each line and reopen it at the start of the next. | |
| 86 | +fn split_balanced(html: &str) -> Vec<String> { | |
| 87 | + let mut lines = Vec::new(); | |
| 88 | + let mut open: Vec<String> = Vec::new(); | |
| 89 | + let mut current = String::new(); | |
| 90 | + let mut rest = html; | |
| 91 | + while !rest.is_empty() { | |
| 92 | + if let Some(after) = rest.strip_prefix("</span>") { | |
| 93 | + open.pop(); | |
| 94 | + current.push_str("</span>"); | |
| 95 | + rest = after; | |
| 96 | + } else if rest.starts_with("<span") { | |
| 97 | + let end = rest.find('>').map(|i| i + 1).unwrap_or(rest.len()); | |
| 98 | + let tag = &rest[..end]; | |
| 99 | + open.push(tag.to_string()); | |
| 100 | + current.push_str(tag); | |
| 101 | + rest = &rest[end..]; | |
| 102 | + } else if let Some(after) = rest.strip_prefix('\n') { | |
| 103 | + for _ in &open { | |
| 104 | + current.push_str("</span>"); | |
| 105 | + } | |
| 106 | + lines.push(std::mem::take(&mut current)); | |
| 107 | + for tag in &open { | |
| 108 | + current.push_str(tag); | |
| 109 | + } | |
| 110 | + rest = after; | |
| 111 | + } else { | |
| 112 | + let next = rest.find(['<', '\n']).unwrap_or(rest.len()); | |
| 113 | + let next = if next == 0 { 1 } else { next }; | |
| 114 | + current.push_str(&rest[..next]); | |
| 115 | + rest = &rest[next..]; | |
| 116 | + } | |
| 117 | + } | |
| 118 | + if !current.is_empty() && current.chars().any(|c| c != '<') { | |
| 119 | + // A last line without a trailing newline. | |
| 120 | + let stripped = current.replace("</span>", ""); | |
| 121 | + if !stripped.trim().is_empty() || !current.is_empty() { | |
| 122 | + for _ in &open { | |
| 123 | + current.push_str("</span>"); | |
| 124 | + } | |
| 125 | + lines.push(current); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + // Drop a trailing empty line produced by a final newline. | |
| 129 | + while lines.last().is_some_and(|l| strip_tags(l).is_empty()) && lines.len() > 1 { | |
| 130 | + lines.pop(); | |
| 131 | + } | |
| 132 | + lines | |
| 133 | +} | |
| 134 | + | |
| 135 | +fn strip_tags(s: &str) -> String { | |
| 136 | + let mut out = String::new(); | |
| 137 | + let mut in_tag = false; | |
| 138 | + for c in s.chars() { | |
| 139 | + match c { | |
| 140 | + '<' => in_tag = true, | |
| 141 | + '>' => in_tag = false, | |
| 142 | + c if !in_tag => out.push(c), | |
| 143 | + _ => {} | |
| 144 | + } | |
| 145 | + } | |
| 146 | + out | |
| 147 | +} | |
| 148 | + | |
| 149 | +#[cfg(test)] | |
| 150 | +mod tests { | |
| 151 | + use super::*; | |
| 152 | + | |
| 153 | + #[test] | |
| 154 | + fn lines_are_balanced() { | |
| 155 | + let lines = highlight_lines("a.rs", "/* multi\nline */\nfn main() {}\n"); | |
| 156 | + assert_eq!(lines.len(), 3); | |
| 157 | + for line in &lines { | |
| 158 | + assert_eq!(line.matches("<span").count(), line.matches("</span>").count(), "{line}"); | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + #[test] | |
| 163 | + fn escapes_plain_text() { | |
| 164 | + let lines = highlight_lines("notes.unknownext", "<script>alert(1)</script>\n"); | |
| 165 | + assert!(!lines[0].contains("<script>")); | |
| 166 | + } | |
| 167 | +} |
+317-0backend/src/web/home.rs
| @@ -0,0 +1,317 @@ | ||
| 1 | +//! The signed-in dashboard at /, /explore, and the new repository and new | |
| 2 | +//! organization forms. | |
| 3 | + | |
| 4 | +use std::collections::HashMap; | |
| 5 | + | |
| 6 | +use axum::{ | |
| 7 | + Form, | |
| 8 | + extract::{Query, Request, State}, | |
| 9 | + response::{IntoResponse, Redirect, Response}, | |
| 10 | +}; | |
| 11 | +use maud::html; | |
| 12 | +use serde::Deserialize; | |
| 13 | + | |
| 14 | +use crate::{ | |
| 15 | + auth::{MaybeViewer, RequireViewer}, | |
| 16 | + error::{AppError, AppResult}, | |
| 17 | + frontend, | |
| 18 | + models::{Account, Package, Repo}, | |
| 19 | + ops, package_select, | |
| 20 | + perm::{self, Area}, | |
| 21 | + repo_select, | |
| 22 | + visible_packages, visible_repos, | |
| 23 | + web::{ | |
| 24 | + components, | |
| 25 | + layout::{Ctx, Page}, | |
| 26 | + profile, ui, | |
| 27 | + }, | |
| 28 | +}; | |
| 29 | + | |
| 30 | +/// Signed in: the dashboard. Signed out: the static landing page. | |
| 31 | +pub async fn index(ctx: Ctx, request: Request) -> AppResult<Response> { | |
| 32 | + let Some(viewer) = ctx.viewer.clone() else { | |
| 33 | + return Ok(frontend::serve(State(ctx.state.clone()), MaybeViewer(None), request.uri().clone()).await); | |
| 34 | + }; | |
| 35 | + let (viewer_id, admin) = perm::visibility_binds(Some(&viewer), Area::Repo); | |
| 36 | + let repos: Vec<Repo> = sqlx::query_as(concat!( | |
| 37 | + repo_select!(), | |
| 38 | + " where (r.owner_id = $3 | |
| 39 | + or exists (select 1 from org_members m where m.org_id = r.owner_id and m.user_id = $3) | |
| 40 | + or exists (select 1 from repo_collaborators c where c.repo_id = r.id and c.user_id = $3)) and ", | |
| 41 | + visible_repos!(), | |
| 42 | + " order by r.pushed_at desc nulls last, r.created_at desc limit 40" | |
| 43 | + )) | |
| 44 | + .bind(viewer_id) | |
| 45 | + .bind(admin) | |
| 46 | + .bind(viewer.id) | |
| 47 | + .fetch_all(&ctx.state.db) | |
| 48 | + .await?; | |
| 49 | + let feed = profile::activity(&ctx, None, Some(viewer.id), 40).await?; | |
| 50 | + let unverified: bool = sqlx::query_scalar("select not exists(select 1 from emails where user_id = $1 and verified_at is not null)") | |
| 51 | + .bind(viewer.id) | |
| 52 | + .fetch_one(&ctx.state.db) | |
| 53 | + .await?; | |
| 54 | + let base = ctx.state.config.base_url(); | |
| 55 | + | |
| 56 | + let body = html! { | |
| 57 | + div class="mx-auto grid max-w-[1280px] gap-5 px-4 py-5 md:grid-cols-[300px_1fr]" { | |
| 58 | + aside class="min-w-0" { | |
| 59 | + div class="mb-2 flex items-center" { | |
| 60 | + h2 class="text-[13px] font-semibold" { "Repositories" } | |
| 61 | + a href="/new" class="btn btn-sm btn-primary ml-auto" { "New" } | |
| 62 | + } | |
| 63 | + @if repos.is_empty() { | |
| 64 | + p class="text-[13px] text-ink-dim" { "Nothing yet. " a href="/new" { "Create your first repository" } "." } | |
| 65 | + } @else { | |
| 66 | + ul class="space-y-0.5" { | |
| 67 | + @for repo in &repos { | |
| 68 | + li class="flex items-center gap-2 rounded-[4px] px-1 py-1 hover:bg-surface-hover" { | |
| 69 | + (ui::avatar(&repo.owner_name, None, 18)) | |
| 70 | + a href=(repo.url()) class="min-w-0 truncate text-[13px] text-ink" { (repo.owner_name) "/" strong { (repo.name) } } | |
| 71 | + @if repo.visibility == "private" { span class="ml-auto text-[10px] text-warn" { "private" } } | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | + } | |
| 77 | + div class="min-w-0" { | |
| 78 | + @if unverified { | |
| 79 | + div class="alert alert-info mb-4" { | |
| 80 | + "Verify your email so commits you push count on your profile. " a href="/settings/emails" { "Email settings" } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + @if repos.is_empty() { | |
| 84 | + div class="box mb-5 p-4" { | |
| 85 | + h2 class="font-semibold" { "Get started" } | |
| 86 | + ol class="mt-2 list-decimal space-y-1 pl-5 text-[13px] text-ink-dim" { | |
| 87 | + li { "Install the CLI: " code class="text-ink" { "curl -fsSL " (base) "/install.sh | sh" } } | |
| 88 | + li { "Sign in from your terminal: " code class="text-ink" { "ig login" } } | |
| 89 | + li { "Create a repo and push: " code class="text-ink" { "ig repo create hello --private" } } | |
| 90 | + li { "Push an image: " code class="text-ink" { "docker push " (ctx.state.config.registry_host()) "/" (viewer.name) "/app:1.0" } } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + } | |
| 94 | + h2 class="mb-2 text-[13px] font-semibold" { "Recent activity" } | |
| 95 | + div class="box" { (profile::activity_list(&feed, true)) } | |
| 96 | + } | |
| 97 | + } | |
| 98 | + }; | |
| 99 | + Ok(ctx.render(Page::new("Dashboard", body).noindex())) | |
| 100 | +} | |
| 101 | + | |
| 102 | +#[derive(Deserialize, Default)] | |
| 103 | +pub struct ExploreQuery { | |
| 104 | + tab: Option<String>, | |
| 105 | + q: Option<String>, | |
| 106 | + page: Option<i64>, | |
| 107 | +} | |
| 108 | + | |
| 109 | +const PAGE_SIZE: i64 = 30; | |
| 110 | + | |
| 111 | +pub async fn explore(ctx: Ctx, Query(query): Query<ExploreQuery>) -> AppResult<Response> { | |
| 112 | + let tab = query.tab.as_deref().unwrap_or("repositories"); | |
| 113 | + let page = query.page.unwrap_or(1).max(1); | |
| 114 | + let offset = (page - 1) * PAGE_SIZE; | |
| 115 | + let pattern = query.q.as_deref().map(str::trim).filter(|q| !q.is_empty()).map(|q| format!("%{}%", q.replace('%', "\\%").replace('_', "\\_"))); | |
| 116 | + let db = &ctx.state.db; | |
| 117 | + | |
| 118 | + let (list, has_next) = match tab { | |
| 119 | + "users" => { | |
| 120 | + let rows: Vec<(String, String, Option<String>, String, String)> = sqlx::query_as( | |
| 121 | + "select a.name::text, a.display_name, a.avatar_key, a.bio, a.kind from accounts a | |
| 122 | + where ($1::text is null or a.name ilike $1 or a.display_name ilike $1) | |
| 123 | + order by (select max(pushed_at) from repos r where r.owner_id = a.id and r.visibility = 'public') desc nulls last, a.created_at desc | |
| 124 | + limit $2 offset $3", | |
| 125 | + ) | |
| 126 | + .bind(&pattern) | |
| 127 | + .bind(PAGE_SIZE + 1) | |
| 128 | + .bind(offset) | |
| 129 | + .fetch_all(db) | |
| 130 | + .await?; | |
| 131 | + let has_next = rows.len() as i64 > PAGE_SIZE; | |
| 132 | + ( | |
| 133 | + html! { | |
| 134 | + div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3" { | |
| 135 | + @for (name, display, avatar, bio, kind) in rows.iter().take(PAGE_SIZE as usize) { | |
| 136 | + a href={ "/" (name) } class="box flex items-start gap-3 p-3 text-ink no-underline hover:bg-surface-hover hover:no-underline" { | |
| 137 | + (ui::avatar(name, avatar.as_deref(), 48)) | |
| 138 | + div class="min-w-0" { | |
| 139 | + div class="flex items-center gap-1.5" { | |
| 140 | + span class="truncate font-semibold" { @if display.is_empty() { (name) } @else { (display) } } | |
| 141 | + @if kind == "org" { span class="tag" { "Org" } } | |
| 142 | + } | |
| 143 | + @if !display.is_empty() { div class="text-xs text-ink-dim" { (name) } } | |
| 144 | + @if !bio.is_empty() { p class="mt-1 line-clamp-2 text-xs text-ink-dim" { (bio) } } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + } | |
| 148 | + } | |
| 149 | + }, | |
| 150 | + has_next, | |
| 151 | + ) | |
| 152 | + } | |
| 153 | + "images" => { | |
| 154 | + let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Packages); | |
| 155 | + let packages: Vec<Package> = sqlx::query_as(concat!( | |
| 156 | + package_select!(), | |
| 157 | + " where ", | |
| 158 | + visible_packages!(), | |
| 159 | + " and p.visibility = 'public' and ($3::text is null or p.name ilike $3 or a.name ilike $3) | |
| 160 | + order by p.updated_at desc limit $4 offset $5" | |
| 161 | + )) | |
| 162 | + .bind(viewer_id) | |
| 163 | + .bind(admin) | |
| 164 | + .bind(&pattern) | |
| 165 | + .bind(PAGE_SIZE + 1) | |
| 166 | + .bind(offset) | |
| 167 | + .fetch_all(db) | |
| 168 | + .await?; | |
| 169 | + let has_next = packages.len() as i64 > PAGE_SIZE; | |
| 170 | + let ids: Vec<i64> = packages.iter().map(|p| p.id).collect(); | |
| 171 | + let tags: HashMap<i64, i64> = sqlx::query_as::<_, (i64, i64)>("select package_id, count(*) from tags where package_id = any($1) group by package_id") | |
| 172 | + .bind(&ids) | |
| 173 | + .fetch_all(db) | |
| 174 | + .await? | |
| 175 | + .into_iter() | |
| 176 | + .collect(); | |
| 177 | + ( | |
| 178 | + html! { | |
| 179 | + @if packages.is_empty() { (ui::empty_state("No public images yet", html! { "Make an image public from its settings page." })) } | |
| 180 | + @else { | |
| 181 | + div class="box divide-y divide-edge" { | |
| 182 | + @for p in packages.iter().take(PAGE_SIZE as usize) { (components::package_row(p, true, tags.get(&p.id).copied().unwrap_or(0))) } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + }, | |
| 186 | + has_next, | |
| 187 | + ) | |
| 188 | + } | |
| 189 | + _ => { | |
| 190 | + let repos: Vec<Repo> = sqlx::query_as(concat!( | |
| 191 | + repo_select!(), | |
| 192 | + " where r.visibility = 'public' and ($1::text is null or r.name ilike $1 or r.description ilike $1 or a.name ilike $1) | |
| 193 | + order by r.pushed_at desc nulls last, r.created_at desc limit $2 offset $3" | |
| 194 | + )) | |
| 195 | + .bind(&pattern) | |
| 196 | + .bind(PAGE_SIZE + 1) | |
| 197 | + .bind(offset) | |
| 198 | + .fetch_all(db) | |
| 199 | + .await?; | |
| 200 | + let has_next = repos.len() as i64 > PAGE_SIZE; | |
| 201 | + let shown: Vec<Repo> = repos.into_iter().take(PAGE_SIZE as usize).collect(); | |
| 202 | + (components::repo_list(&shown, true, ui::empty_state("No public repositories yet", html! { "Public repositories from everyone show up here." })), has_next) | |
| 203 | + } | |
| 204 | + }; | |
| 205 | + | |
| 206 | + let tab_link = |slug: &str| match &query.q { | |
| 207 | + Some(q) if !q.is_empty() => format!("/explore?tab={slug}&q={}", crate::auth::urlencode(q)), | |
| 208 | + _ => format!("/explore?tab={slug}"), | |
| 209 | + }; | |
| 210 | + let base = tab_link(tab); | |
| 211 | + let body = html! { | |
| 212 | + div class="mx-auto max-w-[1100px] px-4 py-5" { | |
| 213 | + div class="mb-3 flex flex-wrap items-center gap-3" { | |
| 214 | + h1 class="text-lg font-semibold" { "Explore" } | |
| 215 | + form class="ml-auto flex w-full gap-2 sm:w-auto" { | |
| 216 | + input type="hidden" name="tab" value=(tab); | |
| 217 | + input class="input sm:w-[280px]" name="q" value=[query.q.as_deref()] placeholder="Search"; | |
| 218 | + button class="btn" { "Search" } | |
| 219 | + } | |
| 220 | + } | |
| 221 | + nav class="tabs mb-4" { | |
| 222 | + @for (slug, label) in [("repositories", "Repositories"), ("users", "People and organizations"), ("images", "Images")] { | |
| 223 | + a class="tab" href=(tab_link(slug)) aria-current=[(slug == tab).then_some("page")] { (label) } | |
| 224 | + } | |
| 225 | + } | |
| 226 | + (list) | |
| 227 | + (ui::pager(&base, page, has_next)) | |
| 228 | + } | |
| 229 | + }; | |
| 230 | + Ok(ctx.render(Page::new("Explore", body).description("Public repositories, people and container images on irongit."))) | |
| 231 | +} | |
| 232 | + | |
| 233 | +// --------------------------------------------------------------------------- | |
| 234 | +// New repository | |
| 235 | + | |
| 236 | +#[derive(Deserialize, Default)] | |
| 237 | +pub struct NewRepoQuery { | |
| 238 | + owner: Option<String>, | |
| 239 | +} | |
| 240 | + | |
| 241 | +async fn owner_choices(ctx: &Ctx, viewer_id: i64) -> AppResult<Vec<(String, Option<String>)>> { | |
| 242 | + Ok(sqlx::query_as( | |
| 243 | + "select name::text, avatar_key from accounts where id = $1 | |
| 244 | + union all | |
| 245 | + select a.name::text, a.avatar_key from org_members m join accounts a on a.id = m.org_id where m.user_id = $1", | |
| 246 | + ) | |
| 247 | + .bind(viewer_id) | |
| 248 | + .fetch_all(&ctx.state.db) | |
| 249 | + .await?) | |
| 250 | +} | |
| 251 | + | |
| 252 | +pub async fn new_repo_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Query(query): Query<NewRepoQuery>) -> AppResult<Response> { | |
| 253 | + let owners = owner_choices(&ctx, viewer.id).await?; | |
| 254 | + let form = NewRepoForm { owner: query.owner.unwrap_or(viewer.name.clone()), visibility: "private".into(), ..Default::default() }; | |
| 255 | + Ok(ctx.render(new_repo_view(&owners, &form, None))) | |
| 256 | +} | |
| 257 | + | |
| 258 | +#[derive(Deserialize, Default)] | |
| 259 | +pub struct NewRepoForm { | |
| 260 | + owner: String, | |
| 261 | + name: String, | |
| 262 | + description: String, | |
| 263 | + visibility: String, | |
| 264 | +} | |
| 265 | + | |
| 266 | +fn new_repo_view(owners: &[(String, Option<String>)], form: &NewRepoForm, error: Option<&str>) -> Page { | |
| 267 | + let body = html! { | |
| 268 | + div class="mx-auto max-w-[640px] px-4 py-6" { | |
| 269 | + h1 class="text-lg font-semibold" { "New repository" } | |
| 270 | + p class="mb-4 text-[13px] text-ink-dim" { "A repository holds your code and its history. You can also run " code { "ig repo create <name>" } "." } | |
| 271 | + (ui::alert_error(error)) | |
| 272 | + form method="post" action="/new" class="space-y-4" data-track-submit="repo_create_submitted" { | |
| 273 | + div class="flex flex-wrap items-end gap-2" { | |
| 274 | + div { | |
| 275 | + label class="label" for="owner" { "Owner" } | |
| 276 | + select class="input" id="owner" name="owner" { | |
| 277 | + @for (name, _) in owners { option value=(name) selected[*name == form.owner] { (name) } } | |
| 278 | + } | |
| 279 | + } | |
| 280 | + span class="pb-1 text-lg text-ink-faint" { "/" } | |
| 281 | + div class="min-w-[200px] flex-1" { | |
| 282 | + label class="label" for="name" { "Repository name" } | |
| 283 | + input class="input" id="name" name="name" value=(form.name) required maxlength="100" pattern="[A-Za-z0-9._\\-]+" autofocus; | |
| 284 | + } | |
| 285 | + } | |
| 286 | + div { | |
| 287 | + label class="label" for="description" { "Description " span class="font-normal text-ink-faint" { "(optional)" } } | |
| 288 | + input class="input" id="description" name="description" value=(form.description) maxlength="350"; | |
| 289 | + } | |
| 290 | + fieldset class="box divide-y divide-edge" { | |
| 291 | + @for (value, title, detail) in [("public", "Public", "Anyone can see and clone it. Only people you allow can push."), ("private", "Private", "Only you, your organization and collaborators can see it.")] { | |
| 292 | + label class="flex cursor-pointer items-start gap-3 px-3 py-2.5" { | |
| 293 | + input type="radio" name="visibility" value=(value) checked[form.visibility == value] class="mt-1"; | |
| 294 | + span { | |
| 295 | + span class="block font-semibold" { (title) } | |
| 296 | + span class="block text-xs text-ink-dim" { (detail) } | |
| 297 | + } | |
| 298 | + } | |
| 299 | + } | |
| 300 | + } | |
| 301 | + p class="text-xs text-ink-faint" { "Images you push to the registry have their own visibility, set separately." } | |
| 302 | + button type="submit" class="btn btn-primary" { "Create repository" } | |
| 303 | + } | |
| 304 | + } | |
| 305 | + }; | |
| 306 | + Page::new("New repository", body).noindex() | |
| 307 | +} | |
| 308 | + | |
| 309 | +pub async fn new_repo_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Form(form): Form<NewRepoForm>) -> AppResult<Response> { | |
| 310 | + let owners = owner_choices(&ctx, viewer.id).await?; | |
| 311 | + let owner = Account::by_name(&ctx.state.db, &form.owner).await?.ok_or_else(|| AppError::bad("Choose an owner."))?; | |
| 312 | + match ops::create_repo(&ctx.state, &viewer, &owner, &form.name, &form.description, &form.visibility).await { | |
| 313 | + Ok(repo) => Ok(Redirect::to(&repo.url()).into_response()), | |
| 314 | + Err(AppError::BadRequest(m) | AppError::Conflict(m) | AppError::Forbidden(m)) => Ok(ctx.render(new_repo_view(&owners, &form, Some(&m)))), | |
| 315 | + Err(e) => Err(e), | |
| 316 | + } | |
| 317 | +} |
+87-0backend/src/web/markdown.rs
| @@ -0,0 +1,87 @@ | ||
| 1 | +//! Markdown to HTML for READMEs and .md files. Raw HTML is dropped and | |
| 2 | +//! dangerous URL schemes are neutralized by comrak's safe mode; relative | |
| 3 | +//! links and images are rewritten to point into the repository. | |
| 4 | + | |
| 5 | +use std::sync::Arc; | |
| 6 | + | |
| 7 | +use comrak::{Options, markdown_to_html}; | |
| 8 | + | |
| 9 | +pub struct RepoLinks { | |
| 10 | + /// "/owner/repo" | |
| 11 | + pub repo_url: String, | |
| 12 | + /// Ref the document was read at, e.g. "main". | |
| 13 | + pub git_ref: String, | |
| 14 | + /// Directory of the document inside the repo ("" at the root). | |
| 15 | + pub dir: String, | |
| 16 | +} | |
| 17 | + | |
| 18 | +pub fn render(source: &str, links: Option<&RepoLinks>) -> String { | |
| 19 | + let mut options = Options::default(); | |
| 20 | + options.extension.strikethrough = true; | |
| 21 | + options.extension.table = true; | |
| 22 | + options.extension.autolink = true; | |
| 23 | + options.extension.tasklist = true; | |
| 24 | + options.extension.footnotes = true; | |
| 25 | + options.extension.alerts = true; | |
| 26 | + options.extension.header_id_prefix = Some("md-".into()); | |
| 27 | + options.render.r#unsafe = false; | |
| 28 | + | |
| 29 | + if let Some(links) = links { | |
| 30 | + let (repo, git_ref, dir) = (links.repo_url.clone(), links.git_ref.clone(), links.dir.clone()); | |
| 31 | + let (repo2, ref2, dir2) = (repo.clone(), git_ref.clone(), dir.clone()); | |
| 32 | + options.extension.image_url_rewriter = Some(Arc::new(move |url: &str| rewrite(url, &repo, &git_ref, &dir, "raw"))); | |
| 33 | + options.extension.link_url_rewriter = Some(Arc::new(move |url: &str| rewrite(url, &repo2, &ref2, &dir2, "blob"))); | |
| 34 | + } | |
| 35 | + markdown_to_html(source, &options) | |
| 36 | +} | |
| 37 | + | |
| 38 | +fn rewrite(url: &str, repo: &str, git_ref: &str, dir: &str, kind: &str) -> String { | |
| 39 | + let is_absolute = url.contains("://") || url.starts_with("//") || url.starts_with('#') || url.starts_with("mailto:") || url.starts_with("data:"); | |
| 40 | + if is_absolute || url.is_empty() { | |
| 41 | + return url.to_string(); | |
| 42 | + } | |
| 43 | + let (path, fragment) = match url.split_once('#') { | |
| 44 | + Some((p, f)) => (p, Some(f)), | |
| 45 | + None => (url, None), | |
| 46 | + }; | |
| 47 | + let joined = if let Some(rooted) = path.strip_prefix('/') { rooted.to_string() } else if dir.is_empty() { path.to_string() } else { format!("{dir}/{path}") }; | |
| 48 | + let mut parts: Vec<&str> = Vec::new(); | |
| 49 | + for part in joined.split('/') { | |
| 50 | + match part { | |
| 51 | + "" | "." => {} | |
| 52 | + ".." => { | |
| 53 | + parts.pop(); | |
| 54 | + } | |
| 55 | + other => parts.push(other), | |
| 56 | + } | |
| 57 | + } | |
| 58 | + // Links to directories should open the tree view. | |
| 59 | + let kind = if kind == "blob" && (path.ends_with('/') || !parts.last().is_some_and(|p| p.contains('.'))) { "tree" } else { kind }; | |
| 60 | + let mut out = format!("{repo}/{kind}/{git_ref}/{}", parts.join("/")); | |
| 61 | + if let Some(fragment) = fragment { | |
| 62 | + out.push('#'); | |
| 63 | + out.push_str(fragment); | |
| 64 | + } | |
| 65 | + out | |
| 66 | +} | |
| 67 | + | |
| 68 | +#[cfg(test)] | |
| 69 | +mod tests { | |
| 70 | + use super::*; | |
| 71 | + | |
| 72 | + #[test] | |
| 73 | + fn drops_raw_html_and_js_links() { | |
| 74 | + let html = render("<script>alert(1)</script>\n\n[x](javascript:alert(1))", None); | |
| 75 | + assert!(!html.contains("<script>")); | |
| 76 | + assert!(!html.contains("javascript:")); | |
| 77 | + } | |
| 78 | + | |
| 79 | + #[test] | |
| 80 | + fn rewrites_relative_links() { | |
| 81 | + let links = RepoLinks { repo_url: "/a/b".into(), git_ref: "main".into(), dir: "docs".into() }; | |
| 82 | + let html = render(" [guide](guide.md#setup) [ext](https://x.io)", Some(&links)); | |
| 83 | + assert!(html.contains("/a/b/raw/main/img/logo.png")); | |
| 84 | + assert!(html.contains("/a/b/blob/main/docs/guide.md#setup")); | |
| 85 | + assert!(html.contains("https://x.io")); | |
| 86 | + } | |
| 87 | +} |
+33-2backend/src/web/mod.rs
| @@ -1,14 +1,45 @@ | ||
| 1 | 1 | //! Server-rendered pages (maud), poured into the Astro shell. |
| 2 | 2 | |
| 3 | +pub mod account; | |
| 3 | 4 | pub mod admin; |
| 4 | 5 | pub mod avatars; |
| 6 | +pub mod components; | |
| 7 | +pub mod heatmap; | |
| 8 | +pub mod highlight; | |
| 9 | +pub mod home; | |
| 5 | 10 | pub mod layout; |
| 11 | +pub mod markdown; | |
| 12 | +pub mod orgs; | |
| 13 | +pub mod profile; | |
| 14 | +pub mod repo; | |
| 15 | +pub mod settings; | |
| 6 | 16 | pub mod ui; |
| 7 | 17 | |
| 8 | -use axum::{Router, routing::get}; | |
| 18 | +use axum::{ | |
| 19 | + Router, | |
| 20 | + http::header, | |
| 21 | + response::IntoResponse, | |
| 22 | + routing::get, | |
| 23 | +}; | |
| 9 | 24 | |
| 10 | 25 | use crate::state::AppState; |
| 11 | 26 | |
| 12 | 27 | pub fn router() -> Router<AppState> { |
| 13 | - Router::new().route("/avatars/{name}", get(avatars::serve)).merge(admin::router()) | |
| 28 | + Router::new() | |
| 29 | + .route("/", get(home::index)) | |
| 30 | + .route("/explore", get(home::explore)) | |
| 31 | + .route("/new", get(home::new_repo_page).post(home::new_repo_submit)) | |
| 32 | + .route("/avatars/{name}", get(avatars::serve)) | |
| 33 | + .route("/assets/syntax.css", get(syntax_css)) | |
| 34 | + .route("/{owner}", get(profile::show)) | |
| 35 | + .merge(account::router()) | |
| 36 | + .merge(settings::router()) | |
| 37 | + .merge(orgs::router()) | |
| 38 | + .merge(repo::router()) | |
| 39 | + .merge(admin::router()) | |
| 40 | +} | |
| 41 | + | |
| 42 | +async fn syntax_css() -> impl IntoResponse { | |
| 43 | + let cache = if cfg!(debug_assertions) { "no-store" } else { "public, max-age=86400" }; | |
| 44 | + ([(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, cache)], highlight::CSS.as_str()) | |
| 14 | 45 | } |
+268-0backend/src/web/orgs.rs
| @@ -0,0 +1,268 @@ | ||
| 1 | +//! /organizations/new and /organizations/{org}/settings. | |
| 2 | + | |
| 3 | +use axum::{ | |
| 4 | + Form, Router, | |
| 5 | + extract::{DefaultBodyLimit, Multipart, Path, Query}, | |
| 6 | + response::{IntoResponse, Redirect, Response}, | |
| 7 | + routing::{get, post}, | |
| 8 | +}; | |
| 9 | +use maud::html; | |
| 10 | +use serde::Deserialize; | |
| 11 | +use serde_json::json; | |
| 12 | + | |
| 13 | +use crate::{ | |
| 14 | + analytics, | |
| 15 | + auth::{self, RequireViewer, Viewer}, | |
| 16 | + error::{AppError, AppResult}, | |
| 17 | + models::{Account, audit}, | |
| 18 | + ops, perm, | |
| 19 | + state::AppState, | |
| 20 | + web::{ | |
| 21 | + avatars, | |
| 22 | + layout::{Ctx, Page}, | |
| 23 | + settings::{clean_website, read_upload}, | |
| 24 | + ui, | |
| 25 | + }, | |
| 26 | +}; | |
| 27 | + | |
| 28 | +pub fn router() -> Router<AppState> { | |
| 29 | + Router::new() | |
| 30 | + .route("/organizations/new", get(new_page).post(new_submit)) | |
| 31 | + .route("/organizations/{org}/settings", get(settings_page).post(profile_submit)) | |
| 32 | + .route("/organizations/{org}/settings/avatar", post(avatar_upload).layer(DefaultBodyLimit::max(3 * 1024 * 1024))) | |
| 33 | + .route("/organizations/{org}/settings/members", post(members_submit)) | |
| 34 | + .route("/organizations/{org}/settings/delete", post(delete_submit)) | |
| 35 | +} | |
| 36 | + | |
| 37 | +#[derive(Deserialize, Default)] | |
| 38 | +pub struct NewOrgForm { | |
| 39 | + name: String, | |
| 40 | + display_name: String, | |
| 41 | +} | |
| 42 | + | |
| 43 | +fn new_view(form: &NewOrgForm, error: Option<&str>) -> Page { | |
| 44 | + let body = html! { | |
| 45 | + div class="mx-auto max-w-[560px] px-4 py-6" { | |
| 46 | + h1 class="text-lg font-semibold" { "New organization" } | |
| 47 | + p class="mb-4 text-[13px] text-ink-dim" { "Organizations own repositories and images that several people work on. You become its first owner." } | |
| 48 | + (ui::alert_error(error)) | |
| 49 | + form method="post" action="/organizations/new" class="space-y-3" data-track-submit="org_create_submitted" { | |
| 50 | + div { | |
| 51 | + label class="label" for="name" { "Organization name" } | |
| 52 | + input class="input" id="name" name="name" value=(form.name) required pattern="[a-z0-9-]{1,39}" maxlength="39" autofocus; | |
| 53 | + p class="hint" { "Used in URLs and image names: /name and registry/name/image." } | |
| 54 | + } | |
| 55 | + div { | |
| 56 | + label class="label" for="display_name" { "Display name " span class="font-normal text-ink-faint" { "(optional)" } } | |
| 57 | + input class="input" id="display_name" name="display_name" value=(form.display_name) maxlength="80"; | |
| 58 | + } | |
| 59 | + button type="submit" class="btn btn-primary" { "Create organization" } | |
| 60 | + } | |
| 61 | + } | |
| 62 | + }; | |
| 63 | + Page::new("New organization", body).noindex() | |
| 64 | +} | |
| 65 | + | |
| 66 | +pub async fn new_page(ctx: Ctx, _viewer: RequireViewer) -> Response { | |
| 67 | + ctx.render(new_view(&NewOrgForm::default(), None)) | |
| 68 | +} | |
| 69 | + | |
| 70 | +pub async fn new_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Form(form): Form<NewOrgForm>) -> AppResult<Response> { | |
| 71 | + match ops::create_org(&ctx.state, &viewer, &form.name, &form.display_name).await { | |
| 72 | + Ok(org) => Ok(Redirect::to(&format!("/{}", org.name)).into_response()), | |
| 73 | + Err(AppError::BadRequest(m) | AppError::Conflict(m)) => Ok(ctx.render(new_view(&form, Some(&m)))), | |
| 74 | + Err(e) => Err(e), | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +/// The org, if the viewer owns it (or is a site admin). | |
| 79 | +async fn owned_org(ctx: &Ctx, viewer: &Viewer, name: &str) -> AppResult<Account> { | |
| 80 | + let org = Account::by_name(&ctx.state.db, name).await?.filter(|a| a.is_org()).ok_or(AppError::NotFound)?; | |
| 81 | + if !perm::owner_access(&ctx.state.db, Some(viewer), org.id).await?.is_admin() { | |
| 82 | + return Err(AppError::NotFound); | |
| 83 | + } | |
| 84 | + Ok(org) | |
| 85 | +} | |
| 86 | + | |
| 87 | +#[derive(Deserialize, Default)] | |
| 88 | +pub struct Notice { | |
| 89 | + saved: Option<String>, | |
| 90 | + error: Option<String>, | |
| 91 | +} | |
| 92 | + | |
| 93 | +pub async fn settings_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(name): Path<String>, Query(notice): Query<Notice>) -> AppResult<Response> { | |
| 94 | + let org = owned_org(&ctx, &viewer, &name).await?; | |
| 95 | + let members: Vec<(String, Option<String>, String)> = sqlx::query_as( | |
| 96 | + "select a.name::text, a.avatar_key, m.role from org_members m join accounts a on a.id = m.user_id where m.org_id = $1 order by m.role desc, a.name", | |
| 97 | + ) | |
| 98 | + .bind(org.id) | |
| 99 | + .fetch_all(&ctx.state.db) | |
| 100 | + .await?; | |
| 101 | + let action = format!("/organizations/{}/settings", org.name); | |
| 102 | + let body = html! { | |
| 103 | + div class="mx-auto max-w-[900px] px-4 py-6" { | |
| 104 | + div class="mb-4 flex items-center gap-3" { | |
| 105 | + (ui::avatar(&org.name, org.avatar_key.as_deref(), 40)) | |
| 106 | + h1 class="text-lg font-semibold" { a href={ "/" (org.name) } class="text-ink" { (org.name) } " settings" } | |
| 107 | + } | |
| 108 | + (ui::alert_ok(notice.saved.as_deref())) | |
| 109 | + (ui::alert_error(notice.error.as_deref())) | |
| 110 | + section class="mb-6 grid gap-6 md:grid-cols-[1fr_180px]" { | |
| 111 | + form method="post" action=(action) class="space-y-3" data-track-submit="org_profile_saved" { | |
| 112 | + h2 class="font-semibold" { "Profile" } | |
| 113 | + div { label class="label" for="display_name" { "Display name" } input class="input" id="display_name" name="display_name" value=(org.display_name) maxlength="80"; } | |
| 114 | + div { label class="label" for="bio" { "Description" } textarea class="input" id="bio" name="bio" maxlength="300" { (org.bio) } } | |
| 115 | + div class="grid gap-3 sm:grid-cols-2" { | |
| 116 | + div { label class="label" for="location" { "Location" } input class="input" id="location" name="location" value=(org.location) maxlength="80"; } | |
| 117 | + div { label class="label" for="website" { "Website" } input class="input" id="website" name="website" value=(org.website) maxlength="200"; } | |
| 118 | + } | |
| 119 | + button class="btn btn-primary" { "Save" } | |
| 120 | + } | |
| 121 | + div { | |
| 122 | + div class="label" { "Avatar" } | |
| 123 | + (ui::avatar(&org.name, org.avatar_key.as_deref(), 160)) | |
| 124 | + form method="post" action={ (action) "/avatar" } enctype="multipart/form-data" class="mt-2 space-y-2" { | |
| 125 | + input type="file" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" required class="block w-full text-xs text-ink-dim"; | |
| 126 | + button class="btn btn-sm w-full" { "Upload" } | |
| 127 | + } | |
| 128 | + } | |
| 129 | + } | |
| 130 | + section class="mb-6" { | |
| 131 | + h2 class="mb-2 font-semibold" { "Members" } | |
| 132 | + div class="box mb-3 divide-y divide-edge" { | |
| 133 | + @for (name, avatar, role) in &members { | |
| 134 | + form method="post" action={ (action) "/members" } class="flex flex-wrap items-center gap-2 px-3 py-2" { | |
| 135 | + input type="hidden" name="username" value=(name); | |
| 136 | + (ui::avatar(name, avatar.as_deref(), 28)) | |
| 137 | + a href={ "/" (name) } class="flex-1 font-semibold" { (name) } | |
| 138 | + select class="input w-auto" name="role" { | |
| 139 | + option value="owner" selected[role == "owner"] { "Owner" } | |
| 140 | + option value="member" selected[role == "member"] { "Member" } | |
| 141 | + } | |
| 142 | + button class="btn btn-sm" name="action" value="set" { "Update" } | |
| 143 | + button class="btn btn-sm btn-danger" name="action" value="remove" { "Remove" } | |
| 144 | + } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + form method="post" action={ (action) "/members" } class="flex flex-wrap gap-2" data-track-submit="org_member_added" { | |
| 148 | + input type="hidden" name="action" value="set"; | |
| 149 | + input class="input max-w-[240px]" name="username" placeholder="Username" required; | |
| 150 | + select class="input w-auto" name="role" { option value="member" { "Member" } option value="owner" { "Owner" } } | |
| 151 | + button class="btn btn-primary" { "Add member" } | |
| 152 | + } | |
| 153 | + p class="hint" { "Owners manage settings and members and administer every repository and image. Members can create and push to them." } | |
| 154 | + } | |
| 155 | + section class="box border-danger/50 p-3" { | |
| 156 | + h2 class="font-semibold text-danger" { "Delete organization" } | |
| 157 | + p class="mt-1 text-[13px] text-ink-dim" { "Deletes the organization with all of its repositories and images. This cannot be undone." } | |
| 158 | + form method="post" action={ (action) "/delete" } class="mt-2 flex flex-wrap gap-2" { | |
| 159 | + input class="input max-w-[240px]" name="confirm" placeholder=(org.name) data-confirm-value=(org.name) autocomplete="off"; | |
| 160 | + button type="submit" class="btn btn-danger" { "Delete this organization" } | |
| 161 | + } | |
| 162 | + } | |
| 163 | + } | |
| 164 | + }; | |
| 165 | + Ok(ctx.render(Page::new(format!("{} settings", org.name), body).noindex())) | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[derive(Deserialize)] | |
| 169 | +pub struct OrgProfileForm { | |
| 170 | + display_name: String, | |
| 171 | + bio: String, | |
| 172 | + location: String, | |
| 173 | + website: String, | |
| 174 | +} | |
| 175 | + | |
| 176 | +pub async fn profile_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(name): Path<String>, Form(form): Form<OrgProfileForm>) -> AppResult<Response> { | |
| 177 | + let org = owned_org(&ctx, &viewer, &name).await?; | |
| 178 | + let back = format!("/organizations/{}/settings", org.name); | |
| 179 | + let website = match clean_website(&form.website) { | |
| 180 | + Ok(w) => w, | |
| 181 | + Err(e) => return Ok(Redirect::to(&format!("{back}?error={}", auth::urlencode(&e.public_message()))).into_response()), | |
| 182 | + }; | |
| 183 | + sqlx::query("update accounts set display_name = $2, bio = $3, location = $4, website = $5, updated_at = now() where id = $1") | |
| 184 | + .bind(org.id) | |
| 185 | + .bind(form.display_name.trim().chars().take(80).collect::<String>()) | |
| 186 | + .bind(form.bio.trim().chars().take(300).collect::<String>()) | |
| 187 | + .bind(form.location.trim().chars().take(80).collect::<String>()) | |
| 188 | + .bind(website) | |
| 189 | + .execute(&ctx.state.db) | |
| 190 | + .await?; | |
| 191 | + Ok(Redirect::to(&format!("{back}?saved=Saved.")).into_response()) | |
| 192 | +} | |
| 193 | + | |
| 194 | +pub async fn avatar_upload(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(name): Path<String>, multipart: Multipart) -> AppResult<Response> { | |
| 195 | + let org = owned_org(&ctx, &viewer, &name).await?; | |
| 196 | + let back = format!("/organizations/{}/settings", org.name); | |
| 197 | + let Some(bytes) = read_upload(multipart).await? else { return Ok(Redirect::to(&back).into_response()) }; | |
| 198 | + match avatars::store(&ctx.state, org.id, bytes).await { | |
| 199 | + Ok(()) => Ok(Redirect::to(&format!("{back}?saved=Avatar+updated.")).into_response()), | |
| 200 | + Err(AppError::BadRequest(m)) => Ok(Redirect::to(&format!("{back}?error={}", auth::urlencode(&m))).into_response()), | |
| 201 | + Err(e) => Err(e), | |
| 202 | + } | |
| 203 | +} | |
| 204 | + | |
| 205 | +#[derive(Deserialize)] | |
| 206 | +pub struct MemberForm { | |
| 207 | + username: String, | |
| 208 | + role: Option<String>, | |
| 209 | + action: String, | |
| 210 | +} | |
| 211 | + | |
| 212 | +pub async fn members_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(name): Path<String>, Form(form): Form<MemberForm>) -> AppResult<Response> { | |
| 213 | + let org = owned_org(&ctx, &viewer, &name).await?; | |
| 214 | + let back = format!("/organizations/{}/settings", org.name); | |
| 215 | + let db = &ctx.state.db; | |
| 216 | + let user: Option<i64> = sqlx::query_scalar("select id from accounts where name = $1 and kind = 'user'").bind(form.username.trim()).fetch_optional(db).await?; | |
| 217 | + let Some(user_id) = user else { | |
| 218 | + return Ok(Redirect::to(&format!("{back}?error={}", auth::urlencode(&format!("No user named {}.", form.username.trim())))).into_response()); | |
| 219 | + }; | |
| 220 | + let owners: i64 = sqlx::query_scalar("select count(*) from org_members where org_id = $1 and role = 'owner' and user_id <> $2").bind(org.id).bind(user_id).fetch_one(db).await?; | |
| 221 | + let message = match form.action.as_str() { | |
| 222 | + "remove" => { | |
| 223 | + if owners == 0 { | |
| 224 | + return Ok(Redirect::to(&format!("{back}?error=An+organization+needs+at+least+one+owner.")).into_response()); | |
| 225 | + } | |
| 226 | + sqlx::query("delete from org_members where org_id = $1 and user_id = $2").bind(org.id).bind(user_id).execute(db).await?; | |
| 227 | + audit(db, Some(viewer.id), "org.member.remove", &org.name, json!({ "user": form.username }), None).await; | |
| 228 | + "Member removed." | |
| 229 | + } | |
| 230 | + _ => { | |
| 231 | + let role = if form.role.as_deref() == Some("owner") { "owner" } else { "member" }; | |
| 232 | + if role == "member" && owners == 0 { | |
| 233 | + return Ok(Redirect::to(&format!("{back}?error=An+organization+needs+at+least+one+owner.")).into_response()); | |
| 234 | + } | |
| 235 | + sqlx::query("insert into org_members (org_id, user_id, role) values ($1, $2, $3) on conflict (org_id, user_id) do update set role = excluded.role") | |
| 236 | + .bind(org.id) | |
| 237 | + .bind(user_id) | |
| 238 | + .bind(role) | |
| 239 | + .execute(db) | |
| 240 | + .await?; | |
| 241 | + audit(db, Some(viewer.id), "org.member.set", &org.name, json!({ "user": form.username, "role": role }), None).await; | |
| 242 | + analytics::track(&ctx.state, "org_member_set", Some(&viewer.name), &back, json!({ "role": role })); | |
| 243 | + "Members updated." | |
| 244 | + } | |
| 245 | + }; | |
| 246 | + Ok(Redirect::to(&format!("{back}?saved={}", auth::urlencode(message))).into_response()) | |
| 247 | +} | |
| 248 | + | |
| 249 | +#[derive(Deserialize)] | |
| 250 | +pub struct DeleteForm { | |
| 251 | + confirm: String, | |
| 252 | +} | |
| 253 | + | |
| 254 | +pub async fn delete_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(name): Path<String>, Form(form): Form<DeleteForm>) -> AppResult<Response> { | |
| 255 | + let org = owned_org(&ctx, &viewer, &name).await?; | |
| 256 | + if form.confirm.trim() != org.name { | |
| 257 | + return Ok(Redirect::to(&format!("/organizations/{}/settings?error=Type+the+organization+name+to+confirm.", org.name)).into_response()); | |
| 258 | + } | |
| 259 | + // Repositories first so their files are removed from disk. | |
| 260 | + let repos: Vec<crate::models::Repo> = sqlx::query_as(concat!(crate::repo_select!(), " where r.owner_id = $1")).bind(org.id).fetch_all(&ctx.state.db).await?; | |
| 261 | + for repo in &repos { | |
| 262 | + ops::delete_repo(&ctx.state, &viewer, repo).await?; | |
| 263 | + } | |
| 264 | + sqlx::query("delete from accounts where id = $1").bind(org.id).execute(&ctx.state.db).await?; | |
| 265 | + audit(&ctx.state.db, Some(viewer.id), "org.delete", &org.name, json!({ "repos": repos.len() }), None).await; | |
| 266 | + analytics::track(&ctx.state, "org_deleted", Some(&viewer.name), "/organizations", json!({})); | |
| 267 | + Ok(Redirect::to("/settings/organizations").into_response()) | |
| 268 | +} |
+441-0backend/src/web/profile.rs
| @@ -0,0 +1,441 @@ | ||
| 1 | +//! /{name}: a person's public profile (avatar, heatmap, repositories, | |
| 2 | +//! images, activity) or an organization's page. | |
| 3 | + | |
| 4 | +use std::collections::HashMap; | |
| 5 | + | |
| 6 | +use axum::{ | |
| 7 | + extract::{Path, Query, Request}, | |
| 8 | + response::{IntoResponse, Response}, | |
| 9 | +}; | |
| 10 | +use chrono::{DateTime, NaiveDate, Utc}; | |
| 11 | +use maud::{Markup, html}; | |
| 12 | +use serde::Deserialize; | |
| 13 | + | |
| 14 | +use crate::{ | |
| 15 | + error::AppResult, | |
| 16 | + frontend, | |
| 17 | + git::ZERO_SHA, | |
| 18 | + models::{Account, Package, Repo}, | |
| 19 | + package_select, | |
| 20 | + perm::{self, Area}, | |
| 21 | + repo_select, visible_packages, visible_repos, | |
| 22 | + web::{ | |
| 23 | + components, | |
| 24 | + heatmap::{self, Contributions}, | |
| 25 | + layout::{Ctx, Page}, | |
| 26 | + ui, | |
| 27 | + }, | |
| 28 | +}; | |
| 29 | + | |
| 30 | +#[derive(Deserialize, Default)] | |
| 31 | +pub struct ProfileQuery { | |
| 32 | + tab: Option<String>, | |
| 33 | + q: Option<String>, | |
| 34 | + welcome: Option<String>, | |
| 35 | +} | |
| 36 | + | |
| 37 | +pub async fn show(ctx: Ctx, Path(name): Path<String>, Query(query): Query<ProfileQuery>, request: Request) -> AppResult<Response> { | |
| 38 | + let Some(account) = Account::by_name(&ctx.state.db, &name).await? else { | |
| 39 | + // Not an account: maybe a static file such as /favicon.svg. | |
| 40 | + return Ok(frontend::serve(axum::extract::State(ctx.state.clone()), crate::auth::MaybeViewer(ctx.viewer.clone()), request.uri().clone()).await); | |
| 41 | + }; | |
| 42 | + if account.name != name { | |
| 43 | + // Canonical lowercase URL. | |
| 44 | + return Ok(axum::response::Redirect::permanent(&format!("/{}", account.name)).into_response()); | |
| 45 | + } | |
| 46 | + if account.is_org() { org_page(&ctx, &account, &query).await } else { user_page(&ctx, &account, &query).await } | |
| 47 | +} | |
| 48 | + | |
| 49 | +async fn owned_repos(ctx: &Ctx, owner_id: i64, q: Option<&str>, limit: i64) -> AppResult<Vec<Repo>> { | |
| 50 | + let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Repo); | |
| 51 | + let pattern = q.filter(|q| !q.trim().is_empty()).map(|q| format!("%{}%", q.trim().replace('%', "\\%").replace('_', "\\_"))); | |
| 52 | + Ok(sqlx::query_as(concat!( | |
| 53 | + repo_select!(), | |
| 54 | + " where r.owner_id = $3 and ", | |
| 55 | + visible_repos!(), | |
| 56 | + " and ($4::text is null or r.name ilike $4 or r.description ilike $4) | |
| 57 | + order by r.pushed_at desc nulls last, r.created_at desc limit $5" | |
| 58 | + )) | |
| 59 | + .bind(viewer_id) | |
| 60 | + .bind(admin) | |
| 61 | + .bind(owner_id) | |
| 62 | + .bind(pattern) | |
| 63 | + .bind(limit) | |
| 64 | + .fetch_all(&ctx.state.db) | |
| 65 | + .await?) | |
| 66 | +} | |
| 67 | + | |
| 68 | +async fn owned_packages(ctx: &Ctx, owner_id: i64) -> AppResult<Vec<(Package, i64)>> { | |
| 69 | + let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Packages); | |
| 70 | + let packages: Vec<Package> = sqlx::query_as(concat!(package_select!(), " where p.owner_id = $3 and ", visible_packages!(), " order by p.updated_at desc limit 200")) | |
| 71 | + .bind(viewer_id) | |
| 72 | + .bind(admin) | |
| 73 | + .bind(owner_id) | |
| 74 | + .fetch_all(&ctx.state.db) | |
| 75 | + .await?; | |
| 76 | + let ids: Vec<i64> = packages.iter().map(|p| p.id).collect(); | |
| 77 | + let counts: HashMap<i64, i64> = sqlx::query_as::<_, (i64, i64)>("select package_id, count(*) from tags where package_id = any($1) group by package_id") | |
| 78 | + .bind(&ids) | |
| 79 | + .fetch_all(&ctx.state.db) | |
| 80 | + .await? | |
| 81 | + .into_iter() | |
| 82 | + .collect(); | |
| 83 | + Ok(packages.into_iter().map(|p| { let n = counts.get(&p.id).copied().unwrap_or(0); (p, n) }).collect()) | |
| 84 | +} | |
| 85 | + | |
| 86 | +async fn counts(ctx: &Ctx, owner_id: i64) -> AppResult<(i64, i64)> { | |
| 87 | + let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Repo); | |
| 88 | + let repos: i64 = sqlx::query_scalar(concat!("select count(*) from repos r where r.owner_id = $3 and ", visible_repos!())) | |
| 89 | + .bind(viewer_id) | |
| 90 | + .bind(admin) | |
| 91 | + .bind(owner_id) | |
| 92 | + .fetch_one(&ctx.state.db) | |
| 93 | + .await?; | |
| 94 | + let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Packages); | |
| 95 | + let packages: i64 = sqlx::query_scalar(concat!("select count(*) from packages p where p.owner_id = $3 and ", visible_packages!())) | |
| 96 | + .bind(viewer_id) | |
| 97 | + .bind(admin) | |
| 98 | + .bind(owner_id) | |
| 99 | + .fetch_one(&ctx.state.db) | |
| 100 | + .await?; | |
| 101 | + Ok((repos, packages)) | |
| 102 | +} | |
| 103 | + | |
| 104 | +pub async fn contributions(ctx: &Ctx, user_id: i64) -> AppResult<Contributions> { | |
| 105 | + let today = Utc::now().date_naive(); | |
| 106 | + let start = Contributions::start(today); | |
| 107 | + let show_private: bool = sqlx::query_scalar("select show_private_contributions from users where account_id = $1") | |
| 108 | + .bind(user_id) | |
| 109 | + .fetch_one(&ctx.state.db) | |
| 110 | + .await?; | |
| 111 | + let include_all = show_private || ctx.viewer_id() == Some(user_id); | |
| 112 | + let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Repo); | |
| 113 | + let rows: Vec<(NaiveDate, i64)> = sqlx::query_as(concat!( | |
| 114 | + "select c.day, count(*)::bigint from contribution_commits c join repos r on r.id = c.repo_id | |
| 115 | + where c.user_id = $3 and c.day >= $4 and ($5 or ", | |
| 116 | + visible_repos!(), | |
| 117 | + ") group by c.day" | |
| 118 | + )) | |
| 119 | + .bind(viewer_id) | |
| 120 | + .bind(admin) | |
| 121 | + .bind(user_id) | |
| 122 | + .bind(start) | |
| 123 | + .bind(include_all) | |
| 124 | + .fetch_all(&ctx.state.db) | |
| 125 | + .await?; | |
| 126 | + Ok(Contributions { days: rows.into_iter().collect(), today }) | |
| 127 | +} | |
| 128 | + | |
| 129 | +pub struct Activity { | |
| 130 | + pub owner_name: String, | |
| 131 | + pub repo_name: String, | |
| 132 | + pub ref_name: String, | |
| 133 | + pub commit_count: i32, | |
| 134 | + pub head_message: String, | |
| 135 | + pub before_sha: String, | |
| 136 | + pub after_sha: String, | |
| 137 | + pub created_at: DateTime<Utc>, | |
| 138 | + pub pusher: Option<(String, Option<String>)>, | |
| 139 | +} | |
| 140 | + | |
| 141 | +/// Push events visible to the viewer. `pusher` filters to one person. | |
| 142 | +pub async fn activity(ctx: &Ctx, pusher: Option<i64>, involved: Option<i64>, limit: i64) -> AppResult<Vec<Activity>> { | |
| 143 | + let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Repo); | |
| 144 | + let rows: Vec<(String, String, String, i32, String, String, String, DateTime<Utc>, Option<String>, Option<String>)> = sqlx::query_as(concat!( | |
| 145 | + "select a.name::text, r.name::text, e.ref_name, e.commit_count, e.head_message, e.before_sha, e.after_sha, e.created_at, | |
| 146 | + pa.name::text, pa.avatar_key | |
| 147 | + from push_events e join repos r on r.id = e.repo_id join accounts a on a.id = r.owner_id | |
| 148 | + left join accounts pa on pa.id = e.pusher_id | |
| 149 | + where ($3::bigint is null or e.pusher_id = $3) | |
| 150 | + and ($4::bigint is null or r.owner_id = $4 | |
| 151 | + or exists (select 1 from org_members m where m.org_id = r.owner_id and m.user_id = $4) | |
| 152 | + or exists (select 1 from repo_collaborators c where c.repo_id = r.id and c.user_id = $4)) | |
| 153 | + and ", | |
| 154 | + visible_repos!(), | |
| 155 | + " order by e.created_at desc limit $5" | |
| 156 | + )) | |
| 157 | + .bind(viewer_id) | |
| 158 | + .bind(admin) | |
| 159 | + .bind(pusher) | |
| 160 | + .bind(involved) | |
| 161 | + .bind(limit) | |
| 162 | + .fetch_all(&ctx.state.db) | |
| 163 | + .await?; | |
| 164 | + Ok(rows | |
| 165 | + .into_iter() | |
| 166 | + .map(|(owner_name, repo_name, ref_name, commit_count, head_message, before_sha, after_sha, created_at, pusher, avatar)| Activity { | |
| 167 | + owner_name, | |
| 168 | + repo_name, | |
| 169 | + ref_name, | |
| 170 | + commit_count, | |
| 171 | + head_message, | |
| 172 | + before_sha, | |
| 173 | + after_sha, | |
| 174 | + created_at, | |
| 175 | + pusher: pusher.map(|p| (p, avatar)), | |
| 176 | + }) | |
| 177 | + .collect()) | |
| 178 | +} | |
| 179 | + | |
| 180 | +pub fn activity_list(items: &[Activity], show_pusher: bool) -> Markup { | |
| 181 | + html! { | |
| 182 | + @if items.is_empty() { | |
| 183 | + p class="px-3 py-4 text-center text-[13px] text-ink-dim" { "No public activity yet." } | |
| 184 | + } | |
| 185 | + ol class="divide-y divide-edge" { | |
| 186 | + @for item in items { | |
| 187 | + @let repo_url = format!("/{}/{}", item.owner_name, item.repo_name); | |
| 188 | + @let short_ref = item.ref_name.strip_prefix("refs/heads/").or_else(|| item.ref_name.strip_prefix("refs/tags/")).unwrap_or(&item.ref_name); | |
| 189 | + @let is_tag = item.ref_name.starts_with("refs/tags/"); | |
| 190 | + li class="flex gap-2.5 px-3 py-2 text-[13px]" { | |
| 191 | + @if show_pusher { | |
| 192 | + @if let Some((name, avatar)) = &item.pusher { a href={ "/" (name) } class="shrink-0" { (ui::avatar(name, avatar.as_deref(), 24)) } } | |
| 193 | + } | |
| 194 | + div class="min-w-0 flex-1" { | |
| 195 | + div { | |
| 196 | + @if show_pusher { @if let Some((name, _)) = &item.pusher { a href={ "/" (name) } class="font-semibold text-ink" { (name) } " " } } | |
| 197 | + @if item.after_sha == ZERO_SHA { | |
| 198 | + "deleted " @if is_tag { "tag " } @else { "branch " } code class="text-xs" { (short_ref) } " in " | |
| 199 | + } @else if item.before_sha == ZERO_SHA { | |
| 200 | + @if is_tag { "tagged " } @else { "created branch " } | |
| 201 | + a href={ (repo_url) "/tree/" (short_ref) } class="font-mono text-xs" { (short_ref) } " in " | |
| 202 | + } @else { | |
| 203 | + "pushed " (ui::plural(item.commit_count as i64, "commit", "commits")) " to " | |
| 204 | + a href={ (repo_url) "/tree/" (short_ref) } class="font-mono text-xs" { (short_ref) } " in " | |
| 205 | + } | |
| 206 | + a href=(repo_url) class="font-semibold" { (item.owner_name) "/" (item.repo_name) } | |
| 207 | + } | |
| 208 | + @if !item.head_message.is_empty() && item.after_sha != ZERO_SHA { | |
| 209 | + div class="mt-0.5 truncate text-xs text-ink-dim" { | |
| 210 | + a href={ (repo_url) "/commit/" (item.after_sha) } class="font-mono text-ink-faint" { (&item.after_sha[..7]) } " " (item.head_message) | |
| 211 | + } | |
| 212 | + } | |
| 213 | + } | |
| 214 | + span class="shrink-0 text-xs text-ink-faint" { (ui::time(item.created_at)) } | |
| 215 | + } | |
| 216 | + } | |
| 217 | + } | |
| 218 | + } | |
| 219 | +} | |
| 220 | + | |
| 221 | +fn tabs(base: &str, active: &str, items: &[(&str, &str, Option<i64>)]) -> Markup { | |
| 222 | + html! { | |
| 223 | + nav class="tabs mb-4" { | |
| 224 | + @for (slug, label, count) in items { | |
| 225 | + a class="tab" href={ (base) @if !slug.is_empty() { "?tab=" (slug) } } aria-current=[(*slug == active).then_some("page")] { | |
| 226 | + (label) | |
| 227 | + @if let Some(count) = count { span class="rounded-[3px] bg-surface-hover px-1 text-[11px] text-ink-dim" { (count) } } | |
| 228 | + } | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | +} | |
| 233 | + | |
| 234 | +async fn user_page(ctx: &Ctx, account: &Account, query: &ProfileQuery) -> AppResult<Response> { | |
| 235 | + let db = &ctx.state.db; | |
| 236 | + let is_self = ctx.viewer_id() == Some(account.id); | |
| 237 | + let (repo_count, package_count) = counts(ctx, account.id).await?; | |
| 238 | + let orgs: Vec<(String, Option<String>)> = sqlx::query_as( | |
| 239 | + "select a.name::text, a.avatar_key from org_members m join accounts a on a.id = m.org_id where m.user_id = $1 order by a.name", | |
| 240 | + ) | |
| 241 | + .bind(account.id) | |
| 242 | + .fetch_all(db) | |
| 243 | + .await?; | |
| 244 | + let tab = query.tab.as_deref().unwrap_or(""); | |
| 245 | + let base = format!("/{}", account.name); | |
| 246 | + | |
| 247 | + let main = match tab { | |
| 248 | + "repositories" => { | |
| 249 | + let repos = owned_repos(ctx, account.id, query.q.as_deref(), 500).await?; | |
| 250 | + html! { | |
| 251 | + form class="mb-3 flex gap-2" { | |
| 252 | + input type="hidden" name="tab" value="repositories"; | |
| 253 | + input class="input max-w-[320px]" name="q" value=[query.q.as_deref()] placeholder="Find a repository"; | |
| 254 | + @if is_self { a href="/new" class="btn btn-primary ml-auto" { "New" } } | |
| 255 | + } | |
| 256 | + (components::repo_list(&repos, false, ui::empty_state("No repositories", html! { @if is_self { "Create one with " a href="/new" { "New repository" } " or " code { "ig repo create" } "." } @else { (account.name) " has no public repositories yet." } }))) | |
| 257 | + } | |
| 258 | + } | |
| 259 | + "packages" => { | |
| 260 | + let packages = owned_packages(ctx, account.id).await?; | |
| 261 | + html! { | |
| 262 | + @if packages.is_empty() { | |
| 263 | + (ui::empty_state("No images", html! { "Push one with " code { "docker push " (ctx.state.config.registry_host()) "/" (account.name) "/app:1.0" } })) | |
| 264 | + } @else { | |
| 265 | + div class="box divide-y divide-edge" { @for (p, tags) in &packages { (components::package_row(p, false, *tags)) } } | |
| 266 | + } | |
| 267 | + } | |
| 268 | + } | |
| 269 | + _ => { | |
| 270 | + let data = contributions(ctx, account.id).await?; | |
| 271 | + let popular = owned_repos(ctx, account.id, None, 6).await?; | |
| 272 | + let feed = activity(ctx, Some(account.id), None, 25).await?; | |
| 273 | + html! { | |
| 274 | + @if !popular.is_empty() { | |
| 275 | + h2 class="mb-2 text-[13px] font-semibold" { "Repositories" } | |
| 276 | + div class="mb-5 grid gap-2 sm:grid-cols-2" { | |
| 277 | + @for repo in &popular { | |
| 278 | + div class="box flex flex-col p-3" { | |
| 279 | + div class="flex items-center gap-2" { | |
| 280 | + (ui::icon_repo()) | |
| 281 | + a href=(repo.url()) class="truncate font-semibold" { (repo.name) } | |
| 282 | + (ui::visibility_tag(&repo.visibility)) | |
| 283 | + } | |
| 284 | + p class="mt-1 line-clamp-2 flex-1 text-xs text-ink-dim" { (repo.description) } | |
| 285 | + @if let Some(pushed) = repo.pushed_at { div class="mt-1.5 text-[11px] text-ink-faint" { "Updated " (ui::time(pushed)) } } | |
| 286 | + } | |
| 287 | + } | |
| 288 | + } | |
| 289 | + } | |
| 290 | + div class="mb-2 flex flex-wrap items-baseline gap-x-4" { | |
| 291 | + h2 class="text-[13px] font-semibold" { (data.total()) " contributions in the last year" } | |
| 292 | + span class="text-xs text-ink-faint" { "Current streak " (data.current_streak()) " days, longest " (data.longest_streak()) } | |
| 293 | + } | |
| 294 | + div class="box mb-5 p-3" { | |
| 295 | + (heatmap::render(&data)) | |
| 296 | + @if is_self { | |
| 297 | + p class="mt-2 text-[11px] text-ink-faint" { | |
| 298 | + "Commits count when they land on a default branch and their author email is one of your " | |
| 299 | + a href="/settings/emails" { "verified emails" } "." | |
| 300 | + } | |
| 301 | + } | |
| 302 | + } | |
| 303 | + h2 class="mb-2 text-[13px] font-semibold" { "Activity" } | |
| 304 | + div class="box" { (activity_list(&feed, false)) } | |
| 305 | + } | |
| 306 | + } | |
| 307 | + }; | |
| 308 | + | |
| 309 | + let welcome = query.welcome.is_some() && is_self; | |
| 310 | + let body = html! { | |
| 311 | + div class="mx-auto max-w-[1280px] px-4 py-6" { | |
| 312 | + @if welcome { | |
| 313 | + div class="alert alert-info mb-5" { | |
| 314 | + p class="font-semibold" { "Welcome to irongit, " (account.name) "." } | |
| 315 | + ol class="mt-1 list-decimal pl-5 text-[13px] text-ink-dim" { | |
| 316 | + li { "Install the CLI: " code { "curl -fsSL " (ctx.state.config.base_url()) "/install.sh | sh" } " then " code { "ig login" } } | |
| 317 | + li { a href="/new" { "Create a repository" } " and push to it" } | |
| 318 | + li { a href="/settings/emails" { "Verify your email" } " so your commits light up the heatmap" } | |
| 319 | + li { a href="/settings/profile" { "Add a profile picture" } } | |
| 320 | + } | |
| 321 | + } | |
| 322 | + } | |
| 323 | + div class="grid gap-6 md:grid-cols-[260px_1fr]" { | |
| 324 | + aside { | |
| 325 | + div class="flex items-center gap-4 md:block" { | |
| 326 | + div class="w-24 shrink-0 md:w-full" { | |
| 327 | + img src=(ui::avatar_url(&account.name, account.avatar_key.as_deref())) alt=(account.name) | |
| 328 | + class="aspect-square w-full rounded-[4px] border border-edge bg-surface-hover object-cover"; | |
| 329 | + } | |
| 330 | + div class="min-w-0 md:mt-3" { | |
| 331 | + @if !account.display_name.is_empty() { h1 class="text-xl leading-tight font-semibold" { (account.display_name) } } | |
| 332 | + div class={ @if account.display_name.is_empty() { "text-xl font-semibold" } @else { "text-[15px] text-ink-dim" } } { (account.name) } | |
| 333 | + } | |
| 334 | + } | |
| 335 | + @if !account.bio.is_empty() { p class="mt-3 text-[13px] whitespace-pre-line" { (account.bio) } } | |
| 336 | + @if is_self { a href="/settings/profile" class="btn mt-3 w-full" data-track="edit_profile_clicked" { "Edit profile" } } | |
| 337 | + ul class="mt-3 space-y-1 text-[13px] text-ink-dim" { | |
| 338 | + @if !account.location.is_empty() { li { (account.location) } } | |
| 339 | + @if !account.website.is_empty() { li class="truncate" { a href=(account.website) rel="nofollow noopener" target="_blank" { (account.website.trim_start_matches("https://").trim_start_matches("http://")) } } } | |
| 340 | + li { "Joined " (account.created_at.format("%B %Y")) } | |
| 341 | + li { a href={ (base) "?tab=repositories" } class="text-ink-dim" { (ui::plural(repo_count, "repository", "repositories")) } " · " a href={ (base) "?tab=packages" } class="text-ink-dim" { (ui::plural(package_count, "image", "images")) } } | |
| 342 | + } | |
| 343 | + @if !orgs.is_empty() { | |
| 344 | + h2 class="mt-4 mb-1.5 border-t border-edge pt-3 text-xs font-semibold text-ink-dim" { "Organizations" } | |
| 345 | + div class="flex flex-wrap gap-1.5" { | |
| 346 | + @for (org, avatar) in &orgs { a href={ "/" (org) } title=(org) { (ui::avatar(org, avatar.as_deref(), 32)) } } | |
| 347 | + } | |
| 348 | + } | |
| 349 | + } | |
| 350 | + div class="min-w-0" { | |
| 351 | + (tabs(&base, tab, &[("", "Overview", None), ("repositories", "Repositories", Some(repo_count)), ("packages", "Images", Some(package_count))])) | |
| 352 | + (main) | |
| 353 | + } | |
| 354 | + } | |
| 355 | + } | |
| 356 | + }; | |
| 357 | + let description = if account.bio.is_empty() { format!("{} on irongit", account.label()) } else { account.bio.clone() }; | |
| 358 | + let image = format!("{}{}", ctx.state.config.base_url(), ui::avatar_url(&account.name, account.avatar_key.as_deref())); | |
| 359 | + Ok(ctx.render(Page::new(format!("{} ({})", account.name, account.label()), body).description(description).og_image(image))) | |
| 360 | +} | |
| 361 | + | |
| 362 | +async fn org_page(ctx: &Ctx, org: &Account, query: &ProfileQuery) -> AppResult<Response> { | |
| 363 | + let db = &ctx.state.db; | |
| 364 | + let (repo_count, package_count) = counts(ctx, org.id).await?; | |
| 365 | + let members: Vec<(String, Option<String>, String, String)> = sqlx::query_as( | |
| 366 | + "select a.name::text, a.avatar_key, a.display_name, m.role from org_members m join accounts a on a.id = m.user_id | |
| 367 | + where m.org_id = $1 order by m.role desc, a.name", | |
| 368 | + ) | |
| 369 | + .bind(org.id) | |
| 370 | + .fetch_all(db) | |
| 371 | + .await?; | |
| 372 | + let access = perm::owner_access(db, ctx.viewer.as_ref(), org.id).await?; | |
| 373 | + let tab = query.tab.as_deref().unwrap_or(""); | |
| 374 | + let base = format!("/{}", org.name); | |
| 375 | + let main = match tab { | |
| 376 | + "packages" => { | |
| 377 | + let packages = owned_packages(ctx, org.id).await?; | |
| 378 | + html! { | |
| 379 | + @if packages.is_empty() { | |
| 380 | + (ui::empty_state("No images", html! { "Members can push to " code { (ctx.state.config.registry_host()) "/" (org.name) "/<image>" } })) | |
| 381 | + } @else { | |
| 382 | + div class="box divide-y divide-edge" { @for (p, tags) in &packages { (components::package_row(p, false, *tags)) } } | |
| 383 | + } | |
| 384 | + } | |
| 385 | + } | |
| 386 | + "people" => html! { | |
| 387 | + div class="box divide-y divide-edge" { | |
| 388 | + @for (name, avatar, display, role) in &members { | |
| 389 | + div class="flex items-center gap-3 px-3 py-2" { | |
| 390 | + (ui::avatar(name, avatar.as_deref(), 32)) | |
| 391 | + div class="min-w-0 flex-1" { | |
| 392 | + a href={ "/" (name) } class="font-semibold" { (name) } | |
| 393 | + @if !display.is_empty() { span class="ml-2 text-ink-dim" { (display) } } | |
| 394 | + } | |
| 395 | + @if access.can_write() { span class="tag" { (role) } } | |
| 396 | + } | |
| 397 | + } | |
| 398 | + } | |
| 399 | + }, | |
| 400 | + _ => { | |
| 401 | + let repos = owned_repos(ctx, org.id, query.q.as_deref(), 500).await?; | |
| 402 | + html! { | |
| 403 | + form class="mb-3 flex gap-2" { | |
| 404 | + input class="input max-w-[320px]" name="q" value=[query.q.as_deref()] placeholder="Find a repository"; | |
| 405 | + @if access.can_write() { a href={ "/new?owner=" (org.name) } class="btn btn-primary ml-auto" { "New repository" } } | |
| 406 | + } | |
| 407 | + (components::repo_list(&repos, false, ui::empty_state("No repositories", html! { "This organization has no repositories you can see." }))) | |
| 408 | + } | |
| 409 | + } | |
| 410 | + }; | |
| 411 | + let body = html! { | |
| 412 | + div class="border-b border-edge bg-surface-raised" { | |
| 413 | + div class="mx-auto flex max-w-[1280px] flex-wrap items-center gap-4 px-4 py-5" { | |
| 414 | + img src=(ui::avatar_url(&org.name, org.avatar_key.as_deref())) alt=(org.name) width="72" height="72" | |
| 415 | + class="h-[72px] w-[72px] rounded-[4px] border border-edge object-cover"; | |
| 416 | + div class="min-w-0 flex-1" { | |
| 417 | + h1 class="text-xl font-semibold" { (org.label()) } | |
| 418 | + @if !org.display_name.is_empty() { div class="text-ink-dim" { (org.name) } } | |
| 419 | + @if !org.bio.is_empty() { p class="mt-1 text-[13px]" { (org.bio) } } | |
| 420 | + div class="mt-1 flex flex-wrap gap-x-3 text-xs text-ink-dim" { | |
| 421 | + @if !org.location.is_empty() { span { (org.location) } } | |
| 422 | + @if !org.website.is_empty() { a href=(org.website) rel="nofollow noopener" target="_blank" { (org.website.trim_start_matches("https://")) } } | |
| 423 | + span { (ui::plural(members.len() as i64, "member", "members")) } | |
| 424 | + } | |
| 425 | + } | |
| 426 | + div class="flex -space-x-1.5" { | |
| 427 | + @for (name, avatar, _, _) in members.iter().take(8) { | |
| 428 | + a href={ "/" (name) } title=(name) class="rounded-[4px] ring-2 ring-surface-raised" { (ui::avatar(name, avatar.as_deref(), 28)) } | |
| 429 | + } | |
| 430 | + } | |
| 431 | + @if access.is_admin() { a href={ "/organizations/" (org.name) "/settings" } class="btn btn-sm" { "Settings" } } | |
| 432 | + } | |
| 433 | + } | |
| 434 | + div class="mx-auto max-w-[1280px] px-4 py-4" { | |
| 435 | + (tabs(&base, tab, &[("", "Repositories", Some(repo_count)), ("packages", "Images", Some(package_count)), ("people", "People", Some(members.len() as i64))])) | |
| 436 | + (main) | |
| 437 | + } | |
| 438 | + }; | |
| 439 | + let description = if org.bio.is_empty() { format!("{} on irongit", org.label()) } else { org.bio.clone() }; | |
| 440 | + Ok(ctx.render(Page::new(org.label().to_string(), body).description(description))) | |
| 441 | +} |
+1171-0backend/src/web/repo.rs
| @@ -0,0 +1,1171 @@ | ||
| 1 | +//! Repository pages: code browser, file viewer, raw files, history, | |
| 2 | +//! commits with diffs, branches, tags, archives and settings. | |
| 3 | + | |
| 4 | +use std::collections::HashMap; | |
| 5 | + | |
| 6 | +use axum::{ | |
| 7 | + Form, Router, | |
| 8 | + body::Body, | |
| 9 | + extract::{Path, Query}, | |
| 10 | + http::{HeaderValue, header}, | |
| 11 | + response::{IntoResponse, Redirect, Response}, | |
| 12 | + routing::{get, post}, | |
| 13 | +}; | |
| 14 | +use maud::{Markup, PreEscaped, html}; | |
| 15 | +use serde::Deserialize; | |
| 16 | +use serde_json::json; | |
| 17 | +use tokio_util::io::ReaderStream; | |
| 18 | + | |
| 19 | +use crate::{ | |
| 20 | + analytics, | |
| 21 | + auth::{self, RequireViewer}, | |
| 22 | + error::{AppError, AppResult}, | |
| 23 | + git::{Commit, Git, RefInfo, TreeEntry, is_hex_sha}, | |
| 24 | + models::{Package, Repo, audit}, | |
| 25 | + ops, package_select, | |
| 26 | + perm::{self, Access, Area}, | |
| 27 | + web::{ | |
| 28 | + highlight, | |
| 29 | + layout::{Ctx, Page}, | |
| 30 | + markdown::{self, RepoLinks}, | |
| 31 | + ui, | |
| 32 | + }, | |
| 33 | +}; | |
| 34 | + | |
| 35 | +pub fn router() -> Router<AppState> { | |
| 36 | + Router::new() | |
| 37 | + .route("/{owner}/{repo}", get(home)) | |
| 38 | + .route("/{owner}/{repo}/tree/{*rest}", get(tree)) | |
| 39 | + .route("/{owner}/{repo}/blob/{*rest}", get(blob)) | |
| 40 | + .route("/{owner}/{repo}/raw/{*rest}", get(raw)) | |
| 41 | + .route("/{owner}/{repo}/commits", get(commits_default)) | |
| 42 | + .route("/{owner}/{repo}/commits/{*rest}", get(commits)) | |
| 43 | + .route("/{owner}/{repo}/commit/{sha}", get(commit)) | |
| 44 | + .route("/{owner}/{repo}/branches", get(branches)) | |
| 45 | + .route("/{owner}/{repo}/tags", get(tags)) | |
| 46 | + .route("/{owner}/{repo}/archive/{*file}", get(archive)) | |
| 47 | + .route("/{owner}/{repo}/settings", get(settings_page).post(settings_submit)) | |
| 48 | + .route("/{owner}/{repo}/settings/collaborators", post(collaborators_submit)) | |
| 49 | + .route("/{owner}/{repo}/settings/delete", post(delete_submit)) | |
| 50 | +} | |
| 51 | + | |
| 52 | +use crate::state::AppState; | |
| 53 | + | |
| 54 | +const MAX_DISPLAY_BYTES: u64 = 1024 * 1024; | |
| 55 | +const MAX_HIGHLIGHT_LINES: usize = 20_000; | |
| 56 | +const MAX_PATCH_BYTES: usize = 1_500_000; | |
| 57 | +const COMMITS_PER_PAGE: usize = 35; | |
| 58 | + | |
| 59 | +/// A repo the viewer may read, with their access level. | |
| 60 | +pub struct Loaded { | |
| 61 | + pub repo: Repo, | |
| 62 | + pub access: Access, | |
| 63 | + pub git: Git, | |
| 64 | +} | |
| 65 | + | |
| 66 | +pub async fn load(ctx: &Ctx, owner: &str, name: &str) -> AppResult<Loaded> { | |
| 67 | + let repo = Repo::by_path(&ctx.state.db, owner, name).await?.ok_or(AppError::NotFound)?; | |
| 68 | + let access = perm::repo_access(&ctx.state.db, ctx.viewer.as_ref(), &repo, Area::Repo).await?; | |
| 69 | + if !access.can_read() { | |
| 70 | + return Err(AppError::NotFound); | |
| 71 | + } | |
| 72 | + let git = Git::new(repo.disk_path(&ctx.state.config)); | |
| 73 | + Ok(Loaded { repo, access, git }) | |
| 74 | +} | |
| 75 | + | |
| 76 | +/// Splits "feature/x/src/main.rs" into the longest matching ref and a path. | |
| 77 | +async fn resolve(git: &Git, rest: &str) -> AppResult<Option<(String, String, String)>> { | |
| 78 | + let rest = rest.trim_matches('/'); | |
| 79 | + let refs = git.refs().await?; | |
| 80 | + let mut best: Option<&RefInfo> = None; | |
| 81 | + for r in &refs { | |
| 82 | + let name = r.short_name(); | |
| 83 | + if (rest == name || rest.starts_with(&format!("{name}/"))) && best.is_none_or(|b| b.short_name().len() < name.len()) { | |
| 84 | + best = Some(r); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + if let Some(r) = best { | |
| 88 | + let name = r.short_name().to_string(); | |
| 89 | + let path = rest[name.len()..].trim_start_matches('/').to_string(); | |
| 90 | + return Ok(Some((name, r.commit.clone(), path))); | |
| 91 | + } | |
| 92 | + let (first, path) = rest.split_once('/').unwrap_or((rest, "")); | |
| 93 | + if is_hex_sha(first) && first.len() >= 7 { | |
| 94 | + if let Some(sha) = git.resolve_commit(first).await? { | |
| 95 | + return Ok(Some((first.to_string(), sha, path.to_string()))); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + Ok(None) | |
| 99 | +} | |
| 100 | + | |
| 101 | +fn clone_urls(ctx: &Ctx, repo: &Repo) -> (String, String) { | |
| 102 | + let https = format!("{}/{}/{}.git", ctx.state.config.base_url(), repo.owner_name, repo.name); | |
| 103 | + let ssh_host = &ctx.state.config.ssh_host; | |
| 104 | + let ssh = match ssh_host.split_once(':') { | |
| 105 | + Some((host, port)) => format!("ssh://git@{host}:{port}/{}/{}.git", repo.owner_name, repo.name), | |
| 106 | + None => format!("git@{ssh_host}:{}/{}.git", repo.owner_name, repo.name), | |
| 107 | + }; | |
| 108 | + (https, ssh) | |
| 109 | +} | |
| 110 | + | |
| 111 | +fn header_markup(_ctx: &Ctx, loaded: &Loaded, active: &str, counts: Option<(usize, usize)>) -> Markup { | |
| 112 | + let repo = &loaded.repo; | |
| 113 | + let base = repo.url(); | |
| 114 | + let tab = |slug: &str, label: &str, href: String, count: Option<usize>| { | |
| 115 | + html! { | |
| 116 | + a class="tab" href=(href) aria-current=[(slug == active).then_some("page")] { | |
| 117 | + (label) | |
| 118 | + @if let Some(count) = count { span class="rounded-[3px] bg-surface-hover px-1 text-[11px] text-ink-dim" { (count) } } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + }; | |
| 122 | + html! { | |
| 123 | + div class="border-b border-edge bg-surface-raised" { | |
| 124 | + div class="mx-auto max-w-[1280px] px-4 pt-3" { | |
| 125 | + div class="flex flex-wrap items-center gap-2 text-[17px]" { | |
| 126 | + a href={ "/" (repo.owner_name) } class="flex items-center gap-2 text-ink" { | |
| 127 | + (ui::avatar(&repo.owner_name, None, 22)) | |
| 128 | + (repo.owner_name) | |
| 129 | + } | |
| 130 | + span class="text-ink-faint" { "/" } | |
| 131 | + a href=(base) class="font-semibold text-ink" { (repo.name) } | |
| 132 | + (ui::visibility_tag(&repo.visibility)) | |
| 133 | + @if repo.archived { span class="tag border-warn/50 text-warn" { "Archived" } } | |
| 134 | + } | |
| 135 | + @if !repo.description.is_empty() { p class="mt-1 text-[13px] text-ink-dim" { (repo.description) } } | |
| 136 | + nav class="tabs mt-2 border-b-0" { | |
| 137 | + (tab("code", "Code", base.clone(), None)) | |
| 138 | + (tab("commits", "Commits", format!("{base}/commits"), None)) | |
| 139 | + (tab("branches", "Branches", format!("{base}/branches"), counts.map(|c| c.0))) | |
| 140 | + (tab("tags", "Tags", format!("{base}/tags"), counts.map(|c| c.1))) | |
| 141 | + @if loaded.access.is_admin() { (tab("settings", "Settings", format!("{base}/settings"), None)) } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + } | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +fn page(ctx: &Ctx, loaded: &Loaded, active: &str, title: String, content: Markup) -> Response { | |
| 149 | + let body = html! { | |
| 150 | + (header_markup(ctx, loaded, active, None)) | |
| 151 | + div class="mx-auto max-w-[1280px] px-4 py-4" { (content) } | |
| 152 | + }; | |
| 153 | + let mut p = Page::new(title, body); | |
| 154 | + if !loaded.repo.description.is_empty() { | |
| 155 | + p = p.description(loaded.repo.description.clone()); | |
| 156 | + } | |
| 157 | + if !loaded.repo.is_public() { | |
| 158 | + p = p.noindex(); | |
| 159 | + } | |
| 160 | + ctx.render(p) | |
| 161 | +} | |
| 162 | + | |
| 163 | +/// Avatars and profile links for commit authors whose email is verified. | |
| 164 | +async fn authors(ctx: &Ctx, commits: &[Commit]) -> AppResult<HashMap<String, (String, Option<String>)>> { | |
| 165 | + let emails: Vec<String> = commits.iter().map(|c| c.author_email.to_lowercase()).collect(); | |
| 166 | + let rows: Vec<(String, String, Option<String>)> = sqlx::query_as( | |
| 167 | + "select lower(e.email::text), a.name::text, a.avatar_key from emails e join accounts a on a.id = e.user_id | |
| 168 | + where e.verified_at is not null and e.email = any($1::citext[])", | |
| 169 | + ) | |
| 170 | + .bind(&emails) | |
| 171 | + .fetch_all(&ctx.state.db) | |
| 172 | + .await?; | |
| 173 | + Ok(rows.into_iter().map(|(email, name, avatar)| (email, (name, avatar))).collect()) | |
| 174 | +} | |
| 175 | + | |
| 176 | +fn author_markup(commit: &Commit, known: &HashMap<String, (String, Option<String>)>, size: u32) -> Markup { | |
| 177 | + match known.get(&commit.author_email.to_lowercase()) { | |
| 178 | + Some((name, avatar)) => html! { | |
| 179 | + a href={ "/" (name) } class="flex shrink-0 items-center gap-1.5 font-semibold text-ink" title=(commit.author_email) { | |
| 180 | + (ui::avatar(name, avatar.as_deref(), size)) (name) | |
| 181 | + } | |
| 182 | + }, | |
| 183 | + None => html! { | |
| 184 | + span class="flex shrink-0 items-center gap-1.5 font-semibold" title=(commit.author_email) { | |
| 185 | + img src={ "/avatars/" (auth::urlencode(&commit.author_name)) } width=(size) height=(size) alt="" class="rounded-[4px]" loading="lazy"; | |
| 186 | + (commit.author_name) | |
| 187 | + } | |
| 188 | + }, | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +fn breadcrumbs(repo: &Repo, git_ref: &str, path: &str) -> Markup { | |
| 193 | + let base = format!("{}/tree/{git_ref}", repo.url()); | |
| 194 | + let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); | |
| 195 | + html! { | |
| 196 | + div class="flex min-w-0 flex-wrap items-center gap-1 text-[15px]" { | |
| 197 | + a href=(base) class="font-semibold" { (repo.name) } | |
| 198 | + @for (i, part) in parts.iter().enumerate() { | |
| 199 | + span class="text-ink-faint" { "/" } | |
| 200 | + @if i + 1 == parts.len() { | |
| 201 | + span class="font-semibold" { (part) } | |
| 202 | + } @else { | |
| 203 | + a href={ (base) "/" (parts[..=i].join("/")) } { (part) } | |
| 204 | + } | |
| 205 | + } | |
| 206 | + } | |
| 207 | + } | |
| 208 | +} | |
| 209 | + | |
| 210 | +fn ref_selector(repo: &Repo, refs: &[RefInfo], current: &str, kind: &str, path: &str) -> Markup { | |
| 211 | + let path_suffix = if path.is_empty() { String::new() } else { format!("/{path}") }; | |
| 212 | + let is_known = refs.iter().any(|r| r.short_name() == current); | |
| 213 | + html! { | |
| 214 | + select class="input w-auto max-w-[220px] py-0.5 font-mono text-xs" data-autosubmit data-navigate aria-label="Switch branch or tag" { | |
| 215 | + @if !is_known { option selected { (current) } } | |
| 216 | + optgroup label="Branches" { | |
| 217 | + @for r in refs.iter().filter(|r| r.is_branch()) { | |
| 218 | + option value={ (repo.url()) "/" (kind) "/" (r.short_name()) (path_suffix) } selected[r.short_name() == current] { (r.short_name()) } | |
| 219 | + } | |
| 220 | + } | |
| 221 | + @if refs.iter().any(|r| r.is_tag()) { | |
| 222 | + optgroup label="Tags" { | |
| 223 | + @for r in refs.iter().filter(|r| r.is_tag()) { | |
| 224 | + option value={ (repo.url()) "/" (kind) "/" (r.short_name()) (path_suffix) } selected[r.short_name() == current] { (r.short_name()) } | |
| 225 | + } | |
| 226 | + } | |
| 227 | + } | |
| 228 | + } | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +fn clone_box(ctx: &Ctx, repo: &Repo) -> Markup { | |
| 233 | + let (https, ssh) = clone_urls(ctx, repo); | |
| 234 | + html! { | |
| 235 | + details class="relative" { | |
| 236 | + summary class="btn btn-primary list-none [&::-webkit-details-marker]:hidden" data-track="clone_menu_opened" { "Clone ▾" } | |
| 237 | + div class="absolute right-0 z-20 mt-1 w-[min(420px,90vw)] space-y-2 rounded-[4px] border border-edge-strong bg-surface-raised p-3 shadow-lg" { | |
| 238 | + div { div class="label" { "HTTPS" } (ui::copy_field("clone-https", &https, "clone_https_copied")) } | |
| 239 | + div { div class="label" { "SSH" } (ui::copy_field("clone-ssh", &ssh, "clone_ssh_copied")) } | |
| 240 | + div { div class="label" { "CLI" } (ui::copy_field("clone-cli", &format!("ig repo clone {}", repo.full_name()), "clone_cli_copied")) } | |
| 241 | + div class="flex gap-2 border-t border-edge pt-2 text-xs" { | |
| 242 | + a href={ (repo.url()) "/archive/" (repo.default_branch) ".zip" } data-track="archive_downloaded" { "Download ZIP" } | |
| 243 | + a href={ (repo.url()) "/archive/" (repo.default_branch) ".tar.gz" } data-track="archive_downloaded" { "Download tar.gz" } | |
| 244 | + } | |
| 245 | + } | |
| 246 | + } | |
| 247 | + } | |
| 248 | +} | |
| 249 | + | |
| 250 | +// --------------------------------------------------------------------------- | |
| 251 | +// Code | |
| 252 | + | |
| 253 | +pub async fn home(ctx: Ctx, Path((owner, name)): Path<(String, String)>) -> AppResult<Response> { | |
| 254 | + if let Some(stripped) = name.strip_suffix(".git") { | |
| 255 | + return Ok(Redirect::permanent(&format!("/{owner}/{stripped}")).into_response()); | |
| 256 | + } | |
| 257 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 258 | + if loaded.repo.owner_name != owner || loaded.repo.name != name { | |
| 259 | + return Ok(Redirect::permanent(&loaded.repo.url()).into_response()); | |
| 260 | + } | |
| 261 | + if !loaded.git.has_commits().await? { | |
| 262 | + return Ok(empty_repo(&ctx, &loaded)); | |
| 263 | + } | |
| 264 | + let default = loaded.repo.default_branch.clone(); | |
| 265 | + let commit = match loaded.git.resolve_commit(&default).await? { | |
| 266 | + Some(sha) => sha, | |
| 267 | + None => { | |
| 268 | + // Default branch missing: show the newest branch instead. | |
| 269 | + let refs = loaded.git.refs().await?; | |
| 270 | + match refs.iter().find(|r| r.is_branch()) { | |
| 271 | + Some(r) => return Ok(Redirect::to(&format!("{}/tree/{}", loaded.repo.url(), r.short_name())).into_response()), | |
| 272 | + None => return Ok(empty_repo(&ctx, &loaded)), | |
| 273 | + } | |
| 274 | + } | |
| 275 | + }; | |
| 276 | + tree_view(&ctx, &loaded, &default, &commit, "").await | |
| 277 | +} | |
| 278 | + | |
| 279 | +pub async fn tree(ctx: Ctx, Path((owner, name, rest)): Path<(String, String, String)>) -> AppResult<Response> { | |
| 280 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 281 | + let (git_ref, commit, path) = resolve(&loaded.git, &rest).await?.ok_or(AppError::NotFound)?; | |
| 282 | + tree_view(&ctx, &loaded, &git_ref, &commit, &path).await | |
| 283 | +} | |
| 284 | + | |
| 285 | +async fn tree_view(ctx: &Ctx, loaded: &Loaded, git_ref: &str, commit: &str, path: &str) -> AppResult<Response> { | |
| 286 | + let repo = &loaded.repo; | |
| 287 | + let git = &loaded.git; | |
| 288 | + let Some(entries) = git.ls_tree(commit, path).await? else { | |
| 289 | + // A file path under /tree/ goes to the file view. | |
| 290 | + if git.object_at(commit, path).await?.is_some_and(|(kind, _, _)| kind == "blob") { | |
| 291 | + return Ok(Redirect::to(&format!("{}/blob/{git_ref}/{path}", repo.url())).into_response()); | |
| 292 | + } | |
| 293 | + return Err(AppError::NotFound); | |
| 294 | + }; | |
| 295 | + let refs = git.refs().await?; | |
| 296 | + let last = git.log(commit, Some(path), 0, 1).await?.into_iter().next(); | |
| 297 | + let commit_count = git.count_commits(commit).await?; | |
| 298 | + let known = authors(ctx, last.as_slice()).await?; | |
| 299 | + | |
| 300 | + let readme = entries | |
| 301 | + .iter() | |
| 302 | + .filter(|e| e.kind == "blob") | |
| 303 | + .find(|e| matches!(e.name.to_lowercase().as_str(), "readme.md" | "readme.markdown" | "readme" | "readme.txt" | "readme.rst")); | |
| 304 | + let readme_html = match readme { | |
| 305 | + Some(entry) if entry.size.unwrap_or(0) <= MAX_DISPLAY_BYTES => { | |
| 306 | + let bytes = git.read_blob(&entry.sha).await?; | |
| 307 | + let text = String::from_utf8_lossy(&bytes); | |
| 308 | + let is_md = entry.name.to_lowercase().ends_with(".md") || entry.name.to_lowercase().ends_with(".markdown"); | |
| 309 | + Some((entry.name.clone(), if is_md { | |
| 310 | + PreEscaped(markdown::render(&text, Some(&RepoLinks { repo_url: repo.url(), git_ref: git_ref.to_string(), dir: path.to_string() }))) | |
| 311 | + } else { | |
| 312 | + html! { pre class="whitespace-pre-wrap font-mono text-[12.5px]" { (text) } } | |
| 313 | + })) | |
| 314 | + } | |
| 315 | + _ => None, | |
| 316 | + }; | |
| 317 | + | |
| 318 | + let base = repo.url(); | |
| 319 | + let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); | |
| 320 | + let is_root = path.is_empty(); | |
| 321 | + let packages: Vec<Package> = if is_root { | |
| 322 | + sqlx::query_as(concat!(package_select!(), " where p.repo_id = $1 order by p.updated_at desc")) | |
| 323 | + .bind(repo.id) | |
| 324 | + .fetch_all(&ctx.state.db) | |
| 325 | + .await? | |
| 326 | + } else { | |
| 327 | + Vec::new() | |
| 328 | + }; | |
| 329 | + let mut visible_packages = Vec::new(); | |
| 330 | + for package in packages { | |
| 331 | + if perm::package_access(&ctx.state.db, ctx.viewer.as_ref(), &package).await?.can_read() { | |
| 332 | + visible_packages.push(package); | |
| 333 | + } | |
| 334 | + } | |
| 335 | + let branch_count = refs.iter().filter(|r| r.is_branch()).count(); | |
| 336 | + let tag_count = refs.iter().filter(|r| r.is_tag()).count(); | |
| 337 | + | |
| 338 | + let files = html! { | |
| 339 | + div class="mb-3 flex flex-wrap items-center gap-2" { | |
| 340 | + (ref_selector(repo, &refs, git_ref, "tree", path)) | |
| 341 | + @if !is_root { (breadcrumbs(repo, git_ref, path)) } | |
| 342 | + div class="ml-auto flex items-center gap-2" { | |
| 343 | + a href={ (base) "/commits/" (git_ref) @if !is_root { "/" (path) } } class="btn btn-sm" { (ui::plural(commit_count as i64, "commit", "commits")) } | |
| 344 | + (clone_box(ctx, repo)) | |
| 345 | + } | |
| 346 | + } | |
| 347 | + div class="box overflow-hidden" { | |
| 348 | + @if let Some(last) = &last { | |
| 349 | + div class="flex items-center gap-2 border-b border-edge bg-surface-raised px-3 py-2 text-[13px]" { | |
| 350 | + (author_markup(last, &known, 20)) | |
| 351 | + a href={ (base) "/commit/" (last.sha) } class="min-w-0 flex-1 truncate text-ink-dim" { (last.subject) } | |
| 352 | + a href={ (base) "/commit/" (last.sha) } class="font-mono text-xs text-ink-faint" { (last.short_sha()) } | |
| 353 | + span class="shrink-0 text-xs text-ink-faint" { (ui::time(last.committed_at)) } | |
| 354 | + } | |
| 355 | + } | |
| 356 | + table class="w-full text-[13px]" { | |
| 357 | + tbody class="divide-y divide-edge" { | |
| 358 | + @if !is_root { | |
| 359 | + tr class="hover:bg-surface-hover" { | |
| 360 | + td class="px-3 py-1.5" colspan="2" { | |
| 361 | + a href={ (base) "/tree/" (git_ref) @if !parent.is_empty() { "/" (parent) } } class="text-ink-dim" { ".." } | |
| 362 | + } | |
| 363 | + } | |
| 364 | + } | |
| 365 | + @for entry in &entries { (entry_row(&base, git_ref, path, entry)) } | |
| 366 | + } | |
| 367 | + } | |
| 368 | + } | |
| 369 | + @if let Some((name, html)) = &readme_html { | |
| 370 | + div class="box mt-4" { | |
| 371 | + div class="box-head font-semibold" { (ui::icon_file()) (name) } | |
| 372 | + div class="markdown px-5 py-4" { (html) } | |
| 373 | + } | |
| 374 | + } | |
| 375 | + }; | |
| 376 | + | |
| 377 | + let content = if is_root { | |
| 378 | + html! { | |
| 379 | + div class="grid gap-5 lg:grid-cols-[1fr_280px]" { | |
| 380 | + div class="min-w-0" { (files) } | |
| 381 | + aside class="space-y-4 text-[13px]" { | |
| 382 | + div { | |
| 383 | + h2 class="mb-1 font-semibold" { "About" } | |
| 384 | + @if repo.description.is_empty() { p class="text-ink-faint" { "No description." } } @else { p class="text-ink-dim" { (repo.description) } } | |
| 385 | + } | |
| 386 | + ul class="space-y-1 text-ink-dim" { | |
| 387 | + li { a href={ (base) "/branches" } class="text-ink-dim" { (ui::plural(branch_count as i64, "branch", "branches")) } } | |
| 388 | + li { a href={ (base) "/tags" } class="text-ink-dim" { (ui::plural(tag_count as i64, "tag", "tags")) } } | |
| 389 | + li { (ui::bytes(repo.size_bytes as u64)) " on disk" } | |
| 390 | + @if let Some(pushed) = repo.pushed_at { li { "Last push " (ui::time(pushed)) } } | |
| 391 | + } | |
| 392 | + @if !visible_packages.is_empty() { | |
| 393 | + div { | |
| 394 | + h2 class="mb-1 font-semibold" { "Images" } | |
| 395 | + ul class="space-y-1" { | |
| 396 | + @for p in &visible_packages { | |
| 397 | + li class="flex items-center gap-1.5" { (ui::icon_package()) a href=(p.url()) { (p.name) } (ui::visibility_tag(&p.visibility)) } | |
| 398 | + } | |
| 399 | + } | |
| 400 | + } | |
| 401 | + } | |
| 402 | + } | |
| 403 | + } | |
| 404 | + } | |
| 405 | + } else { | |
| 406 | + files | |
| 407 | + }; | |
| 408 | + | |
| 409 | + let title = if is_root { repo.full_name() } else { format!("{} at {git_ref} · {}", path, repo.full_name()) }; | |
| 410 | + let body = html! { | |
| 411 | + (header_markup(ctx, loaded, "code", Some((branch_count, tag_count)))) | |
| 412 | + div class="mx-auto max-w-[1280px] px-4 py-4" { (content) } | |
| 413 | + }; | |
| 414 | + let mut p = Page::new(title, body); | |
| 415 | + if !repo.description.is_empty() { | |
| 416 | + p = p.description(repo.description.clone()); | |
| 417 | + } | |
| 418 | + if !repo.is_public() { | |
| 419 | + p = p.noindex(); | |
| 420 | + } | |
| 421 | + Ok(ctx.render(p)) | |
| 422 | +} | |
| 423 | + | |
| 424 | +fn entry_row(base: &str, git_ref: &str, dir: &str, entry: &TreeEntry) -> Markup { | |
| 425 | + let full = if dir.is_empty() { entry.name.clone() } else { format!("{dir}/{}", entry.name) }; | |
| 426 | + let (href, icon) = match entry.kind.as_str() { | |
| 427 | + "tree" => (format!("{base}/tree/{git_ref}/{full}"), ui::icon_folder()), | |
| 428 | + "commit" => (String::new(), ui::icon_repo()), | |
| 429 | + _ => (format!("{base}/blob/{git_ref}/{full}"), ui::icon_file()), | |
| 430 | + }; | |
| 431 | + html! { | |
| 432 | + tr class="hover:bg-surface-hover" { | |
| 433 | + td class="px-3 py-1.5" { | |
| 434 | + div class="flex items-center gap-2" { | |
| 435 | + (icon) | |
| 436 | + @if href.is_empty() { | |
| 437 | + span title="Submodule" { (entry.name) " @ " span class="font-mono text-xs text-ink-faint" { (&entry.sha[..7]) } } | |
| 438 | + } @else { | |
| 439 | + a href=(href) class="text-ink" { (entry.name) } | |
| 440 | + } | |
| 441 | + @if entry.is_symlink() { span class="tag" { "symlink" } } | |
| 442 | + } | |
| 443 | + } | |
| 444 | + td class="w-24 px-3 text-right text-xs text-ink-faint" { | |
| 445 | + @if let Some(size) = entry.size { (ui::bytes(size)) } | |
| 446 | + } | |
| 447 | + } | |
| 448 | + } | |
| 449 | +} | |
| 450 | + | |
| 451 | +fn empty_repo(ctx: &Ctx, loaded: &Loaded) -> Response { | |
| 452 | + let repo = &loaded.repo; | |
| 453 | + let (https, ssh) = clone_urls(ctx, repo); | |
| 454 | + let can_push = loaded.access.can_write(); | |
| 455 | + let content = html! { | |
| 456 | + div class="box p-4" { | |
| 457 | + h2 class="font-semibold" { "This repository is empty" } | |
| 458 | + @if can_push { | |
| 459 | + p class="mt-1 text-[13px] text-ink-dim" { "Push some commits to get started. Files over " (ui::bytes(ctx.state.config.limits.max_file_bytes)) " need Git LFS." } | |
| 460 | + div class="mt-3 grid gap-3 md:grid-cols-2" { | |
| 461 | + div { div class="label" { "HTTPS" } (ui::copy_field("clone-https", &https, "clone_https_copied")) } | |
| 462 | + div { div class="label" { "SSH" } (ui::copy_field("clone-ssh", &ssh, "clone_ssh_copied")) } | |
| 463 | + } | |
| 464 | + h3 class="mt-4 mb-1 text-[13px] font-semibold" { "Push an existing repository" } | |
| 465 | + pre class="overflow-x-auto rounded-[4px] border border-edge bg-surface-sunken p-3 font-mono text-xs" { | |
| 466 | + "git remote add origin " (https) "\ngit push -u origin main" | |
| 467 | + } | |
| 468 | + h3 class="mt-4 mb-1 text-[13px] font-semibold" { "Start a new one" } | |
| 469 | + pre class="overflow-x-auto rounded-[4px] border border-edge bg-surface-sunken p-3 font-mono text-xs" { | |
| 470 | + "ig repo clone " (repo.full_name()) "\ncd " (repo.name) "\necho \"# " (repo.name) "\" > README.md\ngit add README.md && git commit -m \"First commit\"\ngit push -u origin main" | |
| 471 | + } | |
| 472 | + p class="mt-3 text-xs text-ink-faint" { "Git asks for a password over HTTPS: use a " a href="/settings/tokens" { "personal access token" } ", or run " code { "ig login" } " once and git is set up for you." } | |
| 473 | + } @else { | |
| 474 | + p class="mt-1 text-[13px] text-ink-dim" { "Nothing has been pushed yet." } | |
| 475 | + } | |
| 476 | + } | |
| 477 | + }; | |
| 478 | + page(ctx, loaded, "code", repo.full_name(), content) | |
| 479 | +} | |
| 480 | + | |
| 481 | +// --------------------------------------------------------------------------- | |
| 482 | +// Files | |
| 483 | + | |
| 484 | +#[derive(Deserialize, Default)] | |
| 485 | +pub struct BlobQuery { | |
| 486 | + plain: Option<String>, | |
| 487 | +} | |
| 488 | + | |
| 489 | +pub async fn blob(ctx: Ctx, Path((owner, name, rest)): Path<(String, String, String)>, Query(query): Query<BlobQuery>) -> AppResult<Response> { | |
| 490 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 491 | + let (git_ref, commit, path) = resolve(&loaded.git, &rest).await?.ok_or(AppError::NotFound)?; | |
| 492 | + let (kind, sha, size) = loaded.git.object_at(&commit, &path).await?.ok_or(AppError::NotFound)?; | |
| 493 | + let repo = &loaded.repo; | |
| 494 | + if kind == "tree" { | |
| 495 | + return Ok(Redirect::to(&format!("{}/tree/{git_ref}/{path}", repo.url())).into_response()); | |
| 496 | + } | |
| 497 | + let refs = loaded.git.refs().await?; | |
| 498 | + let raw_url = format!("{}/raw/{git_ref}/{path}", repo.url()); | |
| 499 | + let history_url = format!("{}/commits/{git_ref}/{path}", repo.url()); | |
| 500 | + let lower = path.to_lowercase(); | |
| 501 | + let image = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp", ".avif"].iter().any(|e| lower.ends_with(e)); | |
| 502 | + | |
| 503 | + let (content, line_count) = if image { | |
| 504 | + (html! { div class="flex justify-center bg-surface-sunken p-4" { img src=(raw_url) alt=(path) class="max-w-full"; } }, None) | |
| 505 | + } else if size > MAX_DISPLAY_BYTES { | |
| 506 | + (html! { p class="p-6 text-center text-ink-dim" { "This file is " (ui::bytes(size)) ", too large to show here. " a href=(raw_url) { "View raw" } } }, None) | |
| 507 | + } else { | |
| 508 | + let bytes = loaded.git.read_blob(&sha).await?; | |
| 509 | + if let Some(lfs) = lfs_pointer(&bytes) { | |
| 510 | + (html! { | |
| 511 | + div class="p-6 text-center" { | |
| 512 | + p class="font-semibold" { "Stored with Git LFS" } | |
| 513 | + p class="mt-1 text-[13px] text-ink-dim" { (ui::bytes(lfs.1)) ", oid " span class="font-mono text-xs" { (&lfs.0[..12]) } } | |
| 514 | + a href={ (raw_url) "?download=1" } class="btn mt-3" data-track="lfs_download_clicked" { "Download" } | |
| 515 | + } | |
| 516 | + }, None) | |
| 517 | + } else if bytes.iter().take(8000).any(|b| *b == 0) { | |
| 518 | + (html! { p class="p-6 text-center text-ink-dim" { "Binary file, " (ui::bytes(size)) ". " a href={ (raw_url) "?download=1" } { "Download" } } }, None) | |
| 519 | + } else { | |
| 520 | + let text = String::from_utf8_lossy(&bytes); | |
| 521 | + let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown"); | |
| 522 | + if is_markdown && query.plain.is_none() { | |
| 523 | + let dir = path.rsplit_once('/').map(|(d, _)| d).unwrap_or("").to_string(); | |
| 524 | + let html = markdown::render(&text, Some(&RepoLinks { repo_url: repo.url(), git_ref: git_ref.clone(), dir })); | |
| 525 | + (html! { div class="markdown px-5 py-4" { (PreEscaped(html)) } }, Some(text.lines().count())) | |
| 526 | + } else { | |
| 527 | + let lines = if text.lines().count() > MAX_HIGHLIGHT_LINES { highlight::plain_lines(&text) } else { highlight::highlight_lines(&path, &text) }; | |
| 528 | + let count = lines.len(); | |
| 529 | + (html! { | |
| 530 | + div class="overflow-x-auto" { | |
| 531 | + table class="code-table" { | |
| 532 | + tbody { | |
| 533 | + @for (i, line) in lines.iter().enumerate() { | |
| 534 | + tr id={ "L" (i + 1) } { | |
| 535 | + td class="ln" { a href={ "#L" (i + 1) } { (i + 1) } } | |
| 536 | + td class="lc" { (PreEscaped(line)) } | |
| 537 | + } | |
| 538 | + } | |
| 539 | + } | |
| 540 | + } | |
| 541 | + } | |
| 542 | + }, Some(count)) | |
| 543 | + } | |
| 544 | + } | |
| 545 | + }; | |
| 546 | + | |
| 547 | + let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown"); | |
| 548 | + let language = highlight::language_name(&path, ""); | |
| 549 | + let body = html! { | |
| 550 | + link rel="stylesheet" href="/assets/syntax.css"; | |
| 551 | + div class="mb-3 flex flex-wrap items-center gap-2" { | |
| 552 | + (ref_selector(repo, &refs, &git_ref, "blob", &path)) | |
| 553 | + (breadcrumbs(repo, &git_ref, &path)) | |
| 554 | + } | |
| 555 | + div class="box overflow-hidden" { | |
| 556 | + div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-edge bg-surface-raised px-3 py-1.5 text-xs text-ink-dim" { | |
| 557 | + @if let Some(lines) = line_count { span { (ui::plural(lines as i64, "line", "lines")) } } | |
| 558 | + span { (ui::bytes(size)) } | |
| 559 | + @if !image && language != "Plain Text" { span { (language) } } | |
| 560 | + div class="ml-auto flex gap-1" { | |
| 561 | + @if is_markdown { | |
| 562 | + @if query.plain.is_some() { a class="btn btn-sm" href="?" { "Preview" } } @else { a class="btn btn-sm" href="?plain=1" { "Source" } } | |
| 563 | + } | |
| 564 | + a class="btn btn-sm" href=(raw_url) { "Raw" } | |
| 565 | + a class="btn btn-sm" href={ (raw_url) "?download=1" } data-track="file_downloaded" { "Download" } | |
| 566 | + a class="btn btn-sm" href=(history_url) { "History" } | |
| 567 | + } | |
| 568 | + } | |
| 569 | + (content) | |
| 570 | + } | |
| 571 | + }; | |
| 572 | + let title = format!("{path} at {git_ref} · {}", repo.full_name()); | |
| 573 | + Ok(page(&ctx, &loaded, "code", title, body)) | |
| 574 | +} | |
| 575 | + | |
| 576 | +/// (oid, size) when the blob is a Git LFS pointer file. | |
| 577 | +fn lfs_pointer(bytes: &[u8]) -> Option<(String, u64)> { | |
| 578 | + if bytes.len() > 512 || !bytes.starts_with(b"version https://git-lfs.github.com/spec/") { | |
| 579 | + return None; | |
| 580 | + } | |
| 581 | + let text = std::str::from_utf8(bytes).ok()?; | |
| 582 | + let oid = text.lines().find_map(|l| l.strip_prefix("oid sha256:"))?.trim().to_string(); | |
| 583 | + let size = text.lines().find_map(|l| l.strip_prefix("size "))?.trim().parse().ok()?; | |
| 584 | + (oid.len() == 64 && oid.bytes().all(|b| b.is_ascii_hexdigit())).then_some((oid, size)) | |
| 585 | +} | |
| 586 | + | |
| 587 | +#[derive(Deserialize, Default)] | |
| 588 | +pub struct RawQuery { | |
| 589 | + download: Option<String>, | |
| 590 | +} | |
| 591 | + | |
| 592 | +/// Raw file bytes. Served sandboxed (no scripts, no same-origin access) so a | |
| 593 | +/// committed HTML or SVG file can never act on the site's behalf. | |
| 594 | +pub async fn raw(ctx: Ctx, Path((owner, name, rest)): Path<(String, String, String)>, Query(query): Query<RawQuery>) -> AppResult<Response> { | |
| 595 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 596 | + let (_, commit, path) = resolve(&loaded.git, &rest).await?.ok_or(AppError::NotFound)?; | |
| 597 | + let (kind, sha, size) = loaded.git.object_at(&commit, &path).await?.ok_or(AppError::NotFound)?; | |
| 598 | + if kind != "blob" { | |
| 599 | + return Err(AppError::NotFound); | |
| 600 | + } | |
| 601 | + let filename = path.rsplit('/').next().unwrap_or("file").replace('"', ""); | |
| 602 | + | |
| 603 | + // LFS pointers resolve to the stored object when this repo has it. | |
| 604 | + if size <= 512 { | |
| 605 | + let bytes = loaded.git.read_blob(&sha).await?; | |
| 606 | + if let Some((oid, _)) = lfs_pointer(&bytes) { | |
| 607 | + let linked: bool = sqlx::query_scalar("select exists(select 1 from repo_lfs_objects where repo_id = $1 and oid = $2)") | |
| 608 | + .bind(loaded.repo.id) | |
| 609 | + .bind(&oid) | |
| 610 | + .fetch_one(&ctx.state.db) | |
| 611 | + .await?; | |
| 612 | + if linked { | |
| 613 | + let url = ctx.state.storage.presign_get(&crate::storage::keys::lfs(&oid), std::time::Duration::from_secs(600), Some(&filename)); | |
| 614 | + analytics::track(&ctx.state, "lfs_object_downloaded_web", ctx.viewer.as_ref().map(|v| v.name.as_str()), &loaded.repo.url(), json!({})); | |
| 615 | + return Ok(Redirect::temporary(url.as_str()).into_response()); | |
| 616 | + } | |
| 617 | + } | |
| 618 | + } | |
| 619 | + | |
| 620 | + let lower = filename.to_lowercase(); | |
| 621 | + let content_type = match lower.rsplit('.').next().unwrap_or("") { | |
| 622 | + "png" => "image/png", | |
| 623 | + "jpg" | "jpeg" => "image/jpeg", | |
| 624 | + "gif" => "image/gif", | |
| 625 | + "webp" => "image/webp", | |
| 626 | + "ico" => "image/x-icon", | |
| 627 | + "bmp" => "image/bmp", | |
| 628 | + "avif" => "image/avif", | |
| 629 | + "svg" => "image/svg+xml", | |
| 630 | + "pdf" => "application/pdf", | |
| 631 | + _ if size <= MAX_DISPLAY_BYTES => { | |
| 632 | + let bytes = loaded.git.read_blob(&sha).await?; | |
| 633 | + if std::str::from_utf8(&bytes).is_ok() { "text/plain; charset=utf-8" } else { "application/octet-stream" } | |
| 634 | + } | |
| 635 | + _ => "application/octet-stream", | |
| 636 | + }; | |
| 637 | + let mut child = loaded.git.blob_stream(&sha)?; | |
| 638 | + let stdout = child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout"))?; | |
| 639 | + tokio::spawn(async move { | |
| 640 | + let _ = child.wait().await; | |
| 641 | + }); | |
| 642 | + let mut response = Response::new(Body::from_stream(ReaderStream::new(stdout))); | |
| 643 | + let headers = response.headers_mut(); | |
| 644 | + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); | |
| 645 | + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(size)); | |
| 646 | + headers.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox")); | |
| 647 | + headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); | |
| 648 | + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(if loaded.repo.is_public() { "public, max-age=300" } else { "private, max-age=300" })); | |
| 649 | + if query.download.is_some() || content_type == "application/octet-stream" { | |
| 650 | + if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\"")) { | |
| 651 | + headers.insert(header::CONTENT_DISPOSITION, value); | |
| 652 | + } | |
| 653 | + } | |
| 654 | + Ok(response) | |
| 655 | +} | |
| 656 | + | |
| 657 | +// --------------------------------------------------------------------------- | |
| 658 | +// History | |
| 659 | + | |
| 660 | +#[derive(Deserialize, Default)] | |
| 661 | +pub struct PageQuery { | |
| 662 | + page: Option<usize>, | |
| 663 | +} | |
| 664 | + | |
| 665 | +pub async fn commits_default(ctx: Ctx, Path((owner, name)): Path<(String, String)>, query: Query<PageQuery>) -> AppResult<Response> { | |
| 666 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 667 | + let default = loaded.repo.default_branch.clone(); | |
| 668 | + commits_view(&ctx, &loaded, &default, "", query.0.page.unwrap_or(1)).await | |
| 669 | +} | |
| 670 | + | |
| 671 | +pub async fn commits(ctx: Ctx, Path((owner, name, rest)): Path<(String, String, String)>, query: Query<PageQuery>) -> AppResult<Response> { | |
| 672 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 673 | + let (git_ref, _, path) = resolve(&loaded.git, &rest).await?.ok_or(AppError::NotFound)?; | |
| 674 | + commits_view(&ctx, &loaded, &git_ref, &path, query.0.page.unwrap_or(1)).await | |
| 675 | +} | |
| 676 | + | |
| 677 | +async fn commits_view(ctx: &Ctx, loaded: &Loaded, git_ref: &str, path: &str, page_number: usize) -> AppResult<Response> { | |
| 678 | + let repo = &loaded.repo; | |
| 679 | + let page_number = page_number.max(1); | |
| 680 | + let mut list = loaded.git.log(git_ref, Some(path), (page_number - 1) * COMMITS_PER_PAGE, COMMITS_PER_PAGE + 1).await?; | |
| 681 | + let has_next = list.len() > COMMITS_PER_PAGE; | |
| 682 | + list.truncate(COMMITS_PER_PAGE); | |
| 683 | + let known = authors(ctx, &list).await?; | |
| 684 | + let refs = loaded.git.refs().await?; | |
| 685 | + let base = repo.url(); | |
| 686 | + let mut groups: Vec<(String, Vec<&Commit>)> = Vec::new(); | |
| 687 | + for c in &list { | |
| 688 | + let day = c.committed_at.format("%B %-d, %Y").to_string(); | |
| 689 | + match groups.last_mut() { | |
| 690 | + Some((d, items)) if *d == day => items.push(c), | |
| 691 | + _ => groups.push((day, vec![c])), | |
| 692 | + } | |
| 693 | + } | |
| 694 | + let content = html! { | |
| 695 | + div class="mb-3 flex flex-wrap items-center gap-2" { | |
| 696 | + (ref_selector(repo, &refs, git_ref, "commits", path)) | |
| 697 | + @if !path.is_empty() { span class="text-[13px] text-ink-dim" { "History for " code { (path) } } } | |
| 698 | + } | |
| 699 | + @if list.is_empty() { (ui::empty_state("No commits", html! { "Nothing matches this branch and path." })) } | |
| 700 | + @for (day, items) in &groups { | |
| 701 | + h3 class="mt-3 mb-1.5 text-xs font-semibold text-ink-dim" { "Commits on " (day) } | |
| 702 | + div class="box divide-y divide-edge" { | |
| 703 | + @for c in items { | |
| 704 | + div class="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-[13px]" { | |
| 705 | + div class="min-w-0 flex-1 basis-[260px]" { | |
| 706 | + a href={ (base) "/commit/" (c.sha) } class="font-semibold text-ink" { (c.subject) } | |
| 707 | + div class="mt-0.5 flex items-center gap-1.5 text-xs text-ink-dim" { | |
| 708 | + (author_markup(c, &known, 16)) | |
| 709 | + span { "committed " (ui::time(c.committed_at)) } | |
| 710 | + } | |
| 711 | + } | |
| 712 | + div class="flex items-center gap-1" { | |
| 713 | + a href={ (base) "/commit/" (c.sha) } class="btn btn-sm font-mono" { (c.short_sha()) } | |
| 714 | + button type="button" class="btn btn-sm" data-copy=(c.sha) title="Copy full SHA" { "Copy" } | |
| 715 | + a href={ (base) "/tree/" (c.sha) } class="btn btn-sm" title="Browse files at this commit" { "Browse" } | |
| 716 | + } | |
| 717 | + } | |
| 718 | + } | |
| 719 | + } | |
| 720 | + } | |
| 721 | + (ui::pager(&format!("{base}/commits/{git_ref}{}", if path.is_empty() { String::new() } else { format!("/{path}") }), page_number as i64, has_next)) | |
| 722 | + }; | |
| 723 | + Ok(page(ctx, loaded, "commits", format!("Commits · {}", repo.full_name()), content)) | |
| 724 | +} | |
| 725 | + | |
| 726 | +pub async fn commit(ctx: Ctx, Path((owner, name, sha)): Path<(String, String, String)>) -> AppResult<Response> { | |
| 727 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 728 | + if !is_hex_sha(&sha) { | |
| 729 | + return Err(AppError::NotFound); | |
| 730 | + } | |
| 731 | + let full = loaded.git.resolve_commit(&sha).await?.ok_or(AppError::NotFound)?; | |
| 732 | + let c = loaded.git.commit(&full).await?.ok_or(AppError::NotFound)?; | |
| 733 | + let stats = loaded.git.commit_stats(&full).await?; | |
| 734 | + let (patch, truncated) = loaded.git.commit_patch(&full, MAX_PATCH_BYTES).await?; | |
| 735 | + let files = parse_patch(&patch); | |
| 736 | + let known = authors(&ctx, std::slice::from_ref(&c)).await?; | |
| 737 | + let base = loaded.repo.url(); | |
| 738 | + let adds: u64 = stats.iter().filter_map(|s| s.2).sum(); | |
| 739 | + let dels: u64 = stats.iter().filter_map(|s| s.3).sum(); | |
| 740 | + | |
| 741 | + let content = html! { | |
| 742 | + div class="box mb-4" { | |
| 743 | + div class="px-4 py-3" { | |
| 744 | + h1 class="text-[17px] font-semibold" { (c.subject) } | |
| 745 | + @if !c.body.is_empty() { pre class="mt-2 whitespace-pre-wrap font-sans text-[13px] text-ink-dim" { (c.body) } } | |
| 746 | + } | |
| 747 | + div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-edge bg-surface-raised px-4 py-2 text-xs text-ink-dim" { | |
| 748 | + (author_markup(&c, &known, 20)) | |
| 749 | + span { "authored " (ui::time(c.authored_at)) } | |
| 750 | + @if c.committer_name != c.author_name { span { "committed by " (c.committer_name) } } | |
| 751 | + div class="ml-auto flex flex-wrap items-center gap-2" { | |
| 752 | + @for p in &c.parents { span { "parent " a href={ (base) "/commit/" (p) } class="font-mono" { (&p[..7]) } } } | |
| 753 | + span class="font-mono" { "commit " (c.sha) } | |
| 754 | + a href={ (base) "/tree/" (c.sha) } class="btn btn-sm" { "Browse files" } | |
| 755 | + } | |
| 756 | + } | |
| 757 | + } | |
| 758 | + p class="mb-2 text-[13px]" { | |
| 759 | + (ui::plural(stats.len() as i64, "file", "files")) " changed, " | |
| 760 | + span class="text-ok" { "+" (adds) } " " span class="text-danger" { "-" (dels) } | |
| 761 | + } | |
| 762 | + @if truncated { div class="alert alert-info mb-3" { "This diff is large and was cut short. Clone the repository to see all of it." } } | |
| 763 | + @for file in &files { (diff_file(file)) } | |
| 764 | + }; | |
| 765 | + Ok(page(&ctx, &loaded, "commits", format!("{} · {}", c.subject, loaded.repo.full_name()), html! { link rel="stylesheet" href="/assets/syntax.css"; (content) })) | |
| 766 | +} | |
| 767 | + | |
| 768 | +struct DiffFile { | |
| 769 | + path: String, | |
| 770 | + old_path: Option<String>, | |
| 771 | + binary: bool, | |
| 772 | + lines: Vec<DiffLine>, | |
| 773 | +} | |
| 774 | + | |
| 775 | +enum DiffLine { | |
| 776 | + Hunk(String), | |
| 777 | + Context(u64, u64, String), | |
| 778 | + Add(u64, String), | |
| 779 | + Del(u64, String), | |
| 780 | +} | |
| 781 | + | |
| 782 | +fn parse_patch(patch: &str) -> Vec<DiffFile> { | |
| 783 | + let mut files: Vec<DiffFile> = Vec::new(); | |
| 784 | + let (mut old_no, mut new_no) = (0u64, 0u64); | |
| 785 | + for line in patch.lines() { | |
| 786 | + if let Some(rest) = line.strip_prefix("diff --git ") { | |
| 787 | + let path = rest.rsplit_once(" b/").map(|(_, b)| b.to_string()).unwrap_or_else(|| rest.to_string()); | |
| 788 | + files.push(DiffFile { path, old_path: None, binary: false, lines: Vec::new() }); | |
| 789 | + continue; | |
| 790 | + } | |
| 791 | + let Some(file) = files.last_mut() else { continue }; | |
| 792 | + if let Some(from) = line.strip_prefix("rename from ") { | |
| 793 | + file.old_path = Some(from.to_string()); | |
| 794 | + } else if line.starts_with("Binary files ") { | |
| 795 | + file.binary = true; | |
| 796 | + } else if let Some(hunk) = line.strip_prefix("@@ ") { | |
| 797 | + // @@ -a,b +c,d @@ context | |
| 798 | + let mut parts = hunk.split_whitespace(); | |
| 799 | + old_no = parts.next().and_then(|p| p.trim_start_matches('-').split(',').next()?.parse().ok()).unwrap_or(1); | |
| 800 | + new_no = parts.next().and_then(|p| p.trim_start_matches('+').split(',').next()?.parse().ok()).unwrap_or(1); | |
| 801 | + file.lines.push(DiffLine::Hunk(line.to_string())); | |
| 802 | + } else if file.lines.is_empty() { | |
| 803 | + // Header lines (index, ---, +++, mode changes) before the first hunk. | |
| 804 | + } else if let Some(added) = line.strip_prefix('+') { | |
| 805 | + file.lines.push(DiffLine::Add(new_no, added.to_string())); | |
| 806 | + new_no += 1; | |
| 807 | + } else if let Some(removed) = line.strip_prefix('-') { | |
| 808 | + file.lines.push(DiffLine::Del(old_no, removed.to_string())); | |
| 809 | + old_no += 1; | |
| 810 | + } else if let Some(context) = line.strip_prefix(' ') { | |
| 811 | + file.lines.push(DiffLine::Context(old_no, new_no, context.to_string())); | |
| 812 | + old_no += 1; | |
| 813 | + new_no += 1; | |
| 814 | + } | |
| 815 | + } | |
| 816 | + files | |
| 817 | +} | |
| 818 | + | |
| 819 | +fn diff_file(file: &DiffFile) -> Markup { | |
| 820 | + let adds = file.lines.iter().filter(|l| matches!(l, DiffLine::Add(..))).count(); | |
| 821 | + let dels = file.lines.iter().filter(|l| matches!(l, DiffLine::Del(..))).count(); | |
| 822 | + html! { | |
| 823 | + div class="box mb-3 overflow-hidden" id={ "diff-" (file.path.replace('/', "-")) } { | |
| 824 | + div class="box-head font-mono text-xs" { | |
| 825 | + span class="text-ok" { "+" (adds) } span class="text-danger" { "-" (dels) } | |
| 826 | + @if let Some(old) = &file.old_path { span class="text-ink-dim" { (old) " → " } } | |
| 827 | + span class="font-semibold" { (file.path) } | |
| 828 | + } | |
| 829 | + @if file.binary { | |
| 830 | + p class="px-3 py-2 text-xs text-ink-dim" { "Binary file changed." } | |
| 831 | + } @else if file.lines.is_empty() { | |
| 832 | + p class="px-3 py-2 text-xs text-ink-dim" { "No content changes (mode or rename only)." } | |
| 833 | + } @else { | |
| 834 | + div class="overflow-x-auto" { | |
| 835 | + table class="code-table" { | |
| 836 | + tbody { | |
| 837 | + @for line in &file.lines { | |
| 838 | + @match line { | |
| 839 | + DiffLine::Hunk(text) => tr class="diff-hunk" { td class="ln" {} td class="ln" {} td class="lc text-xs" { (text) } }, | |
| 840 | + DiffLine::Context(o, n, text) => tr { td class="ln" { (o) } td class="ln" { (n) } td class="lc" { " " (text) } }, | |
| 841 | + DiffLine::Add(n, text) => tr class="diff-add" { td class="ln" {} td class="ln" { (n) } td class="lc" { "+" (text) } }, | |
| 842 | + DiffLine::Del(o, text) => tr class="diff-del" { td class="ln" { (o) } td class="ln" {} td class="lc" { "-" (text) } }, | |
| 843 | + } | |
| 844 | + } | |
| 845 | + } | |
| 846 | + } | |
| 847 | + } | |
| 848 | + } | |
| 849 | + } | |
| 850 | + } | |
| 851 | +} | |
| 852 | + | |
| 853 | +// --------------------------------------------------------------------------- | |
| 854 | +// Branches, tags, archives | |
| 855 | + | |
| 856 | +pub async fn branches(ctx: Ctx, Path((owner, name)): Path<(String, String)>) -> AppResult<Response> { | |
| 857 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 858 | + let refs = loaded.git.refs().await?; | |
| 859 | + let base = loaded.repo.url(); | |
| 860 | + let content = html! { | |
| 861 | + @if !refs.iter().any(|r| r.is_branch()) { (ui::empty_state("No branches yet", html! { "Push a branch to see it here." })) } | |
| 862 | + div class="box divide-y divide-edge" { | |
| 863 | + @for r in refs.iter().filter(|r| r.is_branch()) { | |
| 864 | + div class="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-[13px]" { | |
| 865 | + a href={ (base) "/tree/" (r.short_name()) } class="font-mono font-semibold" { (r.short_name()) } | |
| 866 | + @if r.short_name() == loaded.repo.default_branch { span class="tag" { "default" } } | |
| 867 | + span class="min-w-0 flex-1 truncate text-ink-dim" { (r.subject) } | |
| 868 | + @if let Some(at) = r.committed_at { span class="text-xs text-ink-faint" { "updated " (ui::time(at)) } } | |
| 869 | + a href={ (base) "/commits/" (r.short_name()) } class="btn btn-sm" { "History" } | |
| 870 | + } | |
| 871 | + } | |
| 872 | + } | |
| 873 | + }; | |
| 874 | + Ok(page(&ctx, &loaded, "branches", format!("Branches · {}", loaded.repo.full_name()), content)) | |
| 875 | +} | |
| 876 | + | |
| 877 | +pub async fn tags(ctx: Ctx, Path((owner, name)): Path<(String, String)>) -> AppResult<Response> { | |
| 878 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 879 | + let refs = loaded.git.refs().await?; | |
| 880 | + let base = loaded.repo.url(); | |
| 881 | + let content = html! { | |
| 882 | + @if !refs.iter().any(|r| r.is_tag()) { (ui::empty_state("No tags yet", html! { "Create one with " code { "git tag v1.0 && git push --tags" } "." })) } | |
| 883 | + div class="box divide-y divide-edge" { | |
| 884 | + @for r in refs.iter().filter(|r| r.is_tag()) { | |
| 885 | + div class="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-[13px]" { | |
| 886 | + a href={ (base) "/tree/" (r.short_name()) } class="font-mono font-semibold" { (r.short_name()) } | |
| 887 | + span class="min-w-0 flex-1 truncate text-ink-dim" { (r.subject) } | |
| 888 | + @if let Some(at) = r.committed_at { span class="text-xs text-ink-faint" { (ui::time(at)) } } | |
| 889 | + a href={ (base) "/archive/" (r.short_name()) ".zip" } class="btn btn-sm" data-track="archive_downloaded" { "zip" } | |
| 890 | + a href={ (base) "/archive/" (r.short_name()) ".tar.gz" } class="btn btn-sm" data-track="archive_downloaded" { "tar.gz" } | |
| 891 | + } | |
| 892 | + } | |
| 893 | + } | |
| 894 | + }; | |
| 895 | + Ok(page(&ctx, &loaded, "tags", format!("Tags · {}", loaded.repo.full_name()), content)) | |
| 896 | +} | |
| 897 | + | |
| 898 | +pub async fn archive(ctx: Ctx, Path((owner, name, file)): Path<(String, String, String)>) -> AppResult<Response> { | |
| 899 | + let loaded = load(&ctx, &owner, &name).await?; | |
| 900 | + let (rest, format, mime) = if let Some(r) = file.strip_suffix(".tar.gz") { | |
| 901 | + (r, "tar.gz", "application/gzip") | |
| 902 | + } else if let Some(r) = file.strip_suffix(".zip") { | |
| 903 | + (r, "zip", "application/zip") | |
| 904 | + } else { | |
| 905 | + return Err(AppError::NotFound); | |
| 906 | + }; | |
| 907 | + let (git_ref, commit, path) = resolve(&loaded.git, rest).await?.ok_or(AppError::NotFound)?; | |
| 908 | + if !path.is_empty() { | |
| 909 | + return Err(AppError::NotFound); | |
| 910 | + } | |
| 911 | + let label = format!("{}-{}", loaded.repo.name, git_ref.replace('/', "-")); | |
| 912 | + let mut child = loaded | |
| 913 | + .git | |
| 914 | + .command() | |
| 915 | + .args(["archive", &format!("--format={format}"), &format!("--prefix={label}/"), "--end-of-options", &commit]) | |
| 916 | + .stdout(std::process::Stdio::piped()) | |
| 917 | + .stderr(std::process::Stdio::null()) | |
| 918 | + .spawn()?; | |
| 919 | + let stdout = child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout"))?; | |
| 920 | + tokio::spawn(async move { | |
| 921 | + let _ = child.wait().await; | |
| 922 | + }); | |
| 923 | + analytics::track(&ctx.state, "archive_downloaded", ctx.viewer.as_ref().map(|v| v.name.as_str()), &loaded.repo.url(), json!({ "format": format })); | |
| 924 | + let mut response = Response::new(Body::from_stream(ReaderStream::new(stdout))); | |
| 925 | + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(mime)); | |
| 926 | + if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{label}.{format}\"")) { | |
| 927 | + response.headers_mut().insert(header::CONTENT_DISPOSITION, value); | |
| 928 | + } | |
| 929 | + Ok(response) | |
| 930 | +} | |
| 931 | + | |
| 932 | +// --------------------------------------------------------------------------- | |
| 933 | +// Settings | |
| 934 | + | |
| 935 | +#[derive(Deserialize, Default)] | |
| 936 | +pub struct Notice { | |
| 937 | + saved: Option<String>, | |
| 938 | + error: Option<String>, | |
| 939 | +} | |
| 940 | + | |
| 941 | +async fn load_admin(ctx: &Ctx, owner: &str, name: &str) -> AppResult<Loaded> { | |
| 942 | + let loaded = load(ctx, owner, name).await?; | |
| 943 | + if !loaded.access.is_admin() { | |
| 944 | + return Err(AppError::NotFound); | |
| 945 | + } | |
| 946 | + Ok(loaded) | |
| 947 | +} | |
| 948 | + | |
| 949 | +pub async fn settings_page(ctx: Ctx, _viewer: RequireViewer, Path((owner, name)): Path<(String, String)>, Query(notice): Query<Notice>) -> AppResult<Response> { | |
| 950 | + let loaded = load_admin(&ctx, &owner, &name).await?; | |
| 951 | + let repo = &loaded.repo; | |
| 952 | + let refs = loaded.git.refs().await?; | |
| 953 | + let collaborators: Vec<(String, Option<String>, String)> = sqlx::query_as( | |
| 954 | + "select a.name::text, a.avatar_key, c.permission from repo_collaborators c join accounts a on a.id = c.user_id where c.repo_id = $1 order by a.name", | |
| 955 | + ) | |
| 956 | + .bind(repo.id) | |
| 957 | + .fetch_all(&ctx.state.db) | |
| 958 | + .await?; | |
| 959 | + let base = repo.url(); | |
| 960 | + let content = html! { | |
| 961 | + div class="max-w-[820px]" { | |
| 962 | + (ui::alert_ok(notice.saved.as_deref())) | |
| 963 | + (ui::alert_error(notice.error.as_deref())) | |
| 964 | + section class="mb-6" { | |
| 965 | + h2 class="mb-2 font-semibold" { "General" } | |
| 966 | + form method="post" action={ (base) "/settings" } class="space-y-3" data-track-submit="repo_settings_saved" { | |
| 967 | + input type="hidden" name="action" value="general"; | |
| 968 | + div { label class="label" for="description" { "Description" } input class="input" id="description" name="description" value=(repo.description) maxlength="350"; } | |
| 969 | + div class="max-w-[300px]" { | |
| 970 | + label class="label" for="default_branch" { "Default branch" } | |
| 971 | + select class="input" id="default_branch" name="default_branch" { | |
| 972 | + @if refs.iter().all(|r| r.short_name() != repo.default_branch) { option selected { (repo.default_branch) } } | |
| 973 | + @for r in refs.iter().filter(|r| r.is_branch()) { option selected[r.short_name() == repo.default_branch] { (r.short_name()) } } | |
| 974 | + } | |
| 975 | + p class="hint" { "Commits on the default branch count toward contribution heatmaps." } | |
| 976 | + } | |
| 977 | + button class="btn btn-primary" { "Save" } | |
| 978 | + } | |
| 979 | + } | |
| 980 | + section class="mb-6" { | |
| 981 | + h2 class="mb-2 font-semibold" { "Visibility" } | |
| 982 | + form method="post" action={ (base) "/settings" } class="box flex flex-wrap items-center gap-3 p-3" { | |
| 983 | + input type="hidden" name="action" value="visibility"; | |
| 984 | + div class="flex-1 text-[13px]" { | |
| 985 | + "This repository is " strong { (repo.visibility) } "." | |
| 986 | + span class="block text-xs text-ink-faint" { "Container images keep their own visibility, set on each image." } | |
| 987 | + } | |
| 988 | + @if repo.is_public() { | |
| 989 | + input type="hidden" name="visibility" value="private"; | |
| 990 | + button class="btn" { "Make private" } | |
| 991 | + } @else { | |
| 992 | + input type="hidden" name="visibility" value="public"; | |
| 993 | + button class="btn" { "Make public" } | |
| 994 | + } | |
| 995 | + } | |
| 996 | + } | |
| 997 | + section class="mb-6" { | |
| 998 | + h2 class="mb-2 font-semibold" { "Collaborators" } | |
| 999 | + p class="mb-2 text-[13px] text-ink-dim" { "People outside the owner who can read, push to or administer this repository." } | |
| 1000 | + @if !collaborators.is_empty() { | |
| 1001 | + div class="box mb-3 divide-y divide-edge" { | |
| 1002 | + @for (user, avatar, permission) in &collaborators { | |
| 1003 | + form method="post" action={ (base) "/settings/collaborators" } class="flex flex-wrap items-center gap-2 px-3 py-2" { | |
| 1004 | + input type="hidden" name="username" value=(user); | |
| 1005 | + (ui::avatar(user, avatar.as_deref(), 24)) | |
| 1006 | + a href={ "/" (user) } class="flex-1 font-semibold" { (user) } | |
| 1007 | + select class="input w-auto" name="permission" { | |
| 1008 | + @for p in ["read", "write", "admin"] { option value=(p) selected[p == permission] { (p) } } | |
| 1009 | + } | |
| 1010 | + button class="btn btn-sm" name="action" value="set" { "Update" } | |
| 1011 | + button class="btn btn-sm btn-danger" name="action" value="remove" { "Remove" } | |
| 1012 | + } | |
| 1013 | + } | |
| 1014 | + } | |
| 1015 | + } | |
| 1016 | + form method="post" action={ (base) "/settings/collaborators" } class="flex flex-wrap gap-2" data-track-submit="collaborator_added" { | |
| 1017 | + input type="hidden" name="action" value="set"; | |
| 1018 | + input class="input max-w-[220px]" name="username" placeholder="Username" required; | |
| 1019 | + select class="input w-auto" name="permission" { option value="read" { "read" } option value="write" selected { "write" } option value="admin" { "admin" } } | |
| 1020 | + button class="btn btn-primary" { "Add collaborator" } | |
| 1021 | + } | |
| 1022 | + } | |
| 1023 | + section class="box border-danger/50" { | |
| 1024 | + div class="flex flex-wrap items-center gap-3 border-b border-edge p-3" { | |
| 1025 | + div class="flex-1 text-[13px]" { | |
| 1026 | + strong { @if repo.archived { "Unarchive" } @else { "Archive" } " this repository" } | |
| 1027 | + span class="block text-xs text-ink-dim" { "Archived repositories are read-only for everyone." } | |
| 1028 | + } | |
| 1029 | + form method="post" action={ (base) "/settings" } { | |
| 1030 | + input type="hidden" name="action" value="archive"; | |
| 1031 | + button class="btn btn-danger" { @if repo.archived { "Unarchive" } @else { "Archive" } } | |
| 1032 | + } | |
| 1033 | + } | |
| 1034 | + form method="post" action={ (base) "/settings/delete" } class="flex flex-wrap items-center gap-2 p-3" { | |
| 1035 | + div class="w-full text-[13px]" { | |
| 1036 | + strong { "Delete this repository" } | |
| 1037 | + span class="block text-xs text-ink-dim" { "Removes the code and history permanently. Type " code { (repo.full_name()) } " to confirm." } | |
| 1038 | + } | |
| 1039 | + input class="input max-w-[300px]" name="confirm" data-confirm-value=(repo.full_name()) autocomplete="off"; | |
| 1040 | + button type="submit" class="btn btn-danger" { "Delete repository" } | |
| 1041 | + } | |
| 1042 | + } | |
| 1043 | + } | |
| 1044 | + }; | |
| 1045 | + let response = page(&ctx, &loaded, "settings", format!("Settings · {}", repo.full_name()), content); | |
| 1046 | + Ok(response) | |
| 1047 | +} | |
| 1048 | + | |
| 1049 | +#[derive(Deserialize)] | |
| 1050 | +pub struct SettingsForm { | |
| 1051 | + action: String, | |
| 1052 | + description: Option<String>, | |
| 1053 | + default_branch: Option<String>, | |
| 1054 | + visibility: Option<String>, | |
| 1055 | +} | |
| 1056 | + | |
| 1057 | +pub async fn settings_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path((owner, name)): Path<(String, String)>, Form(form): Form<SettingsForm>) -> AppResult<Response> { | |
| 1058 | + let loaded = load_admin(&ctx, &owner, &name).await?; | |
| 1059 | + let repo = &loaded.repo; | |
| 1060 | + let back = format!("{}/settings", repo.url()); | |
| 1061 | + let db = &ctx.state.db; | |
| 1062 | + let message = match form.action.as_str() { | |
| 1063 | + "general" => { | |
| 1064 | + let description: String = form.description.unwrap_or_default().trim().chars().take(350).collect(); | |
| 1065 | + let branch = form.default_branch.unwrap_or_else(|| repo.default_branch.clone()); | |
| 1066 | + if branch != repo.default_branch { | |
| 1067 | + if loaded.git.resolve_commit(&format!("refs/heads/{branch}")).await?.is_none() { | |
| 1068 | + return Ok(Redirect::to(&format!("{back}?error=That+branch+does+not+exist.")).into_response()); | |
| 1069 | + } | |
| 1070 | + loaded.git.set_head(&branch).await?; | |
| 1071 | + } | |
| 1072 | + sqlx::query("update repos set description = $2, default_branch = $3, updated_at = now() where id = $1") | |
| 1073 | + .bind(repo.id) | |
| 1074 | + .bind(description) | |
| 1075 | + .bind(&branch) | |
| 1076 | + .execute(db) | |
| 1077 | + .await?; | |
| 1078 | + "Settings saved." | |
| 1079 | + } | |
| 1080 | + "visibility" => { | |
| 1081 | + ops::set_repo_visibility(&ctx.state, &viewer, repo, form.visibility.as_deref().unwrap_or("")).await?; | |
| 1082 | + "Visibility updated." | |
| 1083 | + } | |
| 1084 | + "archive" => { | |
| 1085 | + sqlx::query("update repos set archived = not archived, updated_at = now() where id = $1").bind(repo.id).execute(db).await?; | |
| 1086 | + audit(db, Some(viewer.id), if repo.archived { "repo.unarchive" } else { "repo.archive" }, &repo.full_name(), json!({}), None).await; | |
| 1087 | + if repo.archived { "Repository unarchived." } else { "Repository archived." } | |
| 1088 | + } | |
| 1089 | + _ => return Err(AppError::bad("Unknown action.")), | |
| 1090 | + }; | |
| 1091 | + Ok(Redirect::to(&format!("{back}?saved={}", auth::urlencode(message))).into_response()) | |
| 1092 | +} | |
| 1093 | + | |
| 1094 | +#[derive(Deserialize)] | |
| 1095 | +pub struct CollaboratorForm { | |
| 1096 | + action: String, | |
| 1097 | + username: String, | |
| 1098 | + permission: Option<String>, | |
| 1099 | +} | |
| 1100 | + | |
| 1101 | +pub async fn collaborators_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path((owner, name)): Path<(String, String)>, Form(form): Form<CollaboratorForm>) -> AppResult<Response> { | |
| 1102 | + let loaded = load_admin(&ctx, &owner, &name).await?; | |
| 1103 | + let repo = &loaded.repo; | |
| 1104 | + let back = format!("{}/settings", repo.url()); | |
| 1105 | + let db = &ctx.state.db; | |
| 1106 | + let user: Option<i64> = sqlx::query_scalar("select id from accounts where name = $1 and kind = 'user'").bind(form.username.trim()).fetch_optional(db).await?; | |
| 1107 | + let Some(user_id) = user else { | |
| 1108 | + return Ok(Redirect::to(&format!("{back}?error={}", auth::urlencode(&format!("No user named {}.", form.username.trim())))).into_response()); | |
| 1109 | + }; | |
| 1110 | + if user_id == repo.owner_id { | |
| 1111 | + return Ok(Redirect::to(&format!("{back}?error=The+owner+already+has+full+access.")).into_response()); | |
| 1112 | + } | |
| 1113 | + let message = if form.action == "remove" { | |
| 1114 | + sqlx::query("delete from repo_collaborators where repo_id = $1 and user_id = $2").bind(repo.id).bind(user_id).execute(db).await?; | |
| 1115 | + audit(db, Some(viewer.id), "repo.collaborator.remove", &repo.full_name(), json!({ "user": form.username }), None).await; | |
| 1116 | + "Collaborator removed." | |
| 1117 | + } else { | |
| 1118 | + let permission = match form.permission.as_deref() { | |
| 1119 | + Some("read") => "read", | |
| 1120 | + Some("admin") => "admin", | |
| 1121 | + _ => "write", | |
| 1122 | + }; | |
| 1123 | + sqlx::query("insert into repo_collaborators (repo_id, user_id, permission) values ($1, $2, $3) on conflict (repo_id, user_id) do update set permission = excluded.permission") | |
| 1124 | + .bind(repo.id) | |
| 1125 | + .bind(user_id) | |
| 1126 | + .bind(permission) | |
| 1127 | + .execute(db) | |
| 1128 | + .await?; | |
| 1129 | + audit(db, Some(viewer.id), "repo.collaborator.set", &repo.full_name(), json!({ "user": form.username, "permission": permission }), None).await; | |
| 1130 | + analytics::track(&ctx.state, "collaborator_set", Some(&viewer.name), &repo.url(), json!({ "permission": permission })); | |
| 1131 | + "Collaborators updated." | |
| 1132 | + }; | |
| 1133 | + Ok(Redirect::to(&format!("{back}?saved={}", auth::urlencode(message))).into_response()) | |
| 1134 | +} | |
| 1135 | + | |
| 1136 | +#[derive(Deserialize)] | |
| 1137 | +pub struct DeleteForm { | |
| 1138 | + confirm: String, | |
| 1139 | +} | |
| 1140 | + | |
| 1141 | +pub async fn delete_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path((owner, name)): Path<(String, String)>, Form(form): Form<DeleteForm>) -> AppResult<Response> { | |
| 1142 | + let loaded = load_admin(&ctx, &owner, &name).await?; | |
| 1143 | + if form.confirm.trim() != loaded.repo.full_name() { | |
| 1144 | + return Ok(Redirect::to(&format!("{}/settings?error=Type+the+full+name+to+confirm.", loaded.repo.url())).into_response()); | |
| 1145 | + } | |
| 1146 | + ops::delete_repo(&ctx.state, &viewer, &loaded.repo).await?; | |
| 1147 | + Ok(Redirect::to(&format!("/{}", loaded.repo.owner_name)).into_response()) | |
| 1148 | +} | |
| 1149 | + | |
| 1150 | +#[cfg(test)] | |
| 1151 | +mod tests { | |
| 1152 | + use super::*; | |
| 1153 | + | |
| 1154 | + #[test] | |
| 1155 | + fn parses_unified_diff() { | |
| 1156 | + let patch = "diff --git a/x.txt b/x.txt\nindex 1..2 100644\n--- a/x.txt\n+++ b/x.txt\n@@ -1,2 +1,2 @@\n keep\n-old\n+new\n"; | |
| 1157 | + let files = parse_patch(patch); | |
| 1158 | + assert_eq!(files.len(), 1); | |
| 1159 | + assert_eq!(files[0].path, "x.txt"); | |
| 1160 | + assert_eq!(files[0].lines.len(), 4); | |
| 1161 | + assert!(matches!(files[0].lines[2], DiffLine::Del(2, _))); | |
| 1162 | + assert!(matches!(files[0].lines[3], DiffLine::Add(2, _))); | |
| 1163 | + } | |
| 1164 | + | |
| 1165 | + #[test] | |
| 1166 | + fn detects_lfs_pointers() { | |
| 1167 | + let pointer = b"version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345\n"; | |
| 1168 | + assert_eq!(lfs_pointer(pointer).map(|p| p.1), Some(12345)); | |
| 1169 | + assert!(lfs_pointer(b"hello").is_none()); | |
| 1170 | + } | |
| 1171 | +} |
+750-0backend/src/web/settings.rs
| @@ -0,0 +1,750 @@ | ||
| 1 | +//! /settings: profile and avatar, security (password, Google, sessions), | |
| 2 | +//! emails, SSH keys, access tokens and organizations. | |
| 3 | + | |
| 4 | +use axum::{ | |
| 5 | + Form, Router, | |
| 6 | + extract::{DefaultBodyLimit, Multipart, Path, Query}, | |
| 7 | + http::HeaderMap, | |
| 8 | + response::{IntoResponse, Redirect, Response}, | |
| 9 | + routing::{get, post}, | |
| 10 | +}; | |
| 11 | +use chrono::{DateTime, Duration, Utc}; | |
| 12 | +use maud::{Markup, html}; | |
| 13 | +use serde::Deserialize; | |
| 14 | +use serde_json::json; | |
| 15 | + | |
| 16 | +use crate::{ | |
| 17 | + analytics, | |
| 18 | + auth::{self, RequireViewer, Scopes}, | |
| 19 | + error::{AppError, AppResult}, | |
| 20 | + models::{Account, audit}, | |
| 21 | + ops, sshkeys, | |
| 22 | + state::AppState, | |
| 23 | + web::{ | |
| 24 | + account, avatars, | |
| 25 | + layout::{Ctx, Page}, | |
| 26 | + ui, | |
| 27 | + }, | |
| 28 | +}; | |
| 29 | + | |
| 30 | +pub fn router() -> Router<AppState> { | |
| 31 | + Router::new() | |
| 32 | + .route("/settings", get(|| async { Redirect::to("/settings/profile") })) | |
| 33 | + .route("/settings/profile", get(profile_page).post(profile_submit)) | |
| 34 | + .route("/settings/avatar", post(avatar_upload).layer(DefaultBodyLimit::max(3 * 1024 * 1024))) | |
| 35 | + .route("/settings/avatar/remove", post(avatar_remove)) | |
| 36 | + .route("/settings/security", get(security_page)) | |
| 37 | + .route("/settings/security/password", post(password_submit)) | |
| 38 | + .route("/settings/security/google/unlink", post(google_unlink)) | |
| 39 | + .route("/settings/security/sessions/revoke", post(sessions_revoke)) | |
| 40 | + .route("/settings/emails", get(emails_page).post(email_add)) | |
| 41 | + .route("/settings/emails/{id}/{action}", post(email_action)) | |
| 42 | + .route("/settings/keys", get(keys_page).post(key_add)) | |
| 43 | + .route("/settings/keys/{id}/delete", post(key_delete)) | |
| 44 | + .route("/settings/tokens", get(tokens_page).post(token_create)) | |
| 45 | + .route("/settings/tokens/{id}/revoke", post(token_revoke)) | |
| 46 | + .route("/settings/organizations", get(orgs_page)) | |
| 47 | +} | |
| 48 | + | |
| 49 | +const SECTIONS: &[(&str, &str)] = &[ | |
| 50 | + ("profile", "Profile"), | |
| 51 | + ("security", "Password and sign-in"), | |
| 52 | + ("emails", "Emails"), | |
| 53 | + ("keys", "SSH keys"), | |
| 54 | + ("tokens", "Access tokens"), | |
| 55 | + ("organizations", "Organizations"), | |
| 56 | +]; | |
| 57 | + | |
| 58 | +fn shell(ctx: &Ctx, active: &str, title: &str, body: Markup) -> Response { | |
| 59 | + let viewer = ctx.viewer.as_ref().expect("settings require a viewer"); | |
| 60 | + let page = html! { | |
| 61 | + div class="mx-auto max-w-[1100px] px-4 py-5" { | |
| 62 | + div class="mb-4 flex items-center gap-3" { | |
| 63 | + (ui::avatar(&viewer.name, viewer.avatar_key.as_deref(), 40)) | |
| 64 | + div { | |
| 65 | + div class="font-semibold" { (viewer.name) } | |
| 66 | + div class="text-xs text-ink-dim" { "Your personal account" } | |
| 67 | + } | |
| 68 | + a href={ "/" (viewer.name) } class="btn btn-sm ml-auto" { "View profile" } | |
| 69 | + } | |
| 70 | + div class="grid gap-5 md:grid-cols-[200px_1fr]" { | |
| 71 | + nav class="flex gap-1 overflow-x-auto md:flex-col md:gap-0" aria-label="Settings" { | |
| 72 | + @for (slug, label) in SECTIONS { | |
| 73 | + a href={ "/settings/" (slug) } | |
| 74 | + class={ "whitespace-nowrap rounded-[4px] px-2 py-1 text-[13px] no-underline hover:bg-surface-hover hover:no-underline " | |
| 75 | + @if *slug == active { "bg-surface-hover font-semibold text-ink border-l-2 border-ember" } @else { "text-ink-dim" } } { (label) } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + div class="min-w-0" { | |
| 79 | + h1 class="mb-3 border-b border-edge pb-2 text-lg font-semibold" { (title) } | |
| 80 | + (body) | |
| 81 | + } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + }; | |
| 85 | + ctx.render(Page::new(title, page).noindex()) | |
| 86 | +} | |
| 87 | + | |
| 88 | +#[derive(Deserialize, Default)] | |
| 89 | +pub struct Notice { | |
| 90 | + saved: Option<String>, | |
| 91 | + error: Option<String>, | |
| 92 | + google: Option<String>, | |
| 93 | +} | |
| 94 | + | |
| 95 | +// --------------------------------------------------------------------------- | |
| 96 | +// Profile | |
| 97 | + | |
| 98 | +pub async fn profile_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Query(notice): Query<Notice>) -> AppResult<Response> { | |
| 99 | + let account = Account::by_id(&ctx.state.db, viewer.id).await?.ok_or(AppError::NotFound)?; | |
| 100 | + let show_private: bool = sqlx::query_scalar("select show_private_contributions from users where account_id = $1") | |
| 101 | + .bind(viewer.id) | |
| 102 | + .fetch_one(&ctx.state.db) | |
| 103 | + .await?; | |
| 104 | + let body = html! { | |
| 105 | + (ui::alert_ok(notice.saved.as_deref().map(|_| "Profile saved."))) | |
| 106 | + (ui::alert_error(notice.error.as_deref())) | |
| 107 | + div class="grid gap-6 lg:grid-cols-[1fr_220px]" { | |
| 108 | + form method="post" action="/settings/profile" class="space-y-3" data-track-submit="profile_saved" { | |
| 109 | + div { | |
| 110 | + label class="label" for="display_name" { "Name" } | |
| 111 | + input class="input" id="display_name" name="display_name" value=(account.display_name) maxlength="80"; | |
| 112 | + } | |
| 113 | + div { | |
| 114 | + label class="label" for="bio" { "Bio" } | |
| 115 | + textarea class="input min-h-[72px]" id="bio" name="bio" maxlength="300" { (account.bio) } | |
| 116 | + } | |
| 117 | + div class="grid gap-3 sm:grid-cols-2" { | |
| 118 | + div { | |
| 119 | + label class="label" for="location" { "Location" } | |
| 120 | + input class="input" id="location" name="location" value=(account.location) maxlength="80"; | |
| 121 | + } | |
| 122 | + div { | |
| 123 | + label class="label" for="website" { "Website" } | |
| 124 | + input class="input" id="website" name="website" value=(account.website) maxlength="200" placeholder="https://"; | |
| 125 | + } | |
| 126 | + } | |
| 127 | + label class="flex items-start gap-2 text-[13px]" { | |
| 128 | + input type="checkbox" name="show_private_contributions" value="1" checked[show_private] class="mt-0.5"; | |
| 129 | + span { | |
| 130 | + "Count private repository contributions on my public heatmap" | |
| 131 | + span class="block text-xs text-ink-faint" { "Visitors see only the number per day, never which repository." } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + button type="submit" class="btn btn-primary" { "Save profile" } | |
| 135 | + } | |
| 136 | + div { | |
| 137 | + div class="label" { "Avatar" } | |
| 138 | + (ui::avatar(&account.name, account.avatar_key.as_deref(), 200)) | |
| 139 | + form method="post" action="/settings/avatar" enctype="multipart/form-data" class="mt-2 space-y-2" data-track-submit="avatar_uploaded" { | |
| 140 | + input type="file" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" required class="block w-full text-xs text-ink-dim file:mr-2 file:rounded-[4px] file:border file:border-edge-strong file:bg-surface-raised file:px-2 file:py-1 file:text-ink"; | |
| 141 | + button type="submit" class="btn btn-sm w-full" { "Upload new picture" } | |
| 142 | + } | |
| 143 | + @if account.avatar_key.is_some() { | |
| 144 | + form method="post" action="/settings/avatar/remove" class="mt-1" data-track-submit="avatar_removed" { | |
| 145 | + button type="submit" class="btn btn-sm w-full" { "Use generated pattern" } | |
| 146 | + } | |
| 147 | + } | |
| 148 | + p class="hint" { "PNG, JPEG, WebP or GIF, up to 2 MB. Square images look best." } | |
| 149 | + } | |
| 150 | + } | |
| 151 | + }; | |
| 152 | + Ok(shell(&ctx, "profile", "Public profile", body)) | |
| 153 | +} | |
| 154 | + | |
| 155 | +#[derive(Deserialize)] | |
| 156 | +pub struct ProfileForm { | |
| 157 | + display_name: String, | |
| 158 | + bio: String, | |
| 159 | + location: String, | |
| 160 | + website: String, | |
| 161 | + show_private_contributions: Option<String>, | |
| 162 | +} | |
| 163 | + | |
| 164 | +pub fn clean_website(website: &str) -> Result<String, AppError> { | |
| 165 | + let website = website.trim(); | |
| 166 | + if website.is_empty() { | |
| 167 | + return Ok(String::new()); | |
| 168 | + } | |
| 169 | + let with_scheme = if website.starts_with("http://") || website.starts_with("https://") { website.to_string() } else { format!("https://{website}") }; | |
| 170 | + url::Url::parse(&with_scheme).map_err(|_| AppError::bad("Website must be a valid URL."))?; | |
| 171 | + Ok(with_scheme) | |
| 172 | +} | |
| 173 | + | |
| 174 | +pub async fn profile_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Form(form): Form<ProfileForm>) -> AppResult<Response> { | |
| 175 | + let website = match clean_website(&form.website) { | |
| 176 | + Ok(w) => w, | |
| 177 | + Err(e) => return Ok(Redirect::to(&format!("/settings/profile?error={}", auth::urlencode(&e.public_message()))).into_response()), | |
| 178 | + }; | |
| 179 | + sqlx::query("update accounts set display_name = $2, bio = $3, location = $4, website = $5, updated_at = now() where id = $1") | |
| 180 | + .bind(viewer.id) | |
| 181 | + .bind(form.display_name.trim().chars().take(80).collect::<String>()) | |
| 182 | + .bind(form.bio.trim().chars().take(300).collect::<String>()) | |
| 183 | + .bind(form.location.trim().chars().take(80).collect::<String>()) | |
| 184 | + .bind(website) | |
| 185 | + .execute(&ctx.state.db) | |
| 186 | + .await?; | |
| 187 | + sqlx::query("update users set show_private_contributions = $2 where account_id = $1") | |
| 188 | + .bind(viewer.id) | |
| 189 | + .bind(form.show_private_contributions.is_some()) | |
| 190 | + .execute(&ctx.state.db) | |
| 191 | + .await?; | |
| 192 | + analytics::track(&ctx.state, "profile_updated", Some(&viewer.name), "/settings/profile", json!({})); | |
| 193 | + Ok(Redirect::to("/settings/profile?saved=1").into_response()) | |
| 194 | +} | |
| 195 | + | |
| 196 | +/// Reads the first file field of a multipart upload. | |
| 197 | +pub async fn read_upload(mut multipart: Multipart) -> AppResult<Option<bytes::Bytes>> { | |
| 198 | + while let Some(field) = multipart.next_field().await.map_err(|e| AppError::bad(format!("Upload failed: {e}")))? { | |
| 199 | + if field.file_name().is_some() { | |
| 200 | + let data = field.bytes().await.map_err(|e| AppError::bad(format!("Upload failed: {e}")))?; | |
| 201 | + return Ok(Some(data)); | |
| 202 | + } | |
| 203 | + } | |
| 204 | + Ok(None) | |
| 205 | +} | |
| 206 | + | |
| 207 | +pub async fn avatar_upload(ctx: Ctx, RequireViewer(viewer): RequireViewer, multipart: Multipart) -> AppResult<Response> { | |
| 208 | + let Some(bytes) = read_upload(multipart).await? else { | |
| 209 | + return Ok(Redirect::to("/settings/profile?error=Choose+an+image+to+upload.").into_response()); | |
| 210 | + }; | |
| 211 | + match avatars::store(&ctx.state, viewer.id, bytes).await { | |
| 212 | + Ok(()) => { | |
| 213 | + analytics::track(&ctx.state, "avatar_uploaded", Some(&viewer.name), "/settings/profile", json!({})); | |
| 214 | + Ok(Redirect::to("/settings/profile?saved=1").into_response()) | |
| 215 | + } | |
| 216 | + Err(AppError::BadRequest(message)) => Ok(Redirect::to(&format!("/settings/profile?error={}", auth::urlencode(&message))).into_response()), | |
| 217 | + Err(other) => Err(other), | |
| 218 | + } | |
| 219 | +} | |
| 220 | + | |
| 221 | +pub async fn avatar_remove(ctx: Ctx, RequireViewer(viewer): RequireViewer) -> AppResult<Response> { | |
| 222 | + avatars::remove(&ctx.state, viewer.id).await?; | |
| 223 | + Ok(Redirect::to("/settings/profile?saved=1").into_response()) | |
| 224 | +} | |
| 225 | + | |
| 226 | +// --------------------------------------------------------------------------- | |
| 227 | +// Security | |
| 228 | + | |
| 229 | +pub async fn security_page(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer): RequireViewer, Query(notice): Query<Notice>) -> AppResult<Response> { | |
| 230 | + let db = &ctx.state.db; | |
| 231 | + let has_password: bool = sqlx::query_scalar("select password_hash is not null from users where account_id = $1").bind(viewer.id).fetch_one(db).await?; | |
| 232 | + let google: Option<(Option<String>, DateTime<Utc>)> = | |
| 233 | + sqlx::query_as("select email::text, created_at from oauth_identities where provider = 'google' and user_id = $1") | |
| 234 | + .bind(viewer.id) | |
| 235 | + .fetch_optional(db) | |
| 236 | + .await?; | |
| 237 | + let sessions: Vec<(String, Option<String>, Option<String>, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as( | |
| 238 | + "select id_hash, ip, user_agent, created_at, last_seen_at from sessions where user_id = $1 and expires_at > now() order by last_seen_at desc limit 50", | |
| 239 | + ) | |
| 240 | + .bind(viewer.id) | |
| 241 | + .fetch_all(db) | |
| 242 | + .await?; | |
| 243 | + let current = auth::session_token(&headers).map(auth::sha256_hex); | |
| 244 | + let google_notice = notice.google.as_deref().map(|g| match g { | |
| 245 | + "linked" => ("ok", "Google account connected. You can now sign in with Google."), | |
| 246 | + "already" => ("ok", "That Google account is already connected."), | |
| 247 | + "taken" => ("error", "That Google account is connected to a different irongit account."), | |
| 248 | + _ => ("error", "Google connection failed."), | |
| 249 | + }); | |
| 250 | + | |
| 251 | + let body = html! { | |
| 252 | + (ui::alert_ok(notice.saved.as_deref().map(|_| "Saved."))) | |
| 253 | + (ui::alert_error(notice.error.as_deref())) | |
| 254 | + @if let Some((kind, message)) = google_notice { | |
| 255 | + @if kind == "ok" { (ui::alert_ok(Some(message))) } @else { (ui::alert_error(Some(message))) } | |
| 256 | + } | |
| 257 | + section class="mb-6" { | |
| 258 | + h2 class="mb-2 font-semibold" { @if has_password { "Change password" } @else { "Set a password" } } | |
| 259 | + @if !has_password { | |
| 260 | + p class="mb-2 text-[13px] text-ink-dim" { "You sign in with Google. Setting a password lets you sign in without it." } | |
| 261 | + } | |
| 262 | + form method="post" action="/settings/security/password" class="max-w-[420px] space-y-3" data-track-submit="password_changed" { | |
| 263 | + @if has_password { | |
| 264 | + div { | |
| 265 | + label class="label" for="current" { "Current password" } | |
| 266 | + input class="input" id="current" name="current" type="password" autocomplete="current-password" required; | |
| 267 | + } | |
| 268 | + } | |
| 269 | + div { | |
| 270 | + label class="label" for="new" { "New password" } | |
| 271 | + input class="input" id="new" name="new" type="password" minlength="10" autocomplete="new-password" required; | |
| 272 | + } | |
| 273 | + button type="submit" class="btn btn-primary" { @if has_password { "Update password" } @else { "Set password" } } | |
| 274 | + } | |
| 275 | + } | |
| 276 | + section class="mb-6" { | |
| 277 | + h2 class="mb-2 font-semibold" { "Google" } | |
| 278 | + div class="box flex flex-wrap items-center gap-3 p-3" { | |
| 279 | + @if let Some((email, since)) = &google { | |
| 280 | + div class="min-w-0 flex-1" { | |
| 281 | + div { "Connected" @if let Some(email) = email { " as " strong { (email) } } } | |
| 282 | + div class="text-xs text-ink-faint" { "Since " (ui::time(*since)) } | |
| 283 | + } | |
| 284 | + form method="post" action="/settings/security/google/unlink" { | |
| 285 | + button type="submit" class="btn btn-sm btn-danger" disabled[!has_password] title=[(!has_password).then_some("Set a password first")] { "Disconnect" } | |
| 286 | + } | |
| 287 | + } @else if ctx.state.config.google.is_some() { | |
| 288 | + div class="flex-1 text-[13px] text-ink-dim" { "Sign in with your Google account as well as your password." } | |
| 289 | + a href="/login/google?link=1&next=/settings/security" class="btn btn-sm" data-track="google_connect_clicked" { "Connect Google" } | |
| 290 | + } @else { | |
| 291 | + div class="text-[13px] text-ink-dim" { "Google sign-in is not configured on this server." } | |
| 292 | + } | |
| 293 | + } | |
| 294 | + } | |
| 295 | + section { | |
| 296 | + div class="mb-2 flex items-center" { | |
| 297 | + h2 class="font-semibold" { "Sessions" } | |
| 298 | + form method="post" action="/settings/security/sessions/revoke" class="ml-auto" { | |
| 299 | + button type="submit" class="btn btn-sm" { "Sign out all other sessions" } | |
| 300 | + } | |
| 301 | + } | |
| 302 | + div class="box divide-y divide-edge" { | |
| 303 | + @for (id_hash, ip, agent, created, seen) in &sessions { | |
| 304 | + div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 px-3 py-2 text-[13px]" { | |
| 305 | + span class="min-w-0 flex-1 truncate" title=[agent.as_deref()] { (short_agent(agent.as_deref())) } | |
| 306 | + span class="font-mono text-xs text-ink-dim" { (ip.as_deref().unwrap_or("-")) } | |
| 307 | + span class="text-xs text-ink-faint" { "active " (ui::time(*seen)) ", signed in " (ui::time(*created)) } | |
| 308 | + @if current.as_deref() == Some(id_hash.as_str()) { span class="tag border-ok/50 text-ok" { "This browser" } } | |
| 309 | + } | |
| 310 | + } | |
| 311 | + } | |
| 312 | + } | |
| 313 | + }; | |
| 314 | + Ok(shell(&ctx, "security", "Password and sign-in", body)) | |
| 315 | +} | |
| 316 | + | |
| 317 | +fn short_agent(agent: Option<&str>) -> String { | |
| 318 | + let Some(agent) = agent else { return "Unknown device".into() }; | |
| 319 | + let browser = ["Firefox", "Edg", "Chrome", "Safari", "curl", "ig/"].into_iter().find(|b| agent.contains(b)).unwrap_or("Browser"); | |
| 320 | + let os = ["Windows", "Mac OS", "Android", "iPhone", "Linux"].into_iter().find(|o| agent.contains(o)).unwrap_or(""); | |
| 321 | + let browser = if browser == "Edg" { "Edge" } else { browser }; | |
| 322 | + if os.is_empty() { browser.to_string() } else { format!("{browser} on {os}") } | |
| 323 | +} | |
| 324 | + | |
| 325 | +#[derive(Deserialize)] | |
| 326 | +pub struct PasswordForm { | |
| 327 | + current: Option<String>, | |
| 328 | + new: String, | |
| 329 | +} | |
| 330 | + | |
| 331 | +pub async fn password_submit(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer): RequireViewer, Form(form): Form<PasswordForm>) -> AppResult<Response> { | |
| 332 | + let existing: Option<String> = sqlx::query_scalar("select password_hash from users where account_id = $1").bind(viewer.id).fetch_one(&ctx.state.db).await?; | |
| 333 | + if let Some(hash) = existing { | |
| 334 | + let current = form.current.unwrap_or_default(); | |
| 335 | + if !auth::verify_password_async(current, hash).await { | |
| 336 | + return Ok(Redirect::to("/settings/security?error=Current+password+is+incorrect.").into_response()); | |
| 337 | + } | |
| 338 | + } | |
| 339 | + if let Err(message) = auth::validate_password(&form.new) { | |
| 340 | + return Ok(Redirect::to(&format!("/settings/security?error={}", auth::urlencode(message))).into_response()); | |
| 341 | + } | |
| 342 | + let hash = auth::hash_password_async(form.new).await?; | |
| 343 | + sqlx::query("update users set password_hash = $2 where account_id = $1").bind(viewer.id).bind(hash).execute(&ctx.state.db).await?; | |
| 344 | + // Other sessions are signed out when the password changes. | |
| 345 | + if let Some(token) = auth::session_token(&headers) { | |
| 346 | + sqlx::query("delete from sessions where user_id = $1 and id_hash <> $2").bind(viewer.id).bind(auth::sha256_hex(token)).execute(&ctx.state.db).await?; | |
| 347 | + } | |
| 348 | + audit(&ctx.state.db, Some(viewer.id), "user.password", &viewer.name, json!({}), auth::client_ip(&headers).as_deref()).await; | |
| 349 | + analytics::track(&ctx.state, "password_changed", Some(&viewer.name), "/settings/security", json!({})); | |
| 350 | + Ok(Redirect::to("/settings/security?saved=1").into_response()) | |
| 351 | +} | |
| 352 | + | |
| 353 | +pub async fn google_unlink(ctx: Ctx, viewer: RequireViewer) -> AppResult<Response> { | |
| 354 | + match account::unlink_google(&ctx.state, &viewer).await { | |
| 355 | + Ok(()) => { | |
| 356 | + analytics::track(&ctx.state, "google_unlinked", Some(&viewer.0.name), "/settings/security", json!({})); | |
| 357 | + Ok(Redirect::to("/settings/security?saved=1").into_response()) | |
| 358 | + } | |
| 359 | + Err(AppError::BadRequest(m)) => Ok(Redirect::to(&format!("/settings/security?error={}", auth::urlencode(&m))).into_response()), | |
| 360 | + Err(e) => Err(e), | |
| 361 | + } | |
| 362 | +} | |
| 363 | + | |
| 364 | +pub async fn sessions_revoke(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer): RequireViewer) -> AppResult<Response> { | |
| 365 | + let current = auth::session_token(&headers).map(auth::sha256_hex).unwrap_or_default(); | |
| 366 | + let removed = sqlx::query("delete from sessions where user_id = $1 and id_hash <> $2").bind(viewer.id).bind(current).execute(&ctx.state.db).await?.rows_affected(); | |
| 367 | + audit(&ctx.state.db, Some(viewer.id), "sessions.revoke", &viewer.name, json!({ "count": removed }), None).await; | |
| 368 | + Ok(Redirect::to("/settings/security?saved=1").into_response()) | |
| 369 | +} | |
| 370 | + | |
| 371 | +// --------------------------------------------------------------------------- | |
| 372 | +// Emails | |
| 373 | + | |
| 374 | +pub async fn emails_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Query(notice): Query<Notice>) -> AppResult<Response> { | |
| 375 | + let emails: Vec<(i64, String, bool, Option<DateTime<Utc>>, Option<DateTime<Utc>>)> = sqlx::query_as( | |
| 376 | + "select id, email::text, is_primary, verified_at, verify_sent_at from emails where user_id = $1 order by is_primary desc, created_at", | |
| 377 | + ) | |
| 378 | + .bind(viewer.id) | |
| 379 | + .fetch_all(&ctx.state.db) | |
| 380 | + .await?; | |
| 381 | + let body = html! { | |
| 382 | + (ui::alert_ok(notice.saved.as_deref())) | |
| 383 | + (ui::alert_error(notice.error.as_deref())) | |
| 384 | + p class="mb-3 text-[13px] text-ink-dim" { | |
| 385 | + "Commits whose author email matches a verified address count toward your contribution heatmap. Add every address you commit with." | |
| 386 | + } | |
| 387 | + div class="box mb-4 divide-y divide-edge" { | |
| 388 | + @for (id, email, primary, verified, sent) in &emails { | |
| 389 | + div class="flex flex-wrap items-center gap-2 px-3 py-2 text-[13px]" { | |
| 390 | + span class="font-medium" { (email) } | |
| 391 | + @if *primary { span class="tag" { "Primary" } } | |
| 392 | + @if verified.is_some() { span class="tag border-ok/50 text-ok" { "Verified" } } @else { span class="tag border-warn/50 text-warn" { "Unverified" } } | |
| 393 | + div class="ml-auto flex gap-1" { | |
| 394 | + @if verified.is_none() { | |
| 395 | + form method="post" action={ "/settings/emails/" (id) "/resend" } { | |
| 396 | + button class="btn btn-sm" title=[sent.map(|s| format!("Last sent {}", ui::ago(s)))] { "Resend verification" } | |
| 397 | + } | |
| 398 | + } | |
| 399 | + @if !*primary && verified.is_some() { | |
| 400 | + form method="post" action={ "/settings/emails/" (id) "/primary" } { button class="btn btn-sm" { "Make primary" } } | |
| 401 | + } | |
| 402 | + @if !*primary { | |
| 403 | + form method="post" action={ "/settings/emails/" (id) "/delete" } { button class="btn btn-sm btn-danger" { "Remove" } } | |
| 404 | + } | |
| 405 | + } | |
| 406 | + } | |
| 407 | + } | |
| 408 | + } | |
| 409 | + form method="post" action="/settings/emails" class="flex max-w-[480px] gap-2" data-track-submit="email_added" { | |
| 410 | + input class="input" type="email" name="email" placeholder="you@example.com" required; | |
| 411 | + button type="submit" class="btn btn-primary whitespace-nowrap" { "Add email" } | |
| 412 | + } | |
| 413 | + }; | |
| 414 | + Ok(shell(&ctx, "emails", "Emails", body)) | |
| 415 | +} | |
| 416 | + | |
| 417 | +#[derive(Deserialize)] | |
| 418 | +pub struct EmailForm { | |
| 419 | + email: String, | |
| 420 | +} | |
| 421 | + | |
| 422 | +pub async fn email_add(ctx: Ctx, RequireViewer(viewer): RequireViewer, Form(form): Form<EmailForm>) -> AppResult<Response> { | |
| 423 | + let email = form.email.trim().to_string(); | |
| 424 | + if !ops::valid_email(&email) { | |
| 425 | + return Ok(Redirect::to("/settings/emails?error=Enter+a+valid+email+address.").into_response()); | |
| 426 | + } | |
| 427 | + let count: i64 = sqlx::query_scalar("select count(*) from emails where user_id = $1").bind(viewer.id).fetch_one(&ctx.state.db).await?; | |
| 428 | + if count >= 20 { | |
| 429 | + return Ok(Redirect::to("/settings/emails?error=You+can+have+at+most+20+addresses.").into_response()); | |
| 430 | + } | |
| 431 | + let inserted = sqlx::query("insert into emails (user_id, email) values ($1, $2) on conflict (email) do nothing") | |
| 432 | + .bind(viewer.id) | |
| 433 | + .bind(&email) | |
| 434 | + .execute(&ctx.state.db) | |
| 435 | + .await? | |
| 436 | + .rows_affected(); | |
| 437 | + if inserted == 0 { | |
| 438 | + return Ok(Redirect::to("/settings/emails?error=That+address+is+already+in+use.").into_response()); | |
| 439 | + } | |
| 440 | + ops::send_verification(&ctx.state, viewer.id, &email).await?; | |
| 441 | + analytics::track(&ctx.state, "email_added", Some(&viewer.name), "/settings/emails", json!({})); | |
| 442 | + Ok(Redirect::to("/settings/emails?saved=Verification+link+sent.").into_response()) | |
| 443 | +} | |
| 444 | + | |
| 445 | +pub async fn email_action(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path((id, action)): Path<(i64, String)>) -> AppResult<Response> { | |
| 446 | + let email: Option<(String, bool, bool)> = | |
| 447 | + sqlx::query_as("select email::text, is_primary, verified_at is not null from emails where id = $1 and user_id = $2") | |
| 448 | + .bind(id) | |
| 449 | + .bind(viewer.id) | |
| 450 | + .fetch_optional(&ctx.state.db) | |
| 451 | + .await?; | |
| 452 | + let Some((email, primary, verified)) = email else { return Err(AppError::NotFound) }; | |
| 453 | + let message = match action.as_str() { | |
| 454 | + "resend" if !verified => { | |
| 455 | + ops::send_verification(&ctx.state, viewer.id, &email).await?; | |
| 456 | + "Verification link sent." | |
| 457 | + } | |
| 458 | + "primary" if verified => { | |
| 459 | + let mut tx = ctx.state.db.begin().await?; | |
| 460 | + sqlx::query("update emails set is_primary = false where user_id = $1").bind(viewer.id).execute(&mut *tx).await?; | |
| 461 | + sqlx::query("update emails set is_primary = true where id = $1").bind(id).execute(&mut *tx).await?; | |
| 462 | + tx.commit().await?; | |
| 463 | + "Primary email updated." | |
| 464 | + } | |
| 465 | + "delete" if !primary => { | |
| 466 | + sqlx::query("delete from emails where id = $1").bind(id).execute(&ctx.state.db).await?; | |
| 467 | + "Email removed." | |
| 468 | + } | |
| 469 | + _ => return Err(AppError::bad("That action is not available for this address.")), | |
| 470 | + }; | |
| 471 | + Ok(Redirect::to(&format!("/settings/emails?saved={}", auth::urlencode(message))).into_response()) | |
| 472 | +} | |
| 473 | + | |
| 474 | +// --------------------------------------------------------------------------- | |
| 475 | +// SSH keys | |
| 476 | + | |
| 477 | +pub async fn keys_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Query(notice): Query<Notice>) -> AppResult<Response> { | |
| 478 | + let keys: Vec<(i64, String, String, Option<DateTime<Utc>>, DateTime<Utc>)> = sqlx::query_as( | |
| 479 | + "select id, title, fingerprint, last_used_at, created_at from ssh_keys where user_id = $1 order by created_at desc", | |
| 480 | + ) | |
| 481 | + .bind(viewer.id) | |
| 482 | + .fetch_all(&ctx.state.db) | |
| 483 | + .await?; | |
| 484 | + let ssh_host = &ctx.state.config.ssh_host; | |
| 485 | + let (host, port) = ssh_host.split_once(':').unwrap_or((ssh_host.as_str(), "22")); | |
| 486 | + let body = html! { | |
| 487 | + (ui::alert_ok(notice.saved.as_deref())) | |
| 488 | + (ui::alert_error(notice.error.as_deref())) | |
| 489 | + @if keys.is_empty() { | |
| 490 | + (ui::empty_state("No SSH keys yet", html! { "Add a key to push and pull over SSH, or use " code { "ig ssh-key add" } "." })) | |
| 491 | + } @else { | |
| 492 | + div class="box mb-4 divide-y divide-edge" { | |
| 493 | + @for (id, title, fingerprint, used, created) in &keys { | |
| 494 | + div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 px-3 py-2 text-[13px]" { | |
| 495 | + div class="min-w-0 flex-1" { | |
| 496 | + div class="font-medium" { (title) } | |
| 497 | + div class="truncate font-mono text-xs text-ink-dim" { (fingerprint) } | |
| 498 | + } | |
| 499 | + span class="text-xs text-ink-faint" { | |
| 500 | + "Added " (ui::time(*created)) ", " | |
| 501 | + @if let Some(used) = used { "last used " (ui::time(*used)) } @else { "never used" } | |
| 502 | + } | |
| 503 | + form method="post" action={ "/settings/keys/" (id) "/delete" } { button class="btn btn-sm btn-danger" { "Delete" } } | |
| 504 | + } | |
| 505 | + } | |
| 506 | + } | |
| 507 | + } | |
| 508 | + h2 class="mt-5 mb-2 font-semibold" { "Add a key" } | |
| 509 | + form method="post" action="/settings/keys" class="space-y-3" data-track-submit="ssh_key_added" { | |
| 510 | + div class="max-w-[420px]" { | |
| 511 | + label class="label" for="title" { "Title" } | |
| 512 | + input class="input" id="title" name="title" placeholder="Laptop" maxlength="80"; | |
| 513 | + } | |
| 514 | + div { | |
| 515 | + label class="label" for="key" { "Public key" } | |
| 516 | + textarea class="input min-h-[96px] font-mono text-xs" id="key" name="key" required placeholder="ssh-ed25519 AAAA... you@laptop" {} | |
| 517 | + p class="hint" { "The contents of " code { "~/.ssh/id_ed25519.pub" } ". Create one with " code { "ssh-keygen -t ed25519" } "." } | |
| 518 | + } | |
| 519 | + button type="submit" class="btn btn-primary" { "Add SSH key" } | |
| 520 | + } | |
| 521 | + @if port != "22" { | |
| 522 | + h2 class="mt-6 mb-2 font-semibold" { "SSH config" } | |
| 523 | + p class="mb-2 text-[13px] text-ink-dim" { "SSH runs on port " (port) ". Add this to " code { "~/.ssh/config" } " so plain " code { "git@" (host) ":owner/repo.git" } " URLs work:" } | |
| 524 | + pre class="box overflow-x-auto bg-surface-sunken p-3 font-mono text-xs" { "Host " (host) "\n Port " (port) "\n User git" } | |
| 525 | + } | |
| 526 | + }; | |
| 527 | + Ok(shell(&ctx, "keys", "SSH keys", body)) | |
| 528 | +} | |
| 529 | + | |
| 530 | +#[derive(Deserialize)] | |
| 531 | +pub struct KeyForm { | |
| 532 | + title: String, | |
| 533 | + key: String, | |
| 534 | +} | |
| 535 | + | |
| 536 | +pub async fn key_add(ctx: Ctx, RequireViewer(viewer): RequireViewer, Form(form): Form<KeyForm>) -> AppResult<Response> { | |
| 537 | + let parsed = match sshkeys::parse_public_key(&form.key) { | |
| 538 | + Ok(parsed) => parsed, | |
| 539 | + Err(message) => return Ok(Redirect::to(&format!("/settings/keys?error={}", auth::urlencode(&message))).into_response()), | |
| 540 | + }; | |
| 541 | + let title = match form.title.trim() { | |
| 542 | + "" if !parsed.comment.is_empty() => parsed.comment.clone(), | |
| 543 | + "" => parsed.algorithm.clone(), | |
| 544 | + t => t.chars().take(80).collect(), | |
| 545 | + }; | |
| 546 | + let inserted = sqlx::query("insert into ssh_keys (user_id, title, public_key, fingerprint) values ($1, $2, $3, $4) on conflict (fingerprint) do nothing") | |
| 547 | + .bind(viewer.id) | |
| 548 | + .bind(&title) | |
| 549 | + .bind(&parsed.normalized) | |
| 550 | + .bind(&parsed.fingerprint) | |
| 551 | + .execute(&ctx.state.db) | |
| 552 | + .await? | |
| 553 | + .rows_affected(); | |
| 554 | + if inserted == 0 { | |
| 555 | + return Ok(Redirect::to("/settings/keys?error=That+key+is+already+in+use.").into_response()); | |
| 556 | + } | |
| 557 | + audit(&ctx.state.db, Some(viewer.id), "ssh_key.add", &parsed.fingerprint, json!({ "title": title }), None).await; | |
| 558 | + analytics::track(&ctx.state, "ssh_key_added", Some(&viewer.name), "/settings/keys", json!({ "algorithm": parsed.algorithm })); | |
| 559 | + Ok(Redirect::to("/settings/keys?saved=Key+added.").into_response()) | |
| 560 | +} | |
| 561 | + | |
| 562 | +pub async fn key_delete(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(id): Path<i64>) -> AppResult<Response> { | |
| 563 | + let fingerprint: Option<String> = sqlx::query_scalar("delete from ssh_keys where id = $1 and user_id = $2 returning fingerprint") | |
| 564 | + .bind(id) | |
| 565 | + .bind(viewer.id) | |
| 566 | + .fetch_optional(&ctx.state.db) | |
| 567 | + .await?; | |
| 568 | + let fingerprint = fingerprint.ok_or(AppError::NotFound)?; | |
| 569 | + audit(&ctx.state.db, Some(viewer.id), "ssh_key.delete", &fingerprint, json!({}), None).await; | |
| 570 | + Ok(Redirect::to("/settings/keys?saved=Key+deleted.").into_response()) | |
| 571 | +} | |
| 572 | + | |
| 573 | +// --------------------------------------------------------------------------- | |
| 574 | +// Access tokens | |
| 575 | + | |
| 576 | +pub async fn tokens_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Query(notice): Query<Notice>) -> AppResult<Response> { | |
| 577 | + let tokens: Vec<(i64, String, String, Vec<String>, Option<DateTime<Utc>>, Option<DateTime<Utc>>, DateTime<Utc>)> = sqlx::query_as( | |
| 578 | + "select id, name, token_prefix, scopes, last_used_at, expires_at, created_at from access_tokens where user_id = $1 order by created_at desc", | |
| 579 | + ) | |
| 580 | + .bind(viewer.id) | |
| 581 | + .fetch_all(&ctx.state.db) | |
| 582 | + .await?; | |
| 583 | + Ok(shell(&ctx, "tokens", "Personal access tokens", tokens_body(&ctx, &tokens, ¬ice, None))) | |
| 584 | +} | |
| 585 | + | |
| 586 | +#[allow(clippy::type_complexity)] | |
| 587 | +fn tokens_body( | |
| 588 | + ctx: &Ctx, | |
| 589 | + tokens: &[(i64, String, String, Vec<String>, Option<DateTime<Utc>>, Option<DateTime<Utc>>, DateTime<Utc>)], | |
| 590 | + notice: &Notice, | |
| 591 | + created: Option<&str>, | |
| 592 | +) -> Markup { | |
| 593 | + let is_admin = ctx.viewer.as_ref().is_some_and(|v| v.is_admin); | |
| 594 | + html! { | |
| 595 | + (ui::alert_ok(notice.saved.as_deref())) | |
| 596 | + (ui::alert_error(notice.error.as_deref())) | |
| 597 | + @if let Some(secret) = created { | |
| 598 | + div class="alert alert-ok mb-4" { | |
| 599 | + p class="mb-2 font-semibold" { "Copy your new token now. You will not see it again." } | |
| 600 | + (ui::copy_field("new-token", secret, "token_copied")) | |
| 601 | + p class="mt-2 text-xs text-ink-dim" { "Use it as the password for git over HTTPS and " code { "docker login" } ", or run " code { "ig login --with-token" } "." } | |
| 602 | + } | |
| 603 | + } | |
| 604 | + p class="mb-3 text-[13px] text-ink-dim" { "Tokens authenticate git over HTTPS, docker, Git LFS and the API. " code { "ig login" } " creates one for you." } | |
| 605 | + div class="box mb-5 overflow-x-auto" { | |
| 606 | + table class="w-full text-[13px]" { | |
| 607 | + thead class="bg-surface-raised text-left text-xs text-ink-dim" { | |
| 608 | + tr { th class="px-3 py-1.5" { "Name" } th class="px-3" { "Token" } th class="px-3" { "Scopes" } th class="px-3" { "Last used" } th class="px-3" { "Expires" } th {} } | |
| 609 | + } | |
| 610 | + tbody class="divide-y divide-edge" { | |
| 611 | + @if tokens.is_empty() { tr { td colspan="6" class="px-3 py-4 text-center text-ink-dim" { "No tokens yet." } } } | |
| 612 | + @for (id, name, prefix, scopes, used, expires, _created) in tokens { | |
| 613 | + tr { | |
| 614 | + td class="px-3 py-1.5 font-medium" { (name) } | |
| 615 | + td class="px-3 font-mono text-xs text-ink-dim" { (prefix) "..." } | |
| 616 | + td class="px-3 text-xs" { (scopes.join(", ")) } | |
| 617 | + td class="px-3 text-xs text-ink-dim" { @if let Some(u) = used { (ui::time(*u)) } @else { "never" } } | |
| 618 | + td class="px-3 text-xs text-ink-dim" { | |
| 619 | + @match expires { Some(e) if *e < Utc::now() => span class="text-danger" { "expired" }, Some(e) => (e.format("%Y-%m-%d")), None => "never" } | |
| 620 | + } | |
| 621 | + td class="px-3 text-right" { form method="post" action={ "/settings/tokens/" (id) "/revoke" } { button class="btn btn-sm btn-danger" { "Revoke" } } } | |
| 622 | + } | |
| 623 | + } | |
| 624 | + } | |
| 625 | + } | |
| 626 | + } | |
| 627 | + h2 class="mb-2 font-semibold" { "New token" } | |
| 628 | + form method="post" action="/settings/tokens" class="max-w-[520px] space-y-3" data-track-submit="token_created" { | |
| 629 | + div { | |
| 630 | + label class="label" for="name" { "Name" } | |
| 631 | + input class="input" id="name" name="name" required maxlength="80" placeholder="CI deploys"; | |
| 632 | + } | |
| 633 | + fieldset { | |
| 634 | + legend class="label" { "Scopes" } | |
| 635 | + @for (scope, description) in Scopes::NAMES { | |
| 636 | + @if *scope != "admin" || is_admin { | |
| 637 | + label class="flex items-center gap-2 py-0.5 text-[13px]" { | |
| 638 | + input type="checkbox" name="scopes" value=(scope) checked[*scope != "admin"]; | |
| 639 | + code class="text-xs" { (scope) } span class="text-ink-dim" { (description) } | |
| 640 | + } | |
| 641 | + } | |
| 642 | + } | |
| 643 | + } | |
| 644 | + div class="max-w-[200px]" { | |
| 645 | + label class="label" for="expires" { "Expires" } | |
| 646 | + select class="input" id="expires" name="expires" { | |
| 647 | + option value="30" { "30 days" } | |
| 648 | + option value="90" selected { "90 days" } | |
| 649 | + option value="365" { "1 year" } | |
| 650 | + option value="never" { "Never" } | |
| 651 | + } | |
| 652 | + } | |
| 653 | + button type="submit" class="btn btn-primary" { "Create token" } | |
| 654 | + } | |
| 655 | + } | |
| 656 | +} | |
| 657 | + | |
| 658 | +pub async fn token_create(ctx: Ctx, RequireViewer(viewer): RequireViewer, body: String) -> AppResult<Response> { | |
| 659 | + // Repeated checkbox names need the raw form: scopes=repo&scopes=packages. | |
| 660 | + let pairs: Vec<(String, String)> = url::form_urlencoded::parse(body.as_bytes()).into_owned().collect(); | |
| 661 | + let name = pairs.iter().find(|(k, _)| k == "name").map(|(_, v)| v.trim().to_string()).unwrap_or_default(); | |
| 662 | + if name.is_empty() { | |
| 663 | + return Ok(Redirect::to("/settings/tokens?error=Give+the+token+a+name.").into_response()); | |
| 664 | + } | |
| 665 | + let mut scopes: Vec<&str> = pairs | |
| 666 | + .iter() | |
| 667 | + .filter(|(k, _)| k == "scopes") | |
| 668 | + .filter_map(|(_, v)| Scopes::NAMES.iter().map(|(s, _)| *s).find(|s| s == v)) | |
| 669 | + .collect(); | |
| 670 | + if !viewer.is_admin { | |
| 671 | + scopes.retain(|s| *s != "admin"); | |
| 672 | + } | |
| 673 | + if scopes.is_empty() { | |
| 674 | + return Ok(Redirect::to("/settings/tokens?error=Pick+at+least+one+scope.").into_response()); | |
| 675 | + } | |
| 676 | + let expires = match pairs.iter().find(|(k, _)| k == "expires").map(|(_, v)| v.as_str()) { | |
| 677 | + Some("never") => None, | |
| 678 | + Some(days) => Some(Utc::now() + Duration::days(days.parse::<i64>().unwrap_or(90).clamp(1, 3650))), | |
| 679 | + None => Some(Utc::now() + Duration::days(90)), | |
| 680 | + }; | |
| 681 | + let token = auth::create_token(&ctx.state.db, viewer.id, &name.chars().take(80).collect::<String>(), &scopes, expires).await?; | |
| 682 | + audit(&ctx.state.db, Some(viewer.id), "token.create", &name, json!({ "scopes": scopes }), None).await; | |
| 683 | + analytics::track(&ctx.state, "token_created", Some(&viewer.name), "/settings/tokens", json!({ "scopes": scopes.len() })); | |
| 684 | + let tokens = sqlx::query_as( | |
| 685 | + "select id, name, token_prefix, scopes, last_used_at, expires_at, created_at from access_tokens where user_id = $1 order by created_at desc", | |
| 686 | + ) | |
| 687 | + .bind(viewer.id) | |
| 688 | + .fetch_all(&ctx.state.db) | |
| 689 | + .await?; | |
| 690 | + Ok(shell(&ctx, "tokens", "Personal access tokens", tokens_body(&ctx, &tokens, &Notice::default(), Some(&token.secret)))) | |
| 691 | +} | |
| 692 | + | |
| 693 | +pub async fn token_revoke(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(id): Path<i64>) -> AppResult<Response> { | |
| 694 | + let name: Option<String> = sqlx::query_scalar("delete from access_tokens where id = $1 and user_id = $2 returning name") | |
| 695 | + .bind(id) | |
| 696 | + .bind(viewer.id) | |
| 697 | + .fetch_optional(&ctx.state.db) | |
| 698 | + .await?; | |
| 699 | + let name = name.ok_or(AppError::NotFound)?; | |
| 700 | + audit(&ctx.state.db, Some(viewer.id), "token.revoke", &name, json!({}), None).await; | |
| 701 | + Ok(Redirect::to("/settings/tokens?saved=Token+revoked.").into_response()) | |
| 702 | +} | |
| 703 | + | |
| 704 | +// --------------------------------------------------------------------------- | |
| 705 | +// Organizations | |
| 706 | + | |
| 707 | +pub async fn orgs_page(ctx: Ctx, RequireViewer(viewer): RequireViewer) -> AppResult<Response> { | |
| 708 | + let orgs: Vec<(String, String, Option<String>, String, i64)> = sqlx::query_as( | |
| 709 | + "select a.name::text, a.display_name, a.avatar_key, m.role, | |
| 710 | + (select count(*) from org_members x where x.org_id = a.id) | |
| 711 | + from org_members m join accounts a on a.id = m.org_id where m.user_id = $1 order by a.name", | |
| 712 | + ) | |
| 713 | + .bind(viewer.id) | |
| 714 | + .fetch_all(&ctx.state.db) | |
| 715 | + .await?; | |
| 716 | + let body = html! { | |
| 717 | + div class="mb-3 flex" { a href="/organizations/new" class="btn btn-primary ml-auto" { "New organization" } } | |
| 718 | + @if orgs.is_empty() { | |
| 719 | + (ui::empty_state("You are not in any organizations", html! { "Organizations share repositories and images between people." })) | |
| 720 | + } @else { | |
| 721 | + div class="box divide-y divide-edge" { | |
| 722 | + @for (name, display, avatar, role, members) in &orgs { | |
| 723 | + div class="flex items-center gap-3 px-3 py-2" { | |
| 724 | + (ui::avatar(name, avatar.as_deref(), 32)) | |
| 725 | + div class="min-w-0 flex-1" { | |
| 726 | + a href={ "/" (name) } class="font-semibold" { (name) } | |
| 727 | + @if !display.is_empty() { span class="ml-2 text-ink-dim" { (display) } } | |
| 728 | + div class="text-xs text-ink-faint" { (ui::plural(*members, "member", "members")) } | |
| 729 | + } | |
| 730 | + span class="tag" { (role) } | |
| 731 | + @if role == "owner" { a href={ "/organizations/" (name) "/settings" } class="btn btn-sm" { "Settings" } } | |
| 732 | + } | |
| 733 | + } | |
| 734 | + } | |
| 735 | + } | |
| 736 | + }; | |
| 737 | + Ok(shell(&ctx, "organizations", "Organizations", body)) | |
| 738 | +} | |
| 739 | + | |
| 740 | +#[cfg(test)] | |
| 741 | +mod tests { | |
| 742 | + use super::*; | |
| 743 | + | |
| 744 | + #[test] | |
| 745 | + fn websites_get_a_scheme() { | |
| 746 | + assert_eq!(clean_website("example.com").unwrap(), "https://example.com"); | |
| 747 | + assert_eq!(clean_website("").unwrap(), ""); | |
| 748 | + assert!(clean_website("http://exa mple").is_err()); | |
| 749 | + } | |
| 750 | +} |
+8-0frontend/src/scripts/app.ts
| @@ -213,7 +213,15 @@ function initAutoSubmit() { | ||
| 213 | 213 | }); |
| 214 | 214 | } |
| 215 | 215 | |
| 216 | +// Wide horizontal scrollers (the heatmap) start at their newest end. | |
| 217 | +function initScrollEnd() { | |
| 218 | + document.querySelectorAll<HTMLElement>("[data-scroll-end]").forEach((el) => { | |
| 219 | + el.scrollLeft = el.scrollWidth; | |
| 220 | + }); | |
| 221 | +} | |
| 222 | + | |
| 216 | 223 | function init() { |
| 224 | + initScrollEnd(); | |
| 217 | 225 | initLogging(); |
| 218 | 226 | initAnalytics(); |
| 219 | 227 | initTheme(); |
+5-1frontend/src/styles/global.css
| @@ -155,7 +155,11 @@ | ||
| 155 | 155 | } |
| 156 | 156 | |
| 157 | 157 | .tabs { |
| 158 | - @apply flex gap-0 overflow-x-auto border-b border-edge text-[13px]; | |
| 158 | + @apply flex gap-0 overflow-x-auto overflow-y-hidden border-b border-edge text-[13px]; | |
| 159 | + scrollbar-width: none; | |
| 160 | + } | |
| 161 | + .tabs::-webkit-scrollbar { | |
| 162 | + display: none; | |
| 159 | 163 | } |
| 160 | 164 | .tab { |
| 161 | 165 | @apply -mb-px flex items-center gap-1.5 border-b-2 border-transparent px-3 py-1.5 whitespace-nowrap text-ink-dim no-underline; |