Monorepo for Tangled tangled.org

feat: repo deletion

this capability has existed in knots since the first release

authored by oppi.li and committed by Tangled 6c27a747 0a90fef2

Changed files
+188 -30
appview
db
pages
templates
state
rbac
-5
appview/db/profile.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "log" 6 5 "sort" 7 6 "time" 8 7 ) ··· 65 64 return timeline, fmt.Errorf("error getting all repos by did: %w", err) 66 65 } 67 66 68 - log.Println(repos) 69 - 70 67 for _, repo := range repos { 71 68 var sourceRepo *Repo 72 - log.Println("name", repo.Name) 73 69 if repo.Source != "" { 74 - log.Println("source", repo.Source) 75 70 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 76 71 if err != nil { 77 72 return nil, err
+2 -2
appview/db/repos.go
··· 176 176 return err 177 177 } 178 178 179 - func RemoveRepo(e Execer, did, name, rkey string) error { 180 - _, err := e.Exec(`delete from repos where did = ? and name = ? and rkey = ?`, did, name, rkey) 179 + func RemoveRepo(e Execer, did, name string) error { 180 + _, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name) 181 181 return err 182 182 } 183 183
+8
appview/pages/pages.go
··· 246 246 return slices.Contains(r.Roles, "repo:settings") 247 247 } 248 248 249 + func (r RolesInRepo) CollaboratorInviteAllowed() bool { 250 + return slices.Contains(r.Roles, "repo:invite") 251 + } 252 + 253 + func (r RolesInRepo) RepoDeleteAllowed() bool { 254 + return slices.Contains(r.Roles, "repo:delete") 255 + } 256 + 249 257 func (r RolesInRepo) IsOwner() bool { 250 258 return slices.Contains(r.Roles, "repo:owner") 251 259 }
+14 -3
appview/pages/templates/repo/settings.html
··· 22 22 {{ end }} 23 23 </div> 24 24 25 - {{ if .IsCollaboratorInviteAllowed }} 25 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 26 <form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"> 27 27 <label for="collaborator" class="dark:text-white" 28 28 >add collaborator</label ··· 45 45 {{ end }} 46 46 47 47 <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6"> 48 - <label for="branch">default branch:</label> 49 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white"> 48 + <label for="branch">default branch</label> 49 + <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 50 50 {{ range .Branches }} 51 51 <option 52 52 value="{{ . }}" ··· 61 61 </select> 62 62 <button class="btn my-2" type="text">save</button> 63 63 </form> 64 + 65 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 66 + <form hx-confirm="Are you sure you want to delete this repository?" hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" class="mt-6"> 67 + <label for="branch">delete repository</label> 68 + <button class="btn my-2" type="text">delete</button> 69 + <span> 70 + Deleting a repository is irreversible and permanent. 71 + </span> 72 + </form> 73 + {{ end }} 74 + 64 75 {{ end }}
+106
appview/state/repo.go
··· 596 596 597 597 } 598 598 599 + func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 600 + user := s.auth.GetUser(r) 601 + 602 + f, err := fullyResolvedRepo(r) 603 + if err != nil { 604 + log.Println("failed to get repo and knot", err) 605 + return 606 + } 607 + 608 + // remove record from pds 609 + xrpcClient, _ := s.auth.AuthorizedClient(r) 610 + repoRkey := f.RepoAt.RecordKey().String() 611 + _, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{ 612 + Collection: tangled.RepoNSID, 613 + Repo: user.Did, 614 + Rkey: repoRkey, 615 + }) 616 + if err != nil { 617 + log.Printf("failed to delete record: %s", err) 618 + s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 619 + return 620 + } 621 + log.Println("removed repo record ", f.RepoAt.String()) 622 + 623 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 624 + if err != nil { 625 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 626 + return 627 + } 628 + 629 + ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 630 + if err != nil { 631 + log.Println("failed to create client to ", f.Knot) 632 + return 633 + } 634 + 635 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 636 + if err != nil { 637 + log.Printf("failed to make request to %s: %s", f.Knot, err) 638 + return 639 + } 640 + 641 + if ksResp.StatusCode != http.StatusNoContent { 642 + log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 643 + } else { 644 + log.Println("removed repo from knot ", f.Knot) 645 + } 646 + 647 + tx, err := s.db.BeginTx(r.Context(), nil) 648 + if err != nil { 649 + log.Println("failed to start tx") 650 + w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 651 + return 652 + } 653 + defer func() { 654 + tx.Rollback() 655 + err = s.enforcer.E.LoadPolicy() 656 + if err != nil { 657 + log.Println("failed to rollback policies") 658 + } 659 + }() 660 + 661 + // remove collaborator RBAC 662 + repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 663 + if err != nil { 664 + s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 665 + return 666 + } 667 + for _, c := range repoCollaborators { 668 + did := c[0] 669 + s.enforcer.RemoveCollaborator(did, f.Knot, f.OwnerSlashRepo()) 670 + } 671 + log.Println("removed collaborators") 672 + 673 + // remove repo RBAC 674 + err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.OwnerSlashRepo()) 675 + if err != nil { 676 + s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 677 + return 678 + } 679 + 680 + // remove repo from db 681 + err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 682 + if err != nil { 683 + s.pages.Notice(w, "settings-delete", "Failed to update appview") 684 + return 685 + } 686 + log.Println("removed repo from db") 687 + 688 + err = tx.Commit() 689 + if err != nil { 690 + log.Println("failed to commit changes", err) 691 + http.Error(w, err.Error(), http.StatusInternalServerError) 692 + return 693 + } 694 + 695 + err = s.enforcer.E.SavePolicy() 696 + if err != nil { 697 + log.Println("failed to update ACLs", err) 698 + http.Error(w, err.Error(), http.StatusInternalServerError) 699 + return 700 + } 701 + 702 + s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 703 + } 704 + 599 705 func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 600 706 f, err := fullyResolvedRepo(r) 601 707 if err != nil {
+1
appview/state/router.go
··· 153 153 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { 154 154 r.Get("/", s.RepoSettings) 155 155 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) 156 + r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo) 156 157 r.Put("/branches/default", s.SetDefaultBranch) 157 158 }) 158 159 })
+57 -20
rbac/rbac.go
··· 96 96 return err 97 97 } 98 98 99 - func (e *Enforcer) AddRepo(member, domain, repo string) error { 100 - // sanity check, repo must be of the form ownerDid/repo 101 - if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") { 102 - return fmt.Errorf("invalid repo: %s", repo) 103 - } 104 - 105 - _, err := e.E.AddPolicies([][]string{ 99 + func repoPolicies(member, domain, repo string) [][]string { 100 + return [][]string{ 106 101 {member, domain, repo, "repo:settings"}, 107 102 {member, domain, repo, "repo:push"}, 108 103 {member, domain, repo, "repo:owner"}, 109 104 {member, domain, repo, "repo:invite"}, 110 105 {member, domain, repo, "repo:delete"}, 111 106 {"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo 112 - }) 107 + } 108 + } 109 + func (e *Enforcer) AddRepo(member, domain, repo string) error { 110 + err := checkRepoFormat(repo) 111 + if err != nil { 112 + return err 113 + } 114 + 115 + _, err = e.E.AddPolicies(repoPolicies(member, domain, repo)) 116 + return err 117 + } 118 + func (e *Enforcer) RemoveRepo(member, domain, repo string) error { 119 + err := checkRepoFormat(repo) 120 + if err != nil { 121 + return err 122 + } 123 + 124 + _, err = e.E.RemovePolicies(repoPolicies(member, domain, repo)) 113 125 return err 114 126 } 115 127 128 + var ( 129 + collaboratorPolicies = func(collaborator, domain, repo string) [][]string { 130 + return [][]string{ 131 + {collaborator, domain, repo, "repo:collaborator"}, 132 + {collaborator, domain, repo, "repo:settings"}, 133 + {collaborator, domain, repo, "repo:push"}, 134 + } 135 + } 136 + ) 137 + 116 138 func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error { 117 - // sanity check, repo must be of the form ownerDid/repo 118 - if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") { 119 - return fmt.Errorf("invalid repo: %s", repo) 139 + err := checkRepoFormat(repo) 140 + if err != nil { 141 + return err 120 142 } 121 143 122 - _, err := e.E.AddPolicies([][]string{ 123 - {collaborator, domain, repo, "repo:collaborator"}, 124 - {collaborator, domain, repo, "repo:settings"}, 125 - {collaborator, domain, repo, "repo:push"}, 126 - }) 144 + _, err = e.E.AddPolicies(collaboratorPolicies(collaborator, domain, repo)) 145 + return err 146 + } 147 + 148 + func (e *Enforcer) RemoveCollaborator(collaborator, domain, repo string) error { 149 + err := checkRepoFormat(repo) 150 + if err != nil { 151 + return err 152 + } 153 + 154 + _, err = e.E.RemovePolicies(collaboratorPolicies(collaborator, domain, repo)) 127 155 return err 128 156 } 129 157 ··· 165 193 return e.E.Enforce(user, domain, repo, "repo:settings") 166 194 } 167 195 196 + func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) { 197 + return e.E.Enforce(user, domain, repo, "repo:invite") 198 + } 199 + 168 200 // given a repo, what permissions does this user have? repo:owner? repo:invite? etc. 169 201 func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string { 170 202 var permissions []string ··· 179 211 return permissions 180 212 } 181 213 182 - func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) { 183 - return e.E.Enforce(user, domain, repo, "repo:invite") 184 - } 185 - 186 214 // keyMatch2Func is a wrapper for keyMatch2 to make it compatible with Casbin 187 215 func keyMatch2Func(args ...interface{}) (interface{}, error) { 188 216 name1 := args[0].(string) ··· 190 218 191 219 return keyMatch2(name1, name2), nil 192 220 } 221 + 222 + func checkRepoFormat(repo string) error { 223 + // sanity check, repo must be of the form ownerDid/repo 224 + if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") { 225 + return fmt.Errorf("invalid repo: %s", repo) 226 + } 227 + 228 + return nil 229 + }