forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.7 KiB
1//! Page: rename and transfer a repo (`/{owner}/{repo}/settings/rename`). Renaming needs `Action::Admin`
2//! (owner, instance admin, or an Admin collaborator). Transferring needs `Action::Delete` (owner or
3//! instance admin) and a typed `owner/name` confirmation. A non-admin transfer is only offered: the
4//! target accepts it on `pages::repo_transfer`, and an instance admin's moves at once. The old
5//! owner/name keeps working through a redirect (see `store::move_repo` and `pages::repo_ctx`). The
6//! bare repo on disk never moves.
7
8use forge_core::store::orgs::OrgMemberRole;
9use forge_core::{AuditAction, Error as CoreError};
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::cookie::{Cookie, Cookies, SameSite, cookies};
14use topcoat::router::content::Form;
15use topcoat::router::error::{forbidden, see_other};
16use topcoat::router::{page, path_param};
17use topcoat::view::{View, component, view};
18
19use crate::audit::{self, Actor, Target};
20use crate::auth::{require_csrf, require_user};
21use crate::i18n::repo_admin::{self as g, K};
22use crate::i18n::{self, Lang};
23use crate::pages::repo::repo_head;
24use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
25use crate::state::AppState;
26use crate::views::{alert, shell};
27
28path_param!(owner);
29path_param!(repo);
30
31/// Cookie that carries a one-shot notice to the next rename page view. Its value is one of the fixed
32/// words in `NOTICES`, never user text.
33const NOTICE_COOKIE: &str = "forge_rename_notice";
34
35const NOTICES: [(&str, K); 2] = [
36 ("renamed", K::RenamedNotice),
37 ("transferred", K::TransferredNotice),
38];
39
40#[derive(Deserialize)]
41struct RenameForm {
42 #[serde(default)]
43 csrf: String,
44 #[serde(default)]
45 name: String,
46}
47
48#[derive(Deserialize)]
49struct TransferForm {
50 #[serde(default)]
51 csrf: String,
52 #[serde(default)]
53 new_owner: String,
54 #[serde(default)]
55 confirm: String,
56}
57
58/// What the two forms show: the drafts the viewer typed and any error for each form.
59struct Forms {
60 rename_name: String,
61 rename_error: Option<String>,
62 transfer_owner: String,
63 transfer_error: Option<String>,
64}
65
66impl Forms {
67 fn fresh(ctx: &RepoCtx) -> Self {
68 Self {
69 rename_name: ctx.repo.name.clone(),
70 rename_error: None,
71 transfer_owner: String::new(),
72 transfer_error: None,
73 }
74 }
75}
76
77fn set_notice(cx: &Cx, word: &'static str) {
78 cookies(cx).add(
79 Cookie::build((NOTICE_COOKIE, word))
80 .path("/")
81 .http_only(true)
82 .same_site(SameSite::Lax)
83 .build(),
84 );
85}
86
87/// Reads and clears the notice cookie. Only the fixed words are shown.
88fn take_notice(cx: &Cx) -> Option<K> {
89 let jar = cookies(cx);
90 let value = jar.get(NOTICE_COOKIE)?.value().to_owned();
91 jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/"));
92 NOTICES
93 .iter()
94 .find(|(word, _)| *word == value)
95 .map(|(_, key)| *key)
96}
97
98/// Rename and transfer forms. The transfer section is shown only when the viewer may delete the repo.
99#[component]
100async fn rename_body(
101 lang: Lang,
102 head: RepoHead,
103 title: String,
104 csrf: String,
105 can_delete: bool,
106 notice: Option<K>,
107 forms: Forms,
108) -> Result<impl View> {
109 let rename_action = format!("/{title}/settings/rename");
110 let transfer_action = format!("/{title}/settings/transfer");
111 let status_href = format!("/{title}/transfer");
112 let Forms {
113 rename_name,
114 rename_error,
115 transfer_owner,
116 transfer_error,
117 } = forms;
118 Ok(view! {
119 repo_head(head: head)
120 if let Some(key) = notice {
121 <section class="card notice" role="status">(g::t(lang, key))</section>
122 }
123 <section class="card">
124 <h2>(g::t(lang, K::RenameRepo))</h2>
125 <p class="muted small">(g::t(lang, K::RenameHelp))</p>
126 <form method="post" action=(rename_action.as_str()) class="stack">
127 if let Some(message) = rename_error {
128 alert(message: message.as_str())
129 }
130 <input type="hidden" name="csrf" value=(csrf.as_str())>
131 <label>
132 <span>(g::t(lang, K::NewName))</span>
133 <input type="text" name="name" value=(rename_name.as_str()) autocomplete="off" spellcheck="false" required=(true)>
134 </label>
135 <div>
136 <button type="submit">(g::t(lang, K::RenameRepo))</button>
137 </div>
138 </form>
139 </section>
140 if can_delete {
141 <section class="card">
142 <h2>(g::t(lang, K::TransferRepo))</h2>
143 <p class="muted small">(g::t(lang, K::TransferHelp))<a href=(status_href.as_str())>(g::t(lang, K::PendingTransfers))</a></p>
144 <form method="post" action=(transfer_action.as_str()) class="stack">
145 if let Some(message) = transfer_error {
146 alert(message: message.as_str())
147 }
148 <input type="hidden" name="csrf" value=(csrf.as_str())>
149 <label>
150 <span>(g::t(lang, K::NewOwnerLabel))</span>
151 <input type="text" name="new_owner" value=(transfer_owner.as_str()) autocomplete="off" spellcheck="false" required=(true)>
152 </label>
153 <label>
154 <span>(g::t(lang, K::TypeToConfirmBefore))<code>(title.clone())</code>(g::t(lang, K::TypeToConfirmAfter))</span>
155 <input type="text" name="confirm" autocomplete="off" spellcheck="false" required=(true)>
156 </label>
157 <div>
158 <button type="submit" class="danger">(g::t(lang, K::TransferRepo))</button>
159 </div>
160 </form>
161 </section>
162 }
163 })
164}
165
166/// The repo fields the page shows, copied out of the context so the view borrows nothing from it.
167struct Frame {
168 page_title: String,
169 title: String,
170 can_delete: bool,
171 head: RepoHead,
172}
173
174impl Frame {
175 async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result<Self> {
176 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
177 Ok(Self {
178 page_title: g::page_title(lang, &title),
179 title,
180 can_delete: ctx.can_delete(),
181 head: ctx.head(state, "settings").await?,
182 })
183 }
184}
185
186/// The rename and transfer page. Anonymous viewers go to login; signed-in viewers without the
187/// Admin right get 403.
188#[page("/{owner}/{repo}/settings/rename")]
189async fn rename_get(cx: &Cx) -> Result<impl View> {
190 let owner = path_param::<Owner>(cx);
191 let name = path_param::<Repo>(cx);
192 let ctx = load_repo(cx, owner, name).await?;
193 let (user, session) = require_user(cx).await?;
194 if !ctx.can_admin() {
195 return Err(forbidden().into());
196 }
197 let lang = i18n::lang(cx, Some(&user));
198 let notice = take_notice(cx);
199 let forms = Forms::fresh(&ctx);
200 let frame = Frame::new(&ctx, app_context::<AppState>(cx), lang).await?;
201 let csrf = session.csrf_token.clone();
202 Ok(view! {
203 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
204 rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf,
205 can_delete: frame.can_delete,
206 notice: notice, forms: forms))
207 })
208}
209
210/// Renames the repo. The store validates the name with `validate_name`; an invalid name or a name
211/// taken by another repo of the owner re-renders the form with the message.
212#[page(POST "/{owner}/{repo}/settings/rename")]
213async fn rename_post(cx: &Cx, Form(form): Form<RenameForm>) -> Result<impl View> {
214 let owner = path_param::<Owner>(cx);
215 let name = path_param::<Repo>(cx);
216 let ctx = load_repo(cx, owner, name).await?;
217 let (user, session) = require_csrf(cx, &form.csrf).await?;
218 if !ctx.can_admin() {
219 return Err(forbidden().into());
220 }
221 let lang = i18n::lang(cx, Some(&user));
222 let new_name = form.name.trim().to_owned();
223 // Only ASCII is accepted so the new name is safe in URLs and clone addresses.
224 let outcome = if new_name
225 .chars()
226 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
227 {
228 app_context::<AppState>(cx)
229 .store
230 .rename_repo(&ctx.repo.id, &new_name)
231 .await
232 } else {
233 Err(CoreError::InvalidName(g::invalid_name(lang, &new_name)))
234 };
235
236 match outcome {
237 Ok(()) => {
238 tracing::info!(
239 actor_id = %user.id,
240 repo_id = %ctx.repo.id,
241 old_name = %ctx.repo.name,
242 new_name = %new_name,
243 "repository renamed"
244 );
245 audit::record(
246 &app_context::<AppState>(cx).store,
247 Some(cx),
248 Actor::User(&user),
249 AuditAction::RepoRenamed,
250 Target {
251 kind: "repo",
252 id: &ctx.repo.id,
253 name: &new_name,
254 },
255 serde_json::json!({ "from": ctx.repo.name }),
256 )
257 .await;
258 set_notice(cx, "renamed");
259 Err(see_other(format!(
260 "/{}/{new_name}/settings/rename",
261 ctx.repo.owner_name
262 ))
263 .into())
264 }
265 Err(CoreError::InvalidName(message) | CoreError::Conflict(message)) => {
266 let forms = Forms {
267 rename_name: new_name,
268 rename_error: Some(message),
269 ..Forms::fresh(&ctx)
270 };
271 let frame = Frame::new(&ctx, app_context::<AppState>(cx), lang).await?;
272 let csrf = session.csrf_token.clone();
273 Ok(view! {
274 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
275 rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf,
276 can_delete: frame.can_delete,
277 notice: None, forms: forms))
278 })
279 }
280 Err(other) => Err(other.into()),
281 }
282}
283
284/// Transfers the repo to the user named in `new_owner`. Needs `Action::Delete` and the exact
285/// `owner/name` confirmation. The store removes the new owner's collaborator row.
286#[page(POST "/{owner}/{repo}/settings/transfer")]
287async fn transfer_post(cx: &Cx, Form(form): Form<TransferForm>) -> Result<impl View> {
288 let owner = path_param::<Owner>(cx);
289 let name = path_param::<Repo>(cx);
290 let ctx = load_repo(cx, owner, name).await?;
291 let (user, session) = require_csrf(cx, &form.csrf).await?;
292 if !ctx.can_delete() {
293 return Err(forbidden().into());
294 }
295 let lang = i18n::lang(cx, Some(&user));
296 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
297 let target = form.new_owner.trim().to_owned();
298 let state = app_context::<AppState>(cx);
299
300 let error: Option<String> = 'attempt: {
301 if form.confirm.trim() != title {
302 break 'attempt Some(g::confirm_mismatch(lang, &title));
303 }
304 let Some(new_owner) = state.store.user_by_username(&target).await? else {
305 break 'attempt Some(g::no_such_user(lang, &target));
306 };
307 if new_owner.id == ctx.repo.owner_id {
308 break 'attempt Some(g::t(lang, K::AlreadyOwner).to_owned());
309 }
310 if new_owner.is_org() && !user.is_admin {
311 let role = state.store.org_member_role(&new_owner.id, &user.id).await?;
312 if role != Some(OrgMemberRole::Owner) {
313 break 'attempt Some(g::only_org_owner(lang, &new_owner.username));
314 }
315 }
316 if !user.is_admin {
317 // The target must accept first, so the repo does not move yet.
318 match state
319 .store
320 .initiate_transfer(&ctx.repo.id, &new_owner.id, &user.id)
321 .await
322 {
323 Ok(()) => {
324 tracing::info!(
325 actor_id = %user.id,
326 repo_id = %ctx.repo.id,
327 repo = %title,
328 new_owner_id = %new_owner.id,
329 "repository transfer offered"
330 );
331 audit::record(
332 &state.store,
333 Some(cx),
334 Actor::User(&user),
335 AuditAction::RepoTransferInitiated,
336 Target {
337 kind: "repo",
338 id: &ctx.repo.id,
339 name: &title,
340 },
341 serde_json::json!({ "to": new_owner.username }),
342 )
343 .await;
344 return Err(see_other(format!(
345 "/{}/{}/transfer",
346 ctx.repo.owner_name, ctx.repo.name
347 ))
348 .into());
349 }
350 Err(CoreError::Conflict(message)) => break 'attempt Some(message),
351 Err(other) => return Err(other.into()),
352 }
353 }
354 match state.store.transfer_repo(&ctx.repo.id, &new_owner.id).await {
355 Ok(()) => {
356 tracing::info!(
357 actor_id = %user.id,
358 repo_id = %ctx.repo.id,
359 repo = %title,
360 new_owner_id = %new_owner.id,
361 "repository transferred"
362 );
363 audit::record(
364 &state.store,
365 Some(cx),
366 Actor::User(&user),
367 AuditAction::RepoTransferred,
368 Target {
369 kind: "repo",
370 id: &ctx.repo.id,
371 name: &title,
372 },
373 serde_json::json!({ "to": new_owner.username }),
374 )
375 .await;
376 set_notice(cx, "transferred");
377 return Err(see_other(format!(
378 "/{}/{}/settings/rename",
379 new_owner.username, ctx.repo.name
380 ))
381 .into());
382 }
383 Err(CoreError::Conflict(message)) => break 'attempt Some(message),
384 Err(other) => return Err(other.into()),
385 }
386 };
387
388 let forms = Forms {
389 transfer_owner: target,
390 transfer_error: error,
391 ..Forms::fresh(&ctx)
392 };
393 let frame = Frame::new(&ctx, state, lang).await?;
394 let csrf = session.csrf_token.clone();
395 Ok(view! {
396 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
397 rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf,
398 can_delete: frame.can_delete,
399 notice: None, forms: forms))
400 })
401}