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