Sam Jeston
History
490 lines·
13.6 KB
·Rust
| 1 | //! Repository loading, creation, settings, branches and tags. |
| 2 | |
| 3 | use std::collections::HashMap; |
| 4 | use std::path::PathBuf; |
| 5 | |
| 6 | use askama::Template; |
| 7 | use axum::Form; |
| 8 | use axum::extract::{Path, State}; |
| 9 | use serde::Deserialize; |
| 10 | |
| 11 | use super::{AppError, Ctx, WebResult, blocking, redirect, redirect_flash, render, safe_next}; |
| 12 | use crate::db::{self, Repo, User}; |
| 13 | use crate::git::{self, BranchInfo, TagInfo}; |
| 14 | use crate::state::{Access, AppState}; |
| 15 | use crate::validate; |
| 16 | |
| 17 | /// A repository the current user is allowed to read, with their permissions. |
| 18 | #[derive(Debug, Clone)] |
| 19 | pub 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 | |
| 28 | impl 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 | |
| 59 | pub 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")] |
| 90 | struct NewRepoPage { |
| 91 | ctx: Ctx, |
| 92 | name: String, |
| 93 | description: String, |
| 94 | private: bool, |
| 95 | readme: bool, |
| 96 | error: Option<String>, |
| 97 | } |
| 98 | |
| 99 | pub 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)] |
| 112 | pub struct NewRepoForm { |
| 113 | name: String, |
| 114 | #[serde(default)] |
| 115 | description: String, |
| 116 | visibility: String, |
| 117 | readme: Option<String>, |
| 118 | } |
| 119 | |
| 120 | pub 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 | |
| 180 | pub 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")] |
| 188 | struct BranchesPage { |
| 189 | ctx: Ctx, |
| 190 | r: RepoCtx, |
| 191 | tab: &'static str, |
| 192 | rows: Vec<BranchRow>, |
| 193 | } |
| 194 | |
| 195 | pub 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)] |
| 227 | pub struct DeleteBranchForm { |
| 228 | branch: String, |
| 229 | return_to: Option<String>, |
| 230 | pr: Option<i64>, |
| 231 | } |
| 232 | |
| 233 | pub 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")] |
| 279 | struct TagsPage { |
| 280 | ctx: Ctx, |
| 281 | r: RepoCtx, |
| 282 | tab: &'static str, |
| 283 | tags: Vec<TagInfo>, |
| 284 | } |
| 285 | |
| 286 | pub 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")] |
| 308 | struct 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 | |
| 318 | async 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 | |
| 340 | pub 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)] |
| 350 | pub struct SettingsForm { |
| 351 | name: String, |
| 352 | #[serde(default)] |
| 353 | description: String, |
| 354 | visibility: String, |
| 355 | default_branch: Option<String>, |
| 356 | } |
| 357 | |
| 358 | pub 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)] |
| 410 | pub struct CollaboratorForm { |
| 411 | username: String, |
| 412 | } |
| 413 | |
| 414 | pub 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 | |
| 439 | pub 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)] |
| 458 | pub struct DeleteForm { |
| 459 | confirm: String, |
| 460 | } |
| 461 | |
| 462 | pub 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 | } |