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