Monorepo for Tangled
at master 256 lines 8.0 kB view raw
1package xrpc 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "net/http" 9 "os" 10 "strings" 11 "time" 12 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 gogit "github.com/go-git/go-git/v5" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/hook" 18 "tangled.org/core/knotserver/git" 19 "tangled.org/core/knotserver/repodid" 20 "tangled.org/core/rbac" 21 xrpcerr "tangled.org/core/xrpc/errors" 22) 23 24func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 25 l := h.Logger.With("handler", "NewRepo") 26 fail := func(e xrpcerr.XrpcError) { 27 l.Error("failed", "kind", e.Tag, "error", e.Message) 28 writeError(w, e, http.StatusBadRequest) 29 } 30 31 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 32 if !ok { 33 fail(xrpcerr.MissingActorDidError) 34 return 35 } 36 37 isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 38 if err != nil { 39 fail(xrpcerr.GenericError(err)) 40 return 41 } 42 if !isMember { 43 fail(xrpcerr.AccessControlError(actorDid.String())) 44 return 45 } 46 47 var data tangled.RepoCreate_Input 48 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 49 fail(xrpcerr.GenericError(err)) 50 return 51 } 52 53 repoName := data.Name 54 55 if repoName == "" { 56 fail(xrpcerr.GenericError(fmt.Errorf("repository name is required"))) 57 return 58 } 59 60 defaultBranch := h.Config.Repo.MainBranch 61 if data.DefaultBranch != nil && *data.DefaultBranch != "" { 62 defaultBranch = *data.DefaultBranch 63 } 64 65 if err := validateRepoName(repoName); err != nil { 66 l.Error("creating repo", "error", err.Error()) 67 fail(xrpcerr.GenericError(err)) 68 return 69 } 70 71 var repoDid string 72 var prepared *repodid.PreparedDID 73 74 knotServiceUrl := "https://" + h.Config.Server.Hostname 75 if h.Config.Server.Dev { 76 knotServiceUrl = "http://" + h.Config.Server.Hostname 77 } 78 79 switch { 80 case data.RepoDid != nil && strings.HasPrefix(*data.RepoDid, "did:web:"): 81 if err := repodid.VerifyRepoDIDWeb(r.Context(), h.Resolver, *data.RepoDid, knotServiceUrl); err != nil { 82 l.Error("verifying did:web", "error", err.Error()) 83 writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 84 return 85 } 86 87 exists, err := h.Db.RepoDidExists(*data.RepoDid) 88 if err != nil { 89 l.Error("checking did:web uniqueness", "error", err.Error()) 90 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 return 92 } 93 if exists { 94 writeError(w, xrpcerr.GenericError(fmt.Errorf("did:web %s is already in use on this knot", *data.RepoDid)), http.StatusConflict) 95 return 96 } 97 98 repoDid = *data.RepoDid 99 100 case data.RepoDid != nil && *data.RepoDid != "": 101 writeError(w, xrpcerr.GenericError(fmt.Errorf("only did:web is accepted as a user-provided repo DID; did:plc is auto-generated")), http.StatusBadRequest) 102 return 103 104 default: 105 existingDid, dbErr := h.Db.GetRepoDid(actorDid.String(), repoName) 106 if dbErr == nil && existingDid != "" { 107 didRepoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, existingDid) 108 if _, statErr := os.Stat(didRepoPath); statErr == nil { 109 l.Info("repo already exists from previous attempt", "repoDid", existingDid) 110 output := tangled.RepoCreate_Output{RepoDid: &existingDid} 111 writeJson(w, &output) 112 return 113 } 114 l.Warn("stale repo key found without directory, cleaning up", "repoDid", existingDid) 115 if delErr := h.Db.DeleteRepoKey(existingDid); delErr != nil { 116 l.Error("failed to clean up stale repo key", "repoDid", existingDid, "error", delErr.Error()) 117 writeError(w, xrpcerr.GenericError(fmt.Errorf("failed to clean up stale state, retry later")), http.StatusInternalServerError) 118 return 119 } 120 } 121 122 var prepErr error 123 prepared, prepErr = repodid.PrepareRepoDID(h.Config.Server.PlcUrl, knotServiceUrl) 124 if prepErr != nil { 125 l.Error("preparing repo DID", "error", prepErr.Error()) 126 writeError(w, xrpcerr.GenericError(prepErr), http.StatusInternalServerError) 127 return 128 } 129 repoDid = prepared.RepoDid 130 131 if err := h.Db.StoreRepoKey(repoDid, prepared.SigningKeyRaw, actorDid.String(), repoName); err != nil { 132 if strings.Contains(err.Error(), "UNIQUE constraint failed") { 133 writeError(w, xrpcerr.GenericError(fmt.Errorf("repository %s already being created", repoName)), http.StatusConflict) 134 return 135 } 136 l.Error("claiming repo key slot", "error", err.Error()) 137 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 138 return 139 } 140 } 141 142 l = l.With("repoDid", repoDid) 143 144 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, repoDid) 145 rbacPath := repoDid 146 147 cleanup := func() { 148 if rmErr := os.RemoveAll(repoPath); rmErr != nil { 149 l.Error("failed to clean up repo directory", "path", repoPath, "error", rmErr.Error()) 150 } 151 } 152 153 cleanupAll := func() { 154 cleanup() 155 if delErr := h.Db.DeleteRepoKey(repoDid); delErr != nil { 156 l.Error("failed to clean up repo key", "error", delErr.Error()) 157 } 158 } 159 160 if data.Source != nil && *data.Source != "" { 161 err = git.Fork(repoPath, *data.Source, h.Config) 162 if err != nil { 163 l.Error("forking repo", "error", err.Error()) 164 cleanupAll() 165 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 166 return 167 } 168 } else { 169 err = git.InitBare(repoPath, defaultBranch) 170 if err != nil { 171 l.Error("initializing bare repo", "error", err.Error()) 172 cleanupAll() 173 if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 174 fail(xrpcerr.RepoExistsError("repository already exists")) 175 return 176 } 177 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 178 return 179 } 180 } 181 182 if data.RepoDid != nil && strings.HasPrefix(*data.RepoDid, "did:web:") { 183 if err := h.Db.StoreRepoDidWeb(repoDid, actorDid.String(), repoName); err != nil { 184 cleanupAll() 185 if strings.Contains(err.Error(), "UNIQUE constraint failed") { 186 writeError(w, xrpcerr.GenericError(fmt.Errorf("did:web %s is already in use", repoDid)), http.StatusConflict) 187 return 188 } 189 l.Error("storing did:web repo entry", "error", err.Error()) 190 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 191 return 192 } 193 } 194 195 if prepared != nil { 196 plcCtx, plcCancel := context.WithTimeout(context.Background(), 30*time.Second) 197 defer plcCancel() 198 if err := prepared.Submit(plcCtx); err != nil { 199 l.Error("submitting to PLC directory", "error", err.Error()) 200 cleanupAll() 201 writeError(w, xrpcerr.GenericError(fmt.Errorf("PLC directory submission failed: %w", err)), http.StatusInternalServerError) 202 return 203 } 204 } 205 206 // add perms for this user to access the repo 207 err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, rbacPath) 208 if err != nil { 209 l.Error("adding repo permissions", "error", err.Error()) 210 cleanupAll() 211 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 212 return 213 } 214 215 hook.SetupRepo( 216 hook.Config( 217 hook.WithScanPath(h.Config.Repo.ScanPath), 218 hook.WithInternalApi(h.Config.Server.InternalListenAddr), 219 ), 220 repoPath, 221 ) 222 223 writeJson(w, &tangled.RepoCreate_Output{RepoDid: &repoDid}) 224} 225 226func validateRepoName(name string) error { 227 // check for path traversal attempts 228 if name == "." || name == ".." || 229 strings.Contains(name, "/") || strings.Contains(name, "\\") { 230 return fmt.Errorf("Repository name contains invalid path characters") 231 } 232 233 // check for sequences that could be used for traversal when normalized 234 if strings.Contains(name, "./") || strings.Contains(name, "../") || 235 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 236 return fmt.Errorf("Repository name contains invalid path sequence") 237 } 238 239 // then continue with character validation 240 for _, char := range name { 241 if !((char >= 'a' && char <= 'z') || 242 (char >= 'A' && char <= 'Z') || 243 (char >= '0' && char <= '9') || 244 char == '-' || char == '_' || char == '.') { 245 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 246 } 247 } 248 249 // additional check to prevent multiple sequential dots 250 if strings.Contains(name, "..") { 251 return fmt.Errorf("Repository name cannot contain sequential dots") 252 } 253 254 // if all checks pass 255 return nil 256}