forked from
tangled.org/core
Monorepo for Tangled
1package knotserver
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
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", "PostReceiveHook")
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 // did:foo/repo-name or
91 // handle/repo-name or
92 // any of the above with a leading slash (/)
93 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
94 l.Info("command components", "components", components)
95
96 if len(components) != 2 {
97 w.WriteHeader(http.StatusBadRequest)
98 l.Error("invalid repo format", "components", components)
99 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
100 return
101 }
102 repoOwner := components[0]
103 repoName := components[1]
104
105 repoOwnerIdent, err := h.res.ResolveIdent(r.Context(), repoOwner)
106 if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
107 l.Error("Error resolving handle", "handle", repoOwner, "err", err)
108 w.WriteHeader(http.StatusInternalServerError)
109 fmt.Fprintf(w, "error resolving handle: invalid handle\n")
110 return
111 }
112 repoOwnerDid := repoOwnerIdent.DID.String()
113
114 qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
115
116 if gitCommand == "git-receive-pack" {
117 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
118 if err != nil || !ok {
119 w.WriteHeader(http.StatusForbidden)
120 fmt.Fprint(w, repo)
121 return
122 }
123 }
124
125 w.WriteHeader(http.StatusOK)
126 fmt.Fprint(w, qualifiedRepo)
127}
128
129type PushOptions struct {
130 skipCi bool
131 verboseCi bool
132}
133
134func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
135 l := h.l.With("handler", "PostReceiveHook")
136
137 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
138 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
139 if err != nil {
140 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
141 return
142 }
143
144 parts := strings.SplitN(gitRelativeDir, "/", 2)
145 if len(parts) != 2 {
146 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
147 return
148 }
149 repoDid := parts[0]
150 repoName := parts[1]
151
152 gitUserDid := r.Header.Get("X-Git-User-Did")
153
154 lines, err := git.ParsePostReceive(r.Body)
155 if err != nil {
156 l.Error("failed to parse post-receive payload", "err", err)
157 // non-fatal
158 }
159
160 // extract any push options
161 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
162 pushOptions := PushOptions{}
163 for _, option := range pushOptionsRaw {
164 if option == "skip-ci" || option == "ci-skip" {
165 pushOptions.skipCi = true
166 }
167 if option == "verbose-ci" || option == "ci-verbose" {
168 pushOptions.verboseCi = true
169 }
170 }
171
172 resp := hook.HookResponse{
173 Messages: make([]string, 0),
174 }
175
176 for _, line := range lines {
177 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
178 if err != nil {
179 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
180 // non-fatal
181 }
182
183 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
184 if err != nil {
185 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
186 // non-fatal
187 }
188
189 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
190 if err != nil {
191 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
192 // non-fatal
193 }
194 }
195
196 writeJSON(w, resp)
197}
198
199func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
200 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
201 if err != nil {
202 return err
203 }
204
205 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
206 if err != nil {
207 return err
208 }
209
210 gr, err := git.Open(repoPath, line.Ref)
211 if err != nil {
212 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
213 }
214
215 var errs error
216 meta, err := gr.RefUpdateMeta(line)
217 errors.Join(errs, err)
218
219 metaRecord := meta.AsRecord()
220
221 refUpdate := tangled.GitRefUpdate{
222 OldSha: line.OldSha.String(),
223 NewSha: line.NewSha.String(),
224 Ref: line.Ref,
225 CommitterDid: gitUserDid,
226 RepoDid: repoDid,
227 RepoName: repoName,
228 Meta: &metaRecord,
229 }
230 eventJson, err := json.Marshal(refUpdate)
231 if err != nil {
232 return err
233 }
234
235 event := db.Event{
236 Rkey: TID(),
237 Nsid: tangled.GitRefUpdateNSID,
238 EventJson: string(eventJson),
239 }
240
241 return errors.Join(errs, h.db.InsertEvent(event, h.n))
242}
243
244func (h *InternalHandle) triggerPipeline(
245 clientMsgs *[]string,
246 line git.PostReceiveLine,
247 gitUserDid string,
248 repoDid string,
249 repoName string,
250 pushOptions PushOptions,
251) error {
252 if pushOptions.skipCi {
253 return nil
254 }
255
256 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
257 if err != nil {
258 return err
259 }
260
261 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
262 if err != nil {
263 return err
264 }
265
266 gr, err := git.Open(repoPath, line.Ref)
267 if err != nil {
268 return err
269 }
270
271 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
272 if err != nil {
273 return err
274 }
275
276 var pipeline workflow.RawPipeline
277 for _, e := range workflowDir {
278 if !e.IsFile() {
279 continue
280 }
281
282 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
283 contents, err := gr.RawContent(fpath)
284 if err != nil {
285 continue
286 }
287
288 pipeline = append(pipeline, workflow.RawWorkflow{
289 Name: e.Name,
290 Contents: contents,
291 })
292 }
293
294 trigger := tangled.Pipeline_PushTriggerData{
295 Ref: line.Ref,
296 OldSha: line.OldSha.String(),
297 NewSha: line.NewSha.String(),
298 }
299
300 compiler := workflow.Compiler{
301 Trigger: tangled.Pipeline_TriggerMetadata{
302 Kind: string(workflow.TriggerKindPush),
303 Push: &trigger,
304 Repo: &tangled.Pipeline_TriggerRepo{
305 Did: repoDid,
306 Knot: h.c.Server.Hostname,
307 Repo: repoName,
308 },
309 },
310 }
311
312 cp := compiler.Compile(compiler.Parse(pipeline))
313 eventJson, err := json.Marshal(cp)
314 if err != nil {
315 return err
316 }
317
318 for _, e := range compiler.Diagnostics.Errors {
319 *clientMsgs = append(*clientMsgs, e.String())
320 }
321
322 if pushOptions.verboseCi {
323 if compiler.Diagnostics.IsEmpty() {
324 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
325 }
326
327 for _, w := range compiler.Diagnostics.Warnings {
328 *clientMsgs = append(*clientMsgs, w.String())
329 }
330 }
331
332 // do not run empty pipelines
333 if cp.Workflows == nil {
334 return nil
335 }
336
337 event := db.Event{
338 Rkey: TID(),
339 Nsid: tangled.PipelineNSID,
340 EventJson: string(eventJson),
341 }
342
343 return h.db.InsertEvent(event, h.n)
344}
345
346func (h *InternalHandle) emitCompareLink(
347 clientMsgs *[]string,
348 line git.PostReceiveLine,
349 repoDid string,
350 repoName string,
351) error {
352 // this is a second push to a branch, don't reply with the link again
353 if !line.OldSha.IsZero() {
354 return nil
355 }
356
357 // the ref was not updated to a new hash, don't reply with the link
358 //
359 // NOTE: do we need this?
360 if line.NewSha.String() == line.OldSha.String() {
361 return nil
362 }
363
364 pushedRef := plumbing.ReferenceName(line.Ref)
365
366 userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
367 user := repoDid
368 if err == nil {
369 user = userIdent.Handle.String()
370 }
371
372 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
373 if err != nil {
374 return err
375 }
376
377 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
378 if err != nil {
379 return err
380 }
381
382 gr, err := git.PlainOpen(repoPath)
383 if err != nil {
384 return err
385 }
386
387 defaultBranch, err := gr.FindMainBranch()
388 if err != nil {
389 return err
390 }
391
392 // pushing to default branch
393 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
394 return nil
395 }
396
397 // pushing a tag, don't prompt the user the open a PR
398 if pushedRef.IsTag() {
399 return nil
400 }
401
402 ZWS := "\u200B"
403 *clientMsgs = append(*clientMsgs, ZWS)
404 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
405 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
406 *clientMsgs = append(*clientMsgs, ZWS)
407 return nil
408}
409
410func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
411 r := chi.NewRouter()
412 l := log.FromContext(ctx)
413 l = log.SubLogger(l, "internal")
414
415 var res *idresolver.Resolver
416 if c.Server.Dev {
417 res = idresolver.DefaultDevResolver(c.Server.PlcUrl, devPDSURL)
418 } else {
419 res = idresolver.DefaultResolver(c.Server.PlcUrl)
420 }
421
422 h := InternalHandle{
423 db,
424 c,
425 e,
426 l,
427 n,
428 res,
429 }
430
431 r.Get("/push-allowed", h.PushAllowed)
432 r.Get("/keys", h.InternalKeys)
433 r.Get("/guard", h.Guard)
434 r.Post("/hooks/post-receive", h.PostReceiveHook)
435 r.Mount("/debug", middleware.Profiler())
436
437 return r
438}