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 var pipeline workflow.RawPipeline
204 for _, e := range workflowDir {
205 if !e.IsFile {
206 continue
207 }
208
209 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
210 contents, err := gr.RawContent(fpath)
211 if err != nil {
212 continue
213 }
214
215 pipeline = append(pipeline, workflow.RawWorkflow{
216 Name: e.Name,
217 Contents: contents,
218 })
219 }
220
221 trigger := tangled.Pipeline_PushTriggerData{
222 Ref: line.Ref,
223 OldSha: line.OldSha.String(),
224 NewSha: line.NewSha.String(),
225 }
226
227 compiler := workflow.Compiler{
228 Trigger: tangled.Pipeline_TriggerMetadata{
229 Kind: string(workflow.TriggerKindPush),
230 Push: &trigger,
231 Repo: &tangled.Pipeline_TriggerRepo{
232 Did: repoDid,
233 Knot: h.c.Server.Hostname,
234 Repo: repoName,
235 },
236 },
237 }
238
239 cp := compiler.Compile(compiler.Parse(pipeline))
240 eventJson, err := json.Marshal(cp)
241 if err != nil {
242 return err
243 }
244
245 if pushOptions.verboseCi {
246 if compiler.Diagnostics.IsEmpty() {
247 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
248 }
249
250 for _, e := range compiler.Diagnostics.Errors {
251 *clientMsgs = append(*clientMsgs, e.String())
252 }
253
254 for _, w := range compiler.Diagnostics.Warnings {
255 *clientMsgs = append(*clientMsgs, w.String())
256 }
257 }
258
259 // do not run empty pipelines
260 if cp.Workflows == nil {
261 return nil
262 }
263
264 event := db.Event{
265 Rkey: TID(),
266 Nsid: tangled.PipelineNSID,
267 EventJson: string(eventJson),
268 }
269
270 return h.db.InsertEvent(event, h.n)
271}
272
273func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
274 r := chi.NewRouter()
275
276 h := InternalHandle{
277 db,
278 c,
279 e,
280 l,
281 n,
282 }
283
284 r.Get("/push-allowed", h.PushAllowed)
285 r.Get("/keys", h.InternalKeys)
286 r.Post("/hooks/post-receive", h.PostReceiveHook)
287 r.Mount("/debug", middleware.Profiler())
288
289 return r
290}