Monorepo for Tangled tangled.org
3

Configure Feed

Select the types of activity you want to include in your feed.

1package xrpc 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "net/http" 10 "os" 11 "strings" 12 "time" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 gogit "github.com/go-git/go-git/v5" 18 "tangled.org/core/api/tangled" 19 "tangled.org/core/hook" 20 "tangled.org/core/knotserver/git" 21 "tangled.org/core/knotserver/repodid" 22 "tangled.org/core/rbac" 23 xrpcerr "tangled.org/core/xrpc/errors" 24) 25 26func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 27 l := h.Logger.With("handler", "NewRepo") 28 fail := func(e xrpcerr.XrpcError) { 29 l.Error("failed", "kind", e.Tag, "error", e.Message) 30 writeError(w, e, http.StatusBadRequest) 31 } 32 33 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 34 if !ok { 35 fail(xrpcerr.MissingActorDidError) 36 return 37 } 38 39 isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 40 if err != nil { 41 fail(xrpcerr.GenericError(err)) 42 return 43 } 44 if !isMember { 45 fail(xrpcerr.AccessControlError(actorDid.String())) 46 return 47 } 48 49 var data tangled.RepoCreate_Input 50 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 51 fail(xrpcerr.GenericError(err)) 52 return 53 } 54 55 repoName := data.Name 56 57 if repoName == "" { 58 fail(xrpcerr.GenericError(fmt.Errorf("repository name is required"))) 59 return 60 } 61 62 defaultBranch := h.Config.Repo.MainBranch 63 if data.DefaultBranch != nil && *data.DefaultBranch != "" { 64 defaultBranch = *data.DefaultBranch 65 } 66 67 if err := ValidateRepoName(repoName); err != nil { 68 l.Error("creating repo", "error", err.Error()) 69 fail(xrpcerr.GenericError(err)) 70 return 71 } 72 73 var repoDid string 74 var prepared *repodid.PreparedDID 75 76 knotServiceUrl := "https://" + h.Config.Server.Hostname 77 if h.Config.Server.Dev { 78 knotServiceUrl = "http://" + h.Config.Server.Hostname 79 } 80 81 switch { 82 case data.RepoDid != nil && strings.HasPrefix(*data.RepoDid, "did:web:"): 83 if err := repodid.VerifyRepoDIDWeb(r.Context(), h.Resolver, *data.RepoDid, knotServiceUrl); err != nil { 84 l.Error("verifying did:web", "error", err.Error()) 85 writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 86 return 87 } 88 89 exists, err := h.Db.RepoDidExists(*data.RepoDid) 90 if err != nil { 91 l.Error("checking did:web uniqueness", "error", err.Error()) 92 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 93 return 94 } 95 if exists { 96 writeError(w, xrpcerr.GenericError(fmt.Errorf("did:web %s is already in use on this knot", *data.RepoDid)), http.StatusConflict) 97 return 98 } 99 100 repoDid = *data.RepoDid 101 102 case data.RepoDid != nil && *data.RepoDid != "": 103 writeError(w, xrpcerr.GenericError(fmt.Errorf("only did:web is accepted as a user-provided repo DID; did:plc is auto-generated")), http.StatusBadRequest) 104 return 105 106 default: 107 removeOrphan := func(orphanDid string) error { 108 orphanPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, orphanDid) 109 if rmErr := os.RemoveAll(orphanPath); rmErr != nil { 110 l.Warn("failed to remove orphan repo directory", "path", orphanPath, "error", rmErr.Error()) 111 } 112 if rbacErr := h.Enforcer.RemoveRepo(actorDid.String(), rbac.ThisServer, orphanDid); rbacErr != nil { 113 l.Warn("failed to remove orphan rbac entry", "repoDid", orphanDid, "error", rbacErr.Error()) 114 } 115 return h.Db.DeleteRepoKey(orphanDid) 116 } 117 118 existingDid, dbErr := h.Db.GetRepoDid(actorDid.String(), repoName) 119 if dbErr != nil && !errors.Is(dbErr, sql.ErrNoRows) { 120 l.Error("failed to look up repo alias", "error", dbErr.Error()) 121 writeError(w, xrpcerr.GenericError(dbErr), http.StatusInternalServerError) 122 return 123 } 124 if dbErr == nil && existingDid != "" { 125 didRepoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, existingDid) 126 if _, statErr := os.Stat(didRepoPath); statErr == nil { 127 l.Info("repo already exists from previous attempt", "repoDid", existingDid) 128 output := tangled.RepoCreate_Output{RepoDid: &existingDid} 129 h.writeJson(w, &output) 130 return 131 } 132 l.Warn("stale repo key found without directory, cleaning up", "repoDid", existingDid) 133 if delErr := removeOrphan(existingDid); delErr != nil { 134 l.Error("failed to clean up stale repo key", "repoDid", existingDid, "error", delErr.Error()) 135 writeError(w, xrpcerr.GenericError(fmt.Errorf("failed to clean up stale state, retry later")), http.StatusInternalServerError) 136 return 137 } 138 } else { 139 orphanDid, lookupErr := h.Db.GetRepoDidByName(actorDid.String(), repoName) 140 if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) { 141 l.Error("failed to look up orphan repo key", "error", lookupErr.Error()) 142 writeError(w, xrpcerr.GenericError(lookupErr), http.StatusInternalServerError) 143 return 144 } 145 if lookupErr == nil && orphanDid != "" { 146 orphanPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, orphanDid) 147 if _, statErr := os.Stat(orphanPath); statErr == nil { 148 l.Error("orphan repo_keys row but directory present, refusing to overwrite", "repoDid", orphanDid) 149 writeError(w, xrpcerr.GenericError(fmt.Errorf("repository %q is in an inconsistent state, contact a knot admin", repoName)), http.StatusConflict) 150 return 151 } 152 l.Warn("orphan repo_keys row without alias, cleaning up", "repoDid", orphanDid) 153 if delErr := removeOrphan(orphanDid); delErr != nil { 154 l.Error("failed to clean up orphan repo key", "repoDid", orphanDid, "error", delErr.Error()) 155 writeError(w, xrpcerr.GenericError(fmt.Errorf("failed to clean up orphan state, retry later")), http.StatusInternalServerError) 156 return 157 } 158 } 159 } 160 161 var prepErr error 162 prepared, prepErr = repodid.PrepareRepoDID(h.Config.Server.PlcUrl, knotServiceUrl) 163 if prepErr != nil { 164 l.Error("preparing repo DID", "error", prepErr.Error()) 165 writeError(w, xrpcerr.GenericError(prepErr), http.StatusInternalServerError) 166 return 167 } 168 repoDid = prepared.RepoDid 169 170 if err := h.Db.StoreRepoKey(repoDid, prepared.SigningKeyRaw, actorDid.String(), repoName); err != nil { 171 if strings.Contains(err.Error(), "UNIQUE constraint failed") { 172 writeError(w, xrpcerr.GenericError(fmt.Errorf("repository %s already being created", repoName)), http.StatusConflict) 173 return 174 } 175 l.Error("claiming repo key slot", "error", err.Error()) 176 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 177 return 178 } 179 } 180 181 l = l.With("repoDid", repoDid) 182 183 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, repoDid) 184 rbacPath := repoDid 185 repoAddedToRBAC := false 186 187 cleanup := func() { 188 if rmErr := os.RemoveAll(repoPath); rmErr != nil { 189 l.Error("failed to clean up repo directory", "path", repoPath, "error", rmErr.Error()) 190 } 191 } 192 193 cleanupAll := func() { 194 if repoAddedToRBAC { 195 if rmErr := h.Enforcer.RemoveRepo(actorDid.String(), rbac.ThisServer, rbacPath); rmErr != nil { 196 l.Error("failed to clean up repo permissions", "error", rmErr.Error()) 197 } 198 } 199 cleanup() 200 if delErr := h.Db.DeleteRepoKey(repoDid); delErr != nil { 201 l.Error("failed to clean up repo key", "error", delErr.Error()) 202 } 203 } 204 205 if data.Source != nil && *data.Source != "" { 206 err = git.Fork(repoPath, *data.Source, h.Config) 207 if err != nil { 208 l.Error("forking repo", "error", err.Error()) 209 cleanupAll() 210 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 211 return 212 } 213 } else { 214 err = git.InitBare(repoPath, defaultBranch) 215 if err != nil { 216 l.Error("initializing bare repo", "error", err.Error()) 217 cleanupAll() 218 if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 219 fail(xrpcerr.RepoExistsError("repository already exists")) 220 return 221 } 222 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 223 return 224 } 225 } 226 227 if data.RepoDid != nil && strings.HasPrefix(*data.RepoDid, "did:web:") { 228 if err := h.Db.StoreRepoDidWeb(repoDid, actorDid.String(), repoName); err != nil { 229 cleanupAll() 230 if strings.Contains(err.Error(), "UNIQUE constraint failed") { 231 writeError(w, xrpcerr.GenericError(fmt.Errorf("did:web %s is already in use", repoDid)), http.StatusConflict) 232 return 233 } 234 l.Error("storing did:web repo entry", "error", err.Error()) 235 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 236 return 237 } 238 } 239 240 // add perms for this user to access the repo 241 err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, rbacPath) 242 if err != nil { 243 l.Error("adding repo permissions", "error", err.Error()) 244 cleanupAll() 245 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 246 return 247 } 248 repoAddedToRBAC = true 249 250 if err := hook.SetupRepo( 251 hook.Config( 252 hook.WithScanPath(h.Config.Repo.ScanPath), 253 hook.WithInternalApi(h.Config.Server.InternalListenAddr), 254 ), 255 repoPath, 256 ); err != nil { 257 l.Error("setting up repo hooks", "error", err.Error()) 258 cleanupAll() 259 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 260 return 261 } 262 263 if prepared != nil { 264 plcCtx, plcCancel := context.WithTimeout(context.Background(), 30*time.Second) 265 defer plcCancel() 266 if err := prepared.Submit(plcCtx); err != nil { 267 l.Error("submitting to PLC directory", "error", err.Error()) 268 cleanupAll() 269 writeError(w, xrpcerr.GenericError(fmt.Errorf("PLC directory submission failed: %w", err)), http.StatusInternalServerError) 270 return 271 } 272 } 273 274 // HACK: request crawl for this repository 275 // Users won't want to sync entire network from their local knotmirror. 276 // Therefore, to bypass the local tap, requestCrawl directly to the knotmirror. 277 go func() { 278 if h.Config.Server.Dev { 279 repoAt := fmt.Sprintf("at://%s/%s/%s", actorDid, tangled.RepoNSID, data.Rkey) 280 rCtx, rCancel := context.WithTimeout(context.Background(), 10*time.Second) 281 defer rCancel() 282 h.requestCrawl(rCtx, &tangled.SyncRequestCrawl_Input{ 283 Hostname: h.Config.Server.Hostname, 284 EnsureRepo: &repoAt, 285 }) 286 } 287 }() 288 289 h.writeJson(w, &tangled.RepoCreate_Output{RepoDid: &repoDid}) 290} 291 292func (h *Xrpc) requestCrawl(ctx context.Context, input *tangled.SyncRequestCrawl_Input) error { 293 h.Logger.Info("requesting crawl", "mirrors", h.Config.KnotMirrors) 294 for _, knotmirror := range h.Config.KnotMirrors { 295 xrpcc := indigoxrpc.Client{Host: knotmirror} 296 if err := tangled.SyncRequestCrawl(ctx, &xrpcc, input); err != nil { 297 h.Logger.Error("error requesting crawl", "err", err) 298 } else { 299 h.Logger.Info("crawl requested successfully") 300 } 301 } 302 return nil 303} 304 305var reservedRepoNames = map[string]struct{}{ 306 "self": {}, 307} 308 309func ValidateRepoName(name string) error { 310 // check for path traversal attempts 311 if name == "." || name == ".." || 312 strings.Contains(name, "/") || strings.Contains(name, "\\") { 313 return fmt.Errorf("Repository name contains invalid path characters") 314 } 315 316 // check for sequences that could be used for traversal when normalized 317 if strings.Contains(name, "./") || strings.Contains(name, "../") || 318 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 319 return fmt.Errorf("Repository name contains invalid path sequence") 320 } 321 322 if len(name) == 0 { 323 return fmt.Errorf("Repository name cannot be empty") 324 } 325 if len(name) > 100 { 326 return fmt.Errorf("Repository name must be 100 characters or fewer") 327 } 328 329 // then continue with character validation 330 for _, char := range name { 331 if !((char >= 'a' && char <= 'z') || 332 (char >= 'A' && char <= 'Z') || 333 (char >= '0' && char <= '9') || 334 char == '-' || char == '_' || char == '.') { 335 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 336 } 337 } 338 339 // additional check to prevent multiple sequential dots 340 if strings.Contains(name, "..") { 341 return fmt.Errorf("Repository name cannot contain sequential dots") 342 } 343 344 if _, reserved := reservedRepoNames[strings.ToLower(name)]; reserved { 345 return fmt.Errorf("Repository name %q is reserved", name) 346 } 347 348 // if all checks pass 349 return nil 350}