forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.7 KiB
1//! Shared lookup for every repo page: resolve owner/name, authorize Read for the viewer,
2//! and return 404 for both missing and hidden repos so private repos are never revealed.
3//! A past owner/name (a redirect left by a rename or transfer) gets a 301 to the current URL
4//! when the viewer may read the repo it now points at.
5
6use std::path::PathBuf;
7
8use forge_core::authz::{Action, authorize};
9use forge_core::{Repo, Role, Session, User, Visibility, WatchLevel};
10use forge_git::repo::repo_path;
11use http::{HeaderValue, header::LOCATION};
12use topcoat::Result;
13use topcoat::context::{Cx, app_context};
14use topcoat::router::error::not_found;
15use topcoat::router::request::uri;
16use topcoat::router::response::Response;
17use topcoat::router::{Body, Layer, LayerFuture, Next, Path, StatusCode};
18
19use crate::auth::current_user;
20use crate::i18n::{self, Lang};
21use crate::pages::repo_mirrors::{redacted_url, stamp_in_tz};
22use crate::state::AppState;
23
24pub struct RepoCtx {
25 pub repo: Repo,
26 /// On-disk path of the bare repository.
27 pub path: PathBuf,
28 pub viewer: Option<(User, Session)>,
29 /// The viewer's collaborator role on this repo; `None` for anonymous viewers and non-members.
30 pub role: Option<Role>,
31 /// The viewer's role through the organization that owns the repo (see
32 /// `Store::org_repo_role`); `None` for person-owned repos.
33 pub org_role: Option<Role>,
34 /// The viewer's interface language, for the header and the page's strings.
35 pub lang: Lang,
36}
37
38/// The viewer's star control on a repo header. Only signed-in viewers get one.
39#[derive(Clone)]
40pub struct StarButton {
41 pub starred: bool,
42 /// The session's CSRF token for the star form.
43 pub csrf: String,
44}
45
46/// Everything the repo header shows, built once per page from the repo, the viewer and the page's
47/// active tab. `repo_head` renders it, including the archived notice.
48#[derive(Clone)]
49pub struct RepoHead {
50 /// The viewer's interface language, for the strings `repo_head` draws.
51 pub lang: Lang,
52 pub owner: String,
53 pub name: String,
54 pub default_branch: String,
55 pub visibility: Visibility,
56 pub description: String,
57 pub archived: bool,
58 /// The current header tab: `code`, `issues`, `pulls`, `commits`, `branches`, `tags` or
59 /// `settings`, or `security`.
60 pub active: &'static str,
61 /// Whether the viewer may open the Settings tab.
62 pub can_admin: bool,
63 /// Open issues, for the Issues tab count.
64 pub open_issues: u64,
65 /// Open pull requests, for the Pull requests tab count.
66 pub open_pulls: u64,
67 /// The source repo's `(owner, name)` when this repo is a fork and the viewer may read the
68 /// source. `None` otherwise, so a hidden parent is never named.
69 pub forked_from: Option<(String, String)>,
70 /// Whether the viewer may fork this repo: signed in and not its owner.
71 pub can_fork: bool,
72 pub stars: u64,
73 /// `None` for anonymous viewers, who see the star count without a button.
74 pub star: Option<StarButton>,
75 /// Repo watchers (level `watching`), for the header count.
76 pub watchers: u64,
77 /// `None` for anonymous viewers, who see the count only.
78 pub watch: Option<WatchButton>,
79 /// The pull mirror this repo follows, shown as the "Mirror of" line. `None` for other repos.
80 pub mirror: Option<MirrorSource>,
81 /// Whether the repo has discussions turned on, for the Discussions tab.
82 pub discussions: bool,
83 /// Whether the repo's wiki is on, for the Wiki tab.
84 pub wiki: bool,
85 /// Whether the Security tab shows: advisories are enabled and the viewer may manage alerts.
86 pub security: bool,
87}
88
89/// The "Mirror of" line's values: the redacted source URL and the last sync, already formatted in the
90/// viewer's timezone. The URL is host and path only, so credentials and tokens never show.
91#[derive(Clone)]
92pub struct MirrorSource {
93 pub url: String,
94 pub last_sync: Option<String>,
95 pub last_ok: Option<bool>,
96}
97
98/// The viewer's watch control on a repo header: the current level and the session CSRF token.
99#[derive(Clone)]
100pub struct WatchButton {
101 pub level: WatchLevel,
102 pub csrf: String,
103}
104
105/// Raised by `load_repo` when the requested owner/name is a past name of a repo the viewer may
106/// read. The `repo_moved` layer turns it into a 301 to `location`.
107#[derive(Debug)]
108pub struct RepoMoved {
109 pub location: String,
110}
111
112impl std::fmt::Display for RepoMoved {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.write_str("repository moved")
115 }
116}
117
118impl std::error::Error for RepoMoved {}
119
120impl RepoCtx {
121 /// Whether the viewer may delete this repo: its owner or an instance admin only.
122 pub fn can_delete(&self) -> bool {
123 let actor = self.viewer.as_ref().map(|(user, _)| user);
124 authorize(actor, &self.repo, None, self.org_role, Action::Delete).is_ok()
125 }
126
127 /// Whether the viewer may manage the repo's settings (such as branch rules): the owner, an
128 /// instance admin, or a collaborator with the Admin role.
129 pub fn can_admin(&self) -> bool {
130 let actor = self.viewer.as_ref().map(|(user, _)| user);
131 authorize(actor, &self.repo, self.role, self.org_role, Action::Admin).is_ok()
132 }
133
134 /// Whether the viewer may write to the repo (push, and edit the wiki): a collaborator with the
135 /// Write role or higher, an org role that grants it, or the owner. Archived repos stay read-only.
136 pub fn can_write(&self) -> bool {
137 let actor = self.viewer.as_ref().map(|(user, _)| user);
138 !self.repo.archived
139 && authorize(actor, &self.repo, self.role, self.org_role, Action::Write).is_ok()
140 }
141
142 /// Open issue count for the Issues tab: one indexed COUNT query.
143 pub async fn open_issues(&self, state: &AppState) -> Result<u64> {
144 Ok(state.store.issue_counts(&self.repo.id).await?.0)
145 }
146
147 /// The source repo of this fork as `(owner, name)`, if the viewer may read it.
148 async fn forked_from(&self, state: &AppState) -> Result<Option<(String, String)>> {
149 let Some(source_id) = &self.repo.fork_of else {
150 return Ok(None);
151 };
152 let Some(source) = state.store.repo_by_id(source_id).await? else {
153 return Ok(None);
154 };
155 let (role, org_role) = match &self.viewer {
156 Some((user, _)) => (
157 state.store.collaborator_role(&source.id, &user.id).await?,
158 state
159 .store
160 .org_repo_role(&source.owner_id, &user.id, &source.id)
161 .await?,
162 ),
163 None => (None, None),
164 };
165 let actor = self.viewer.as_ref().map(|(user, _)| user);
166 if authorize(actor, &source, role, org_role, Action::Read).is_err() {
167 return Ok(None);
168 }
169 Ok(Some((source.owner_name, source.name)))
170 }
171
172 /// The header for this repo: counts, the fork line, the archived flag and the viewer's star
173 /// state. `active` is the header tab the page is on.
174 pub async fn head(&self, state: &AppState, active: &'static str) -> Result<RepoHead> {
175 let stars = state.store.star_count(&self.repo.id).await?;
176 let open_pulls = state.store.pull_counts(&self.repo.id).await?.0;
177 let forked_from = self.forked_from(state).await?;
178 let can_fork = self
179 .viewer
180 .as_ref()
181 .is_some_and(|(user, _)| user.id != self.repo.owner_id);
182 let star = match &self.viewer {
183 Some((user, session)) => Some(StarButton {
184 starred: state.store.is_starred(&user.id, &self.repo.id).await?,
185 csrf: session.csrf_token.clone(),
186 }),
187 None => None,
188 };
189 let watchers =
190 u64::try_from(state.store.watchers(&self.repo.id).await?.len()).unwrap_or(u64::MAX);
191 let watch = match &self.viewer {
192 Some((user, session)) => Some(WatchButton {
193 level: state.store.watch_level(&user.id, &self.repo.id).await?,
194 csrf: session.csrf_token.clone(),
195 }),
196 None => None,
197 };
198 let discussions = state.store.discussions_enabled(&self.repo.id).await?;
199 let wiki = state.store.wiki_enabled(&self.repo.id).await?;
200 let security =
201 state.config.advisories.enabled && crate::pages::repo_security::can_write(self);
202 let mirror = state
203 .store
204 .pull_mirror(&self.repo.id)
205 .await?
206 .map(|mirror| MirrorSource {
207 url: redacted_url(&mirror.url),
208 last_sync: mirror
209 .last_sync_at
210 .as_deref()
211 .map(|stamp| stamp_in_tz(stamp, self.viewer_tz())),
212 last_ok: mirror.last_ok,
213 });
214 Ok(RepoHead {
215 lang: self.lang,
216 owner: self.repo.owner_name.clone(),
217 name: self.repo.name.clone(),
218 default_branch: self.repo.default_branch.clone(),
219 visibility: self.repo.visibility,
220 description: self.repo.description.clone(),
221 archived: self.repo.archived,
222 active,
223 can_admin: self.can_admin(),
224 open_issues: self.open_issues(state).await?,
225 open_pulls,
226 forked_from,
227 can_fork,
228 stars,
229 star,
230 watchers,
231 watch,
232 mirror,
233 discussions,
234 wiki,
235 security,
236 })
237 }
238
239 /// Timezone for absolute times: the signed-in viewer's, or UTC for anonymous viewers.
240 pub fn viewer_tz(&self) -> &str {
241 self.viewer
242 .as_ref()
243 .map_or("UTC", |(user, _)| user.timezone.as_str())
244 }
245}
246
247/// Load `owner/name` for the current viewer, or 404.
248pub async fn load_repo(cx: &Cx, owner: &str, name: &str) -> Result<RepoCtx> {
249 let state = app_context::<AppState>(cx);
250 let viewer = current_user(cx).await?;
251
252 let Some(repo) = state.store.repo_by_owner_and_name(owner, name).await? else {
253 if let Some(location) = moved_location(cx, state, viewer.as_ref(), owner, name).await? {
254 return Err(RepoMoved { location }.into());
255 }
256 return Err(not_found().into());
257 };
258 let (role, org_role) = match &viewer {
259 Some((user, _)) => (
260 state.store.collaborator_role(&repo.id, &user.id).await?,
261 state
262 .store
263 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
264 .await?,
265 ),
266 None => (None, None),
267 };
268 let actor = viewer.as_ref().map(|(user, _)| user);
269 if authorize(actor, &repo, role, org_role, Action::Read).is_err() {
270 return Err(not_found().into());
271 }
272 let path = repo_path(&state.config.data_dir, &repo.id)?;
273 let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user));
274 Ok(RepoCtx {
275 repo,
276 path,
277 viewer,
278 role,
279 org_role,
280 lang,
281 })
282}
283
284/// The URL to send a past owner/name to: the same path and query with the owner and name segments
285/// replaced. `None` unless the redirect target exists and the viewer may read it, so a hidden repo
286/// answers exactly like a missing one.
287async fn moved_location(
288 cx: &Cx,
289 state: &AppState,
290 viewer: Option<&(User, Session)>,
291 owner: &str,
292 name: &str,
293) -> Result<Option<String>> {
294 let Some(target) = state.store.repo_by_redirect(owner, name).await? else {
295 return Ok(None);
296 };
297 let (role, org_role) = match viewer {
298 Some((user, _)) => (
299 state.store.collaborator_role(&target.id, &user.id).await?,
300 state
301 .store
302 .org_repo_role(&target.owner_id, &user.id, &target.id)
303 .await?,
304 ),
305 None => (None, None),
306 };
307 let actor = viewer.map(|(user, _)| user);
308 if authorize(actor, &target, role, org_role, Action::Read).is_err() {
309 return Ok(None);
310 }
311 // The path is `/{owner}/{name}[/rest]`; `splitn` keeps the rest (and any trailing slash) as is.
312 let request = uri(cx);
313 let rest = request
314 .path()
315 .splitn(4, '/')
316 .nth(3)
317 .map(|rest| format!("/{rest}"))
318 .unwrap_or_default();
319 let query = request
320 .query()
321 .map(|query| format!("?{query}"))
322 .unwrap_or_default();
323 Ok(Some(format!(
324 "/{}/{}{rest}{query}",
325 target.owner_name, target.name
326 )))
327}
328
329/// Wraps every request (no path) so a `RepoMoved` error becomes a 301. Registered explicitly on the
330/// public router in `boot.rs`: a `#[layer("/")]` would clash with the security-headers layer.
331pub struct RepoMovedLayer;
332
333impl Layer for RepoMovedLayer {
334 fn path(&self) -> Option<&Path> {
335 None
336 }
337
338 fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
339 Box::pin(repo_moved(cx, body, next))
340 }
341}
342
343/// Turns a `RepoMoved` error from a handler into a 301 with the new location. Other errors pass
344/// through unchanged.
345async fn repo_moved(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
346 match next.run(cx, body).await {
347 Ok(response) => Ok(response),
348 Err(error) => match error.downcast_ref::<RepoMoved>() {
349 Some(moved) => {
350 let mut response = Response::new(Body::empty());
351 *response.status_mut() = StatusCode::MOVED_PERMANENTLY;
352 // Owner and repo names are plain ASCII and the query came from a valid URI, so this
353 // only fails on a corrupt location; a 404 is the safe answer then.
354 let Ok(location) = HeaderValue::from_str(&moved.location) else {
355 return Err(not_found().into());
356 };
357 response.headers_mut().insert(LOCATION, location);
358 Ok(response)
359 }
360 None => Err(error),
361 },
362 }
363}