steid

@jamesgill /

steid/src/infrastructure/web/repo_settings.rs
13.1 KBCode·Blame·Raw
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
1//! Repository settings — `/{handle}/repos/{name}/settings`.
2//!
3//! Owner-only: change the description, change visibility, delete.
4//!
5//! `settings` sits in the verb position after `{name}`, where nothing user-controlled
6//! ever appears, so unlike `new` it needs no reservation in
7//! [`RepoName`](crate::domain::RepoName).
8//!
9//! **A non-owner gets a 404, not a 403.** That is the rule
10//! [`view_repo`](crate::application::view_repo) already sets for repositories: a 403
11//! would confirm that a private repository by that name exists, and there is no reason
12//! for the settings page to be more talkative than the repository page it belongs to.
13
14use serde::Deserialize;
15use topcoat::{
16 Result,
17 context::Cx,
18 router::{StatusCode, content::Form, error::not_found, page, query_params},
19 view::{attributes, component, view},
20};
21
22use crate::{
23 application::{
24 Error, RepoView,
25 repo::{RepoEdit, delete_repo, update_repo},
26 },
27 components::{
28 button::{ButtonVariant, button},
29 flash::{FlashKind, flash},
30 input::input,
31 label::label,
32 select::select,
33 textarea::textarea,
34 },
35 domain::{DomainError, RepoName, Repository, Visibility},
36};
37
38use super::{
39 context::{current_actor, location, memberships, orgs, repos, server_error, storage},
5d3dfa5feat: a global top bar, and pages choose their own width1d
40 layout::narrow,
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
41 repo::repo_for,
42};
43
44#[derive(Debug, Deserialize)]
45struct SettingsForm {
46 description: String,
47 visibility: String,
ef23868feat: rebuild the profile page on flat navigation7d
48 /// A checkbox: present when ticked, absent entirely when not. `Option` is the shape
49 /// the browser actually sends, so it is the shape parsed.
50 pinned: Option<String>,
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
51}
52
53#[derive(Debug, Deserialize)]
54struct DeleteForm {
55 confirm: String,
56}
57
58/// Set after a successful save so the confirmation survives the redirect.
59///
60/// Post-redirect-get: reloading after a save must not resubmit it.
61#[query_params(error = bad_request)]
62struct Saved {
63 saved: Option<String>,
64}
65
66/// Blank input means "clear this field", which the domain treats as unset.
67fn optional(value: &str) -> Option<String> {
68 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
69}
70
71/// Resolves the repository in the path, or 404 for anyone who does not own it.
72///
73/// [`repo_for`] already 404s for a repository the viewer may not see; this adds the
74/// stronger half. The use cases decide this too — the guard here only keeps a page from
75/// rendering for someone whose submission would be refused anyway.
76async fn owned_repo(cx: &Cx) -> Result<RepoView> {
77 let repo = repo_for(cx).await?;
78
79 if !repo.viewer_is_owner {
80 return Err(not_found().into());
81 }
82
83 Ok(repo)
84}
85
86#[page("/{handle}/repos/{name}/settings")]
87async fn repo_settings_page(cx: &Cx) -> Result {
88 let repo = owned_repo(cx).await?;
89 let saved = query_params::<Saved>(cx)?.saved.is_some();
90
91 view! {
92 settings_view(
93 handle: repo.handle.as_str(),
94 name: repo.name.as_str(),
ef23868feat: rebuild the profile page on flat navigation7d
95 fields: Fields {
96 description: repo.description.as_deref().unwrap_or(""),
97 visibility: repo.visibility,
98 pinned: repo.pinned,
99 },
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
100 saved: saved,
101 error: "",
102 )
103 }
104}
105
106/// Saves the description and visibility.
107///
108/// Success replies 303 so a reload cannot resubmit — see
109/// [`location`](super::context::location) for why it is spelled this way rather than
110/// with `redirect()`, which is a 307 and would re-POST this form to itself.
111///
112/// Failure re-renders with the reason and **what was typed**, rather than bouncing back
113/// to the stored values and hiding what went wrong.
114#[page(POST "/{handle}/repos/{name}/settings")]
115async fn save(cx: &Cx, Form(submitted): Form<SettingsForm>) -> Result {
116 let repo = owned_repo(cx).await?;
117
118 // An unparseable value is a tampered form, not something to default: defaulting
119 // here could publish a repository the owner asked to keep private.
120 let visibility = submitted
121 .visibility
122 .parse::<Visibility>()
123 .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
124
125 let outcome = update_repo(
126 &current_actor(cx).await?,
127 &repo.handle,
128 &repo.name,
129 &RepoEdit {
130 description: optional(&submitted.description),
131 visibility,
ef23868feat: rebuild the profile page on flat navigation7d
132 pinned: submitted.pinned.is_some(),
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
133 },
134 &orgs(cx),
135 &memberships(cx),
136 &repos(cx),
137 )
138 .await;
139
140 let message = match outcome {
141 Ok(_) => {
142 return view! {
143 (StatusCode::SEE_OTHER)
144 (location(&format!(
145 "/{}/repos/{}/settings?saved",
146 repo.handle, repo.name
147 ))?)
148 };
149 }
150 // The visitor's to fix, so it is shown.
151 Err(Error::Domain(DomainError::Validation { field, reason })) => {
152 format!("That {field} is no good: {reason}.")
153 }
154 // Both mean "not yours" here, and both look like a page that is not there.
155 Err(Error::Domain(DomainError::Forbidden | DomainError::NotFound { .. })) => {
156 return Err(not_found().into());
157 }
158 // Ours, so it is logged and answered generically.
159 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
160 };
161
162 view! {
163 settings_view(
164 handle: repo.handle.as_str(),
165 name: repo.name.as_str(),
ef23868feat: rebuild the profile page on flat navigation7d
166 fields: Fields {
167 description: submitted.description.as_str(),
168 visibility,
169 pinned: submitted.pinned.is_some(),
170 },
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
171 saved: false,
172 error: message.as_str(),
173 )
174 }
175}
176
177/// Deletes the repository, then sends the owner back to their profile.
178///
179/// The repository page no longer exists, so there is nowhere else to go.
180///
181/// **Guarded by typing the name.** Deletion takes the git history with it and there is
182/// no undo; a single button is too easy to hit by accident for something unrecoverable.
183/// The typed value goes through [`RepoName::new`], so it is compared the same way the
184/// name was normalised on the way in — `MyRepo` confirms `myrepo`, and stray whitespace
185/// is not a reason to refuse.
186#[page(POST "/{handle}/repos/{name}/settings/delete")]
187async fn delete(cx: &Cx, Form(submitted): Form<DeleteForm>) -> Result {
188 let repo = owned_repo(cx).await?;
189
190 let confirmed = RepoName::new(&submitted.confirm).is_ok_and(|typed| typed == repo.name);
191
192 if !confirmed {
193 let message = format!(
194 "Type {} exactly to confirm. Nothing was deleted.",
195 repo.name
196 );
197
198 return view! {
199 settings_view(
200 handle: repo.handle.as_str(),
201 name: repo.name.as_str(),
ef23868feat: rebuild the profile page on flat navigation7d
202 fields: Fields {
203 description: repo.description.as_deref().unwrap_or(""),
204 visibility: repo.visibility,
205 pinned: repo.pinned,
206 },
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
207 saved: false,
208 error: message.as_str(),
209 )
210 };
211 }
212
213 match delete_repo(
214 &current_actor(cx).await?,
215 &repo.handle,
216 &repo.name,
217 &orgs(cx),
218 &memberships(cx),
219 &repos(cx),
220 &storage(cx),
221 )
222 .await
223 {
224 Ok(()) => {}
225 Err(Error::Domain(DomainError::Forbidden | DomainError::NotFound { .. })) => {
226 return Err(not_found().into());
227 }
228 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
229 }
230
231 view! {
232 (StatusCode::SEE_OTHER)
233 (location(&format!("/{}", repo.handle))?)
234 }
235}
236
ef23868feat: rebuild the profile page on flat navigation7d
237/// The three values the form edits, grouped so the view takes one argument for them
238/// rather than three that must be kept in the same order at four call sites.
239#[derive(Debug, Clone, Copy)]
240struct Fields<'a> {
241 description: &'a str,
242 visibility: Visibility,
243 pinned: bool,
244}
245
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
246/// The settings page.
247///
248/// Values arrive as parameters rather than being read back from storage, so a rejected
249/// submission can re-render exactly what was typed.
250#[component]
251async fn settings_view(
252 handle: &str,
253 name: &str,
ef23868feat: rebuild the profile page on flat navigation7d
254 fields: Fields<'_>,
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
255 saved: bool,
256 error: &str,
257) -> Result {
ef23868feat: rebuild the profile page on flat navigation7d
258 let Fields {
259 description,
260 visibility,
261 pinned,
262 } = fields;
263
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
264 view! {
5d3dfa5feat: a global top bar, and pages choose their own width1d
265 narrow(
266 <h1 class="text-xl font-semibold tracking-tight">"Repository settings"</h1>
267 <p class="mt-1 font-mono text-sm text-muted-foreground">
268 "@" (handle) " / " (name)
269 </p>
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
270
5d3dfa5feat: a global top bar, and pages choose their own width1d
271 if saved {
272 <div class="mt-6">
273 flash(kind: FlashKind::Success, "Repository updated.")
274 </div>
275 }
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
276
5d3dfa5feat: a global top bar, and pages choose their own width1d
277 if !error.is_empty() {
278 <div class="mt-6">
279 flash(kind: FlashKind::Error, (error))
ef23868feat: rebuild the profile page on flat navigation7d
280 </div>
5d3dfa5feat: a global top bar, and pages choose their own width1d
281 }
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
282
283 <form
284 method="post"
5d3dfa5feat: a global top bar, and pages choose their own width1d
285 action=(format!("/{handle}/repos/{name}/settings"))
286 class="mt-6 space-y-5"
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
287 >
5d3dfa5feat: a global top bar, and pages choose their own width1d
288 <div class="space-y-2">
289 label(attrs: attributes! { for="description" }, "Description")
290 textarea(
291 attrs: attributes! {
292 id="description"
293 name="description"
294 rows="2"
295 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
296 placeholder="A sentence for your profile."
297 },
298 (description)
299 )
300 <p class="text-xs text-muted-foreground">
301 "Optional. At most "
302 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
303 </p>
304 </div>
305
306 <div class="space-y-2">
307 label(attrs: attributes! { for="visibility" }, "Visibility")
308 select(
309 attrs: attributes! { id="visibility" name="visibility" },
310 <option value="public" selected=(visibility.is_public())>"Public"</option>
311 <option value="private" selected=(!visibility.is_public())>"Private"</option>
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
312 )
5d3dfa5feat: a global top bar, and pages choose their own width1d
313 <p class="text-xs text-muted-foreground">
314 "A private repository is hidden from your profile and needs a token "
315 "to clone."
316 </p>
317 </div>
318
319 <div class="space-y-2">
320 <div class="flex items-start gap-2">
321 <input
322 id="pinned"
323 name="pinned"
324 type="checkbox"
325 value="on"
326 checked=(pinned)
327 class="mt-0.5"
328 />
329 label(attrs: attributes! { for="pinned" }, "Lead my profile with this")
330 </div>
331 <p class="text-xs text-muted-foreground">
332 "One repository at a time. Choosing this one lets go of whichever "
333 "was chosen before."
334 </p>
335 </div>
336
337 <div class="flex items-center gap-3">
338 button(attrs: attributes! { type="submit" }, "Save")
339 <a
340 href=(format!("/{handle}/repos/{name}"))
341 class="text-sm text-muted-foreground hover:text-foreground"
342 >"Back to repository"</a>
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
343 </div>
344 </form>
5d3dfa5feat: a global top bar, and pages choose their own width1d
345
346 <section class="mt-10 rounded-lg border border-destructive/30 p-4">
347 <h2 class="text-xs font-medium uppercase tracking-wider text-destructive">
348 "Delete this repository"
349 </h2>
350 <p class="mt-2 text-sm text-muted-foreground">
351 "This cannot be undone. The commits, branches and tags go with it, and "
352 "anyone with a clone keeps their copy while this instance keeps nothing."
353 </p>
354
355 <form
356 method="post"
357 action=(format!("/{handle}/repos/{name}/settings/delete"))
358 class="mt-4 space-y-2"
359 >
360 label(
361 attrs: attributes! { for="confirm" },
362 "Type " (name) " to confirm"
363 )
364 input(attrs: attributes! {
365 id="confirm"
366 name="confirm"
367 type="text"
368 value=""
369 placeholder=(name)
370 required=(true)
371 autocomplete="off"
372 })
373 <div class="pt-1">
374 button(
375 attrs: attributes! { type="submit" },
376 variant: ButtonVariant::Destructive,
377 "Delete repository"
378 )
379 </div>
380 </form>
381 </section>
382 )
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
383 }
384}