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