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 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/hook"
18 "tangled.sh/tangled.sh/core/knotserver/config"
19 "tangled.sh/tangled.sh/core/knotserver/db"
20 "tangled.sh/tangled.sh/core/knotserver/git"
21 "tangled.sh/tangled.sh/core/notifier"
22 "tangled.sh/tangled.sh/core/rbac"
23 "tangled.sh/tangled.sh/core/workflow"
24)
25
26type InternalHandle struct {
27 db *db.DB
28 c *config.Config
29 e *rbac.Enforcer
30 l *slog.Logger
31 n *notifier.Notifier
32}
33
34func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
35 user := r.URL.Query().Get("user")
36 repo := r.URL.Query().Get("repo")
37
38 if user == "" || repo == "" {
39 w.WriteHeader(http.StatusBadRequest)
40 return
41 }
42
43 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
44 if err != nil || !ok {
45 w.WriteHeader(http.StatusForbidden)
46 return
47 }
48
49 w.WriteHeader(http.StatusNoContent)
50 return
51}
52
53func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
54 keys, err := h.db.GetAllPublicKeys()
55 if err != nil {
56 writeError(w, err.Error(), http.StatusInternalServerError)
57 return
58 }
59
60 data := make([]map[string]interface{}, 0)
61 for _, key := range keys {
62 j := key.JSON()
63 data = append(data, j)
64 }
65 writeJSON(w, data)
66 return
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 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
124 if err != nil {
125 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
126 // non-fatal
127 }
128 }
129
130 writeJSON(w, resp)
131}
132
133func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
134 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
135 if err != nil {
136 return err
137 }
138
139 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
140 if err != nil {
141 return err
142 }
143
144 gr, err := git.Open(repoPath, line.Ref)
145 if err != nil {
146 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
147 }
148
149 var errs error
150 meta, err := gr.RefUpdateMeta(line)
151 errors.Join(errs, err)
152
153 metaRecord := meta.AsRecord()
154
155 refUpdate := tangled.GitRefUpdate{
156 OldSha: line.OldSha.String(),
157 NewSha: line.NewSha.String(),
158 Ref: line.Ref,
159 CommitterDid: gitUserDid,
160 RepoDid: repoDid,
161 RepoName: repoName,
162 Meta: &metaRecord,
163 }
164 eventJson, err := json.Marshal(refUpdate)
165 if err != nil {
166 return err
167 }
168
169 event := db.Event{
170 Rkey: TID(),
171 Nsid: tangled.GitRefUpdateNSID,
172 EventJson: string(eventJson),
173 }
174
175 return errors.Join(errs, h.db.InsertEvent(event, h.n))
176}
177
178func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
179 if pushOptions.skipCi {
180 return nil
181 }
182
183 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
184 if err != nil {
185 return err
186 }
187
188 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
189 if err != nil {
190 return err
191 }
192
193 gr, err := git.Open(repoPath, line.Ref)
194 if err != nil {
195 return err
196 }
197
198 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
199 if err != nil {
200 return err
201 }
202
203 pipelineParseErrors := []string{}
204
205 var pipeline workflow.Pipeline
206 for _, e := range workflowDir {
207 if !e.IsFile {
208 continue
209 }
210
211 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
212 contents, err := gr.RawContent(fpath)
213 if err != nil {
214 continue
215 }
216
217 wf, err := workflow.FromFile(e.Name, contents)
218 if err != nil {
219 h.l.Error("failed to parse workflow", "err", err, "path", fpath)
220 pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
221 continue
222 }
223
224 pipeline = append(pipeline, wf)
225 }
226
227 trigger := tangled.Pipeline_PushTriggerData{
228 Ref: line.Ref,
229 OldSha: line.OldSha.String(),
230 NewSha: line.NewSha.String(),
231 }
232
233 compiler := workflow.Compiler{
234 Trigger: tangled.Pipeline_TriggerMetadata{
235 Kind: string(workflow.TriggerKindPush),
236 Push: &trigger,
237 Repo: &tangled.Pipeline_TriggerRepo{
238 Did: repoDid,
239 Knot: h.c.Server.Hostname,
240 Repo: repoName,
241 },
242 },
243 }
244
245 cp := compiler.Compile(pipeline)
246 eventJson, err := json.Marshal(cp)
247 if err != nil {
248 return err
249 }
250
251 if pushOptions.verboseCi {
252 hasDiagnostics := false
253 if len(pipelineParseErrors) > 0 {
254 hasDiagnostics = true
255 *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):")
256 for _, error := range pipelineParseErrors {
257 *clientMsgs = append(*clientMsgs, error)
258 }
259 }
260 if len(compiler.Diagnostics.Errors) > 0 {
261 hasDiagnostics = true
262 *clientMsgs = append(*clientMsgs, "error(s) on pipeline:")
263 for _, error := range compiler.Diagnostics.Errors {
264 *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error))
265 }
266 }
267 if len(compiler.Diagnostics.Warnings) > 0 {
268 hasDiagnostics = true
269 *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:")
270 for _, warning := range compiler.Diagnostics.Warnings {
271 *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason))
272 }
273 }
274 if !hasDiagnostics {
275 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
276 }
277 }
278
279 // do not run empty pipelines
280 if cp.Workflows == nil {
281 return nil
282 }
283
284 event := db.Event{
285 Rkey: TID(),
286 Nsid: tangled.PipelineNSID,
287 EventJson: string(eventJson),
288 }
289
290 return h.db.InsertEvent(event, h.n)
291}
292
293func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
294 r := chi.NewRouter()
295
296 h := InternalHandle{
297 db,
298 c,
299 e,
300 l,
301 n,
302 }
303
304 r.Get("/push-allowed", h.PushAllowed)
305 r.Get("/keys", h.InternalKeys)
306 r.Post("/hooks/post-receive", h.PostReceiveHook)
307 r.Mount("/debug", middleware.Profiler())
308
309 return r
310}