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