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