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