1package knotserver
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "path/filepath"
10 "strings"
11
12 securejoin "github.com/cyphar/filepath-securejoin"
13 "github.com/go-chi/chi/v5"
14 "github.com/go-chi/chi/v5/middleware"
15 "tangled.sh/tangled.sh/core/api/tangled"
16 "tangled.sh/tangled.sh/core/knotserver/config"
17 "tangled.sh/tangled.sh/core/knotserver/db"
18 "tangled.sh/tangled.sh/core/knotserver/git"
19 "tangled.sh/tangled.sh/core/notifier"
20 "tangled.sh/tangled.sh/core/rbac"
21 "tangled.sh/tangled.sh/core/workflow"
22)
23
24type InternalHandle struct {
25 db *db.DB
26 c *config.Config
27 e *rbac.Enforcer
28 l *slog.Logger
29 n *notifier.Notifier
30}
31
32func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
33 user := r.URL.Query().Get("user")
34 repo := r.URL.Query().Get("repo")
35
36 if user == "" || repo == "" {
37 w.WriteHeader(http.StatusBadRequest)
38 return
39 }
40
41 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
42 if err != nil || !ok {
43 w.WriteHeader(http.StatusForbidden)
44 return
45 }
46
47 w.WriteHeader(http.StatusNoContent)
48 return
49}
50
51func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
52 keys, err := h.db.GetAllPublicKeys()
53 if err != nil {
54 writeError(w, err.Error(), http.StatusInternalServerError)
55 return
56 }
57
58 data := make([]map[string]interface{}, 0)
59 for _, key := range keys {
60 j := key.JSON()
61 data = append(data, j)
62 }
63 writeJSON(w, data)
64 return
65}
66
67type PushOptions struct {
68 skipCi bool
69}
70
71func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
72 l := h.l.With("handler", "PostReceiveHook")
73
74 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
75 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
76 if err != nil {
77 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
78 return
79 }
80
81 parts := strings.SplitN(gitRelativeDir, "/", 2)
82 if len(parts) != 2 {
83 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
84 return
85 }
86 repoDid := parts[0]
87 repoName := parts[1]
88
89 gitUserDid := r.Header.Get("X-Git-User-Did")
90
91 lines, err := git.ParsePostReceive(r.Body)
92 if err != nil {
93 l.Error("failed to parse post-receive payload", "err", err)
94 // non-fatal
95 }
96
97 // extract any push options
98 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
99 pushOptions := PushOptions{}
100 for _, option := range pushOptionsRaw {
101 if option == "skip-ci" || option == "ci-skip" {
102 pushOptions.skipCi = true
103 }
104 }
105
106 for _, line := range lines {
107 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
108 if err != nil {
109 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
110 // non-fatal
111 }
112
113 err = h.triggerPipeline(line, gitUserDid, repoDid, repoName, pushOptions)
114 if err != nil {
115 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
116 // non-fatal
117 }
118 }
119}
120
121func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
122 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
123 if err != nil {
124 return err
125 }
126
127 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
128 if err != nil {
129 return err
130 }
131
132 gr, err := git.Open(repoPath, line.Ref)
133 if err != nil {
134 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
135 }
136
137 meta := gr.RefUpdateMeta(line)
138
139 metaRecord := meta.AsRecord()
140
141 refUpdate := tangled.GitRefUpdate{
142 OldSha: line.OldSha.String(),
143 NewSha: line.NewSha.String(),
144 Ref: line.Ref,
145 CommitterDid: gitUserDid,
146 RepoDid: repoDid,
147 RepoName: repoName,
148 Meta: &metaRecord,
149 }
150 eventJson, err := json.Marshal(refUpdate)
151 if err != nil {
152 return err
153 }
154
155 event := db.Event{
156 Rkey: TID(),
157 Nsid: tangled.GitRefUpdateNSID,
158 EventJson: string(eventJson),
159 }
160
161 return h.db.InsertEvent(event, h.n)
162}
163
164func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
165 if pushOptions.skipCi {
166 return nil
167 }
168
169 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
170 if err != nil {
171 return err
172 }
173
174 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
175 if err != nil {
176 return err
177 }
178
179 gr, err := git.Open(repoPath, line.Ref)
180 if err != nil {
181 return err
182 }
183
184 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
185 if err != nil {
186 return err
187 }
188
189 var pipeline workflow.Pipeline
190 for _, e := range workflowDir {
191 if !e.IsFile {
192 continue
193 }
194
195 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
196 contents, err := gr.RawContent(fpath)
197 if err != nil {
198 continue
199 }
200
201 wf, err := workflow.FromFile(e.Name, contents)
202 if err != nil {
203 // TODO: log here, respond to client that is pushing
204 h.l.Error("failed to parse workflow", "err", err, "path", fpath)
205 continue
206 }
207
208 pipeline = append(pipeline, wf)
209 }
210
211 trigger := tangled.Pipeline_PushTriggerData{
212 Ref: line.Ref,
213 OldSha: line.OldSha.String(),
214 NewSha: line.NewSha.String(),
215 }
216
217 compiler := workflow.Compiler{
218 Trigger: tangled.Pipeline_TriggerMetadata{
219 Kind: string(workflow.TriggerKindPush),
220 Push: &trigger,
221 Repo: &tangled.Pipeline_TriggerRepo{
222 Did: repoDid,
223 Knot: h.c.Server.Hostname,
224 Repo: repoName,
225 },
226 },
227 }
228
229 // TODO: send the diagnostics back to the user here via stderr
230 cp := compiler.Compile(pipeline)
231 eventJson, err := json.Marshal(cp)
232 if err != nil {
233 return err
234 }
235
236 // do not run empty pipelines
237 if cp.Workflows == nil {
238 return nil
239 }
240
241 event := db.Event{
242 Rkey: TID(),
243 Nsid: tangled.PipelineNSID,
244 EventJson: string(eventJson),
245 }
246
247 return h.db.InsertEvent(event, h.n)
248}
249
250func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
251 r := chi.NewRouter()
252
253 h := InternalHandle{
254 db,
255 c,
256 e,
257 l,
258 n,
259 }
260
261 r.Get("/push-allowed", h.PushAllowed)
262 r.Get("/keys", h.InternalKeys)
263 r.Post("/hooks/post-receive", h.PostReceiveHook)
264 r.Mount("/debug", middleware.Profiler())
265
266 return r
267}