forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
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
67func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
68 l := h.l.With("handler", "PostReceiveHook")
69
70 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
71 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
72 if err != nil {
73 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
74 return
75 }
76
77 parts := strings.SplitN(gitRelativeDir, "/", 2)
78 if len(parts) != 2 {
79 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
80 return
81 }
82 repoDid := parts[0]
83 repoName := parts[1]
84
85 gitUserDid := r.Header.Get("X-Git-User-Did")
86
87 lines, err := git.ParsePostReceive(r.Body)
88 if err != nil {
89 l.Error("failed to parse post-receive payload", "err", err)
90 // non-fatal
91 }
92
93 for _, line := range lines {
94 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
95 if err != nil {
96 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
97 // non-fatal
98 }
99
100 err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
101 if err != nil {
102 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
103 // non-fatal
104 }
105 }
106}
107
108func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
109 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
110 if err != nil {
111 return err
112 }
113
114 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
115 if err != nil {
116 return err
117 }
118
119 gr, err := git.Open(repoPath, line.Ref)
120 if err != nil {
121 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
122 }
123
124 meta := gr.RefUpdateMeta(line)
125
126 metaRecord := meta.AsRecord()
127
128 refUpdate := tangled.GitRefUpdate{
129 OldSha: line.OldSha.String(),
130 NewSha: line.NewSha.String(),
131 Ref: line.Ref,
132 CommitterDid: gitUserDid,
133 RepoDid: repoDid,
134 RepoName: repoName,
135 Meta: &metaRecord,
136 }
137 eventJson, err := json.Marshal(refUpdate)
138 if err != nil {
139 return err
140 }
141
142 event := db.Event{
143 Rkey: TID(),
144 Nsid: tangled.GitRefUpdateNSID,
145 EventJson: string(eventJson),
146 }
147
148 return h.db.InsertEvent(event, h.n)
149}
150
151func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
152 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
153 if err != nil {
154 return err
155 }
156
157 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
158 if err != nil {
159 return err
160 }
161
162 gr, err := git.Open(repoPath, line.Ref)
163 if err != nil {
164 return err
165 }
166
167 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
168 if err != nil {
169 return err
170 }
171
172 var pipeline workflow.Pipeline
173 for _, e := range workflowDir {
174 if !e.IsFile {
175 continue
176 }
177
178 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
179 contents, err := gr.RawContent(fpath)
180 if err != nil {
181 continue
182 }
183
184 wf, err := workflow.FromFile(e.Name, contents)
185 if err != nil {
186 // TODO: log here, respond to client that is pushing
187 h.l.Error("failed to parse workflow", "err", err, "path", fpath)
188 continue
189 }
190
191 pipeline = append(pipeline, wf)
192 }
193
194 trigger := tangled.Pipeline_PushTriggerData{
195 Ref: line.Ref,
196 OldSha: line.OldSha.String(),
197 NewSha: line.NewSha.String(),
198 }
199
200 compiler := workflow.Compiler{
201 Trigger: tangled.Pipeline_TriggerMetadata{
202 Kind: string(workflow.TriggerKindPush),
203 Push: &trigger,
204 Repo: &tangled.Pipeline_TriggerRepo{
205 Did: repoDid,
206 Knot: h.c.Server.Hostname,
207 Repo: repoName,
208 },
209 },
210 }
211
212 // TODO: send the diagnostics back to the user here via stderr
213 cp := compiler.Compile(pipeline)
214 eventJson, err := json.Marshal(cp)
215 if err != nil {
216 return err
217 }
218
219 // do not run empty pipelines
220 if cp.Workflows == nil {
221 return nil
222 }
223
224 event := db.Event{
225 Rkey: TID(),
226 Nsid: tangled.PipelineNSID,
227 EventJson: string(eventJson),
228 }
229
230 return h.db.InsertEvent(event, h.n)
231}
232
233func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
234 r := chi.NewRouter()
235
236 h := InternalHandle{
237 db,
238 c,
239 e,
240 l,
241 n,
242 }
243
244 r.Get("/push-allowed", h.PushAllowed)
245 r.Get("/keys", h.InternalKeys)
246 r.Post("/hooks/post-receive", h.PostReceiveHook)
247 r.Mount("/debug", middleware.Profiler())
248
249 return r
250}