Monorepo for Tangled
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}