Monorepo for Tangled
0

Configure Feed

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

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