this repo has no description
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 // TODO: pass pushOptions to refUpdate
180 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
181 if err != nil {
182 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
183 // non-fatal
184 }
185
186 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
187 if err != nil {
188 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
189 // non-fatal
190 }
191
192 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
193 if err != nil {
194 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
195 // non-fatal
196 }
197 }
198
199 writeJSON(w, resp)
200}
201
202func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
203 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
204 if err != nil {
205 return err
206 }
207
208 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
209 if err != nil {
210 return err
211 }
212
213 gr, err := git.Open(repoPath, line.Ref)
214 if err != nil {
215 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
216 }
217
218 var errs error
219 meta, err := gr.RefUpdateMeta(line)
220 errors.Join(errs, err)
221
222 metaRecord := meta.AsRecord()
223
224 refUpdate := tangled.GitRefUpdate{
225 OldSha: line.OldSha.String(),
226 NewSha: line.NewSha.String(),
227 Ref: line.Ref,
228 CommitterDid: gitUserDid,
229 RepoDid: repoDid,
230 RepoName: repoName,
231 Meta: &metaRecord,
232 }
233 eventJson, err := json.Marshal(refUpdate)
234 if err != nil {
235 return err
236 }
237
238 event := db.Event{
239 Rkey: TID(),
240 Nsid: tangled.GitRefUpdateNSID,
241 EventJson: string(eventJson),
242 }
243
244 return errors.Join(errs, h.db.InsertEvent(event, h.n))
245}
246
247func (h *InternalHandle) triggerPipeline(
248 clientMsgs *[]string,
249 line git.PostReceiveLine,
250 gitUserDid string,
251 repoDid string,
252 repoName string,
253 pushOptions PushOptions,
254) error {
255 if pushOptions.skipCi {
256 return nil
257 }
258
259 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
260 if err != nil {
261 return err
262 }
263
264 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
265 if err != nil {
266 return err
267 }
268
269 gr, err := git.Open(repoPath, line.Ref)
270 if err != nil {
271 return err
272 }
273
274 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
275 if err != nil {
276 return err
277 }
278
279 var pipeline workflow.RawPipeline
280 for _, e := range workflowDir {
281 if !e.IsFile() {
282 continue
283 }
284
285 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
286 contents, err := gr.RawContent(fpath)
287 if err != nil {
288 continue
289 }
290
291 pipeline = append(pipeline, workflow.RawWorkflow{
292 Name: e.Name,
293 Contents: contents,
294 })
295 }
296
297 trigger := tangled.Pipeline_PushTriggerData{
298 Ref: line.Ref,
299 OldSha: line.OldSha.String(),
300 NewSha: line.NewSha.String(),
301 }
302
303 compiler := workflow.Compiler{
304 Trigger: tangled.Pipeline_TriggerMetadata{
305 Kind: string(workflow.TriggerKindPush),
306 Push: &trigger,
307 Repo: &tangled.Pipeline_TriggerRepo{
308 Did: repoDid,
309 Knot: h.c.Server.Hostname,
310 Repo: repoName,
311 },
312 },
313 }
314
315 cp := compiler.Compile(compiler.Parse(pipeline))
316 eventJson, err := json.Marshal(cp)
317 if err != nil {
318 return err
319 }
320
321 for _, e := range compiler.Diagnostics.Errors {
322 *clientMsgs = append(*clientMsgs, e.String())
323 }
324
325 if pushOptions.verboseCi {
326 if compiler.Diagnostics.IsEmpty() {
327 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
328 }
329
330 for _, w := range compiler.Diagnostics.Warnings {
331 *clientMsgs = append(*clientMsgs, w.String())
332 }
333 }
334
335 // do not run empty pipelines
336 if cp.Workflows == nil {
337 return nil
338 }
339
340 event := db.Event{
341 Rkey: TID(),
342 Nsid: tangled.PipelineNSID,
343 EventJson: string(eventJson),
344 }
345
346 return h.db.InsertEvent(event, h.n)
347}
348
349func (h *InternalHandle) emitCompareLink(
350 clientMsgs *[]string,
351 line git.PostReceiveLine,
352 repoDid string,
353 repoName string,
354) error {
355 // this is a second push to a branch, don't reply with the link again
356 if !line.OldSha.IsZero() {
357 return nil
358 }
359
360 // the ref was not updated to a new hash, don't reply with the link
361 //
362 // NOTE: do we need this?
363 if line.NewSha.String() == line.OldSha.String() {
364 return nil
365 }
366
367 pushedRef := plumbing.ReferenceName(line.Ref)
368
369 userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
370 user := repoDid
371 if err == nil {
372 user = userIdent.Handle.String()
373 }
374
375 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
376 if err != nil {
377 return err
378 }
379
380 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
381 if err != nil {
382 return err
383 }
384
385 gr, err := git.PlainOpen(repoPath)
386 if err != nil {
387 return err
388 }
389
390 defaultBranch, err := gr.FindMainBranch()
391 if err != nil {
392 return err
393 }
394
395 // pushing to default branch
396 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
397 return nil
398 }
399
400 // pushing a tag, don't prompt the user the open a PR
401 if pushedRef.IsTag() {
402 return nil
403 }
404
405 ZWS := "\u200B"
406 *clientMsgs = append(*clientMsgs, ZWS)
407 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
408 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
409 *clientMsgs = append(*clientMsgs, ZWS)
410 return nil
411}
412
413func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
414 r := chi.NewRouter()
415 l := log.FromContext(ctx)
416 l = log.SubLogger(l, "internal")
417 res := idresolver.DefaultResolver(c.Server.PlcUrl)
418
419 h := InternalHandle{
420 db,
421 c,
422 e,
423 l,
424 n,
425 res,
426 }
427
428 r.Get("/push-allowed", h.PushAllowed)
429 r.Get("/keys", h.InternalKeys)
430 r.Get("/guard", h.Guard)
431 r.Post("/hooks/post-receive", h.PostReceiveHook)
432 r.Mount("/debug", middleware.Profiler())
433
434 return r
435}