Monorepo for Tangled
1package knotserver
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12
13 securejoin "github.com/cyphar/filepath-securejoin"
14 "github.com/go-chi/chi/v5"
15 "github.com/go-chi/chi/v5/middleware"
16 "github.com/go-git/go-git/v5/plumbing"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/hook"
19 "tangled.org/core/idresolver"
20 "tangled.org/core/knotserver/config"
21 "tangled.org/core/knotserver/db"
22 "tangled.org/core/knotserver/git"
23 "tangled.org/core/log"
24 "tangled.org/core/notifier"
25 "tangled.org/core/rbac"
26 "tangled.org/core/workflow"
27)
28
29type InternalHandle struct {
30 db *db.DB
31 c *config.Config
32 e *rbac.Enforcer
33 l *slog.Logger
34 n *notifier.Notifier
35 res *idresolver.Resolver
36}
37
38func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
39 user := r.URL.Query().Get("user")
40 repo := r.URL.Query().Get("repo")
41
42 if user == "" || repo == "" {
43 w.WriteHeader(http.StatusBadRequest)
44 return
45 }
46
47 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
48 if err != nil || !ok {
49 w.WriteHeader(http.StatusForbidden)
50 return
51 }
52
53 w.WriteHeader(http.StatusNoContent)
54}
55
56func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
57 keys, err := h.db.GetAllPublicKeys()
58 if err != nil {
59 writeError(w, err.Error(), http.StatusInternalServerError)
60 return
61 }
62
63 data := make([]map[string]interface{}, 0)
64 for _, key := range keys {
65 j := key.JSON()
66 data = append(data, j)
67 }
68 writeJSON(w, data)
69}
70
71// response in text/plain format
72// the body will be qualified repository path on success/push-denied
73// or an error message when process failed
74func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
75 l := h.l.With("handler", "Guard")
76
77 var (
78 incomingUser = r.URL.Query().Get("user")
79 repo = r.URL.Query().Get("repo")
80 gitCommand = r.URL.Query().Get("gitCmd")
81 )
82
83 if incomingUser == "" || repo == "" || gitCommand == "" {
84 w.WriteHeader(http.StatusBadRequest)
85 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
86 fmt.Fprintln(w, "invalid internal request")
87 return
88 }
89
90 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
91 l.Info("command components", "components", components)
92
93 var rbacResource string
94 var diskRelative string
95
96 switch {
97 case len(components) == 1 && strings.HasPrefix(components[0], "did:"):
98 repoDid := components[0]
99 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
100 if lookupErr != nil {
101 w.WriteHeader(http.StatusNotFound)
102 l.Error("repo DID not found", "repoDid", repoDid, "err", lookupErr)
103 fmt.Fprintln(w, "repo not found")
104 return
105 }
106 rbacResource = repoDid
107 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath)
108 if relErr != nil {
109 w.WriteHeader(http.StatusInternalServerError)
110 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr)
111 fmt.Fprintln(w, "internal error")
112 return
113 }
114 diskRelative = rel
115
116 case len(components) == 2:
117 repoOwner := components[0]
118 resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
119 repoOwnerIdent, resolveErr := resolver.ResolveIdent(r.Context(), repoOwner)
120 if resolveErr != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
121 l.Error("Error resolving handle", "handle", repoOwner, "err", resolveErr)
122 w.WriteHeader(http.StatusInternalServerError)
123 fmt.Fprintf(w, "error resolving handle: invalid handle\n")
124 return
125 }
126 ownerDid := repoOwnerIdent.DID.String()
127 repoName := components[1]
128 repoDid, didErr := h.db.GetRepoDid(ownerDid, repoName)
129 var repoPath string
130 if didErr == nil {
131 var lookupErr error
132 repoPath, _, _, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
133 if lookupErr != nil {
134 w.WriteHeader(http.StatusNotFound)
135 l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr)
136 fmt.Fprintln(w, "repo not found")
137 return
138 }
139 rbacResource = repoDid
140 } else {
141 legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid, repoName))
142 if joinErr != nil {
143 w.WriteHeader(http.StatusNotFound)
144 fmt.Fprintln(w, "repo not found")
145 return
146 }
147 if _, statErr := os.Stat(legacyPath); statErr != nil {
148 w.WriteHeader(http.StatusNotFound)
149 l.Error("repo not found on disk (legacy)", "owner", ownerDid, "name", repoName)
150 fmt.Fprintln(w, "repo not found")
151 return
152 }
153 repoPath = legacyPath
154 rbacResource = ownerDid + "/" + repoName
155 }
156 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath)
157 if relErr != nil {
158 w.WriteHeader(http.StatusInternalServerError)
159 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr)
160 fmt.Fprintln(w, "internal error")
161 return
162 }
163 diskRelative = rel
164
165 default:
166 w.WriteHeader(http.StatusBadRequest)
167 l.Error("invalid repo format", "components", components)
168 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>")
169 return
170 }
171
172 if gitCommand == "git-receive-pack" {
173 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource)
174 if err != nil || !ok {
175 w.WriteHeader(http.StatusForbidden)
176 fmt.Fprint(w, repo)
177 return
178 }
179 }
180
181 w.WriteHeader(http.StatusOK)
182 fmt.Fprint(w, diskRelative)
183}
184
185type PushOptions struct {
186 skipCi bool
187 verboseCi bool
188}
189
190func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
191 l := h.l.With("handler", "PostReceiveHook")
192
193 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
194 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
195 if err != nil {
196 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
197 w.WriteHeader(http.StatusInternalServerError)
198 return
199 }
200
201 repoDid := gitRelativeDir
202 if !strings.HasPrefix(repoDid, "did:") {
203 l.Error("invalid git dir, expected repo DID", "gitRelativeDir", gitRelativeDir)
204 w.WriteHeader(http.StatusBadRequest)
205 return
206 }
207
208 ownerDid, repoName, err := h.db.GetRepoKeyOwner(repoDid)
209 if err != nil {
210 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err)
211 w.WriteHeader(http.StatusBadRequest)
212 return
213 }
214
215 gitUserDid := r.Header.Get("X-Git-User-Did")
216
217 lines, err := git.ParsePostReceive(r.Body)
218 if err != nil {
219 l.Error("failed to parse post-receive payload", "err", err)
220 // non-fatal
221 }
222
223 // extract any push options
224 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
225 pushOptions := PushOptions{}
226 for _, option := range pushOptionsRaw {
227 if option == "skip-ci" || option == "ci-skip" {
228 pushOptions.skipCi = true
229 }
230 if option == "verbose-ci" || option == "ci-verbose" {
231 pushOptions.verboseCi = true
232 }
233 }
234
235 resp := hook.HookResponse{
236 Messages: make([]string, 0),
237 }
238
239 for _, line := range lines {
240 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid)
241 if err != nil {
242 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
243 }
244
245 err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName, repoDid)
246 if err != nil {
247 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
248 }
249
250 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions)
251 if err != nil {
252 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
253 }
254 }
255
256 writeJSON(w, resp)
257}
258
259func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error {
260 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
261 if resolveErr != nil {
262 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
263 }
264
265 gr, err := git.Open(repoPath, line.Ref)
266 if err != nil {
267 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
268 }
269
270 meta, err := gr.RefUpdateMeta(line)
271 if err != nil {
272 return fmt.Errorf("failed to get ref update metadata: %w", err)
273 }
274
275 metaRecord := meta.AsRecord()
276
277 refUpdate := tangled.GitRefUpdate{
278 OldSha: line.OldSha.String(),
279 NewSha: line.NewSha.String(),
280 Ref: line.Ref,
281 CommitterDid: gitUserDid,
282 OwnerDid: ownerDid,
283 RepoName: repoName,
284 RepoDid: repoDid,
285 Meta: &metaRecord,
286 }
287
288 eventJson, err := json.Marshal(refUpdate)
289 if err != nil {
290 return err
291 }
292
293 event := db.Event{
294 Rkey: TID(),
295 Nsid: tangled.GitRefUpdateNSID,
296 EventJson: string(eventJson),
297 }
298
299 return h.db.InsertEvent(event, h.n)
300}
301
302func (h *InternalHandle) triggerPipeline(
303 clientMsgs *[]string,
304 line git.PostReceiveLine,
305 gitUserDid string,
306 ownerDid string,
307 repoName string,
308 repoDid string,
309 pushOptions PushOptions,
310) error {
311 if pushOptions.skipCi {
312 return nil
313 }
314
315 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
316 if resolveErr != nil {
317 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
318 }
319
320 gr, err := git.Open(repoPath, line.Ref)
321 if err != nil {
322 return err
323 }
324
325 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
326 if err != nil {
327 return err
328 }
329
330 var pipeline workflow.RawPipeline
331 for _, e := range workflowDir {
332 if !e.IsFile() {
333 continue
334 }
335
336 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
337 contents, err := gr.RawContent(fpath)
338 if err != nil {
339 continue
340 }
341
342 pipeline = append(pipeline, workflow.RawWorkflow{
343 Name: e.Name,
344 Contents: contents,
345 })
346 }
347
348 trigger := tangled.Pipeline_PushTriggerData{
349 Ref: line.Ref,
350 OldSha: line.OldSha.String(),
351 NewSha: line.NewSha.String(),
352 }
353
354 triggerRepo := &tangled.Pipeline_TriggerRepo{
355 Did: ownerDid,
356 Knot: h.c.Server.Hostname,
357 Repo: repoName,
358 RepoDid: repoDid,
359 }
360
361 compiler := workflow.Compiler{
362 Trigger: tangled.Pipeline_TriggerMetadata{
363 Kind: string(workflow.TriggerKindPush),
364 Push: &trigger,
365 Repo: triggerRepo,
366 },
367 }
368
369 cp := compiler.Compile(compiler.Parse(pipeline))
370 eventJson, err := json.Marshal(cp)
371 if err != nil {
372 return err
373 }
374
375 for _, e := range compiler.Diagnostics.Errors {
376 *clientMsgs = append(*clientMsgs, e.String())
377 }
378
379 if pushOptions.verboseCi {
380 if compiler.Diagnostics.IsEmpty() {
381 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
382 }
383
384 for _, w := range compiler.Diagnostics.Warnings {
385 *clientMsgs = append(*clientMsgs, w.String())
386 }
387 }
388
389 // do not run empty pipelines
390 if cp.Workflows == nil {
391 return nil
392 }
393
394 event := db.Event{
395 Rkey: TID(),
396 Nsid: tangled.PipelineNSID,
397 EventJson: string(eventJson),
398 }
399
400 return h.db.InsertEvent(event, h.n)
401}
402
403func (h *InternalHandle) emitCompareLink(
404 clientMsgs *[]string,
405 line git.PostReceiveLine,
406 ownerDid string,
407 repoName string,
408 repoDid string,
409) error {
410 // this is a second push to a branch, don't reply with the link again
411 if !line.OldSha.IsZero() {
412 return nil
413 }
414
415 // the ref was not updated to a new hash, don't reply with the link
416 //
417 // NOTE: do we need this?
418 if line.NewSha.String() == line.OldSha.String() {
419 return nil
420 }
421
422 pushedRef := plumbing.ReferenceName(line.Ref)
423
424 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid)
425 user := ownerDid
426 if err == nil {
427 user = userIdent.Handle.String()
428 }
429
430 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
431 if resolveErr != nil {
432 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
433 }
434
435 gr, err := git.PlainOpen(repoPath)
436 if err != nil {
437 return err
438 }
439
440 defaultBranch, err := gr.FindMainBranch()
441 if err != nil {
442 return err
443 }
444
445 // pushing to default branch
446 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
447 return nil
448 }
449
450 // pushing a tag, don't prompt the user the open a PR
451 if pushedRef.IsTag() {
452 return nil
453 }
454
455 ZWS := "\u200B"
456 *clientMsgs = append(*clientMsgs, ZWS)
457 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
458 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
459 *clientMsgs = append(*clientMsgs, ZWS)
460 return nil
461}
462
463func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
464 r := chi.NewRouter()
465 l := log.FromContext(ctx)
466 l = log.SubLogger(l, "internal")
467 res := idresolver.DefaultResolver(c.Server.PlcUrl)
468
469 h := InternalHandle{
470 db,
471 c,
472 e,
473 l,
474 n,
475 res,
476 }
477
478 r.Get("/push-allowed", h.PushAllowed)
479 r.Get("/keys", h.InternalKeys)
480 r.Get("/guard", h.Guard)
481 r.Post("/hooks/post-receive", h.PostReceiveHook)
482 r.Mount("/debug", middleware.Profiler())
483
484 return r
485}