@jpgilldev / steid

1//! Repository pages — `/{handle}/repos/new` and `/{handle}/repos/{name}`.
2//!
3//! `new` is a static segment and `{name}` a parameterised one, so the router prefers
4//! `new`. [`RepoName`] reserves it as well, so the two agree rather than relying on
5//! routing order alone.
6
7use serde::Deserialize;
8use topcoat::{
9 Result,
10 context::Cx,
11 router::{
12 StatusCode,
13 content::Form,
14 error::{RouterErrorExt, forbidden, not_found},
15 page, path_param,
16 },
17 view::{attributes, component, view},
18};
19
20use crate::{
21 application::{Browsed, Error, FileView, NewRepo, RepoView, create_repo, view_repo},
22 components::{
23 badge::{BadgeVariant, badge},
24 button::{ButtonSize, ButtonVariant, button, button_variants},
25 flash::{FlashKind, flash},
26 input::input,
27 label::label,
28 select::select,
29 textarea::textarea,
30 },
31 domain::{
32 DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry, Visibility,
33 },
34};
35
36use super::{
37 browse::{blob, browsed_at, directory, empty_repo, tree_url},
38 context::{
39 current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
40 },
41 markdown,
42 profile::profile_for,
43};
44
45/// `{name}` from the path, raw — validation is [`RepoName`]'s job.
46#[path_param]
47struct Name(str);
48
49#[derive(Debug, Deserialize)]
50struct CreateForm {
51 name: String,
52 description: String,
53 visibility: String,
54}
55
56/// Blank input means unset, which is what the domain stores.
57fn optional(value: &str) -> Option<String> {
58 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
59}
60
61/// Resolves `{handle}/repos/{name}` into a repository the viewer may see, or 404.
62///
63/// A repository the viewer may not see and one that does not exist are the same
64/// answer here, deliberately — see [`view_repo`].
65pub(super) async fn repo_for(cx: &Cx) -> Result<RepoView> {
66 let profile = profile_for(cx).await?;
67 let name = RepoName::new(path_param::<Name>(cx)).map_err(|_| not_found())?;
68 let actor = current_actor(cx).await?;
69
70 Ok(view_repo(
71 &profile.handle,
72 &name,
73 &actor,
74 &orgs(cx),
75 &memberships(cx),
76 &repos(cx),
77 )
78 .await
79 .map_err(server_error)?
80 .ok_or_not_found()?)
81}
82
83#[page("/{handle}/repos/new")]
84async fn new_repo_page(cx: &Cx) -> Result {
85 let profile = profile_for(cx).await?;
86
87 // The use case decides this too; checking here as well keeps the form from
88 // rendering for someone whose submission would only be rejected.
89 if !profile.viewer_is_owner {
90 return Err(forbidden().into());
91 }
92
93 view! {
94 new_repo_form(
95 handle: profile.handle.as_str(),
96 name: "",
97 description: "",
98 visibility: Visibility::Public,
99 error: "",
100 )
101 }
102}
103
104/// Creates the repository.
105///
106/// Success redirects to the new repository, using the **normalised** name from the
107/// created record — someone who typed `MyRepo` belongs at `/{handle}/repos/myrepo`.
108/// Failure re-renders with the reason and what was typed.
109///
110/// The success reply is a 303 — see [`location`] for why it is spelled this way and
111/// not with `redirect()`.
112#[page(POST "/{handle}/repos/new")]
113async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
114 let profile = profile_for(cx).await?;
115
116 // An unparseable value is a tampered form, not something to default: defaulting
117 // here could publish a repository the owner asked to keep private.
118 let visibility = submitted
119 .visibility
120 .parse::<Visibility>()
121 .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
122
123 let outcome = create_repo(
124 &current_actor(cx).await?,
125 &profile.handle,
126 &NewRepo {
127 name: submitted.name.clone(),
128 description: optional(&submitted.description),
129 visibility,
130 },
131 &orgs(cx),
132 &memberships(cx),
133 &repos(cx),
134 &storage(cx),
135 )
136 .await;
137
138 let message = match outcome {
139 Ok(repo) => {
140 return view! {
141 (StatusCode::SEE_OTHER)
142 (location(&format!("/{}/repos/{}", profile.handle, repo.name))?)
143 };
144 }
145 Err(Error::Domain(DomainError::Validation { field, reason })) => {
146 format!("That {field} is no good: {reason}.")
147 }
148 Err(Error::Domain(DomainError::AlreadyExists { .. })) => {
149 format!(
150 "You already have a repository called {}.",
151 submitted.name.trim()
152 )
153 }
154 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
155 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
156 };
157
158 view! {
159 new_repo_form(
160 handle: profile.handle.as_str(),
161 name: submitted.name.as_str(),
162 description: submitted.description.as_str(),
163 visibility: visibility,
164 error: message.as_str(),
165 )
166 }
167}
168
169/// The repository's own page: its default branch, at the root.
170///
171/// The listing is the page rather than a link to one — the reason to open a repository
172/// is to see what is in it. An empty repository gets push instructions instead, which
173/// is the only useful thing to show someone who has just created one.
174#[page("/{handle}/repos/{name}")]
175async fn repo_page(cx: &Cx) -> Result {
176 let repo = repo_for(cx).await?;
177 let clone = clone_url_for(cx, &repo);
178 let browsed = browsed_at(cx, &repo, None, &RepoPath::root()).await?;
179
180 view! {
181 <header class="mb-8">
182 <p class="font-mono text-sm text-muted-foreground">
183 <a href=(format!("/{}", repo.handle)) class="hover:text-foreground">
184 "@" (repo.handle.as_str())
185 </a>
186 " / "
187 </p>
188 <div class="mt-1 flex items-center gap-3">
189 <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1>
190 if !repo.visibility.is_public() {
191 badge(variant: BadgeVariant::Outline, "Private")
192 }
193 // Only the owner can change a repository, so only the owner is offered
194 // the way in. The settings page enforces this again — this is the link
195 // not being a dead end, not the authorization.
196 if repo.viewer_is_owner {
197 <a
198 href=(format!("/{}/repos/{}/settings", repo.handle, repo.name))
199 class=(format!(
200 "ml-auto {}",
201 button_variants(ButtonVariant::Outline, ButtonSize::Sm)
202 ))
203 >"Settings"</a>
204 }
205 </div>
206 ({
207 match &repo.description {
208 Some(description) => view! {
209 <p class="mt-3 text-sm leading-relaxed">(description)</p>
210 },
211 None => view! {},
212 }
213 }?)
214 </header>
215
216 clone_url(url: clone.as_str())
217
218 match &browsed {
219 Browsed::Empty => empty_repo(url: clone.as_str()),
220 Browsed::Directory { rev, path, entries } => {
221 <div class="mt-8 mb-3 flex items-center justify-between text-sm">
222 <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
223 (rev.as_str())
224 </span>
225 <a
226 href=(format!("/{}/repos/{}/log", repo.handle, repo.name))
227 class="text-muted-foreground hover:text-foreground"
228 >"Commits"</a>
229 </div>
230 directory(
231 handle: repo.handle.as_str(),
232 name: repo.name.as_str(),
233 rev: rev,
234 path: path,
235 entries: entries,
236 )
237
238 // No README means nothing here at all — an empty panel saying a
239 // repository has no README is worse than the silence.
240 match readme_of(entries) {
241 Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
242 None => "",
243 }
244 },
245 // The root of a revision is always a directory, so this is unreachable in
246 // practice — rendered rather than errored so it can never be a 500.
247 Browsed::File { rev, path, file } => <div class="mt-8">
248 blob(
249 handle: repo.handle.as_str(),
250 name: repo.name.as_str(),
251 rev: rev,
252 path: path,
253 file: file,
254 )
255 </div>,
256 }
257 }
258}
259
260// --- README -----------------------------------------------------------------------
261
262/// Extensions a README may carry, in the order they are preferred.
263///
264/// Empty last: a plain `README` is a README, but a repository holding both `README.md`
265/// and `README` means the markdown one. Nothing that is not markdown is here — a
266/// `README.rst` rendered as markdown would be worse than the file listing.
267const README_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdown", "mkd", ""];
268
269/// How strongly a name says "this is the README", or `None` if it does not.
270///
271/// Case-insensitive, because the file is spelled `README`, `readme` and `Readme` in the
272/// wild and all three mean the same thing.
273fn readme_rank(name: &str) -> Option<usize> {
274 let lowered = name.to_ascii_lowercase();
275 let extension = match lowered.strip_prefix("readme")? {
276 "" => "",
277 rest => rest.strip_prefix('.')?,
278 };
279
280 README_EXTENSIONS
281 .iter()
282 .position(|candidate| *candidate == extension)
283}
284
285/// The README in a listing, if there is one.
286///
287/// Chosen from the entries the page already has rather than by asking git for a file
288/// that may not exist: every `git` call is a fork of about 12ms, and a speculative one
289/// would be spent on every repository without a README. See
290/// [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
291fn readme_of(entries: &[TreeEntry]) -> Option<&TreeEntry> {
292 entries
293 .iter()
294 .filter(|entry| entry.kind == EntryKind::Blob)
295 .filter_map(|entry| Some((readme_rank(&entry.name)?, entry)))
296 .min_by_key(|(rank, _)| *rank)
297 .map(|(_, entry)| entry)
298}
299
300/// Where a relative link in a README should point.
301///
302/// A README's links are written against the repository's own files, so `./CONTRIBUTING.md`
303/// means a file in the tree and not a Steid route — left alone it would 404. Anything
304/// this cannot make sense of returns `None` and is left exactly as written, which is the
305/// same thing a plain markdown renderer would do.
306///
307/// Only links. A relative *image* is left alone deliberately: an image needs the file's
308/// bytes, and a tree URL serves a page, so rewriting one would trade a 404 for a broken
309/// image. [`markdown::render_with_links`] never offers this an image.
310fn readme_link(handle: &str, name: &str, rev: &RefName, destination: &str) -> Option<String> {
311 // A query or fragment addresses something inside a rendered document; a file in a
312 // tree has neither.
313 let target = destination.split(['?', '#']).next()?;
314 let target = target.strip_prefix("./").unwrap_or(target);
315
316 // `RepoPath` refuses `..` and `.` components, so a link cannot walk out of the
317 // repository — it simply stays as it was written.
318 let path = RepoPath::new(target).ok()?;
319
320 if path.is_root() {
321 return None;
322 }
323
324 Some(tree_url(handle, name, rev, &path))
325}
326
327/// The rendered README, under the file listing.
328///
329/// Reading it costs the page one more `git` call, which is why it is the only file the
330/// page fetches beyond the listing itself.
331///
332/// A component rather than a function because `view!` needs the request context in
333/// scope — see [`browsing`](super::browse).
334#[component]
335async fn readme_card(cx: &Cx, repo: &RepoView, rev: &RefName, entry: &TreeEntry) -> Result {
336 let path = RepoPath::new(&entry.name).map_err(|_| not_found())?;
337
338 // The entry came out of a listing read moments ago, so anything but a file means
339 // the tree changed underneath this request. A vanished README is not a reason to
340 // fail the page it was going to decorate.
341 let Browsed::File { file, .. } = browsed_at(cx, repo, Some(rev), &path).await? else {
342 return view! {};
343 };
344
345 let handle = repo.handle.as_str();
346 let name = repo.name.as_str();
347
348 view! {
349 <section class="mt-6 overflow-hidden rounded-lg border border-border">
350 <div class="border-b border-border px-4 py-2.5">
351 <a
352 href=(tree_url(handle, name, rev, &path))
353 class="font-mono text-sm hover:underline"
354 >(&entry.name)</a>
355 </div>
356 readme_body(handle: handle, name: name, rev: rev, file: &file)
357 </section>
358 }
359}
360
361/// A README's contents, in the three states a file can be in.
362///
363/// Split out so the `view!` holding the rendered markdown is the only place the
364/// escape hatch is used, and it is one line long.
365#[component]
366async fn readme_body(handle: &str, name: &str, rev: &RefName, file: &FileView) -> Result {
367 view! {
368 match &file.text {
369 // The only unescaped content on any Steid page. It is safe because
370 // `markdown` writes every tag itself and never passes source HTML
371 // through — see that module's header.
372 Some(text) => <div class="px-5 py-4 text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
373 (markdown::render_with_links(text, |destination| {
374 readme_link(handle, name, rev, destination)
375 }))
376 </div>,
377 None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
378 "This README is too large to render here. Open it in the file listing above."
379 </p>,
380 None => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
381 "This README is not valid UTF-8, so it cannot be rendered."
382 </p>,
383 }
384 }
385}
386
387/// The creation form.
388///
389/// Values arrive as parameters rather than being read back, so a rejected submission
390/// re-renders exactly what was typed.
391#[component]
392async fn new_repo_form(
393 handle: &str,
394 name: &str,
395 description: &str,
396 visibility: Visibility,
397 error: &str,
398) -> Result {
399 view! {
400 <h1 class="text-xl font-semibold tracking-tight">"New repository"</h1>
401 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
402
403 if !error.is_empty() {
404 <div class="mt-6">
405 flash(kind: FlashKind::Error, (error))
406 </div>
407 }
408
409 <form method="post" action=(format!("/{handle}/repos/new")) class="mt-6 space-y-5">
410 <div class="space-y-2">
411 label(attrs: attributes! { for="name" }, "Name")
412 input(attrs: attributes! {
413 id="name"
414 name="name"
415 type="text"
416 value=(name)
417 placeholder="my-project"
418 required=(true)
419 maxlength=(RepoName::MAX_LEN.to_string())
420 autofocus=(true)
421 })
422 <p class="text-xs text-muted-foreground">
423 "Letters, digits, hyphens, underscores and dots. Lowercased."
424 </p>
425 </div>
426
427 <div class="space-y-2">
428 label(attrs: attributes! { for="description" }, "Description")
429 textarea(
430 attrs: attributes! {
431 id="description"
432 name="description"
433 rows="2"
434 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
435 placeholder="A sentence for your profile."
436 },
437 (description)
438 )
439 <p class="text-xs text-muted-foreground">
440 "Optional. At most "
441 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
442 </p>
443 </div>
444
445 <div class="space-y-2">
446 label(attrs: attributes! { for="visibility" }, "Visibility")
447 select(
448 attrs: attributes! { id="visibility" name="visibility" },
449 <option value="public" selected=(visibility.is_public())>"Public"</option>
450 <option value="private" selected=(!visibility.is_public())>"Private"</option>
451 )
452 <p class="text-xs text-muted-foreground">
453 "Public repositories appear on your profile to anyone."
454 </p>
455 </div>
456
457 <div class="flex items-center gap-3">
458 button(attrs: attributes! { type="submit" }, "Create repository")
459 <a
460 href=(format!("/{handle}"))
461 class="text-sm text-muted-foreground hover:text-foreground"
462 >"Cancel"</a>
463 </div>
464 </form>
465 }
466}
467
468/// The Repositories section of a profile.
469///
470/// Takes the already-filtered summaries rather than fetching: which repositories a
471/// viewer may see is [`list_repos`](crate::application::list_repos)'s decision, and a
472/// component that queried for itself would be a second place that rule could live.
473///
474/// One empty state serves both "no repositories" and "none you may see" — a distinct
475/// message for the second would leak that private repositories exist.
476/// The URL to clone this repository from.
477///
478/// Built from the origin the page is being served on, so it is correct wherever the
479/// instance is deployed without anything having to be configured. A private repository
480/// gets the same URL: cloning it needs a token, not a different address.
481pub(super) fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
482 format!(
483 "{}/{}/repos/{}.git",
484 public_origin(cx),
485 repo.handle,
486 repo.name
487 )
488}
489
490/// The clone address, ready to copy.
491///
492/// Shown for every repository a viewer can see, including an empty one — an empty
493/// repository is exactly when someone needs this, because it is what they push to.
494#[component]
495pub(super) async fn clone_url(url: &str) -> Result {
496 view! {
497 <div class="mt-6">
498 <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
499 "Clone"
500 </p>
501 <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">"git clone " (url)</pre>
502 </div>
503 }
504}
505#[cfg(test)]
506mod tests {
507 use crate::domain::ObjectId;
508
509 use super::*;
510
511 fn entry(name: &str, kind: EntryKind) -> TreeEntry {
512 TreeEntry {
513 name: name.to_owned(),
514 kind,
515 id: ObjectId::from_trusted("0".repeat(40)),
516 size: Some(0),
517 }
518 }
519
520 fn rev() -> RefName {
521 RefName::new("main").expect("valid revision")
522 }
523
524 #[test]
525 fn the_usual_readme_spellings_are_all_readmes() {
526 for name in [
527 "README.md",
528 "readme.md",
529 "Readme.md",
530 "README",
531 "readme",
532 "README.markdown",
533 "README.mkd",
534 ] {
535 assert!(readme_rank(name).is_some(), "{name} should be a README");
536 }
537 }
538
539 #[test]
540 fn things_that_merely_start_with_readme_are_not_readmes() {
541 for name in [
542 "READMEISH.md",
543 "readme-first.md",
544 "README.rst",
545 "README.txt",
546 "docs.md",
547 "",
548 ] {
549 assert!(readme_rank(name).is_none(), "{name} should not be a README");
550 }
551 }
552
553 #[test]
554 fn the_markdown_readme_wins_over_the_plain_one() {
555 let entries = [
556 entry("readme", EntryKind::Blob),
557 entry("README.md", EntryKind::Blob),
558 ];
559
560 assert_eq!(
561 readme_of(&entries).map(|found| found.name.as_str()),
562 Some("README.md")
563 );
564 }
565
566 #[test]
567 fn a_directory_called_readme_is_not_a_readme() {
568 // Reading it would ask git for a blob at a tree's path and get nothing.
569 let entries = [entry("readme", EntryKind::Tree)];
570
571 assert!(readme_of(&entries).is_none());
572 }
573
574 #[test]
575 fn a_listing_without_one_has_no_readme() {
576 let entries = [
577 entry("src", EntryKind::Tree),
578 entry("Cargo.toml", EntryKind::Blob),
579 ];
580
581 assert!(readme_of(&entries).is_none());
582 }
583
584 #[test]
585 fn a_relative_link_becomes_a_link_into_the_tree() {
586 assert_eq!(
587 readme_link("ada", "steid", &rev(), "./CONTRIBUTING.md").as_deref(),
588 Some("/ada/repos/steid/tree/main/-/CONTRIBUTING.md")
589 );
590 assert_eq!(
591 readme_link("ada", "steid", &rev(), "docs/design.md#why").as_deref(),
592 Some("/ada/repos/steid/tree/main/-/docs/design.md")
593 );
594 }
595
596 #[test]
597 fn a_link_that_would_walk_out_of_the_repository_is_left_alone() {
598 for destination in ["../elsewhere.md", "./", "", "a/../b.md"] {
599 assert!(
600 readme_link("ada", "steid", &rev(), destination).is_none(),
601 "{destination} should not resolve"
602 );
603 }
604 }
605}