Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! Repository browsing (M2) and creation.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use axum::extract::{Path as UrlPath, Query, State};
7use axum::response::{IntoResponse, Redirect, Response};
8use axum::Form;
9use df_db::ids::new_id;
10use df_store::StoreError;
11use serde::Deserialize;
12use uuid::Uuid;
13
14use crate::error::{AppError, AppResult};
15use crate::repo_ctx::RepoContext;
16use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
17use crate::views::repo as v;
18use crate::views::{self, Chrome};
19
20/// Map a store error to an HTTP outcome.
21///
22/// `NoSuchRepo` becomes a 404 rather than a 500: it means the database and the
23/// filesystem disagree, which is a real state after a restore (spec §10) and
24/// should look like a missing repository, not a crash.
25fn store_err(e: StoreError) -> AppError {
26 match e {
27 StoreError::NoSuchRepo | StoreError::NoSuchRevision | StoreError::NoSuchPath => {
28 AppError::NotFound
29 }
30 StoreError::IsDirectory => AppError::BadRequest("that path is a directory".into()),
31 StoreError::Path(p) => AppError::BadRequest(p.to_string()),
32 other => AppError::Internal(anyhow::anyhow!(other)),
33 }
34}
35
36/// `GET /{owner}/{repo}` — code view at the default bookmark.
37pub async fn index(
38 State(state): State<AppState>,
39 UrlPath((owner, name)): UrlPath<(String, String)>,
40 CurrentUser(user): CurrentUser,
41 CsrfToken(csrf): CsrfToken,
42 Nonce(nonce): Nonce,
43) -> AppResult<Response> {
44 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
45
46 let empty = state
47 .store
48 .is_empty(ctx.store_id())
49 .await
50 .unwrap_or(true);
51
52 let body = if empty {
53 v::empty_repo(
54 &state.config.https_clone_url(&ctx.owner, &ctx.repo.name),
55 &state.config.ssh_clone_url(&ctx.owner, &ctx.repo.name),
56 &ctx.repo.default_bookmark,
57 )
58 } else {
59 let rev_label = ctx.repo.default_bookmark.clone();
60 let rev = state
61 .store
62 .resolve(ctx.store_id(), &rev_label)
63 .await
64 .map_err(store_err)?;
65 let entries = state
66 .store
67 .list_tree(ctx.store_id(), &rev, Path::new(""))
68 .await
69 .map_err(store_err)?;
70
71 let readme = render_readme(&state, &ctx, &rev, &entries).await;
72 let tip = tip_commit(&state, &ctx, &rev).await;
73 let last_commits = last_commits_for(&state, &ctx, &rev, Path::new(""), &entries).await;
74
75 let marks = bookmark_rows(&state, &ctx).await?;
76 let (contributors, size_bytes) = repo_vitals(&state, &ctx).await;
77 let https = state.config.https_clone_url(&ctx.owner, &ctx.repo.name);
78 let ssh = state.config.ssh_clone_url(&ctx.owner, &ctx.repo.name);
79 let sidebar = v::RepoSidebar {
80 https: &https,
81 ssh: &ssh,
82 open_changes: ctx.nav.open_changes,
83 conflicted: ctx.nav.conflicted,
84 open_issues: ctx.nav.open_issues,
85 contributors,
86 size_bytes,
87 bookmarks: &marks,
88 };
89
90 v::tree_listing(
91 &ctx,
92 v::Tree {
93 rev_label: &rev_label,
94 path: "",
95 entries: &entries,
96 tip: tip.as_ref(),
97 readme: readme.as_ref(),
98 history: &last_commits,
99 sidebar: Some(&sidebar),
100 },
101 )
102 };
103
104 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
105}
106
107/// Bookmarks with the change each one points at.
108///
109/// The `LATERAL` subquery is scoped to the bookmark's own repository: a
110/// revision id is only unique *within* a repository, so joining `revisions` on
111/// `rev` alone would let one repository's bookmark resolve to another's change.
112/// `(name, protected, updated_at, change_id, number, title)`
113type BookmarkTuple = (
114 String,
115 bool,
116 chrono::DateTime<chrono::Utc>,
117 Option<String>,
118 Option<i64>,
119 Option<String>,
120);
121
122async fn bookmark_rows(state: &AppState, ctx: &RepoContext) -> AppResult<Vec<v::MarkRow>> {
123 let rows: Vec<BookmarkTuple> = sqlx::query_as(
124 r#"
125 SELECT b.name,
126 b.protected,
127 b.updated_at,
128 c.change_id,
129 c.number,
130 c.title
131 FROM bookmarks b
132 LEFT JOIN LATERAL (
133 SELECT ch.change_id, ch.number, ch.title
134 FROM revisions r
135 JOIN changes ch ON ch.id = r.change_id_fk
136 WHERE ch.repo_id = b.repo_id AND r.rev = b.target
137 LIMIT 1
138 ) c ON true
139 WHERE b.repo_id = $1
140 ORDER BY b.updated_at DESC
141 "#,
142 )
143 .bind(ctx.repo.id)
144 .fetch_all(&state.db)
145 .await?;
146
147 Ok(rows
148 .into_iter()
149 .map(
150 |(name, protected, updated_at, change_id, number, title)| v::MarkRow {
151 name,
152 protected,
153 updated_at,
154 change_id,
155 number,
156 title,
157 },
158 )
159 .collect())
160}
161
162/// Contributor count and on-disk size for the sidebar.
163///
164/// Best-effort on both counts: the sidebar is context, and neither number is
165/// worth failing a page render over. Contributors are distinct commit *emails*
166/// rather than linked accounts — somebody who has pushed but never signed in is
167/// still a contributor.
168async fn repo_vitals(state: &AppState, ctx: &RepoContext) -> (i64, u64) {
169 let contributors: i64 = sqlx::query_scalar(
170 r#"
171 SELECT count(DISTINCT r.author_email)
172 FROM revisions r
173 JOIN changes c ON c.id = r.change_id_fk
174 WHERE c.repo_id = $1
175 "#,
176 )
177 .bind(ctx.repo.id)
178 .fetch_one(&state.db)
179 .await
180 .unwrap_or(0);
181
182 // The stored size is what the last indexer pass recorded; asking the store
183 // is exact but walks the directory, so it is the fallback rather than the
184 // first choice on a page that renders on every visit.
185 let size = if ctx.repo.size_bytes > 0 {
186 ctx.repo.size_bytes as u64
187 } else {
188 state.store.size_bytes(ctx.store_id()).await.unwrap_or(0)
189 };
190
191 (contributors, size)
192}
193
194#[derive(Deserialize)]
195pub struct RevPath {
196 pub owner: String,
197 pub repo: String,
198 pub rev: String,
199 #[serde(default)]
200 pub path: String,
201}
202
203/// `GET /{owner}/{repo}/tree/{rev}/{path...}`
204pub async fn tree(
205 State(state): State<AppState>,
206 UrlPath(p): UrlPath<RevPath>,
207 CurrentUser(user): CurrentUser,
208 CsrfToken(csrf): CsrfToken,
209 Nonce(nonce): Nonce,
210) -> AppResult<Response> {
211 let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
212
213 let rev = state
214 .store
215 .resolve(ctx.store_id(), &p.rev)
216 .await
217 .map_err(store_err)?;
218
219 let entries = state
220 .store
221 .list_tree(ctx.store_id(), &rev, Path::new(&p.path))
222 .await
223 .map_err(store_err)?;
224
225 let readme = if p.path.is_empty() {
226 render_readme(&state, &ctx, &rev, &entries).await
227 } else {
228 None
229 };
230
231 let tip = tip_commit(&state, &ctx, &rev).await;
232 let last_commits =
233 last_commits_for(&state, &ctx, &rev, Path::new(&p.path), &entries).await;
234
235 // No sidebar below the root: the reader is looking at files, and repeating
236 // the clone commands beside every folder is noise.
237 let body = v::tree_listing(
238 &ctx,
239 v::Tree {
240 rev_label: &p.rev,
241 path: &p.path,
242 entries: &entries,
243 tip: tip.as_ref(),
244 readme: readme.as_ref(),
245 history: &last_commits,
246 sidebar: None,
247 },
248 );
249 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
250}
251
252/// The revision at the tip of what is being browsed.
253///
254/// Best-effort: the listing is the point of the page, so a store that cannot
255/// produce a log still renders the files rather than failing the request.
256async fn tip_commit(
257 state: &AppState,
258 ctx: &RepoContext,
259 rev: &df_store::RevId,
260) -> Option<v::TipCommit> {
261 let revisions = state.store.log(ctx.store_id(), rev, 1).await.ok()?;
262 let r = revisions.into_iter().next()?;
263
264 Some(v::TipCommit {
265 author: r.author.name.clone(),
266 author_handle: handle_for_email(state, &r.author.email).await,
267 summary: r.summary().to_string(),
268 when: r.author.when,
269 change_id: r.change_id.clone(),
270 })
271}
272
273/// The last commit that touched each entry of a directory listing.
274///
275/// One `last_commits_in_dir` call resolves the whole directory (see its docs
276/// for why that beats a lookup per file). Best-effort like `tip_commit`: a
277/// store that cannot walk history still renders the listing, just without
278/// this column.
279async fn last_commits_for(
280 state: &AppState,
281 ctx: &RepoContext,
282 rev: &df_store::RevId,
283 dir: &Path,
284 entries: &[df_store::TreeEntry],
285) -> HashMap<String, v::EntryHistory> {
286 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
287
288 let revisions = state
289 .store
290 .last_commits_in_dir(ctx.store_id(), rev, dir, &names)
291 .await
292 .unwrap_or_default();
293
294 revisions
295 .into_iter()
296 .map(|(name, r)| {
297 (
298 name,
299 v::EntryHistory {
300 summary: r.summary().to_string(),
301 when: r.author.when,
302 change_id: r.change_id,
303 },
304 )
305 })
306 .collect()
307}
308
309/// The account that owns a commit-author email, if any.
310///
311/// The same rule the indexer attributes changes by, applied to a commit read
312/// straight from the store rather than from the index. Best-effort: a lookup
313/// failure renders the commit's own name rather than failing the page.
314pub(crate) async fn handle_for_email(state: &AppState, email: &str) -> Option<String> {
315 let email = email.trim();
316 if email.is_empty() {
317 return None;
318 }
319 sqlx::query_scalar::<_, String>("SELECT handle::text FROM users WHERE email = $1")
320 .bind(email)
321 .fetch_optional(&state.db)
322 .await
323 .ok()
324 .flatten()
325}
326
327/// The same lookup for a whole listing, in one round trip.
328///
329/// A page of 200 commits must not become 200 queries. Returns email → handle
330/// for the ones that matched; callers fall back to the commit's own name.
331pub(crate) async fn handles_for_emails<'a>(
332 state: &AppState,
333 emails: impl IntoIterator<Item = &'a str>,
334) -> HashMap<String, String> {
335 let mut wanted: Vec<String> = emails
336 .into_iter()
337 .map(str::trim)
338 .filter(|e| !e.is_empty())
339 .map(str::to_owned)
340 .collect();
341 wanted.sort();
342 wanted.dedup();
343
344 if wanted.is_empty() {
345 return HashMap::new();
346 }
347
348 // `email` is citext, so the join is case-insensitive without lowering here.
349 sqlx::query_as::<_, (String, String)>(
350 "SELECT email::text, handle::text FROM users WHERE email = ANY($1)",
351 )
352 .bind(&wanted)
353 .fetch_all(&state.db)
354 .await
355 .unwrap_or_else(|e| {
356 tracing::warn!("resolving commit authors failed: {e}");
357 Vec::new()
358 })
359 .into_iter()
360 .collect()
361}
362
363/// Is this path a markdown document?
364///
365/// Extension-based on purpose: sniffing content would mean a file that happens
366/// to start with a `#` gets rendered as a heading, which is worse than a `.txt`
367/// missing out on rendering.
368fn is_markdown(path: &str) -> bool {
369 let lower = path.to_lowercase();
370 lower.ends_with(".md") || lower.ends_with(".markdown")
371}
372
373#[derive(Deserialize, Default)]
374pub struct BlobQuery {
375 /// `source` shows the highlighted source of a markdown file instead of the
376 /// rendered document. Anything else — including absent — renders.
377 #[serde(default)]
378 pub view: Option<String>,
379 /// When `blame=1`, show per-line blame annotations.
380 #[serde(default)]
381 pub blame: Option<String>,
382}
383
384/// `GET /{owner}/{repo}/blob/{rev}/{path...}`
385pub async fn blob(
386 State(state): State<AppState>,
387 UrlPath(p): UrlPath<RevPath>,
388 Query(q): Query<BlobQuery>,
389 CurrentUser(user): CurrentUser,
390 CsrfToken(csrf): CsrfToken,
391 Nonce(nonce): Nonce,
392) -> AppResult<Response> {
393 let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
394
395 let rev = state
396 .store
397 .resolve(ctx.store_id(), &p.rev)
398 .await
399 .map_err(store_err)?;
400
401 let wants_source = q.view.as_deref() == Some("source");
402 let wants_blame = q.blame.as_deref() == Some("1");
403 let markdown_path = is_markdown(&p.path);
404
405 // Fetch the file tree for the sidebar (directory containing this file).
406 let tree_dir = match p.path.rsplit_once('/') {
407 Some((parent, _)) => parent.to_string(),
408 None => String::new(),
409 };
410 let sidebar_entries = state
411 .store
412 .list_tree(ctx.store_id(), &rev, Path::new(&tree_dir))
413 .await
414 .unwrap_or_default();
415
416 // Fetch the last commit that modified this file.
417 let last_commit = state
418 .store
419 .last_commit_for_path(ctx.store_id(), &rev, Path::new(&p.path))
420 .await
421 .ok()
422 .flatten();
423
424 let last_commit_handle = match &last_commit {
425 Some(c) => handle_for_email(&state, &c.author.email).await,
426 None => None,
427 };
428
429 // Optionally fetch blame.
430 let blame_lines = if wants_blame {
431 state
432 .store
433 .blame(ctx.store_id(), &rev, Path::new(&p.path))
434 .await
435 .ok()
436 } else {
437 None
438 };
439
440 let body = match state
441 .store
442 .read_blob(ctx.store_id(), &rev, Path::new(&p.path))
443 .await
444 {
445 Ok(b) => {
446 let limit = state.config.max_blob_render_bytes as u64;
447 if b.size > limit {
448 v::blob_view(
449 &ctx,
450 &p.rev,
451 &p.path,
452 v::BlobBody::TooLarge { size: b.size, limit },
453 None,
454 &v::BlobExtras::default(),
455 )
456 } else if b.text().is_some() {
457 // Highlighting is cached by content address, so re-viewing a
458 // file — or viewing it at a revision that did not change it —
459 // never re-parses (spec §8).
460 let hl = crate::highlight_cache::render(&state.db, &b).await;
461 let text = b.text().unwrap_or("");
462 let lines = text.lines().count();
463
464 // Always sanitised — never trust repository content (spec §9).
465 let rendered = (markdown_path && !wants_source)
466 .then(|| v::rendered_markdown(&df_render::markdown_to_html(text)));
467
468 let markdown = match (markdown_path, &rendered) {
469 (true, Some(doc)) => Some(v::MarkdownView::Rendered(doc)),
470 (true, None) => Some(v::MarkdownView::Source),
471 (false, _) => None,
472 };
473
474 // Extract symbols for the outline panel.
475 let symbols = df_render::symbols::extract_symbols(&p.path, text);
476
477 let extras = v::BlobExtras {
478 sidebar_entries: &sidebar_entries,
479 sidebar_dir: &tree_dir,
480 symbols: &symbols,
481 last_commit: last_commit.as_ref(),
482 last_commit_handle: last_commit_handle.as_deref(),
483 blame: blame_lines.as_deref(),
484 wants_blame,
485 };
486
487 v::blob_view(
488 &ctx,
489 &p.rev,
490 &p.path,
491 v::BlobBody::Text {
492 content: text,
493 lines,
494 highlighted: &hl.lines,
495 language: hl.language.as_deref(),
496 plain_reason: hl.skipped.and_then(plain_reason),
497 },
498 markdown,
499 &extras,
500 )
501 } else {
502 v::blob_view(
503 &ctx,
504 &p.rev,
505 &p.path,
506 v::BlobBody::Binary { size: b.size },
507 None,
508 &v::BlobExtras::default(),
509 )
510 }
511 }
512 // The store's own cap fired before ours.
513 Err(StoreError::TooLarge { size, limit }) => {
514 v::blob_view(
515 &ctx,
516 &p.rev,
517 &p.path,
518 v::BlobBody::TooLarge { size, limit },
519 None,
520 &v::BlobExtras::default(),
521 )
522 }
523 Err(e) => return Err(store_err(e)),
524 };
525
526 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
527}
528
529/// `GET /{owner}/{repo}/raw/{rev}/{path...}`
530///
531/// Spec §9: "Blob content served ... with `Content-Disposition: attachment` and
532/// `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on the
533/// app origin." Everything here is `application/octet-stream` and downloaded,
534/// so a repository containing an HTML page cannot execute against our origin.
535pub async fn raw(
536 State(state): State<AppState>,
537 UrlPath(p): UrlPath<RevPath>,
538 CurrentUser(user): CurrentUser,
539) -> AppResult<Response> {
540 let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
541
542 let rev = state
543 .store
544 .resolve(ctx.store_id(), &p.rev)
545 .await
546 .map_err(store_err)?;
547
548 let blob = state
549 .store
550 .read_blob(ctx.store_id(), &rev, Path::new(&p.path))
551 .await
552 .map_err(store_err)?;
553
554 let filename = p
555 .path
556 .rsplit('/')
557 .next()
558 .filter(|s| !s.is_empty())
559 .unwrap_or("file");
560
561 // The filename reaches a header; strip anything that could break out of the
562 // quoted string or inject a second header.
563 let safe_name: String = filename
564 .chars()
565 .filter(|c| !matches!(c, '"' | '\\' | '\r' | '\n') && !c.is_control())
566 .take(200)
567 .collect();
568
569 Ok((
570 [
571 (
572 axum::http::header::CONTENT_TYPE,
573 "application/octet-stream".to_string(),
574 ),
575 (
576 axum::http::header::CONTENT_DISPOSITION,
577 format!("attachment; filename=\"{safe_name}\""),
578 ),
579 (
580 axum::http::header::X_CONTENT_TYPE_OPTIONS,
581 "nosniff".to_string(),
582 ),
583 ],
584 blob.content,
585 )
586 .into_response())
587}
588
589#[derive(Deserialize)]
590pub struct LogQuery {
591 pub rev: Option<String>,
592 pub limit: Option<usize>,
593}
594
595/// `GET /{owner}/{repo}/log`
596pub async fn log(
597 State(state): State<AppState>,
598 UrlPath((owner, name)): UrlPath<(String, String)>,
599 Query(q): Query<LogQuery>,
600 CurrentUser(user): CurrentUser,
601 CsrfToken(csrf): CsrfToken,
602 Nonce(nonce): Nonce,
603) -> AppResult<Response> {
604 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
605
606 let rev_label = q.rev.unwrap_or_else(|| ctx.repo.default_bookmark.clone());
607 let rev = state
608 .store
609 .resolve(ctx.store_id(), &rev_label)
610 .await
611 .map_err(store_err)?;
612
613 // Clamped: a URL must not be able to ask for unbounded history.
614 let limit = q.limit.unwrap_or(50).clamp(1, 200);
615 let revs = state
616 .store
617 .log(ctx.store_id(), &rev, limit)
618 .await
619 .map_err(store_err)?;
620
621 // One query for every author in the page rather than one per commit.
622 let handles =
623 handles_for_emails(&state, revs.iter().map(|r| r.author.email.as_str())).await;
624
625 let body = v::log_view(&ctx, &rev_label, &revs, &handles);
626 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
627}
628
629#[derive(Deserialize, Default)]
630pub struct CommitQuery {
631 /// Fold every file. A link rather than a script, so the folded view is a
632 /// URL and works with scripting off.
633 pub collapse: Option<String>,
634}
635
636/// `GET /{owner}/{repo}/commit/{rev}`
637///
638/// One commit and its patch against the first parent. A root commit has no
639/// parent and diffs against an empty tree, so the first commit in a repository
640/// renders as an all-additions patch rather than as an error.
641pub async fn commit(
642 State(state): State<AppState>,
643 UrlPath((owner, name, rev_spec)): UrlPath<(String, String, String)>,
644 Query(q): Query<CommitQuery>,
645 CurrentUser(user): CurrentUser,
646 CsrfToken(csrf): CsrfToken,
647 Nonce(nonce): Nonce,
648) -> AppResult<Response> {
649 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
650
651 // Bookmark names resolve too: `/commit/main` is a reasonable thing to type,
652 // and it lands on whichever commit that name points at right now.
653 let rev = state
654 .store
655 .resolve(ctx.store_id(), &rev_spec)
656 .await
657 .map_err(store_err)?;
658
659 let revision = state
660 .store
661 .revision(ctx.store_id(), &rev)
662 .await
663 .map_err(store_err)?;
664
665 let opts = df_store::DiffOpts {
666 context_lines: 3,
667 max_files: state.config.max_diff_files,
668 max_lines: state.config.max_diff_lines,
669 };
670
671 // Best-effort: a commit whose patch cannot be rendered still has a message,
672 // an author and parents worth showing, and the view says so in place of the
673 // diff rather than 500-ing the page.
674 let diff = match state.store.diff_from_parent(ctx.store_id(), &rev, opts).await {
675 Ok(d) => Some(d),
676 Err(e) => {
677 tracing::warn!("diffing {rev} failed: {e}");
678 None
679 }
680 };
681
682 let handles = handles_for_emails(
683 &state,
684 [revision.author.email.as_str(), revision.committer.email.as_str()],
685 )
686 .await;
687
688 let body = v::commit_view(
689 &ctx,
690 v::CommitPage {
691 rev: &revision,
692 author_handle: handles.get(&revision.author.email).map(String::as_str),
693 committer_handle: handles.get(&revision.committer.email).map(String::as_str),
694 diff: diff.as_ref(),
695 collapsed: q.collapse.is_some(),
696 },
697 );
698
699 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
700}
701
702/// `GET /{owner}/{repo}/bookmarks`
703pub async fn bookmarks(
704 State(state): State<AppState>,
705 UrlPath((owner, name)): UrlPath<(String, String)>,
706 CurrentUser(user): CurrentUser,
707 CsrfToken(csrf): CsrfToken,
708 Nonce(nonce): Nonce,
709) -> AppResult<Response> {
710 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
711 let marks = bookmark_rows(&state, &ctx).await?;
712
713 let body = v::bookmarks_view(&ctx, &marks);
714 Ok(render(&ctx, "bookmarks", &csrf, &nonce, user.as_deref(), body))
715}
716
717/// Explain a plain-text fallback to the reader.
718///
719/// "No grammar for this file type" is not worth saying — most files in a
720/// repository have no grammar and the absence of colour is unremarkable. A file
721/// that *would* have been highlighted but was skipped for size is worth saying,
722/// because the reader can otherwise not tell it from a broken renderer.
723fn plain_reason(s: df_render::highlight::Skipped) -> Option<&'static str> {
724 use df_render::highlight::Skipped;
725 match s {
726 Skipped::NoGrammar => None,
727 Skipped::TooLarge => Some("too large to highlight"),
728 Skipped::LineTooLong => Some("lines too long to highlight"),
729 Skipped::Failed => Some("could not be highlighted"),
730 }
731}
732
733// ─── creation ────────────────────────────────────────────────────────────────
734
735/// `GET /new`
736pub async fn new_form(
737 State(state): State<AppState>,
738 CurrentUser(user): CurrentUser,
739 CsrfToken(csrf): CsrfToken,
740 Nonce(nonce): Nonce,
741) -> AppResult<Response> {
742 let Some(user) = user else {
743 return Err(AppError::Unauthorized);
744 };
745 let owners = owner_choices(&state, &user).await?;
746
747 Ok(views::page(
748 Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce },
749 v::new_repo_form(&csrf, None, &owners),
750 )
751 .into_response())
752}
753
754#[derive(Deserialize)]
755pub struct CreateRepo {
756 pub owner: String,
757 pub name: String,
758 pub description: Option<String>,
759 pub default_bookmark: String,
760 pub private: Option<String>,
761}
762
763/// `POST /repos`
764pub async fn create(
765 State(state): State<AppState>,
766 CurrentUser(user): CurrentUser,
767 CsrfToken(csrf): CsrfToken,
768 Nonce(nonce): Nonce,
769 Form(form): Form<CreateRepo>,
770) -> AppResult<Response> {
771 let Some(user) = user else {
772 return Err(AppError::Unauthorized);
773 };
774
775 let owners = owner_choices(&state, &user).await?;
776 let reject = |msg: &str| -> Response {
777 views::page(
778 Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce },
779 v::new_repo_form(&csrf, Some(msg), &owners),
780 )
781 .into_response()
782 };
783
784 let name = form.name.trim();
785 if !valid_repo_name(name) {
786 return Ok(reject(
787 "Repository names must start with a letter or digit and may contain \
788 letters, digits, dots, hyphens and underscores.",
789 ));
790 }
791
792 let bookmark = form.default_bookmark.trim();
793 if !valid_bookmark_name(bookmark) {
794 return Ok(reject("That is not a valid bookmark name."));
795 }
796
797 // The namespace must be the user's own, or an org they administer. Checked
798 // against the database rather than against the form's own option list — the
799 // list is a convenience, not a control.
800 let owner_org: Option<Uuid> = if form.owner == user.handle {
801 None
802 } else {
803 let org: Option<(Uuid,)> = sqlx::query_as(
804 "SELECT o.id FROM orgs o
805 JOIN org_members m ON m.org_id = o.id
806 WHERE o.handle = $1 AND m.user_id = $2 AND m.role = 'admin'",
807 )
808 .bind(&form.owner)
809 .bind(user.id)
810 .fetch_optional(&state.db)
811 .await?;
812
813 match org {
814 Some((id,)) => Some(id),
815 None => return Err(AppError::Forbidden),
816 }
817 };
818
819 let repo_id = new_id();
820 let visibility = if form.private.is_some() { "private" } else { "public" };
821
822 // Create the database row first. If storage creation then fails we can
823 // roll the row back; the reverse ordering would leave an orphan directory
824 // with no owner and no way to reach it.
825 let mut tx = state.db.begin().await?;
826
827 let inserted = sqlx::query(
828 "INSERT INTO repos (id, owner_kind, owner_user_id, owner_org_id, name, description,
829 visibility, default_bookmark)
830 VALUES ($1,
831 CASE WHEN $7::uuid IS NULL THEN 'user' ELSE 'org' END::owner_kind,
832 CASE WHEN $7::uuid IS NULL THEN $2 ELSE NULL END,
833 $7, $3, $4, $5::visibility, $6)
834 ON CONFLICT DO NOTHING",
835 )
836 .bind(repo_id)
837 .bind(user.id)
838 .bind(name)
839 .bind(form.description.as_deref().filter(|d| !d.trim().is_empty()))
840 .bind(visibility)
841 .bind(bookmark)
842 .bind(owner_org)
843 .execute(&mut *tx)
844 .await?;
845
846 if inserted.rows_affected() == 0 {
847 return Ok(reject("That namespace already has a repository with that name."));
848 }
849
850 sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
851 .bind(repo_id)
852 .execute(&mut *tx)
853 .await?;
854
855 // Default labels, so the issue form has something to offer on day one.
856 // Kept in step with the backfill in migration 0003.
857 for (label, color) in [
858 ("bug", "#d06b6b"),
859 ("enhancement", "#6ba9b8"),
860 ("question", "#d9a441"),
861 ("documentation", "#8b7fd4"),
862 ] {
863 sqlx::query(
864 "INSERT INTO labels (id, repo_id, name, color) VALUES ($1, $2, $3, $4)
865 ON CONFLICT DO NOTHING",
866 )
867 .bind(new_id())
868 .bind(repo_id)
869 .bind(label)
870 .bind(color)
871 .execute(&mut *tx)
872 .await?;
873 }
874
875 sqlx::query(
876 "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id)
877 VALUES ($1, $2, $3, 'repo.created', 'repo', $2)",
878 )
879 .bind(new_id())
880 .bind(repo_id)
881 .bind(user.id)
882 .execute(&mut *tx)
883 .await?;
884
885 if let Err(e) = state
886 .store
887 .create(df_store::RepoId(repo_id), bookmark)
888 .await
889 {
890 tracing::error!("creating repository storage failed: {e}");
891 tx.rollback().await?;
892 return Ok(reject(
893 "The repository could not be created on disk. Please try again.",
894 ));
895 }
896
897 // Push validation, before the repository can receive a push. A repository
898 // without this hook accepts anything: no ref-name allowlist, no protected
899 // bookmarks (spec §9). Failing to install it fails the creation rather than
900 // producing a repository that is quietly unvalidated.
901 if let Err(e) =
902 crate::hooks::install(state.store.as_ref(), repo_id, &state.config.hook_binary).await
903 {
904 tracing::error!(repo = %repo_id, "{e:#}");
905 tx.rollback().await?;
906 let _ = state.store.delete(df_store::RepoId(repo_id)).await;
907 return Ok(reject(
908 "The repository could not be prepared to receive pushes. Please try again.",
909 ));
910 }
911
912 tx.commit().await?;
913 tracing::info!(repo = %repo_id, owner = %form.owner, name = %name, "repository created");
914
915 Ok(Redirect::to(&format!("/{}/{}", form.owner, name)).into_response())
916}
917
918// ─── helpers ─────────────────────────────────────────────────────────────────
919
920/// Namespaces this user may create a repository in: their own, plus every org
921/// they administer.
922///
923/// Org *members* are deliberately excluded — membership grants read access to
924/// the org's repositories, not the right to add one.
925async fn owner_choices(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<String>> {
926 let mut owners = vec![user.handle.clone()];
927 let orgs: Vec<String> = sqlx::query_scalar(
928 "SELECT o.handle::text FROM orgs o
929 JOIN org_members m ON m.org_id = o.id
930 WHERE m.user_id = $1 AND m.role = 'admin'
931 ORDER BY o.handle",
932 )
933 .bind(user.id)
934 .fetch_all(&state.db)
935 .await?;
936 owners.extend(orgs);
937 Ok(owners)
938}
939
940/// Mirror of the `name_format` CHECK on `repos`.
941fn valid_repo_name(n: &str) -> bool {
942 !n.is_empty()
943 && n.len() <= 100
944 && !n.contains("..")
945 && n.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
946 && n.chars()
947 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
948}
949
950/// Git ref-name rules, plus the stricter allowlist from spec §9.
951pub fn valid_bookmark_name(n: &str) -> bool {
952 !n.is_empty()
953 && n.len() <= 100
954 && !n.contains("..")
955 && !n.ends_with(".lock")
956 && !n.starts_with('-')
957 && !n.starts_with('/')
958 && !n.ends_with('/')
959 && !n.contains("//")
960 && !n.contains('\\')
961 && !n.contains("@{")
962 && n.chars()
963 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
964}
965
966fn render(
967 ctx: &RepoContext,
968 tab: &str,
969 csrf: &str,
970 nonce: &str,
971 user: Option<&df_db::models::User>,
972 body: maud::Markup,
973) -> Response {
974 views::page_with_bar(
975 Chrome {
976 title: &format!("{}/{}", ctx.owner, ctx.repo.name),
977 user,
978 csrf,
979 nonce,
980 },
981 v::header(ctx, tab),
982 body,
983 )
984 .into_response()
985}
986
987/// Render a README if the directory has one.
988///
989/// Returns the file's own name alongside the markup so the card can be labelled
990/// with what was actually found — a repository with a bare `README` should not
991/// be told it has a `README.md`.
992async fn render_readme(
993 state: &AppState,
994 ctx: &RepoContext,
995 rev: &df_store::RevId,
996 entries: &[df_store::TreeEntry],
997) -> Option<(String, maud::Markup)> {
998 let readme = entries.iter().find(|e| {
999 !e.is_dir()
1000 && matches!(
1001 e.name.to_lowercase().as_str(),
1002 "readme.md" | "readme" | "readme.markdown" | "readme.txt"
1003 )
1004 })?;
1005
1006 let blob = state
1007 .store
1008 .read_blob(ctx.store_id(), rev, Path::new(&readme.path))
1009 .await
1010 .ok()?;
1011 let text = blob.text()?;
1012
1013 // Always sanitised — never trust repository content (spec §9).
1014 let html_str = df_render::markdown_to_html(text);
1015 Some((readme.name.clone(), v::rendered_markdown(&html_str)))
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021
1022 #[test]
1023 fn repo_name_validation_mirrors_the_db_constraint() {
1024 assert!(valid_repo_name("dogfood"));
1025 assert!(valid_repo_name("my.repo_name-1"));
1026 assert!(valid_repo_name("9lives"));
1027
1028 assert!(!valid_repo_name(""));
1029 assert!(!valid_repo_name(".hidden"), "must start alphanumeric");
1030 assert!(!valid_repo_name("-dash"));
1031 assert!(!valid_repo_name("a..b"), "traversal must be rejected");
1032 assert!(!valid_repo_name("with space"));
1033 assert!(!valid_repo_name("with/slash"));
1034 assert!(!valid_repo_name(&"a".repeat(101)));
1035 }
1036
1037 #[test]
1038 fn bookmark_validation_follows_the_ref_rules() {
1039 assert!(valid_bookmark_name("main"));
1040 assert!(valid_bookmark_name("feature/thing"));
1041 assert!(valid_bookmark_name("v1.2.3"));
1042
1043 // Spec §9: reject refs containing `..`, ending in `.lock`, starting
1044 // with `-`, or containing control characters.
1045 assert!(!valid_bookmark_name("a..b"));
1046 assert!(!valid_bookmark_name("main.lock"));
1047 assert!(!valid_bookmark_name("-main"));
1048 assert!(!valid_bookmark_name("main\n"));
1049 assert!(!valid_bookmark_name("main\x1b[31m"));
1050 assert!(!valid_bookmark_name("a//b"));
1051 assert!(!valid_bookmark_name("/main"));
1052 assert!(!valid_bookmark_name("main/"));
1053 assert!(!valid_bookmark_name("main@{1}"));
1054 assert!(!valid_bookmark_name("back\\slash"));
1055 assert!(!valid_bookmark_name(""));
1056 }
1057}

1057 lines · Rust