forked from
tangled.org/core
Monorepo for Tangled
12 kB
351 lines
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.Fork(repoPath, *data.Source, h.Config)
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 prepared != nil {
265 plcCtx, plcCancel := context.WithTimeout(context.Background(), 30*time.Second)
266 defer plcCancel()
267 if err := prepared.Submit(plcCtx); err != nil {
268 l.Error("submitting to PLC directory", "error", err.Error())
269 cleanupAll()
270 writeError(w, xrpcerr.GenericError(fmt.Errorf("PLC directory submission failed: %w", err)), http.StatusInternalServerError)
271 return
272 }
273 }
274
275 // HACK: request crawl for this repository
276 // Users won't want to sync entire network from their local knotmirror.
277 // Therefore, to bypass the local tap, requestCrawl directly to the knotmirror.
278 go func() {
279 if h.Config.Server.Dev {
280 repoAt := fmt.Sprintf("at://%s/%s/%s", actorDid, tangled.RepoNSID, data.Rkey)
281 rCtx, rCancel := context.WithTimeout(context.Background(), 10*time.Second)
282 defer rCancel()
283 h.requestCrawl(rCtx, &tangled.SyncRequestCrawl_Input{
284 Hostname: h.Config.Server.Hostname,
285 EnsureRepo: &repoAt,
286 })
287 }
288 }()
289
290 h.writeJson(w, &tangled.RepoCreate_Output{RepoDid: &repoDid})
291}
292
293func (h *Xrpc) requestCrawl(ctx context.Context, input *tangled.SyncRequestCrawl_Input) error {
294 h.Logger.Info("requesting crawl", "mirrors", h.Config.KnotMirrors)
295 for _, knotmirror := range h.Config.KnotMirrors {
296 xrpcc := indigoxrpc.Client{Host: knotmirror}
297 if err := tangled.SyncRequestCrawl(ctx, &xrpcc, input); err != nil {
298 h.Logger.Error("error requesting crawl", "err", err)
299 } else {
300 h.Logger.Info("crawl requested successfully")
301 }
302 }
303 return nil
304}
305
306var reservedRepoNames = map[string]struct{}{
307 "self": {},
308}
309
310func ValidateRepoName(name string) error {
311 // check for path traversal attempts
312 if name == "." || name == ".." ||
313 strings.Contains(name, "/") || strings.Contains(name, "\\") {
314 return fmt.Errorf("Repository name contains invalid path characters")
315 }
316
317 // check for sequences that could be used for traversal when normalized
318 if strings.Contains(name, "./") || strings.Contains(name, "../") ||
319 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
320 return fmt.Errorf("Repository name contains invalid path sequence")
321 }
322
323 if len(name) == 0 {
324 return fmt.Errorf("Repository name cannot be empty")
325 }
326 if len(name) > 100 {
327 return fmt.Errorf("Repository name must be 100 characters or fewer")
328 }
329
330 // then continue with character validation
331 for _, char := range name {
332 if !((char >= 'a' && char <= 'z') ||
333 (char >= 'A' && char <= 'Z') ||
334 (char >= '0' && char <= '9') ||
335 char == '-' || char == '_' || char == '.') {
336 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
337 }
338 }
339
340 // additional check to prevent multiple sequential dots
341 if strings.Contains(name, "..") {
342 return fmt.Errorf("Repository name cannot contain sequential dots")
343 }
344
345 if _, reserved := reservedRepoNames[strings.ToLower(name)]; reserved {
346 return fmt.Errorf("Repository name %q is reserved", name)
347 }
348
349 // if all checks pass
350 return nil
351}