irongit

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

huncholanehuncholaneauthored
parent 62c3e49commit 7e56670abc02648c6e2516e48331f079a0c91c9eBrowse files

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 = [
31663166 "rustls-platform-verifier",
31673167 "serde",
31683168 "serde_json",
3169+ "serde_urlencoded",
31693170 "sync_wrapper",
31703171 "tokio",
31713172 "tokio-rustls",
+1-1backend/Cargo.toml
@@ -42,7 +42,7 @@ once_cell = "1.21.4"
4242 time = "0.3.55"
4343 tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
4444 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"] }
4646 russh = "0.64.1"
4747 comrak = "0.56.0"
4848 syntect = "5.3.0"
+18-1backend/src/routes.rs
@@ -11,7 +11,13 @@ use axum::{
1111 routing::get,
1212 };
1313 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+};
1521 use tracing::Span;
1622
1723 use crate::{api, clientlog, frontend, git_http, lfs, registry, state::AppState, web};
@@ -32,6 +38,17 @@ pub fn router(state: AppState) -> Router {
3238
3339 app.fallback(frontend::serve)
3440 .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+ ))
3552 .layer(
3653 TraceLayer::new_for_http()
3754 .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 @@
11 //! /avatars/{name}: the uploaded picture from R2, or a generated pattern.
22
3-use std::time::Duration;
4-
53 use axum::{
64 extract::{Path, State},
75 http::{StatusCode, header},
@@ -43,7 +41,6 @@ pub async fn serve(State(state): State<AppState>, Path(name): Path<String>) -> R
4341 Err(error) => tracing::error!(%error, key, "avatar fetch failed"),
4442 }
4543 }
46- let _ = Duration::ZERO;
4744 (
4845 StatusCode::OK,
4946 [(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
5148 )
5249 .into_response()
5350 }
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("![logo](../img/logo.png) [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 @@
11 //! Server-rendered pages (maud), poured into the Astro shell.
22
3+pub mod account;
34 pub mod admin;
45 pub mod avatars;
6+pub mod components;
7+pub mod heatmap;
8+pub mod highlight;
9+pub mod home;
510 pub mod layout;
11+pub mod markdown;
12+pub mod orgs;
13+pub mod profile;
14+pub mod repo;
15+pub mod settings;
616 pub mod ui;
717
8-use axum::{Router, routing::get};
18+use axum::{
19+ Router,
20+ http::header,
21+ response::IntoResponse,
22+ routing::get,
23+};
924
1025 use crate::state::AppState;
1126
1227 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())
1445 }
+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, &notice, 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() {
213213 });
214214 }
215215
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+
216223 function init() {
224+ initScrollEnd();
217225 initLogging();
218226 initAnalytics();
219227 initTheme();
+5-1frontend/src/styles/global.css
@@ -155,7 +155,11 @@
155155 }
156156
157157 .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;
159163 }
160164 .tab {
161165 @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;