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