steid

@jamesgill /

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