forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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 "github.com/go-git/go-git/v5/plumbing"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/hook"
19 "tangled.org/core/idresolver"
20 "tangled.org/core/knotserver/config"
21 "tangled.org/core/knotserver/db"
22 "tangled.org/core/knotserver/git"
23 "tangled.org/core/log"
24 "tangled.org/core/notifier"
25 "tangled.org/core/rbac"
26 "tangled.org/core/workflow"
27)
28
29type InternalHandle struct {
30 db *db.DB
31 c *config.Config
32 e *rbac.Enforcer
33 l *slog.Logger
34 n *notifier.Notifier
35}
36
37func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
38 user := r.URL.Query().Get("user")
39 repo := r.URL.Query().Get("repo")
40
41 if user == "" || repo == "" {
42 w.WriteHeader(http.StatusBadRequest)
43 return
44 }
45
46 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
47 if err != nil || !ok {
48 w.WriteHeader(http.StatusForbidden)
49 return
50 }
51
52 w.WriteHeader(http.StatusNoContent)
53}
54
55func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
56 keys, err := h.db.GetAllPublicKeys()
57 if err != nil {
58 writeError(w, err.Error(), http.StatusInternalServerError)
59 return
60 }
61
62 data := make([]map[string]interface{}, 0)
63 for _, key := range keys {
64 j := key.JSON()
65 data = append(data, j)
66 }
67 writeJSON(w, data)
68}
69
70type PushOptions struct {
71 skipCi bool
72 verboseCi bool
73}
74
75func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
76 l := h.l.With("handler", "PostReceiveHook")
77
78 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
79 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
80 if err != nil {
81 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
82 return
83 }
84
85 parts := strings.SplitN(gitRelativeDir, "/", 2)
86 if len(parts) != 2 {
87 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
88 return
89 }
90 repoDid := parts[0]
91 repoName := parts[1]
92
93 gitUserDid := r.Header.Get("X-Git-User-Did")
94
95 lines, err := git.ParsePostReceive(r.Body)
96 if err != nil {
97 l.Error("failed to parse post-receive payload", "err", err)
98 // non-fatal
99 }
100
101 // extract any push options
102 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
103 pushOptions := PushOptions{}
104 for _, option := range pushOptionsRaw {
105 if option == "skip-ci" || option == "ci-skip" {
106 pushOptions.skipCi = true
107 }
108 if option == "verbose-ci" || option == "ci-verbose" {
109 pushOptions.verboseCi = true
110 }
111 }
112
113 resp := hook.HookResponse{
114 Messages: make([]string, 0),
115 }
116
117 for _, line := range lines {
118 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
119 if err != nil {
120 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
121 // non-fatal
122 }
123
124 if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() {
125 msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context())
126 if err != nil {
127 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
128 // non-fatal
129 } else {
130 for msgLine := range msg {
131 resp.Messages = append(resp.Messages, msg[msgLine])
132 }
133 }
134 }
135
136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
137 if err != nil {
138 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
139 // non-fatal
140 }
141 }
142
143 writeJSON(w, resp)
144}
145
146func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) {
147 l := h.l.With("handler", "replyCompare")
148 userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner)
149 user := repoOwner
150 if err != nil {
151 l.Error("Failed to fetch user identity", "err", err)
152 // non-fatal
153 } else {
154 user = userIdent.Handle.String()
155 }
156 gr, err := git.PlainOpen(gitRelativeDir)
157 if err != nil {
158 l.Error("Failed to open git repository", "err", err)
159 return []string{}, err
160 }
161 defaultBranch, err := gr.FindMainBranch()
162 if err != nil {
163 l.Error("Failed to fetch default branch", "err", err)
164 return []string{}, err
165 }
166 if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() {
167 return []string{}, nil
168 }
169 ZWS := "\u200B"
170 var msg []string
171 msg = append(msg, ZWS)
172 msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
173 msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
174 msg = append(msg, ZWS)
175 return msg, nil
176}
177
178func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
180 if err != nil {
181 return err
182 }
183
184 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
185 if err != nil {
186 return err
187 }
188
189 gr, err := git.Open(repoPath, line.Ref)
190 if err != nil {
191 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
192 }
193
194 var errs error
195 meta, err := gr.RefUpdateMeta(line)
196 errors.Join(errs, err)
197
198 metaRecord := meta.AsRecord()
199
200 refUpdate := tangled.GitRefUpdate{
201 OldSha: line.OldSha.String(),
202 NewSha: line.NewSha.String(),
203 Ref: line.Ref,
204 CommitterDid: gitUserDid,
205 RepoDid: repoDid,
206 RepoName: repoName,
207 Meta: &metaRecord,
208 }
209 eventJson, err := json.Marshal(refUpdate)
210 if err != nil {
211 return err
212 }
213
214 event := db.Event{
215 Rkey: TID(),
216 Nsid: tangled.GitRefUpdateNSID,
217 EventJson: string(eventJson),
218 }
219
220 return errors.Join(errs, h.db.InsertEvent(event, h.n))
221}
222
223func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
224 if pushOptions.skipCi {
225 return nil
226 }
227
228 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
229 if err != nil {
230 return err
231 }
232
233 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
234 if err != nil {
235 return err
236 }
237
238 gr, err := git.Open(repoPath, line.Ref)
239 if err != nil {
240 return err
241 }
242
243 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
244 if err != nil {
245 return err
246 }
247
248 var pipeline workflow.RawPipeline
249 for _, e := range workflowDir {
250 if !e.IsFile {
251 continue
252 }
253
254 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
255 contents, err := gr.RawContent(fpath)
256 if err != nil {
257 continue
258 }
259
260 pipeline = append(pipeline, workflow.RawWorkflow{
261 Name: e.Name,
262 Contents: contents,
263 })
264 }
265
266 trigger := tangled.Pipeline_PushTriggerData{
267 Ref: line.Ref,
268 OldSha: line.OldSha.String(),
269 NewSha: line.NewSha.String(),
270 }
271
272 compiler := workflow.Compiler{
273 Trigger: tangled.Pipeline_TriggerMetadata{
274 Kind: string(workflow.TriggerKindPush),
275 Push: &trigger,
276 Repo: &tangled.Pipeline_TriggerRepo{
277 Did: repoDid,
278 Knot: h.c.Server.Hostname,
279 Repo: repoName,
280 },
281 },
282 }
283
284 cp := compiler.Compile(compiler.Parse(pipeline))
285 eventJson, err := json.Marshal(cp)
286 if err != nil {
287 return err
288 }
289
290 for _, e := range compiler.Diagnostics.Errors {
291 *clientMsgs = append(*clientMsgs, e.String())
292 }
293
294 if pushOptions.verboseCi {
295 if compiler.Diagnostics.IsEmpty() {
296 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
297 }
298
299 for _, w := range compiler.Diagnostics.Warnings {
300 *clientMsgs = append(*clientMsgs, w.String())
301 }
302 }
303
304 // do not run empty pipelines
305 if cp.Workflows == nil {
306 return nil
307 }
308
309 event := db.Event{
310 Rkey: TID(),
311 Nsid: tangled.PipelineNSID,
312 EventJson: string(eventJson),
313 }
314
315 return h.db.InsertEvent(event, h.n)
316}
317
318func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
319 r := chi.NewRouter()
320 l := log.FromContext(ctx)
321 l = log.SubLogger(l, "internal")
322
323 h := InternalHandle{
324 db,
325 c,
326 e,
327 l,
328 n,
329 }
330
331 r.Get("/push-allowed", h.PushAllowed)
332 r.Get("/keys", h.InternalKeys)
333 r.Post("/hooks/post-receive", h.PostReceiveHook)
334 r.Mount("/debug", middleware.Profiler())
335
336 return r
337}