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