samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
490 lines· 13.6 KB ·Rust
Raw
1//! Repository loading, creation, settings, branches and tags.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use askama::Template;
7use axum::Form;
8use axum::extract::{Path, State};
9use serde::Deserialize;
10
11use super::{AppError, Ctx, WebResult, blocking, redirect, redirect_flash, render, safe_next};
12use crate::db::{self, Repo, User};
13use crate::git::{self, BranchInfo, TagInfo};
14use crate::state::{Access, AppState};
15use crate::validate;
16
17/// A repository the current user is allowed to read, with their permissions.
18#[derive(Debug, Clone)]
19pub struct RepoCtx {
20 pub repo: Repo,
21 pub access: Access,
22 pub path: PathBuf,
23 pub open_pulls: i64,
24 pub ssh_url: String,
25 pub http_url: String,
26}
27
28impl RepoCtx {
29 /// URL prefix of the repository, e.g. `/alice/project`.
30 pub fn base(&self) -> String {
31 format!("/{}/{}", self.repo.owner, self.repo.name)
32 }
33
34 pub fn pull_url(&self, number: &i64) -> String {
35 format!("{}/pull/{number}", self.base())
36 }
37
38 pub fn require_write(&self) -> Result<(), AppError> {
39 if self.access.write {
40 Ok(())
41 } else {
42 Err(AppError::Forbidden(
43 "You need write access to this repository to do that.".into(),
44 ))
45 }
46 }
47
48 pub fn require_admin(&self) -> Result<(), AppError> {
49 if self.access.admin {
50 Ok(())
51 } else {
52 Err(AppError::Forbidden(
53 "Only the repository owner can do that.".into(),
54 ))
55 }
56 }
57}
58
59pub async fn load_repo(
60 state: &AppState,
61 ctx: &Ctx,
62 owner: &str,
63 name: &str,
64) -> Result<RepoCtx, AppError> {
65 let repo = db::repo_by_name(&state.db, owner, name)
66 .await?
67 .ok_or(AppError::NotFound)?;
68 let access = state.access(&repo, ctx.user.as_deref()).await?;
69 if !access.read {
70 // Private repositories are indistinguishable from missing ones.
71 return Err(AppError::NotFound);
72 }
73 let (open_pulls, _) = db::count_pulls(&state.db, repo.id).await?;
74 Ok(RepoCtx {
75 path: state.config.repo_path(repo.id),
76 ssh_url: state.config.ssh_clone_url(&repo.owner, &repo.name),
77 http_url: state.config.http_clone_url(&repo.owner, &repo.name),
78 repo,
79 access,
80 open_pulls,
81 })
82}
83
84// ---------------------------------------------------------------------------
85// New repository
86// ---------------------------------------------------------------------------
87
88#[derive(Template)]
89#[template(path = "new_repo.html")]
90struct NewRepoPage {
91 ctx: Ctx,
92 name: String,
93 description: String,
94 private: bool,
95 readme: bool,
96 error: Option<String>,
97}
98
99pub async fn new_form(ctx: Ctx) -> WebResult {
100 ctx.require_user()?;
101 render(&NewRepoPage {
102 ctx,
103 name: String::new(),
104 description: String::new(),
105 private: false,
106 readme: false,
107 error: None,
108 })
109}
110
111#[derive(Deserialize)]
112pub struct NewRepoForm {
113 name: String,
114 #[serde(default)]
115 description: String,
116 visibility: String,
117 readme: Option<String>,
118}
119
120pub async fn create(
121 State(state): State<AppState>,
122 ctx: Ctx,
123 Form(f): Form<NewRepoForm>,
124) -> WebResult {
125 let user = ctx.require_user()?.clone();
126 let name = f.name.trim().to_string();
127 let description = f.description.trim().chars().take(350).collect::<String>();
128 let private = f.visibility == "private";
129 let readme = f.readme.is_some();
130
131 let mut error = validate::repo_name(&name).err().map(str::to_string);
132 if error.is_none() && db::repo_name_taken(&state.db, user.id, &name, -1).await? {
133 error = Some(format!(
134 "You already have a repository named \u{201c}{name}\u{201d}."
135 ));
136 }
137 if let Some(error) = error {
138 return render(&NewRepoPage {
139 ctx,
140 name,
141 description,
142 private,
143 readme,
144 error: Some(error),
145 });
146 }
147
148 let id = db::create_repo(&state.db, user.id, &name, &description, private, "main").await?;
149 let path = state.config.repo_path(id);
150 let (repo_name, desc) = (name.clone(), description.clone());
151 let init = blocking(move || {
152 let repo = git::init_bare(&path, "main")?;
153 if readme {
154 let mut content = format!("# {repo_name}\n");
155 if !desc.is_empty() {
156 content.push_str(&format!("\n{desc}\n"));
157 }
158 let sig = git2::Signature::now(user.name(), &user.email)?;
159 git::initial_commit(&repo, "main", &[("README.md", content.as_bytes())], &sig)?;
160 }
161 Ok(())
162 })
163 .await;
164 if let Err(e) = init {
165 db::delete_repo(&state.db, id).await?;
166 let _ = std::fs::remove_dir_all(state.config.repo_path(id));
167 return Err(e);
168 }
169 Ok(redirect(&format!(
170 "/{}/{}",
171 ctx.require_user()?.username,
172 name
173 )))
174}
175
176// ---------------------------------------------------------------------------
177// Branches and tags
178// ---------------------------------------------------------------------------
179
180pub struct BranchRow {
181 pub branch: BranchInfo,
182 pub pull: Option<i64>,
183 pub is_default: bool,
184}
185
186#[derive(Template)]
187#[template(path = "repo/branches.html")]
188struct BranchesPage {
189 ctx: Ctx,
190 r: RepoCtx,
191 tab: &'static str,
192 rows: Vec<BranchRow>,
193}
194
195pub async fn branches(
196 State(state): State<AppState>,
197 ctx: Ctx,
198 Path((owner, name)): Path<(String, String)>,
199) -> WebResult {
200 let r = load_repo(&state, &ctx, &owner, &name).await?;
201 let path = r.path.clone();
202 let default = r.repo.default_branch.clone();
203 let list = blocking(move || Ok(git::branches(&git::open(&path)?, &default)?)).await?;
204 let pulls: HashMap<String, i64> = db::list_pulls(&state.db, r.repo.id, true)
205 .await?
206 .into_iter()
207 .map(|p| (p.head_branch, p.number))
208 .collect();
209 let mut rows: Vec<BranchRow> = list
210 .into_iter()
211 .map(|b| BranchRow {
212 pull: pulls.get(&b.name).copied(),
213 is_default: b.name == r.repo.default_branch,
214 branch: b,
215 })
216 .collect();
217 rows.sort_by_key(|row| !row.is_default);
218 render(&BranchesPage {
219 ctx,
220 r,
221 tab: "branches",
222 rows,
223 })
224}
225
226#[derive(Deserialize)]
227pub struct DeleteBranchForm {
228 branch: String,
229 return_to: Option<String>,
230 pr: Option<i64>,
231}
232
233pub async fn delete_branch(
234 State(state): State<AppState>,
235 ctx: Ctx,
236 Path((owner, name)): Path<(String, String)>,
237 Form(f): Form<DeleteBranchForm>,
238) -> WebResult {
239 let user = ctx.require_user()?.clone();
240 let r = load_repo(&state, &ctx, &owner, &name).await?;
241 r.require_write()?;
242 if f.branch == r.repo.default_branch {
243 return Err(AppError::BadRequest(
244 "The default branch cannot be deleted.".into(),
245 ));
246 }
247 let path = r.path.clone();
248 let branch = f.branch.clone();
249 blocking(move || {
250 let repo = git::open(&path)?;
251 if !git::branch_exists(&repo, &branch) {
252 return Err(AppError::NotFound);
253 }
254 git::delete_branch(&repo, &branch)?;
255 Ok(())
256 })
257 .await?;
258 if let Some(number) = f.pr {
259 if let Some(pr) = db::pull_by_number(&state.db, r.repo.id, number).await? {
260 if pr.head_branch == f.branch {
261 db::add_event(&state.db, pr.id, Some(user.id), "branch_deleted", &f.branch).await?;
262 }
263 }
264 }
265 let to = f
266 .return_to
267 .as_deref()
268 .map(|t| safe_next(Some(t)))
269 .unwrap_or_else(|| format!("{}/branches", r.base()));
270 Ok(redirect_flash(
271 &to,
272 "success",
273 &format!("Deleted branch {}.", f.branch),
274 ))
275}
276
277#[derive(Template)]
278#[template(path = "repo/tags.html")]
279struct TagsPage {
280 ctx: Ctx,
281 r: RepoCtx,
282 tab: &'static str,
283 tags: Vec<TagInfo>,
284}
285
286pub async fn tags(
287 State(state): State<AppState>,
288 ctx: Ctx,
289 Path((owner, name)): Path<(String, String)>,
290) -> WebResult {
291 let r = load_repo(&state, &ctx, &owner, &name).await?;
292 let path = r.path.clone();
293 let tags = blocking(move || Ok(git::tags(&git::open(&path)?)?)).await?;
294 render(&TagsPage {
295 ctx,
296 r,
297 tab: "code",
298 tags,
299 })
300}
301
302// ---------------------------------------------------------------------------
303// Settings
304// ---------------------------------------------------------------------------
305
306#[derive(Template)]
307#[template(path = "repo/settings.html")]
308struct SettingsPage {
309 ctx: Ctx,
310 r: RepoCtx,
311 tab: &'static str,
312 branches: Vec<String>,
313 collaborators: Vec<User>,
314 error: Option<String>,
315 collab_error: Option<String>,
316}
317
318async fn settings_page(
319 state: &AppState,
320 ctx: Ctx,
321 r: RepoCtx,
322 error: Option<String>,
323 collab_error: Option<String>,
324) -> WebResult {
325 r.require_admin()?;
326 let path = r.path.clone();
327 let branches = blocking(move || Ok(git::branch_names(&git::open(&path)?)?)).await?;
328 let collaborators = db::list_collaborators(&state.db, r.repo.id).await?;
329 render(&SettingsPage {
330 ctx,
331 r,
332 tab: "settings",
333 branches,
334 collaborators,
335 error,
336 collab_error,
337 })
338}
339
340pub async fn settings(
341 State(state): State<AppState>,
342 ctx: Ctx,
343 Path((owner, name)): Path<(String, String)>,
344) -> WebResult {
345 let r = load_repo(&state, &ctx, &owner, &name).await?;
346 settings_page(&state, ctx, r, None, None).await
347}
348
349#[derive(Deserialize)]
350pub struct SettingsForm {
351 name: String,
352 #[serde(default)]
353 description: String,
354 visibility: String,
355 default_branch: Option<String>,
356}
357
358pub async fn update_settings(
359 State(state): State<AppState>,
360 ctx: Ctx,
361 Path((owner, name)): Path<(String, String)>,
362 Form(f): Form<SettingsForm>,
363) -> WebResult {
364 let r = load_repo(&state, &ctx, &owner, &name).await?;
365 r.require_admin()?;
366 let new_name = f.name.trim().to_string();
367 let description = f.description.trim().chars().take(350).collect::<String>();
368 let mut error = validate::repo_name(&new_name).err().map(str::to_string);
369 if error.is_none()
370 && db::repo_name_taken(&state.db, r.repo.owner_id, &new_name, r.repo.id).await?
371 {
372 error = Some("A repository with that name already exists.".into());
373 }
374 if let Some(error) = error {
375 return settings_page(&state, ctx, r, Some(error), None).await;
376 }
377
378 let mut default_branch = r.repo.default_branch.clone();
379 if let Some(branch) = f.default_branch.filter(|b| *b != default_branch) {
380 let path = r.path.clone();
381 let b = branch.clone();
382 blocking(move || {
383 let repo = git::open(&path)?;
384 if !git::branch_exists(&repo, &b) {
385 return Err(AppError::BadRequest("That branch does not exist.".into()));
386 }
387 git::set_head(&repo, &b)?;
388 Ok(())
389 })
390 .await?;
391 default_branch = branch;
392 }
393 db::update_repo(
394 &state.db,
395 r.repo.id,
396 &new_name,
397 &description,
398 f.visibility == "private",
399 &default_branch,
400 )
401 .await?;
402 Ok(redirect_flash(
403 &format!("/{}/{}/settings", r.repo.owner, new_name),
404 "success",
405 "Repository settings saved.",
406 ))
407}
408
409#[derive(Deserialize)]
410pub struct CollaboratorForm {
411 username: String,
412}
413
414pub async fn add_collaborator(
415 State(state): State<AppState>,
416 ctx: Ctx,
417 Path((owner, name)): Path<(String, String)>,
418 Form(f): Form<CollaboratorForm>,
419) -> WebResult {
420 let r = load_repo(&state, &ctx, &owner, &name).await?;
421 r.require_admin()?;
422 let username = f.username.trim();
423 let Some(user) = db::user_by_username(&state.db, username).await? else {
424 let msg = format!("No user named \u{201c}{username}\u{201d} exists.");
425 return settings_page(&state, ctx, r, None, Some(msg)).await;
426 };
427 if user.id == r.repo.owner_id {
428 let msg = "The owner already has full access.".to_string();
429 return settings_page(&state, ctx, r, None, Some(msg)).await;
430 }
431 db::add_collaborator(&state.db, r.repo.id, user.id).await?;
432 Ok(redirect_flash(
433 &format!("{}/settings", r.base()),
434 "success",
435 &format!("{} can now push to this repository.", user.username),
436 ))
437}
438
439pub async fn remove_collaborator(
440 State(state): State<AppState>,
441 ctx: Ctx,
442 Path((owner, name)): Path<(String, String)>,
443 Form(f): Form<CollaboratorForm>,
444) -> WebResult {
445 let r = load_repo(&state, &ctx, &owner, &name).await?;
446 r.require_admin()?;
447 if let Some(user) = db::user_by_username(&state.db, &f.username).await? {
448 db::remove_collaborator(&state.db, r.repo.id, user.id).await?;
449 }
450 Ok(redirect_flash(
451 &format!("{}/settings", r.base()),
452 "success",
453 "Collaborator removed.",
454 ))
455}
456
457#[derive(Deserialize)]
458pub struct DeleteForm {
459 confirm: String,
460}
461
462pub async fn delete(
463 State(state): State<AppState>,
464 ctx: Ctx,
465 Path((owner, name)): Path<(String, String)>,
466 Form(f): Form<DeleteForm>,
467) -> WebResult {
468 let r = load_repo(&state, &ctx, &owner, &name).await?;
469 r.require_admin()?;
470 let full_name = format!("{}/{}", r.repo.owner, r.repo.name);
471 if f.confirm.trim() != full_name {
472 return settings_page(
473 &state,
474 ctx,
475 r,
476 Some(format!("Type {full_name} to confirm deletion.")),
477 None,
478 )
479 .await;
480 }
481 db::delete_repo(&state.db, r.repo.id).await?;
482 if let Err(e) = tokio::fs::remove_dir_all(&r.path).await {
483 tracing::warn!("could not remove {}: {e}", r.path.display());
484 }
485 Ok(redirect_flash(
486 "/",
487 "success",
488 &format!("Deleted {full_name}."),
489 ))
490}