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