Expand 13 hidden lines 14 14 use std:: collections:: HashMap ; 15 15 use std:: path:: Path as FsPath ; 16 16 17 + use axum:: Form ; 17 18 use axum:: extract:: { Path , Query , State } ; 18 19 use axum:: http:: { HeaderMap , Uri , header} ; 19 - use axum:: response:: { Html , IntoResponse , Response } ; 20 + use axum:: response:: { Html , IntoResponse , Redirect , Response } ; 20 21 use axum_extra:: extract:: cookie:: { Cookie , CookieJar , SameSite } ; 21 22 use maud:: { Markup , html} ; 22 - use model:: { Repo , User } ; 23 + use model:: { Repo , User , Visibility } ; 23 24 use serde:: Deserialize ; 24 25 25 26 use crate :: AppState ; Expand 3 hidden lines 29 30 use crate :: layout:: page; 30 31 use crate :: markdown; 31 32 use crate :: pages:: build_chrome; 32 - use crate :: session:: MaybeUser ; 33 + use crate :: session:: { MaybeUser , verify_csrf} ; 33 34 34 35 35 36 const DIFF_COOKIE : & str = "fabrica_diffview" ; Expand 27 hidden lines 63 64 Branches , 64 65 Tags , 65 66 Search , 67 + Settings , 66 68 } 67 69 68 70 69 71 pub ( crate ) struct RepoCtx { 70 72 pub ( crate ) owner: User , 71 73 pub ( crate ) repo: Repo , 72 - 73 - 74 - # [ allow ( dead_code ) ] 74 + 75 + 75 76 pub ( crate ) access: auth:: Access , 76 77 } 77 78 Expand 210 hidden lines 288 289 let raw_query = dq. q. clone ( ) . unwrap_or_default ( ) ; 289 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 294 _ => Err ( AppError :: NotFound ) , 293 295 } 294 296 } Expand 412 hidden lines 707 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 919 async fn commits_view ( Expand 635 hidden lines 1348 1555 ( tab ( Tab :: Commits , "Commits" , Icon :: History , format ! ( "{base}/-/commits/{rev}" ) ) ) 1349 1556 ( tab ( Tab :: Branches , "Branches" , Icon :: GitBranch , format ! ( "{base}/-/branches" ) ) ) 1350 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 1562 div class="repo-actions" { 1353 1563 ( branch_menu ( & base, rev, branches) )