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}
36
37func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
38 user := r.URL.Query().Get("user")
39 repo := r.URL.Query().Get("repo")
40
41 if user == "" || repo == "" {
42 w.WriteHeader(http.StatusBadRequest)
43 return
44 }
45
46 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
47 if err != nil || !ok {
48 w.WriteHeader(http.StatusForbidden)
49 return
50 }
51
52 w.WriteHeader(http.StatusNoContent)
53}
54
55func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
56 keys, err := h.db.GetAllPublicKeys()
57 if err != nil {
58 writeError(w, err.Error(), http.StatusInternalServerError)
59 return
60 }
61
62 data := make([]map[string]interface{}, 0)
63 for _, key := range keys {
64 j := key.JSON()
65 data = append(data, j)
66 }
67 writeJSON(w, data)
68}
69
70type PushOptions struct {
71 skipCi bool
72 verboseCi bool
73}
74
75func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
76 l := h.l.With("handler", "PostReceiveHook")
77
78 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
79 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
80 if err != nil {
81 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
82 return
83 }
84
85 parts := strings.SplitN(gitRelativeDir, "/", 2)
86 if len(parts) != 2 {
87 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
88 return
89 }
90 repoDid := parts[0]
91 repoName := parts[1]
92
93 gitUserDid := r.Header.Get("X-Git-User-Did")
94
95 lines, err := git.ParsePostReceive(r.Body)
96 if err != nil {
97 l.Error("failed to parse post-receive payload", "err", err)
98 // non-fatal
99 }
100
101 // extract any push options
102 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
103 pushOptions := PushOptions{}
104 for _, option := range pushOptionsRaw {
105 if option == "skip-ci" || option == "ci-skip" {
106 pushOptions.skipCi = true
107 }
108 if option == "verbose-ci" || option == "ci-verbose" {
109 pushOptions.verboseCi = true
110 }
111 }
112
113 resp := hook.HookResponse{
114 Messages: make([]string, 0),
115 }
116
117 for _, line := range lines {
118 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
119 if err != nil {
120 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
121 // non-fatal
122 }
123
124 if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() {
125 msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context())
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 } else {
130 for msgLine := range msg {
131 resp.Messages = append(resp.Messages, msg[msgLine])
132 }
133 }
134 }
135
136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
137 if err != nil {
138 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
139 // non-fatal
140 }
141 }
142
143 writeJSON(w, resp)
144}
145
146func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) {
147 l := h.l.With("handler", "replyCompare")
148 userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner)
149 user := repoOwner
150 if err != nil {
151 l.Error("Failed to fetch user identity", "err", err)
152 // non-fatal
153 } else {
154 user = userIdent.Handle.String()
155 }
156 gr, err := git.PlainOpen(gitRelativeDir)
157 if err != nil {
158 l.Error("Failed to open git repository", "err", err)
159 return []string{}, err
160 }
161 defaultBranch, err := gr.FindMainBranch()
162 if err != nil {
163 l.Error("Failed to fetch default branch", "err", err)
164 return []string{}, err
165 }
166 if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() {
167 return []string{}, nil
168 }
169 ZWS := "\u200B"
170 var msg []string
171 msg = append(msg, ZWS)
172 msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
173 msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
174 msg = append(msg, ZWS)
175 return msg, nil
176}
177
178func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
180 if err != nil {
181 return err
182 }
183
184 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
185 if err != nil {
186 return err
187 }
188
189 gr, err := git.Open(repoPath, line.Ref)
190 if err != nil {
191 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
192 }
193
194 var errs error
195 meta, err := gr.RefUpdateMeta(line)
196 errors.Join(errs, err)
197
198 metaRecord := meta.AsRecord()
199
200 refUpdate := tangled.GitRefUpdate{
201 OldSha: line.OldSha.String(),
202 NewSha: line.NewSha.String(),
203 Ref: line.Ref,
204 CommitterDid: gitUserDid,
205 RepoDid: repoDid,
206 RepoName: repoName,
207 Meta: &metaRecord,
208 }
209 eventJson, err := json.Marshal(refUpdate)
210 if err != nil {
211 return err
212 }
213
214 event := db.Event{
215 Rkey: TID(),
216 Nsid: tangled.GitRefUpdateNSID,
217 EventJson: string(eventJson),
218 }
219
220 return errors.Join(errs, h.db.InsertEvent(event, h.n))
221}
222
223func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
224 if pushOptions.skipCi {
225 return nil
226 }
227
228 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
229 if err != nil {
230 return err
231 }
232
233 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
234 if err != nil {
235 return err
236 }
237
238 gr, err := git.Open(repoPath, line.Ref)
239 if err != nil {
240 return err
241 }
242
243 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
244 if err != nil {
245 return err
246 }
247
248 var pipeline workflow.RawPipeline
249 for _, e := range workflowDir {
250 if !e.IsFile {
251 continue
252 }
253
254 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
255 contents, err := gr.RawContent(fpath)
256 if err != nil {
257 continue
258 }
259
260 pipeline = append(pipeline, workflow.RawWorkflow{
261 Name: e.Name,
262 Contents: contents,
263 })
264 }
265
266 trigger := tangled.Pipeline_PushTriggerData{
267 Ref: line.Ref,
268 OldSha: line.OldSha.String(),
269 NewSha: line.NewSha.String(),
270 }
271
272 compiler := workflow.Compiler{
273 Trigger: tangled.Pipeline_TriggerMetadata{
274 Kind: string(workflow.TriggerKindPush),
275 Push: &trigger,
276 Repo: &tangled.Pipeline_TriggerRepo{
277 Did: repoDid,
278 Knot: h.c.Server.Hostname,
279 Repo: repoName,
280 },
281 },
282 }
283
284 cp := compiler.Compile(compiler.Parse(pipeline))
285 eventJson, err := json.Marshal(cp)
286 if err != nil {
287 return err
288 }
289
290 for _, e := range compiler.Diagnostics.Errors {
291 *clientMsgs = append(*clientMsgs, e.String())
292 }
293
294 if pushOptions.verboseCi {
295 if compiler.Diagnostics.IsEmpty() {
296 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
297 }
298
299 for _, w := range compiler.Diagnostics.Warnings {
300 *clientMsgs = append(*clientMsgs, w.String())
301 }
302 }
303
304 // do not run empty pipelines
305 if cp.Workflows == nil {
306 return nil
307 }
308
309 event := db.Event{
310 Rkey: TID(),
311 Nsid: tangled.PipelineNSID,
312 EventJson: string(eventJson),
313 }
314
315 return h.db.InsertEvent(event, h.n)
316}
317
318func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
319 r := chi.NewRouter()
320 l := log.FromContext(ctx)
321 l = log.SubLogger(l, "internal")
322
323 h := InternalHandle{
324 db,
325 c,
326 e,
327 l,
328 n,
329 }
330
331 r.Get("/push-allowed", h.PushAllowed)
332 r.Get("/keys", h.InternalKeys)
333 r.Post("/hooks/post-receive", h.PostReceiveHook)
334 r.Mount("/debug", middleware.Profiler())
335
336 return r
337}