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