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