diff --git a/rbac/rbac.go b/rbac/rbac.go --- a/rbac/rbac.go +++ b/rbac/rbac.go @@ -281,8 +281,8 @@ return e.E.Enforce(user, domain, domain, "repo:create") } -func (e *Enforcer) IsRepoDeleteAllowed(user, domain string) (bool, error) { - return e.E.Enforce(user, domain, domain, "repo:delete") +func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { + return e.E.Enforce(user, domain, repo, "repo:delete") } func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { diff --git a/api/tangled/repodelete.go b/api/tangled/repodelete.go --- a/api/tangled/repodelete.go +++ b/api/tangled/repodelete.go @@ -20,6 +20,8 @@ Did string `json:"did" cborgen:"did"` // name: Name of the repository to delete Name string `json:"name" cborgen:"name"` + // rkey: Rkey of the repository record + Rkey string `json:"rkey" cborgen:"rkey"` } // RepoDelete calls the XRPC method "sh.tangled.repo.delete". diff --git a/appview/repo/repo.go b/appview/repo/repo.go --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -972,6 +972,7 @@ &tangled.RepoDelete_Input{ Did: f.OwnerDid(), Name: f.Name, + Rkey: f.Rkey, }, ) if err := xrpcclient.HandleXrpcErr(err); err != nil { diff --git a/knotserver/xrpc/delete_repo.go b/knotserver/xrpc/delete_repo.go --- a/knotserver/xrpc/delete_repo.go +++ b/knotserver/xrpc/delete_repo.go @@ -1,82 +1,96 @@ package xrpc -// import ( -// "encoding/json" -// "fmt" -// "net/http" -// "os" -// "path/filepath" -// -// "github.com/bluesky-social/indigo/atproto/syntax" -// securejoin "github.com/cyphar/filepath-securejoin" -// "tangled.sh/tangled.sh/core/api/tangled" -// "tangled.sh/tangled.sh/core/rbac" -// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" -// ) +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" -// func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { -// l := x.Logger.With("handler", "DeleteRepo") -// fail := func(e xrpcerr.XrpcError) { -// l.Error("failed", "kind", e.Tag, "error", e.Message) -// writeError(w, e, http.StatusBadRequest) -// } -// -// actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) -// if !ok { -// fail(xrpcerr.MissingActorDidError) -// return -// } -// -// isMember, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer) -// if err != nil { -// fail(xrpcerr.GenericError(err)) -// return -// } -// if !isMember { -// fail(xrpcerr.AccessControlError(actorDid.String())) -// return -// } -// -// var data tangled.RepoDelete_Input -// if err := json.NewDecoder(r.Body).Decode(&data); err != nil { -// fail(xrpcerr.GenericError(err)) -// return -// } -// -// did := data.Did -// name := data.Name -// -// if did == "" || name == "" { -// fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) -// return -// } -// -// relativeRepoPath := filepath.Join(did, name) -// if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { -// l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) -// writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) -// return -// } -// -// repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) -// if err != nil { -// fail(xrpcerr.GenericError(err)) -// return -// } -// -// err = os.RemoveAll(repoPath) -// if err != nil { -// l.Error("deleting repo", "error", err.Error()) -// writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) -// return -// } -// -// err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) -// if err != nil { -// l.Error("failed to delete repo from enforcer", "error", err.Error()) -// writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) -// return -// } -// -// w.WriteHeader(http.StatusOK) -// } + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/rbac" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "DeleteRepo") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + var data tangled.RepoDelete_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + did := data.Did + name := data.Name + rkey := data.Rkey + + if did == "" || name == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) + return + } + + ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) + if err != nil || ident.Handle.IsInvalidHandle() { + fail(xrpcerr.GenericError(err)) + return + } + + xrpcc := xrpc.Client{ + Host: ident.PDSEndpoint(), + } + + // ensure that the record does not exists + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) + if err == nil { + fail(xrpcerr.RecordExistsError(rkey)) + return + } + + relativeRepoPath := filepath.Join(did, name) + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + if !isDeleteAllowed { + fail(xrpcerr.AccessControlError(actorDid.String())) + return + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + err = os.RemoveAll(repoPath) + if err != nil { + l.Error("deleting repo", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) + if err != nil { + l.Error("failed to delete repo from enforcer", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/knotserver/xrpc/xrpc.go b/knotserver/xrpc/xrpc.go --- a/knotserver/xrpc/xrpc.go +++ b/knotserver/xrpc/xrpc.go @@ -37,6 +37,7 @@ r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) diff --git a/lexicons/repo/delete.json b/lexicons/repo/delete.json --- a/lexicons/repo/delete.json +++ b/lexicons/repo/delete.json @@ -9,7 +9,7 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["did", "name"], + "required": ["did", "name", "rkey"], "properties": { "did": { "type": "string", @@ -19,6 +19,10 @@ "name": { "type": "string", "description": "Name of the repository to delete" + }, + "rkey": { + "type": "string", + "description": "Rkey of the repository record" } } } diff --git a/xrpc/errors/errors.go b/xrpc/errors/errors.go --- a/xrpc/errors/errors.go +++ b/xrpc/errors/errors.go @@ -86,6 +86,13 @@ ) } +var RecordExistsError = func(r string) XrpcError { + return NewXrpcError( + WithTag("RecordExists"), + WithError(fmt.Errorf("repo already exists: %s", r)), + ) +} + func GenericError(err error) XrpcError { return NewXrpcError( WithTag("Generic"),