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