| 14 | use std::collections::HashMap; | 14 | use std::collections::HashMap; |
| 15 | use std::path::Path as FsPath; | 15 | use std::path::Path as FsPath; |
| 16 | | 16 | |
| | 17 | use axum::Form; |
| 17 | use axum::extract::{Path, Query, State}; | 18 | use axum::extract::{Path, Query, State}; |
| 18 | use axum::http::{HeaderMap, Uri, header}; | 19 | use axum::http::{HeaderMap, Uri, header}; |
| 19 | use axum::response::{Html, IntoResponse, Response}; | 20 | use axum::response::{Html, IntoResponse, Redirect, Response}; |
| 20 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | 21 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; |
| 21 | use maud::{Markup, html}; | 22 | use maud::{Markup, html}; |
| 22 | use model::{Repo, User}; | 23 | use model::{Repo, User, Visibility}; |
| 23 | use serde::Deserialize; | 24 | use serde::Deserialize; |
| 24 | | 25 | |
| 25 | use crate::AppState; | 26 | use crate::AppState; |
| 29 | use crate::layout::page; | 30 | use crate::layout::page; |
| 30 | use crate::markdown; | 31 | use crate::markdown; |
| 31 | use crate::pages::build_chrome; | 32 | use crate::pages::build_chrome; |
| 32 | use crate::session::MaybeUser; | 33 | use crate::session::{MaybeUser, verify_csrf}; |
| 33 | | 34 | |
| 34 | | 35 | |
| 35 | const DIFF_COOKIE: &str = "fabrica_diffview"; | 36 | const DIFF_COOKIE: &str = "fabrica_diffview"; |
| 63 | Branches, | 64 | Branches, |
| 64 | Tags, | 65 | Tags, |
| 65 | Search, | 66 | Search, |
| | 67 | Settings, |
| 66 | } | 68 | } |
| 67 | | 69 | |
| 68 | | 70 | |
| 69 | pub(crate) struct RepoCtx { | 71 | pub(crate) struct RepoCtx { |
| 70 | pub(crate) owner: User, | 72 | pub(crate) owner: User, |
| 71 | pub(crate) repo: Repo, | 73 | pub(crate) repo: Repo, |
| 72 | | 74 | |
| 73 | | 75 | |
| 74 | #[allow(dead_code)] | | |
| 75 | pub(crate) access: auth::Access, | 76 | pub(crate) access: auth::Access, |
| 76 | } | 77 | } |
| 77 | | 78 | |
| 288 | let raw_query = dq.q.clone().unwrap_or_default(); | 289 | let raw_query = dq.q.clone().unwrap_or_default(); |
| 289 | crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await | 290 | crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await |
| 290 | } | 291 | } |
| 291 | | 292 | "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await, |
| | 293 | |
| 292 | _ => Err(AppError::NotFound), | 294 | _ => Err(AppError::NotFound), |
| 293 | } | 295 | } |
| 294 | } | 296 | } |
| 707 | Ok((jar, page(&chrome, &title, body)).into_response()) | 709 | Ok((jar, page(&chrome, &title, body)).into_response()) |
| 708 | } | 710 | } |
| 709 | | 711 | |
| | 712 | |
| | 713 | |
| | 714 | |
| | 715 | |
| | 716 | async fn repo_settings_view( |
| | 717 | state: &AppState, |
| | 718 | viewer: Option<User>, |
| | 719 | jar: CookieJar, |
| | 720 | uri: &Uri, |
| | 721 | ctx: RepoCtx, |
| | 722 | ) -> AppResult<Response> { |
| | 723 | if ctx.access != auth::Access::Admin { |
| | 724 | return Err(AppError::NotFound); |
| | 725 | } |
| | 726 | let branches = branch_names(state, &ctx.repo.id).await; |
| | 727 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); |
| | 728 | |
| | 729 | let id = &ctx.repo.id; |
| | 730 | let vis = ctx.repo.visibility; |
| | 731 | let vis_option = |value: Visibility, label: &str| { |
| | 732 | html! { |
| | 733 | option value=(value.as_str()) selected[vis == value] { (label) } |
| | 734 | } |
| | 735 | }; |
| | 736 | let body = html! { |
| | 737 | (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches)) |
| | 738 | section class="listing" { |
| | 739 | h2 { "General" } |
| | 740 | div class="card" { |
| | 741 | form method="post" action=(format!("/repo-settings/{id}/general")) { |
| | 742 | input type="hidden" name="_csrf" value=(chrome.csrf); |
| | 743 | label for="name" { "Repository name" } |
| | 744 | input type="text" id="name" name="name" value=(ctx.repo.name) required; |
| | 745 | label for="visibility" { "Visibility" } |
| | 746 | select id="visibility" name="visibility" { |
| | 747 | (vis_option(Visibility::Public, "Public — visible to everyone")) |
| | 748 | (vis_option(Visibility::Internal, "Internal — any signed-in user")) |
| | 749 | (vis_option(Visibility::Private, "Private — only you and collaborators")) |
| | 750 | } |
| | 751 | button class="btn btn-primary" type="submit" { "Save changes" } |
| | 752 | } |
| | 753 | } |
| | 754 | } |
| | 755 | section class="listing" { |
| | 756 | h2 { "Danger zone" } |
| | 757 | div class="card danger-zone" { |
| | 758 | p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." } |
| | 759 | form method="post" action=(format!("/repo-settings/{id}/delete")) { |
| | 760 | input type="hidden" name="_csrf" value=(chrome.csrf); |
| | 761 | button class="btn btn-danger" type="submit" { "Delete repository" } |
| | 762 | } |
| | 763 | } |
| | 764 | } |
| | 765 | }; |
| | 766 | let title = format!("{}/{}: settings", ctx.owner.username, ctx.repo.path); |
| | 767 | Ok((jar, page(&chrome, &title, body)).into_response()) |
| | 768 | } |
| | 769 | |
| | 770 | |
| | 771 | |
| | 772 | async fn require_admin_repo( |
| | 773 | state: &AppState, |
| | 774 | viewer: Option<&User>, |
| | 775 | repo_id: &str, |
| | 776 | ) -> AppResult<Repo> { |
| | 777 | let repo = state |
| | 778 | .store |
| | 779 | .repo_by_id(repo_id) |
| | 780 | .await? |
| | 781 | .ok_or(AppError::NotFound)?; |
| | 782 | let viewer_id = viewer.map(|u| auth::Viewer { |
| | 783 | id: u.id.clone(), |
| | 784 | is_admin: u.is_admin, |
| | 785 | }); |
| | 786 | let collab = match viewer { |
| | 787 | Some(u) => state |
| | 788 | .store |
| | 789 | .collaborator_permission(&repo.id, &u.id) |
| | 790 | .await? |
| | 791 | .and_then(|p| auth::Permission::parse(&p)), |
| | 792 | None => None, |
| | 793 | }; |
| | 794 | let access = auth::access( |
| | 795 | viewer_id.as_ref(), |
| | 796 | &repo, |
| | 797 | collab, |
| | 798 | state.config.instance.allow_anonymous, |
| | 799 | ); |
| | 800 | if access == auth::Access::Admin { |
| | 801 | Ok(repo) |
| | 802 | } else { |
| | 803 | Err(AppError::NotFound) |
| | 804 | } |
| | 805 | } |
| | 806 | |
| | 807 | |
| | 808 | #[derive(Debug, Deserialize)] |
| | 809 | pub struct RepoGeneralForm { |
| | 810 | |
| | 811 | #[serde(default)] |
| | 812 | name: String, |
| | 813 | |
| | 814 | #[serde(default)] |
| | 815 | visibility: String, |
| | 816 | |
| | 817 | #[serde(rename = "_csrf")] |
| | 818 | csrf: String, |
| | 819 | } |
| | 820 | |
| | 821 | |
| | 822 | pub async fn settings_general( |
| | 823 | State(state): State<AppState>, |
| | 824 | MaybeUser(viewer): MaybeUser, |
| | 825 | jar: CookieJar, |
| | 826 | headers: HeaderMap, |
| | 827 | Path(id): Path<String>, |
| | 828 | Form(form): Form<RepoGeneralForm>, |
| | 829 | ) -> Response { |
| | 830 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 831 | return AppError::Forbidden.into_response(); |
| | 832 | } |
| | 833 | let result = async { |
| | 834 | let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; |
| | 835 | |
| | 836 | let new_name = form.name.trim(); |
| | 837 | let new_path = match repo.path.rsplit_once('/') { |
| | 838 | Some((group, _)) => format!("{group}/{new_name}"), |
| | 839 | None => new_name.to_string(), |
| | 840 | }; |
| | 841 | state |
| | 842 | .store |
| | 843 | .rename_repo(&repo.id, new_name, &new_path) |
| | 844 | .await?; |
| | 845 | if let Some(vis) = Visibility::from_token(&form.visibility) { |
| | 846 | state.store.set_visibility(&repo.id, vis).await?; |
| | 847 | } |
| | 848 | let owner = state |
| | 849 | .store |
| | 850 | .user_by_id(&repo.owner_id) |
| | 851 | .await? |
| | 852 | .map_or_else(|| repo.owner_id.clone(), |u| u.username); |
| | 853 | Ok::<String, AppError>(format!("/{owner}/{new_path}/-/settings")) |
| | 854 | } |
| | 855 | .await; |
| | 856 | match result { |
| | 857 | Ok(dest) => Redirect::to(&dest).into_response(), |
| | 858 | Err(err) => err.into_response(), |
| | 859 | } |
| | 860 | } |
| | 861 | |
| | 862 | |
| | 863 | #[derive(Debug, Deserialize)] |
| | 864 | pub struct RepoDeleteForm { |
| | 865 | |
| | 866 | #[serde(rename = "_csrf")] |
| | 867 | csrf: String, |
| | 868 | } |
| | 869 | |
| | 870 | |
| | 871 | |
| | 872 | pub async fn settings_delete( |
| | 873 | State(state): State<AppState>, |
| | 874 | MaybeUser(viewer): MaybeUser, |
| | 875 | jar: CookieJar, |
| | 876 | headers: HeaderMap, |
| | 877 | Path(id): Path<String>, |
| | 878 | Form(form): Form<RepoDeleteForm>, |
| | 879 | ) -> Response { |
| | 880 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 881 | return AppError::Forbidden.into_response(); |
| | 882 | } |
| | 883 | let result = async { |
| | 884 | let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; |
| | 885 | let owner = state |
| | 886 | .store |
| | 887 | .user_by_id(&repo.owner_id) |
| | 888 | .await? |
| | 889 | .map_or_else(|| repo.owner_id.clone(), |u| u.username); |
| | 890 | state.store.delete_repo(&repo.id).await?; |
| | 891 | |
| | 892 | |
| | 893 | if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id) |
| | 894 | && dir.exists() |
| | 895 | { |
| | 896 | let trash = state.config.storage.data_dir.join("trash").join(format!( |
| | 897 | "{}-{}.git", |
| | 898 | repo.id, |
| | 899 | crate::now_ms() |
| | 900 | )); |
| | 901 | if let Some(parent) = trash.parent() { |
| | 902 | let _ = std::fs::create_dir_all(parent); |
| | 903 | } |
| | 904 | if let Err(err) = std::fs::rename(&dir, &trash) { |
| | 905 | tracing::warn!(repo = %repo.id, %err, "could not move deleted repo to trash"); |
| | 906 | } |
| | 907 | } |
| | 908 | Ok::<String, AppError>(format!("/{owner}")) |
| | 909 | } |
| | 910 | .await; |
| | 911 | match result { |
| | 912 | Ok(dest) => Redirect::to(&dest).into_response(), |
| | 913 | Err(err) => err.into_response(), |
| | 914 | } |
| | 915 | } |
| | 916 | |
| 710 | | 917 | |
| 711 | | 918 | |
| 712 | async fn commits_view( | 919 | async fn commits_view( |
| 1348 | (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}"))) | 1555 | (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}"))) |
| 1349 | (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) | 1556 | (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) |
| 1350 | (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags"))) | 1557 | (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags"))) |
| | 1558 | @if ctx.access == auth::Access::Admin { |
| | 1559 | (tab(Tab::Settings, "Settings", Icon::Settings, format!("{base}/-/settings"))) |
| | 1560 | } |
| 1351 | } | 1561 | } |
| 1352 | div class="repo-actions" { | 1562 | div class="repo-actions" { |
| 1353 | (branch_menu(&base, rev, branches)) | 1563 | (branch_menu(&base, rev, branches)) |