forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.1 KiB
1//! Repo transfer status and actions. `GET /{owner}/{repo}/transfer` shows the open transfer of a
2//! repo, if any, to viewers who can read it. The POST actions accept, decline and cancel a transfer
3//! by its repo id under `/-/transfers/{id}/...`: a target who cannot read a private repo can still
4//! act on it from the inbox (`transfer_list_for`), so those routes check the transfer rules in the
5//! store and the repo rules here, not the read rule of `load_repo`.
6//!
7//! Starting a transfer is on the settings page (see `pages::repo_rename`). Instance admins skip
8//! acceptance entirely.
9
10use forge_core::authz::{Action, authorize};
11use forge_core::store::transfers::PendingTransfer;
12use forge_core::{AuditAction, Error as CoreError};
13use serde::Deserialize;
14use topcoat::Result;
15use topcoat::context::{Cx, app_context};
16use topcoat::router::content::Form;
17use topcoat::router::error::{SeeOther, forbidden, see_other};
18use topcoat::router::{page, path_param, route};
19use topcoat::view::{View, component, view};
20
21use crate::audit::{self, Actor, Target};
22use crate::auth::{require_csrf, require_user};
23use crate::i18n::repo_admin::{self as g, K};
24use crate::i18n::{self, Lang};
25use crate::pages::repo::repo_head;
26use crate::pages::repo_ctx::{RepoHead, load_repo};
27use crate::state::AppState;
28use crate::views::{csrf_field, shell};
29
30path_param!(owner);
31path_param!(repo);
32path_param!(transfer);
33
34#[derive(Deserialize)]
35struct ActionForm {
36 #[serde(default)]
37 csrf: String,
38}
39
40/// One line describing an open transfer, for the repo page.
41fn summary_text(lang: Lang, transfer: &PendingTransfer) -> String {
42 let until = transfer.expires_at.split('T').next().unwrap_or_default();
43 g::transfer_summary(
44 lang,
45 transfer.to_is_org,
46 &transfer.to_owner_name,
47 &transfer.initiated_by_name,
48 until,
49 )
50}
51
52/// The open transfer and the action forms the viewer may use. Each action is `None` when hidden.
53#[component]
54async fn transfer_body(
55 lang: Lang,
56 head: RepoHead,
57 summary: String,
58 accept: Option<String>,
59 decline: Option<String>,
60 cancel: Option<String>,
61 csrf: String,
62 settings: Option<String>,
63) -> Result<impl View> {
64 Ok(view! {
65 repo_head(head: head)
66 <section class="card">
67 <h2>(g::t(lang, K::TransferTitle))</h2>
68 if !summary.is_empty() {
69 <p>(summary.as_str())</p>
70 if let Some(action) = accept {
71 <form method="post" action=(action.as_str()) class="stack">
72 csrf_field(token: csrf.as_str())
73 <div>
74 <button type="submit">(g::t(lang, K::AcceptTransfer))</button>
75 </div>
76 </form>
77 }
78 if let Some(action) = decline {
79 <form method="post" action=(action.as_str()) class="stack">
80 csrf_field(token: csrf.as_str())
81 <div>
82 <button type="submit" class="danger">(g::t(lang, K::DeclineTransfer))</button>
83 </div>
84 </form>
85 }
86 if let Some(action) = cancel {
87 <form method="post" action=(action.as_str()) class="stack">
88 csrf_field(token: csrf.as_str())
89 <div>
90 <button type="submit" class="danger">(g::t(lang, K::CancelTransfer))</button>
91 </div>
92 </form>
93 }
94 }
95 if summary.is_empty() {
96 <p class="muted">(g::t(lang, K::NoPendingTransfer))</p>
97 }
98 if let Some(href) = settings {
99 <p class="muted small"><a href=(href.as_str())>(g::t(lang, K::StartTransferLink))</a></p>
100 }
101 </section>
102 })
103}
104
105/// The transfer status page. Anonymous viewers go to login; viewers who cannot read the repo get
106/// 404 from `load_repo`.
107#[page("/{owner}/{repo}/transfer")]
108async fn transfer_get(cx: &Cx) -> Result<impl View> {
109 let owner = path_param::<Owner>(cx);
110 let name = path_param::<Repo>(cx);
111 let ctx = load_repo(cx, owner, name).await?;
112 let (user, session) = require_user(cx).await?;
113 let lang = i18n::lang(cx, Some(&user));
114 let state = app_context::<AppState>(cx);
115 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
116 let may_delete = ctx.can_delete();
117 let pending = state.store.pending_transfer(&ctx.repo.id).await?;
118
119 let (summary, accept, decline, cancel) = match &pending {
120 Some(transfer) => {
121 let may_accept = state.store.can_accept_transfer(&user.id, transfer).await?;
122 let may_cancel = may_delete || transfer.initiated_by_id == user.id;
123 (
124 summary_text(lang, transfer),
125 may_accept.then(|| format!("/-/transfers/{}/accept", transfer.repo_id)),
126 may_accept.then(|| format!("/-/transfers/{}/decline", transfer.repo_id)),
127 may_cancel.then(|| format!("/-/transfers/{}/cancel", transfer.repo_id)),
128 )
129 }
130 None => (String::new(), None, None, None),
131 };
132 let settings = may_delete.then(|| format!("/{title}/settings/rename"));
133 let head = ctx.head(state, "settings").await?;
134 let page_title = g::transfer_page_title(lang, &title);
135 let csrf = session.csrf_token.clone();
136 Ok(view! {
137 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
138 transfer_body(lang: lang, head: head, summary: summary, accept: accept, decline: decline,
139 cancel: cancel, csrf: csrf, settings: settings))
140 })
141}
142
143/// The open transfer `repo_id`, or `NotFound`.
144async fn open_transfer(state: &AppState, repo_id: &str) -> Result<PendingTransfer> {
145 Ok(state
146 .store
147 .pending_transfer(repo_id)
148 .await?
149 .ok_or(CoreError::NotFound)?)
150}
151
152/// Accepts the transfer as the target and moves the repo. Then goes to the repo at its new owner.
153#[route(POST "/-/transfers/{transfer}/accept")]
154async fn accept_post(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> {
155 let repo_id = path_param::<Transfer>(cx).to_owned();
156 let (user, _) = require_csrf(cx, &form.csrf).await?;
157 let state = app_context::<AppState>(cx);
158 let pending = open_transfer(state, &repo_id).await?;
159 state.store.accept_transfer(&repo_id, &user.id).await?;
160 audit::record(
161 &state.store,
162 Some(cx),
163 Actor::User(&user),
164 AuditAction::RepoTransferAccepted,
165 Target {
166 kind: "repo",
167 id: &repo_id,
168 name: &pending.repo_name,
169 },
170 serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }),
171 )
172 .await;
173 Ok(see_other(format!(
174 "/{}/{}",
175 pending.to_owner_name, pending.repo_name
176 )))
177}
178
179/// Declines the transfer as one of its target's owners. The repo stays where it is.
180#[route(POST "/-/transfers/{transfer}/decline")]
181async fn decline_post(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> {
182 let repo_id = path_param::<Transfer>(cx).to_owned();
183 let (user, _) = require_csrf(cx, &form.csrf).await?;
184 let state = app_context::<AppState>(cx);
185 let pending = open_transfer(state, &repo_id).await?;
186 state.store.decline_transfer(&repo_id, &user.id).await?;
187 audit::record(
188 &state.store,
189 Some(cx),
190 Actor::User(&user),
191 AuditAction::RepoTransferDeclined,
192 Target {
193 kind: "repo",
194 id: &repo_id,
195 name: &pending.repo_name,
196 },
197 serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }),
198 )
199 .await;
200 Ok(see_other("/notifications"))
201}
202
203/// Cancels the transfer. The initiator may, and so may anyone who may delete the repo.
204#[route(POST "/-/transfers/{transfer}/cancel")]
205async fn cancel_post(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> {
206 let repo_id = path_param::<Transfer>(cx).to_owned();
207 let (user, _) = require_csrf(cx, &form.csrf).await?;
208 let state = app_context::<AppState>(cx);
209 let pending = open_transfer(state, &repo_id).await?;
210 let repo = state
211 .store
212 .repo_by_id(&repo_id)
213 .await?
214 .ok_or(CoreError::NotFound)?;
215 let org_role = state
216 .store
217 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
218 .await?;
219 let may_delete = authorize(Some(&user), &repo, None, org_role, Action::Delete).is_ok();
220 if !may_delete && pending.initiated_by_id != user.id {
221 return Err(forbidden().into());
222 }
223 state.store.cancel_transfer(&repo_id).await?;
224 audit::record(
225 &state.store,
226 Some(cx),
227 Actor::User(&user),
228 AuditAction::RepoTransferCancelled,
229 Target {
230 kind: "repo",
231 id: &repo_id,
232 name: &pending.repo_name,
233 },
234 serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }),
235 )
236 .await;
237 Ok(see_other(format!(
238 "/{}/{}/transfer",
239 pending.from_owner_name, pending.repo_name
240 )))
241}
242
243/// One inbox line: a transfer waiting for the viewer, with its accept and decline forms.
244struct InboxRow {
245 line: String,
246 accept: String,
247 decline: String,
248}
249
250/// The inbox section listing open transfers the viewer may accept. Renders nothing when there are
251/// none. The forms post to `/-/transfers/{id}/...`, so they work for a target who cannot read a
252/// private repo.
253#[component]
254pub(crate) async fn transfer_list_for(
255 lang: Lang,
256 transfers: Vec<PendingTransfer>,
257 csrf: String,
258) -> Result<impl View> {
259 let rows: Vec<InboxRow> = transfers
260 .into_iter()
261 .map(|t| InboxRow {
262 line: g::inbox_line(
263 lang,
264 &t.from_owner_name,
265 &t.repo_name,
266 &t.to_owner_name,
267 &t.initiated_by_name,
268 ),
269 accept: format!("/-/transfers/{}/accept", t.repo_id),
270 decline: format!("/-/transfers/{}/decline", t.repo_id),
271 })
272 .collect();
273 Ok(view! {
274 if !rows.is_empty() {
275 <section class="card notif-group">
276 <header class="notif-group-head">
277 <h2>(g::t(lang, K::RepoTransfers))</h2>
278 </header>
279 <ul class="notif-list">
280 for row in rows.into_iter() {
281 <li>
282 <span class="notif-title">(row.line.as_str())</span>
283 <form method="post" action=(row.accept.as_str()) class="notif-inline-form">
284 csrf_field(token: csrf.as_str())
285 <button type="submit">(g::t(lang, K::Accept))</button>
286 </form>
287 <form method="post" action=(row.decline.as_str()) class="notif-inline-form">
288 csrf_field(token: csrf.as_str())
289 <button type="submit" class="link-button">(g::t(lang, K::Decline))</button>
290 </form>
291 </li>
292 }
293 </ul>
294 </section>
295 }
296 })
297}