1package xrpc
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "net/http"
8 "path/filepath"
9 "strings"
10
11 "github.com/bluesky-social/indigo/atproto/syntax"
12 securejoin "github.com/cyphar/filepath-securejoin"
13 gogit "github.com/go-git/go-git/v5"
14 "tangled.sh/tangled.sh/core/api/tangled"
15 "tangled.sh/tangled.sh/core/hook"
16 "tangled.sh/tangled.sh/core/knotserver/git"
17 "tangled.sh/tangled.sh/core/rbac"
18 xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19)
20
21func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
22 l := h.Logger.With("handler", "NewRepo")
23 fail := func(e xrpcerr.XrpcError) {
24 l.Error("failed", "kind", e.Tag, "error", e.Message)
25 writeError(w, e, http.StatusBadRequest)
26 }
27
28 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
29 if !ok {
30 fail(xrpcerr.MissingActorDidError)
31 return
32 }
33
34 isMember, err := h.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer)
35 if err != nil {
36 fail(xrpcerr.GenericError(err))
37 return
38 }
39 if !isMember {
40 fail(xrpcerr.AccessControlError(actorDid.String()))
41 return
42 }
43
44 var data tangled.RepoCreate_Input
45 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
46 fail(xrpcerr.GenericError(err))
47 return
48 }
49
50 defaultBranch := h.Config.Repo.MainBranch
51 if data.Default_branch != nil && *data.Default_branch != "" {
52 defaultBranch = *data.Default_branch
53 }
54
55 did := data.Did
56 name := data.Name
57
58 if err := validateRepoName(name); err != nil {
59 l.Error("creating repo", "error", err.Error())
60 fail(xrpcerr.GenericError(err))
61 return
62 }
63
64 relativeRepoPath := filepath.Join(did, name)
65 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
66 err = git.InitBare(repoPath, defaultBranch)
67 if err != nil {
68 l.Error("initializing bare repo", "error", err.Error())
69 if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
70 fail(xrpcerr.RepoExistsError("repository already exists"))
71 return
72 } else {
73 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
74 return
75 }
76 }
77
78 // add perms for this user to access the repo
79 err = h.Enforcer.AddRepo(did, rbac.ThisServer, relativeRepoPath)
80 if err != nil {
81 l.Error("adding repo permissions", "error", err.Error())
82 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
83 return
84 }
85
86 hook.SetupRepo(
87 hook.Config(
88 hook.WithScanPath(h.Config.Repo.ScanPath),
89 hook.WithInternalApi(h.Config.Server.InternalListenAddr),
90 ),
91 repoPath,
92 )
93
94 w.WriteHeader(http.StatusOK)
95}
96
97func validateRepoName(name string) error {
98 // check for path traversal attempts
99 if name == "." || name == ".." ||
100 strings.Contains(name, "/") || strings.Contains(name, "\\") {
101 return fmt.Errorf("Repository name contains invalid path characters")
102 }
103
104 // check for sequences that could be used for traversal when normalized
105 if strings.Contains(name, "./") || strings.Contains(name, "../") ||
106 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
107 return fmt.Errorf("Repository name contains invalid path sequence")
108 }
109
110 // then continue with character validation
111 for _, char := range name {
112 if !((char >= 'a' && char <= 'z') ||
113 (char >= 'A' && char <= 'Z') ||
114 (char >= '0' && char <= '9') ||
115 char == '-' || char == '_' || char == '.') {
116 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
117 }
118 }
119
120 // additional check to prevent multiple sequential dots
121 if strings.Contains(name, "..") {
122 return fmt.Errorf("Repository name cannot contain sequential dots")
123 }
124
125 // if all checks pass
126 return nil
127}