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