Monorepo for Tangled tangled.org
5

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