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