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