Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Repository browsing (M2) and creation.
Matt W2
Matt W3use std::collections::HashMap;
Matt W4use std::path::Path;
Matt W5
Matt W6use axum::extract::{Path as UrlPath, Query, State};
Matt W7use axum::response::{IntoResponse, Redirect, Response};
Matt W8use axum::Form;
Matt W9use df_db::ids::new_id;
Matt W10use df_store::StoreError;
Matt W11use serde::Deserialize;
Matt W12use uuid::Uuid;
Matt W13
Matt W14use crate::error::{AppError, AppResult};
Matt W15use crate::repo_ctx::RepoContext;
Matt W16use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W17use crate::views::repo as v;
Matt W18use crate::views::{self, Chrome};
Matt W19
Matt W20/// Map a store error to an HTTP outcome.
Matt W21///
Matt W22/// `NoSuchRepo` becomes a 404 rather than a 500: it means the database and the
Matt W23/// filesystem disagree, which is a real state after a restore (spec §10) and
Matt W24/// should look like a missing repository, not a crash.
Matt W25fn store_err(e: StoreError) -> AppError {
Matt W26 match e {
Matt W27 StoreError::NoSuchRepo | StoreError::NoSuchRevision | StoreError::NoSuchPath => {
Matt W28 AppError::NotFound
Matt W29 }
Matt W30 StoreError::IsDirectory => AppError::BadRequest("that path is a directory".into()),
Matt W31 StoreError::Path(p) => AppError::BadRequest(p.to_string()),
Matt W32 other => AppError::Internal(anyhow::anyhow!(other)),
Matt W33 }
Matt W34}
Matt W35
Matt W36/// `GET /{owner}/{repo}` — code view at the default bookmark.
Matt W37pub async fn index(
Matt W38 State(state): State<AppState>,
Matt W39 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W40 CurrentUser(user): CurrentUser,
Matt W41 CsrfToken(csrf): CsrfToken,
Matt W42 Nonce(nonce): Nonce,
Matt W43) -> AppResult<Response> {
Matt W44 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W45
Matt W46 let empty = state
Matt W47 .store
Matt W48 .is_empty(ctx.store_id())
Matt W49 .await
Matt W50 .unwrap_or(true);
Matt W51
Matt W52 let body = if empty {
Matt W53 v::empty_repo(
Matt W54 &state.config.https_clone_url(&ctx.owner, &ctx.repo.name),
Matt W55 &state.config.ssh_clone_url(&ctx.owner, &ctx.repo.name),
Matt W56 &ctx.repo.default_bookmark,
Matt W57 )
Matt W58 } else {
Matt W59 let rev_label = ctx.repo.default_bookmark.clone();
Matt W60 let rev = state
Matt W61 .store
Matt W62 .resolve(ctx.store_id(), &rev_label)
Matt W63 .await
Matt W64 .map_err(store_err)?;
Matt W65 let entries = state
Matt W66 .store
Matt W67 .list_tree(ctx.store_id(), &rev, Path::new(""))
Matt W68 .await
Matt W69 .map_err(store_err)?;
Matt W70
Matt W71 let readme = render_readme(&state, &ctx, &rev, &entries).await;
Matt W72 let tip = tip_commit(&state, &ctx, &rev).await;
Matt W73 let last_commits = last_commits_for(&state, &ctx, &rev, Path::new(""), &entries).await;
Matt W74
Matt W75 let marks = bookmark_rows(&state, &ctx).await?;
Matt W76 let (contributors, size_bytes) = repo_vitals(&state, &ctx).await;
Matt W77 let https = state.config.https_clone_url(&ctx.owner, &ctx.repo.name);
Matt W78 let ssh = state.config.ssh_clone_url(&ctx.owner, &ctx.repo.name);
Matt W79 let sidebar = v::RepoSidebar {
Matt W80 https: &https,
Matt W81 ssh: &ssh,
Matt W82 open_changes: ctx.nav.open_changes,
Matt W83 conflicted: ctx.nav.conflicted,
Matt W84 open_issues: ctx.nav.open_issues,
Matt W85 contributors,
Matt W86 size_bytes,
Matt W87 bookmarks: &marks,
Matt W88 };
Matt W89
Matt W90 v::tree_listing(
Matt W91 &ctx,
Matt W92 v::Tree {
Matt W93 rev_label: &rev_label,
Matt W94 path: "",
Matt W95 entries: &entries,
Matt W96 tip: tip.as_ref(),
Matt W97 readme: readme.as_ref(),
Matt W98 history: &last_commits,
Matt W99 sidebar: Some(&sidebar),
Matt W100 },
Matt W101 )
Matt W102 };
Matt W103
Matt W104 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
Matt W105}
Matt W106
Matt W107/// Bookmarks with the change each one points at.
Matt W108///
Matt W109/// The `LATERAL` subquery is scoped to the bookmark's own repository: a
Matt W110/// revision id is only unique *within* a repository, so joining `revisions` on
Matt W111/// `rev` alone would let one repository's bookmark resolve to another's change.
Matt W112/// `(name, protected, updated_at, change_id, number, title)`
Matt W113type BookmarkTuple = (
Matt W114 String,
Matt W115 bool,
Matt W116 chrono::DateTime<chrono::Utc>,
Matt W117 Option<String>,
Matt W118 Option<i64>,
Matt W119 Option<String>,
Matt W120);
Matt W121
Matt W122async fn bookmark_rows(state: &AppState, ctx: &RepoContext) -> AppResult<Vec<v::MarkRow>> {
Matt W123 let rows: Vec<BookmarkTuple> = sqlx::query_as(
Matt W124 r#"
Matt W125 SELECT b.name,
Matt W126 b.protected,
Matt W127 b.updated_at,
Matt W128 c.change_id,
Matt W129 c.number,
Matt W130 c.title
Matt W131 FROM bookmarks b
Matt W132 LEFT JOIN LATERAL (
Matt W133 SELECT ch.change_id, ch.number, ch.title
Matt W134 FROM revisions r
Matt W135 JOIN changes ch ON ch.id = r.change_id_fk
Matt W136 WHERE ch.repo_id = b.repo_id AND r.rev = b.target
Matt W137 LIMIT 1
Matt W138 ) c ON true
Matt W139 WHERE b.repo_id = $1
Matt W140 ORDER BY b.updated_at DESC
Matt W141 "#,
Matt W142 )
Matt W143 .bind(ctx.repo.id)
Matt W144 .fetch_all(&state.db)
Matt W145 .await?;
Matt W146
Matt W147 Ok(rows
Matt W148 .into_iter()
Matt W149 .map(
Matt W150 |(name, protected, updated_at, change_id, number, title)| v::MarkRow {
Matt W151 name,
Matt W152 protected,
Matt W153 updated_at,
Matt W154 change_id,
Matt W155 number,
Matt W156 title,
Matt W157 },
Matt W158 )
Matt W159 .collect())
Matt W160}
Matt W161
Matt W162/// Contributor count and on-disk size for the sidebar.
Matt W163///
Matt W164/// Best-effort on both counts: the sidebar is context, and neither number is
Matt W165/// worth failing a page render over. Contributors are distinct commit *emails*
Matt W166/// rather than linked accounts — somebody who has pushed but never signed in is
Matt W167/// still a contributor.
Matt W168async fn repo_vitals(state: &AppState, ctx: &RepoContext) -> (i64, u64) {
Matt W169 let contributors: i64 = sqlx::query_scalar(
Matt W170 r#"
Matt W171 SELECT count(DISTINCT r.author_email)
Matt W172 FROM revisions r
Matt W173 JOIN changes c ON c.id = r.change_id_fk
Matt W174 WHERE c.repo_id = $1
Matt W175 "#,
Matt W176 )
Matt W177 .bind(ctx.repo.id)
Matt W178 .fetch_one(&state.db)
Matt W179 .await
Matt W180 .unwrap_or(0);
Matt W181
Matt W182 // The stored size is what the last indexer pass recorded; asking the store
Matt W183 // is exact but walks the directory, so it is the fallback rather than the
Matt W184 // first choice on a page that renders on every visit.
Matt W185 let size = if ctx.repo.size_bytes > 0 {
Matt W186 ctx.repo.size_bytes as u64
Matt W187 } else {
Matt W188 state.store.size_bytes(ctx.store_id()).await.unwrap_or(0)
Matt W189 };
Matt W190
Matt W191 (contributors, size)
Matt W192}
Matt W193
Matt W194#[derive(Deserialize)]
Matt W195pub struct RevPath {
Matt W196 pub owner: String,
Matt W197 pub repo: String,
Matt W198 pub rev: String,
Matt W199 #[serde(default)]
Matt W200 pub path: String,
Matt W201}
Matt W202
Matt W203/// `GET /{owner}/{repo}/tree/{rev}/{path...}`
Matt W204pub async fn tree(
Matt W205 State(state): State<AppState>,
Matt W206 UrlPath(p): UrlPath<RevPath>,
Matt W207 CurrentUser(user): CurrentUser,
Matt W208 CsrfToken(csrf): CsrfToken,
Matt W209 Nonce(nonce): Nonce,
Matt W210) -> AppResult<Response> {
Matt W211 let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
Matt W212
Matt W213 let rev = state
Matt W214 .store
Matt W215 .resolve(ctx.store_id(), &p.rev)
Matt W216 .await
Matt W217 .map_err(store_err)?;
Matt W218
Matt W219 let entries = state
Matt W220 .store
Matt W221 .list_tree(ctx.store_id(), &rev, Path::new(&p.path))
Matt W222 .await
Matt W223 .map_err(store_err)?;
Matt W224
Matt W225 let readme = if p.path.is_empty() {
Matt W226 render_readme(&state, &ctx, &rev, &entries).await
Matt W227 } else {
Matt W228 None
Matt W229 };
Matt W230
Matt W231 let tip = tip_commit(&state, &ctx, &rev).await;
Matt W232 let last_commits =
Matt W233 last_commits_for(&state, &ctx, &rev, Path::new(&p.path), &entries).await;
Matt W234
Matt W235 // No sidebar below the root: the reader is looking at files, and repeating
Matt W236 // the clone commands beside every folder is noise.
Matt W237 let body = v::tree_listing(
Matt W238 &ctx,
Matt W239 v::Tree {
Matt W240 rev_label: &p.rev,
Matt W241 path: &p.path,
Matt W242 entries: &entries,
Matt W243 tip: tip.as_ref(),
Matt W244 readme: readme.as_ref(),
Matt W245 history: &last_commits,
Matt W246 sidebar: None,
Matt W247 },
Matt W248 );
Matt W249 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
Matt W250}
Matt W251
Matt W252/// The revision at the tip of what is being browsed.
Matt W253///
Matt W254/// Best-effort: the listing is the point of the page, so a store that cannot
Matt W255/// produce a log still renders the files rather than failing the request.
Matt W256async fn tip_commit(
Matt W257 state: &AppState,
Matt W258 ctx: &RepoContext,
Matt W259 rev: &df_store::RevId,
Matt W260) -> Option<v::TipCommit> {
Matt W261 let revisions = state.store.log(ctx.store_id(), rev, 1).await.ok()?;
Matt W262 let r = revisions.into_iter().next()?;
Matt W263
Matt W264 Some(v::TipCommit {
Matt W265 author: r.author.name.clone(),
Matt W266 author_handle: handle_for_email(state, &r.author.email).await,
Matt W267 summary: r.summary().to_string(),
Matt W268 when: r.author.when,
Matt W269 change_id: r.change_id.clone(),
Matt W270 })
Matt W271}
Matt W272
Matt W273/// The last commit that touched each entry of a directory listing.
Matt W274///
Matt W275/// One `last_commits_in_dir` call resolves the whole directory (see its docs
Matt W276/// for why that beats a lookup per file). Best-effort like `tip_commit`: a
Matt W277/// store that cannot walk history still renders the listing, just without
Matt W278/// this column.
Matt W279async fn last_commits_for(
Matt W280 state: &AppState,
Matt W281 ctx: &RepoContext,
Matt W282 rev: &df_store::RevId,
Matt W283 dir: &Path,
Matt W284 entries: &[df_store::TreeEntry],
Matt W285) -> HashMap<String, v::EntryHistory> {
Matt W286 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
Matt W287
Matt W288 let revisions = state
Matt W289 .store
Matt W290 .last_commits_in_dir(ctx.store_id(), rev, dir, &names)
Matt W291 .await
Matt W292 .unwrap_or_default();
Matt W293
Matt W294 revisions
Matt W295 .into_iter()
Matt W296 .map(|(name, r)| {
Matt W297 (
Matt W298 name,
Matt W299 v::EntryHistory {
Matt W300 summary: r.summary().to_string(),
Matt W301 when: r.author.when,
Matt W302 change_id: r.change_id,
Matt W303 },
Matt W304 )
Matt W305 })
Matt W306 .collect()
Matt W307}
Matt W308
Matt W309/// The account that owns a commit-author email, if any.
Matt W310///
Matt W311/// The same rule the indexer attributes changes by, applied to a commit read
Matt W312/// straight from the store rather than from the index. Best-effort: a lookup
Matt W313/// failure renders the commit's own name rather than failing the page.
Matt W314pub(crate) async fn handle_for_email(state: &AppState, email: &str) -> Option<String> {
Matt W315 let email = email.trim();
Matt W316 if email.is_empty() {
Matt W317 return None;
Matt W318 }
Matt W319 sqlx::query_scalar::<_, String>("SELECT handle::text FROM users WHERE email = $1")
Matt W320 .bind(email)
Matt W321 .fetch_optional(&state.db)
Matt W322 .await
Matt W323 .ok()
Matt W324 .flatten()
Matt W325}
Matt W326
Matt W327/// The same lookup for a whole listing, in one round trip.
Matt W328///
Matt W329/// A page of 200 commits must not become 200 queries. Returns email → handle
Matt W330/// for the ones that matched; callers fall back to the commit's own name.
Matt W331pub(crate) async fn handles_for_emails<'a>(
Matt W332 state: &AppState,
Matt W333 emails: impl IntoIterator<Item = &'a str>,
Matt W334) -> HashMap<String, String> {
Matt W335 let mut wanted: Vec<String> = emails
Matt W336 .into_iter()
Matt W337 .map(str::trim)
Matt W338 .filter(|e| !e.is_empty())
Matt W339 .map(str::to_owned)
Matt W340 .collect();
Matt W341 wanted.sort();
Matt W342 wanted.dedup();
Matt W343
Matt W344 if wanted.is_empty() {
Matt W345 return HashMap::new();
Matt W346 }
Matt W347
Matt W348 // `email` is citext, so the join is case-insensitive without lowering here.
Matt W349 sqlx::query_as::<_, (String, String)>(
Matt W350 "SELECT email::text, handle::text FROM users WHERE email = ANY($1)",
Matt W351 )
Matt W352 .bind(&wanted)
Matt W353 .fetch_all(&state.db)
Matt W354 .await
Matt W355 .unwrap_or_else(|e| {
Matt W356 tracing::warn!("resolving commit authors failed: {e}");
Matt W357 Vec::new()
Matt W358 })
Matt W359 .into_iter()
Matt W360 .collect()
Matt W361}
Matt W362
Matt W363/// Is this path a markdown document?
Matt W364///
Matt W365/// Extension-based on purpose: sniffing content would mean a file that happens
Matt W366/// to start with a `#` gets rendered as a heading, which is worse than a `.txt`
Matt W367/// missing out on rendering.
Matt W368fn is_markdown(path: &str) -> bool {
Matt W369 let lower = path.to_lowercase();
Matt W370 lower.ends_with(".md") || lower.ends_with(".markdown")
Matt W371}
Matt W372
Matt W373#[derive(Deserialize, Default)]
Matt W374pub struct BlobQuery {
Matt W375 /// `source` shows the highlighted source of a markdown file instead of the
Matt W376 /// rendered document. Anything else — including absent — renders.
Matt W377 #[serde(default)]
Matt W378 pub view: Option<String>,
Matt W379 /// When `blame=1`, show per-line blame annotations.
Matt W380 #[serde(default)]
Matt W381 pub blame: Option<String>,
Matt W382}
Matt W383
Matt W384/// `GET /{owner}/{repo}/blob/{rev}/{path...}`
Matt W385pub async fn blob(
Matt W386 State(state): State<AppState>,
Matt W387 UrlPath(p): UrlPath<RevPath>,
Matt W388 Query(q): Query<BlobQuery>,
Matt W389 CurrentUser(user): CurrentUser,
Matt W390 CsrfToken(csrf): CsrfToken,
Matt W391 Nonce(nonce): Nonce,
Matt W392) -> AppResult<Response> {
Matt W393 let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
Matt W394
Matt W395 let rev = state
Matt W396 .store
Matt W397 .resolve(ctx.store_id(), &p.rev)
Matt W398 .await
Matt W399 .map_err(store_err)?;
Matt W400
Matt W401 let wants_source = q.view.as_deref() == Some("source");
Matt W402 let wants_blame = q.blame.as_deref() == Some("1");
Matt W403 let markdown_path = is_markdown(&p.path);
Matt W404
Matt W405 // Fetch the file tree for the sidebar (directory containing this file).
Matt W406 let tree_dir = match p.path.rsplit_once('/') {
Matt W407 Some((parent, _)) => parent.to_string(),
Matt W408 None => String::new(),
Matt W409 };
Matt W410 let sidebar_entries = state
Matt W411 .store
Matt W412 .list_tree(ctx.store_id(), &rev, Path::new(&tree_dir))
Matt W413 .await
Matt W414 .unwrap_or_default();
Matt W415
Matt W416 // Fetch the last commit that modified this file.
Matt W417 let last_commit = state
Matt W418 .store
Matt W419 .last_commit_for_path(ctx.store_id(), &rev, Path::new(&p.path))
Matt W420 .await
Matt W421 .ok()
Matt W422 .flatten();
Matt W423
Matt W424 let last_commit_handle = match &last_commit {
Matt W425 Some(c) => handle_for_email(&state, &c.author.email).await,
Matt W426 None => None,
Matt W427 };
Matt W428
Matt W429 // Optionally fetch blame.
Matt W430 let blame_lines = if wants_blame {
Matt W431 state
Matt W432 .store
Matt W433 .blame(ctx.store_id(), &rev, Path::new(&p.path))
Matt W434 .await
Matt W435 .ok()
Matt W436 } else {
Matt W437 None
Matt W438 };
Matt W439
Matt W440 let body = match state
Matt W441 .store
Matt W442 .read_blob(ctx.store_id(), &rev, Path::new(&p.path))
Matt W443 .await
Matt W444 {
Matt W445 Ok(b) => {
Matt W446 let limit = state.config.max_blob_render_bytes as u64;
Matt W447 if b.size > limit {
Matt W448 v::blob_view(
Matt W449 &ctx,
Matt W450 &p.rev,
Matt W451 &p.path,
Matt W452 v::BlobBody::TooLarge { size: b.size, limit },
Matt W453 None,
Matt W454 &v::BlobExtras::default(),
Matt W455 )
Matt W456 } else if b.text().is_some() {
Matt W457 // Highlighting is cached by content address, so re-viewing a
Matt W458 // file — or viewing it at a revision that did not change it —
Matt W459 // never re-parses (spec §8).
Matt W460 let hl = crate::highlight_cache::render(&state.db, &b).await;
Matt W461 let text = b.text().unwrap_or("");
Matt W462 let lines = text.lines().count();
Matt W463
Matt W464 // Always sanitised — never trust repository content (spec §9).
Matt W465 let rendered = (markdown_path && !wants_source)
Matt W466 .then(|| v::rendered_markdown(&df_render::markdown_to_html(text)));
Matt W467
Matt W468 let markdown = match (markdown_path, &rendered) {
Matt W469 (true, Some(doc)) => Some(v::MarkdownView::Rendered(doc)),
Matt W470 (true, None) => Some(v::MarkdownView::Source),
Matt W471 (false, _) => None,
Matt W472 };
Matt W473
Matt W474 // Extract symbols for the outline panel.
Matt W475 let symbols = df_render::symbols::extract_symbols(&p.path, text);
Matt W476
Matt W477 let extras = v::BlobExtras {
Matt W478 sidebar_entries: &sidebar_entries,
Matt W479 sidebar_dir: &tree_dir,
Matt W480 symbols: &symbols,
Matt W481 last_commit: last_commit.as_ref(),
Matt W482 last_commit_handle: last_commit_handle.as_deref(),
Matt W483 blame: blame_lines.as_deref(),
Matt W484 wants_blame,
Matt W485 };
Matt W486
Matt W487 v::blob_view(
Matt W488 &ctx,
Matt W489 &p.rev,
Matt W490 &p.path,
Matt W491 v::BlobBody::Text {
Matt W492 content: text,
Matt W493 lines,
Matt W494 highlighted: &hl.lines,
Matt W495 language: hl.language.as_deref(),
Matt W496 plain_reason: hl.skipped.and_then(plain_reason),
Matt W497 },
Matt W498 markdown,
Matt W499 &extras,
Matt W500 )
Matt W501 } else {
Matt W502 v::blob_view(
Matt W503 &ctx,
Matt W504 &p.rev,
Matt W505 &p.path,
Matt W506 v::BlobBody::Binary { size: b.size },
Matt W507 None,
Matt W508 &v::BlobExtras::default(),
Matt W509 )
Matt W510 }
Matt W511 }
Matt W512 // The store's own cap fired before ours.
Matt W513 Err(StoreError::TooLarge { size, limit }) => {
Matt W514 v::blob_view(
Matt W515 &ctx,
Matt W516 &p.rev,
Matt W517 &p.path,
Matt W518 v::BlobBody::TooLarge { size, limit },
Matt W519 None,
Matt W520 &v::BlobExtras::default(),
Matt W521 )
Matt W522 }
Matt W523 Err(e) => return Err(store_err(e)),
Matt W524 };
Matt W525
Matt W526 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
Matt W527}
Matt W528
Matt W529/// `GET /{owner}/{repo}/raw/{rev}/{path...}`
Matt W530///
Matt W531/// Spec §9: "Blob content served ... with `Content-Disposition: attachment` and
Matt W532/// `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on the
Matt W533/// app origin." Everything here is `application/octet-stream` and downloaded,
Matt W534/// so a repository containing an HTML page cannot execute against our origin.
Matt W535pub async fn raw(
Matt W536 State(state): State<AppState>,
Matt W537 UrlPath(p): UrlPath<RevPath>,
Matt W538 CurrentUser(user): CurrentUser,
Matt W539) -> AppResult<Response> {
Matt W540 let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
Matt W541
Matt W542 let rev = state
Matt W543 .store
Matt W544 .resolve(ctx.store_id(), &p.rev)
Matt W545 .await
Matt W546 .map_err(store_err)?;
Matt W547
Matt W548 let blob = state
Matt W549 .store
Matt W550 .read_blob(ctx.store_id(), &rev, Path::new(&p.path))
Matt W551 .await
Matt W552 .map_err(store_err)?;
Matt W553
Matt W554 let filename = p
Matt W555 .path
Matt W556 .rsplit('/')
Matt W557 .next()
Matt W558 .filter(|s| !s.is_empty())
Matt W559 .unwrap_or("file");
Matt W560
Matt W561 // The filename reaches a header; strip anything that could break out of the
Matt W562 // quoted string or inject a second header.
Matt W563 let safe_name: String = filename
Matt W564 .chars()
Matt W565 .filter(|c| !matches!(c, '"' | '\\' | '\r' | '\n') && !c.is_control())
Matt W566 .take(200)
Matt W567 .collect();
Matt W568
Matt W569 Ok((
Matt W570 [
Matt W571 (
Matt W572 axum::http::header::CONTENT_TYPE,
Matt W573 "application/octet-stream".to_string(),
Matt W574 ),
Matt W575 (
Matt W576 axum::http::header::CONTENT_DISPOSITION,
Matt W577 format!("attachment; filename=\"{safe_name}\""),
Matt W578 ),
Matt W579 (
Matt W580 axum::http::header::X_CONTENT_TYPE_OPTIONS,
Matt W581 "nosniff".to_string(),
Matt W582 ),
Matt W583 ],
Matt W584 blob.content,
Matt W585 )
Matt W586 .into_response())
Matt W587}
Matt W588
Matt W589#[derive(Deserialize)]
Matt W590pub struct LogQuery {
Matt W591 pub rev: Option<String>,
Matt W592 pub limit: Option<usize>,
Matt W593}
Matt W594
Matt W595/// `GET /{owner}/{repo}/log`
Matt W596pub async fn log(
Matt W597 State(state): State<AppState>,
Matt W598 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W599 Query(q): Query<LogQuery>,
Matt W600 CurrentUser(user): CurrentUser,
Matt W601 CsrfToken(csrf): CsrfToken,
Matt W602 Nonce(nonce): Nonce,
Matt W603) -> AppResult<Response> {
Matt W604 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W605
Matt W606 let rev_label = q.rev.unwrap_or_else(|| ctx.repo.default_bookmark.clone());
Matt W607 let rev = state
Matt W608 .store
Matt W609 .resolve(ctx.store_id(), &rev_label)
Matt W610 .await
Matt W611 .map_err(store_err)?;
Matt W612
Matt W613 // Clamped: a URL must not be able to ask for unbounded history.
Matt W614 let limit = q.limit.unwrap_or(50).clamp(1, 200);
Matt W615 let revs = state
Matt W616 .store
Matt W617 .log(ctx.store_id(), &rev, limit)
Matt W618 .await
Matt W619 .map_err(store_err)?;
Matt W620
Matt W621 // One query for every author in the page rather than one per commit.
Matt W622 let handles =
Matt W623 handles_for_emails(&state, revs.iter().map(|r| r.author.email.as_str())).await;
Matt W624
Matt W625 let body = v::log_view(&ctx, &rev_label, &revs, &handles);
Matt W626 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
Matt W627}
Matt W628
Matt W629#[derive(Deserialize, Default)]
Matt W630pub struct CommitQuery {
Matt W631 /// Fold every file. A link rather than a script, so the folded view is a
Matt W632 /// URL and works with scripting off.
Matt W633 pub collapse: Option<String>,
Matt W634}
Matt W635
Matt W636/// `GET /{owner}/{repo}/commit/{rev}`
Matt W637///
Matt W638/// One commit and its patch against the first parent. A root commit has no
Matt W639/// parent and diffs against an empty tree, so the first commit in a repository
Matt W640/// renders as an all-additions patch rather than as an error.
Matt W641pub async fn commit(
Matt W642 State(state): State<AppState>,
Matt W643 UrlPath((owner, name, rev_spec)): UrlPath<(String, String, String)>,
Matt W644 Query(q): Query<CommitQuery>,
Matt W645 CurrentUser(user): CurrentUser,
Matt W646 CsrfToken(csrf): CsrfToken,
Matt W647 Nonce(nonce): Nonce,
Matt W648) -> AppResult<Response> {
Matt W649 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W650
Matt W651 // Bookmark names resolve too: `/commit/main` is a reasonable thing to type,
Matt W652 // and it lands on whichever commit that name points at right now.
Matt W653 let rev = state
Matt W654 .store
Matt W655 .resolve(ctx.store_id(), &rev_spec)
Matt W656 .await
Matt W657 .map_err(store_err)?;
Matt W658
Matt W659 let revision = state
Matt W660 .store
Matt W661 .revision(ctx.store_id(), &rev)
Matt W662 .await
Matt W663 .map_err(store_err)?;
Matt W664
Matt W665 let opts = df_store::DiffOpts {
Matt W666 context_lines: 3,
Matt W667 max_files: state.config.max_diff_files,
Matt W668 max_lines: state.config.max_diff_lines,
Matt W669 };
Matt W670
Matt W671 // Best-effort: a commit whose patch cannot be rendered still has a message,
Matt W672 // an author and parents worth showing, and the view says so in place of the
Matt W673 // diff rather than 500-ing the page.
Matt W674 let diff = match state.store.diff_from_parent(ctx.store_id(), &rev, opts).await {
Matt W675 Ok(d) => Some(d),
Matt W676 Err(e) => {
Matt W677 tracing::warn!("diffing {rev} failed: {e}");
Matt W678 None
Matt W679 }
Matt W680 };
Matt W681
Matt W682 let handles = handles_for_emails(
Matt W683 &state,
Matt W684 [revision.author.email.as_str(), revision.committer.email.as_str()],
Matt W685 )
Matt W686 .await;
Matt W687
Matt W688 let body = v::commit_view(
Matt W689 &ctx,
Matt W690 v::CommitPage {
Matt W691 rev: &revision,
Matt W692 author_handle: handles.get(&revision.author.email).map(String::as_str),
Matt W693 committer_handle: handles.get(&revision.committer.email).map(String::as_str),
Matt W694 diff: diff.as_ref(),
Matt W695 collapsed: q.collapse.is_some(),
Matt W696 },
Matt W697 );
Matt W698
Matt W699 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
Matt W700}
Matt W701
Matt W702/// `GET /{owner}/{repo}/bookmarks`
Matt W703pub async fn bookmarks(
Matt W704 State(state): State<AppState>,
Matt W705 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W706 CurrentUser(user): CurrentUser,
Matt W707 CsrfToken(csrf): CsrfToken,
Matt W708 Nonce(nonce): Nonce,
Matt W709) -> AppResult<Response> {
Matt W710 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W711 let marks = bookmark_rows(&state, &ctx).await?;
Matt W712
Matt W713 let body = v::bookmarks_view(&ctx, &marks);
Matt W714 Ok(render(&ctx, "bookmarks", &csrf, &nonce, user.as_deref(), body))
Matt W715}
Matt W716
Matt W717/// Explain a plain-text fallback to the reader.
Matt W718///
Matt W719/// "No grammar for this file type" is not worth saying — most files in a
Matt W720/// repository have no grammar and the absence of colour is unremarkable. A file
Matt W721/// that *would* have been highlighted but was skipped for size is worth saying,
Matt W722/// because the reader can otherwise not tell it from a broken renderer.
Matt W723fn plain_reason(s: df_render::highlight::Skipped) -> Option<&'static str> {
Matt W724 use df_render::highlight::Skipped;
Matt W725 match s {
Matt W726 Skipped::NoGrammar => None,
Matt W727 Skipped::TooLarge => Some("too large to highlight"),
Matt W728 Skipped::LineTooLong => Some("lines too long to highlight"),
Matt W729 Skipped::Failed => Some("could not be highlighted"),
Matt W730 }
Matt W731}
Matt W732
Matt W733// ─── creation ────────────────────────────────────────────────────────────────
Matt W734
Matt W735/// `GET /new`
Matt W736pub async fn new_form(
Matt W737 State(state): State<AppState>,
Matt W738 CurrentUser(user): CurrentUser,
Matt W739 CsrfToken(csrf): CsrfToken,
Matt W740 Nonce(nonce): Nonce,
Matt W741) -> AppResult<Response> {
Matt W742 let Some(user) = user else {
Matt W743 return Err(AppError::Unauthorized);
Matt W744 };
Matt W745 let owners = owner_choices(&state, &user).await?;
Matt W746
Matt W747 Ok(views::page(
Matt W748 Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce },
Matt W749 v::new_repo_form(&csrf, None, &owners),
Matt W750 )
Matt W751 .into_response())
Matt W752}
Matt W753
Matt W754#[derive(Deserialize)]
Matt W755pub struct CreateRepo {
Matt W756 pub owner: String,
Matt W757 pub name: String,
Matt W758 pub description: Option<String>,
Matt W759 pub default_bookmark: String,
Matt W760 pub private: Option<String>,
Matt W761}
Matt W762
Matt W763/// `POST /repos`
Matt W764pub async fn create(
Matt W765 State(state): State<AppState>,
Matt W766 CurrentUser(user): CurrentUser,
Matt W767 CsrfToken(csrf): CsrfToken,
Matt W768 Nonce(nonce): Nonce,
Matt W769 Form(form): Form<CreateRepo>,
Matt W770) -> AppResult<Response> {
Matt W771 let Some(user) = user else {
Matt W772 return Err(AppError::Unauthorized);
Matt W773 };
Matt W774
Matt W775 let owners = owner_choices(&state, &user).await?;
Matt W776 let reject = |msg: &str| -> Response {
Matt W777 views::page(
Matt W778 Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce },
Matt W779 v::new_repo_form(&csrf, Some(msg), &owners),
Matt W780 )
Matt W781 .into_response()
Matt W782 };
Matt W783
Matt W784 let name = form.name.trim();
Matt W785 if !valid_repo_name(name) {
Matt W786 return Ok(reject(
Matt W787 "Repository names must start with a letter or digit and may contain \
Matt W788 letters, digits, dots, hyphens and underscores.",
Matt W789 ));
Matt W790 }
Matt W791
Matt W792 let bookmark = form.default_bookmark.trim();
Matt W793 if !valid_bookmark_name(bookmark) {
Matt W794 return Ok(reject("That is not a valid bookmark name."));
Matt W795 }
Matt W796
Matt W797 // The namespace must be the user's own, or an org they administer. Checked
Matt W798 // against the database rather than against the form's own option list — the
Matt W799 // list is a convenience, not a control.
Matt W800 let owner_org: Option<Uuid> = if form.owner == user.handle {
Matt W801 None
Matt W802 } else {
Matt W803 let org: Option<(Uuid,)> = sqlx::query_as(
Matt W804 "SELECT o.id FROM orgs o
Matt W805 JOIN org_members m ON m.org_id = o.id
Matt W806 WHERE o.handle = $1 AND m.user_id = $2 AND m.role = 'admin'",
Matt W807 )
Matt W808 .bind(&form.owner)
Matt W809 .bind(user.id)
Matt W810 .fetch_optional(&state.db)
Matt W811 .await?;
Matt W812
Matt W813 match org {
Matt W814 Some((id,)) => Some(id),
Matt W815 None => return Err(AppError::Forbidden),
Matt W816 }
Matt W817 };
Matt W818
Matt W819 let repo_id = new_id();
Matt W820 let visibility = if form.private.is_some() { "private" } else { "public" };
Matt W821
Matt W822 // Create the database row first. If storage creation then fails we can
Matt W823 // roll the row back; the reverse ordering would leave an orphan directory
Matt W824 // with no owner and no way to reach it.
Matt W825 let mut tx = state.db.begin().await?;
Matt W826
Matt W827 let inserted = sqlx::query(
Matt W828 "INSERT INTO repos (id, owner_kind, owner_user_id, owner_org_id, name, description,
Matt W829 visibility, default_bookmark)
Matt W830 VALUES ($1,
Matt W831 CASE WHEN $7::uuid IS NULL THEN 'user' ELSE 'org' END::owner_kind,
Matt W832 CASE WHEN $7::uuid IS NULL THEN $2 ELSE NULL END,
Matt W833 $7, $3, $4, $5::visibility, $6)
Matt W834 ON CONFLICT DO NOTHING",
Matt W835 )
Matt W836 .bind(repo_id)
Matt W837 .bind(user.id)
Matt W838 .bind(name)
Matt W839 .bind(form.description.as_deref().filter(|d| !d.trim().is_empty()))
Matt W840 .bind(visibility)
Matt W841 .bind(bookmark)
Matt W842 .bind(owner_org)
Matt W843 .execute(&mut *tx)
Matt W844 .await?;
Matt W845
Matt W846 if inserted.rows_affected() == 0 {
Matt W847 return Ok(reject("That namespace already has a repository with that name."));
Matt W848 }
Matt W849
Matt W850 sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
Matt W851 .bind(repo_id)
Matt W852 .execute(&mut *tx)
Matt W853 .await?;
Matt W854
Matt W855 // Default labels, so the issue form has something to offer on day one.
Matt W856 // Kept in step with the backfill in migration 0003.
Matt W857 for (label, color) in [
Matt W858 ("bug", "#d06b6b"),
Matt W859 ("enhancement", "#6ba9b8"),
Matt W860 ("question", "#d9a441"),
Matt W861 ("documentation", "#8b7fd4"),
Matt W862 ] {
Matt W863 sqlx::query(
Matt W864 "INSERT INTO labels (id, repo_id, name, color) VALUES ($1, $2, $3, $4)
Matt W865 ON CONFLICT DO NOTHING",
Matt W866 )
Matt W867 .bind(new_id())
Matt W868 .bind(repo_id)
Matt W869 .bind(label)
Matt W870 .bind(color)
Matt W871 .execute(&mut *tx)
Matt W872 .await?;
Matt W873 }
Matt W874
Matt W875 sqlx::query(
Matt W876 "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id)
Matt W877 VALUES ($1, $2, $3, 'repo.created', 'repo', $2)",
Matt W878 )
Matt W879 .bind(new_id())
Matt W880 .bind(repo_id)
Matt W881 .bind(user.id)
Matt W882 .execute(&mut *tx)
Matt W883 .await?;
Matt W884
Matt W885 if let Err(e) = state
Matt W886 .store
Matt W887 .create(df_store::RepoId(repo_id), bookmark)
Matt W888 .await
Matt W889 {
Matt W890 tracing::error!("creating repository storage failed: {e}");
Matt W891 tx.rollback().await?;
Matt W892 return Ok(reject(
Matt W893 "The repository could not be created on disk. Please try again.",
Matt W894 ));
Matt W895 }
Matt W896
Matt W897 // Push validation, before the repository can receive a push. A repository
Matt W898 // without this hook accepts anything: no ref-name allowlist, no protected
Matt W899 // bookmarks (spec §9). Failing to install it fails the creation rather than
Matt W900 // producing a repository that is quietly unvalidated.
Matt W901 if let Err(e) =
Matt W902 crate::hooks::install(state.store.as_ref(), repo_id, &state.config.hook_binary).await
Matt W903 {
Matt W904 tracing::error!(repo = %repo_id, "{e:#}");
Matt W905 tx.rollback().await?;
Matt W906 let _ = state.store.delete(df_store::RepoId(repo_id)).await;
Matt W907 return Ok(reject(
Matt W908 "The repository could not be prepared to receive pushes. Please try again.",
Matt W909 ));
Matt W910 }
Matt W911
Matt W912 tx.commit().await?;
Matt W913 tracing::info!(repo = %repo_id, owner = %form.owner, name = %name, "repository created");
Matt W914
Matt W915 Ok(Redirect::to(&format!("/{}/{}", form.owner, name)).into_response())
Matt W916}
Matt W917
Matt W918// ─── helpers ─────────────────────────────────────────────────────────────────
Matt W919
Matt W920/// Namespaces this user may create a repository in: their own, plus every org
Matt W921/// they administer.
Matt W922///
Matt W923/// Org *members* are deliberately excluded — membership grants read access to
Matt W924/// the org's repositories, not the right to add one.
Matt W925async fn owner_choices(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<String>> {
Matt W926 let mut owners = vec![user.handle.clone()];
Matt W927 let orgs: Vec<String> = sqlx::query_scalar(
Matt W928 "SELECT o.handle::text FROM orgs o
Matt W929 JOIN org_members m ON m.org_id = o.id
Matt W930 WHERE m.user_id = $1 AND m.role = 'admin'
Matt W931 ORDER BY o.handle",
Matt W932 )
Matt W933 .bind(user.id)
Matt W934 .fetch_all(&state.db)
Matt W935 .await?;
Matt W936 owners.extend(orgs);
Matt W937 Ok(owners)
Matt W938}
Matt W939
Matt W940/// Mirror of the `name_format` CHECK on `repos`.
Matt W941fn valid_repo_name(n: &str) -> bool {
Matt W942 !n.is_empty()
Matt W943 && n.len() <= 100
Matt W944 && !n.contains("..")
Matt W945 && n.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
Matt W946 && n.chars()
Matt W947 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
Matt W948}
Matt W949
Matt W950/// Git ref-name rules, plus the stricter allowlist from spec §9.
Matt W951pub fn valid_bookmark_name(n: &str) -> bool {
Matt W952 !n.is_empty()
Matt W953 && n.len() <= 100
Matt W954 && !n.contains("..")
Matt W955 && !n.ends_with(".lock")
Matt W956 && !n.starts_with('-')
Matt W957 && !n.starts_with('/')
Matt W958 && !n.ends_with('/')
Matt W959 && !n.contains("//")
Matt W960 && !n.contains('\\')
Matt W961 && !n.contains("@{")
Matt W962 && n.chars()
Matt W963 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
Matt W964}
Matt W965
Matt W966fn render(
Matt W967 ctx: &RepoContext,
Matt W968 tab: &str,
Matt W969 csrf: &str,
Matt W970 nonce: &str,
Matt W971 user: Option<&df_db::models::User>,
Matt W972 body: maud::Markup,
Matt W973) -> Response {
Matt W974 views::page_with_bar(
Matt W975 Chrome {
Matt W976 title: &format!("{}/{}", ctx.owner, ctx.repo.name),
Matt W977 user,
Matt W978 csrf,
Matt W979 nonce,
Matt W980 },
Matt W981 v::header(ctx, tab),
Matt W982 body,
Matt W983 )
Matt W984 .into_response()
Matt W985}
Matt W986
Matt W987/// Render a README if the directory has one.
Matt W988///
Matt W989/// Returns the file's own name alongside the markup so the card can be labelled
Matt W990/// with what was actually found — a repository with a bare `README` should not
Matt W991/// be told it has a `README.md`.
Matt W992async fn render_readme(
Matt W993 state: &AppState,
Matt W994 ctx: &RepoContext,
Matt W995 rev: &df_store::RevId,
Matt W996 entries: &[df_store::TreeEntry],
Matt W997) -> Option<(String, maud::Markup)> {
Matt W998 let readme = entries.iter().find(|e| {
Matt W999 !e.is_dir()
Matt W1000 && matches!(
Matt W1001 e.name.to_lowercase().as_str(),
Matt W1002 "readme.md" | "readme" | "readme.markdown" | "readme.txt"
Matt W1003 )
Matt W1004 })?;
Matt W1005
Matt W1006 let blob = state
Matt W1007 .store
Matt W1008 .read_blob(ctx.store_id(), rev, Path::new(&readme.path))
Matt W1009 .await
Matt W1010 .ok()?;
Matt W1011 let text = blob.text()?;
Matt W1012
Matt W1013 // Always sanitised — never trust repository content (spec §9).
Matt W1014 let html_str = df_render::markdown_to_html(text);
Matt W1015 Some((readme.name.clone(), v::rendered_markdown(&html_str)))
Matt W1016}
Matt W1017
Matt W1018#[cfg(test)]
Matt W1019mod tests {
Matt W1020 use super::*;
Matt W1021
Matt W1022 #[test]
Matt W1023 fn repo_name_validation_mirrors_the_db_constraint() {
Matt W1024 assert!(valid_repo_name("dogfood"));
Matt W1025 assert!(valid_repo_name("my.repo_name-1"));
Matt W1026 assert!(valid_repo_name("9lives"));
Matt W1027
Matt W1028 assert!(!valid_repo_name(""));
Matt W1029 assert!(!valid_repo_name(".hidden"), "must start alphanumeric");
Matt W1030 assert!(!valid_repo_name("-dash"));
Matt W1031 assert!(!valid_repo_name("a..b"), "traversal must be rejected");
Matt W1032 assert!(!valid_repo_name("with space"));
Matt W1033 assert!(!valid_repo_name("with/slash"));
Matt W1034 assert!(!valid_repo_name(&"a".repeat(101)));
Matt W1035 }
Matt W1036
Matt W1037 #[test]
Matt W1038 fn bookmark_validation_follows_the_ref_rules() {
Matt W1039 assert!(valid_bookmark_name("main"));
Matt W1040 assert!(valid_bookmark_name("feature/thing"));
Matt W1041 assert!(valid_bookmark_name("v1.2.3"));
Matt W1042
Matt W1043 // Spec §9: reject refs containing `..`, ending in `.lock`, starting
Matt W1044 // with `-`, or containing control characters.
Matt W1045 assert!(!valid_bookmark_name("a..b"));
Matt W1046 assert!(!valid_bookmark_name("main.lock"));
Matt W1047 assert!(!valid_bookmark_name("-main"));
Matt W1048 assert!(!valid_bookmark_name("main\n"));
Matt W1049 assert!(!valid_bookmark_name("main\x1b[31m"));
Matt W1050 assert!(!valid_bookmark_name("a//b"));
Matt W1051 assert!(!valid_bookmark_name("/main"));
Matt W1052 assert!(!valid_bookmark_name("main/"));
Matt W1053 assert!(!valid_bookmark_name("main@{1}"));
Matt W1054 assert!(!valid_bookmark_name("back\\slash"));
Matt W1055 assert!(!valid_bookmark_name(""));
Matt W1056 }
Matt W1057}

1057 lines · Rust