A vibe coded tangled fork which supports pijul.
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 res *idresolver.Resolver
36}
37
38func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
39 user := r.URL.Query().Get("user")
40 repo := r.URL.Query().Get("repo")
41
42 if user == "" || repo == "" {
43 w.WriteHeader(http.StatusBadRequest)
44 return
45 }
46
47 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
48 if err != nil || !ok {
49 w.WriteHeader(http.StatusForbidden)
50 return
51 }
52
53 w.WriteHeader(http.StatusNoContent)
54}
55
56func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
57 keys, err := h.db.GetAllPublicKeys()
58 if err != nil {
59 writeError(w, err.Error(), http.StatusInternalServerError)
60 return
61 }
62
63 data := make([]map[string]interface{}, 0)
64 for _, key := range keys {
65 j := key.JSON()
66 data = append(data, j)
67 }
68 writeJSON(w, data)
69}
70
71// response in text/plain format
72// the body will be qualified repository path on success/push-denied
73// or an error message when process failed
74func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
75 l := h.l.With("handler", "PostReceiveHook")
76
77 var (
78 incomingUser = r.URL.Query().Get("user")
79 repo = r.URL.Query().Get("repo")
80 gitCommand = r.URL.Query().Get("gitCmd")
81 )
82
83 if incomingUser == "" || repo == "" || gitCommand == "" {
84 w.WriteHeader(http.StatusBadRequest)
85 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
86 fmt.Fprintln(w, "invalid internal request")
87 return
88 }
89
90 // did:foo/repo-name or
91 // handle/repo-name or
92 // any of the above with a leading slash (/)
93 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'\""), "/"), "/")
94 l.Info("command components", "components", components)
95
96 if len(components) != 2 {
97 w.WriteHeader(http.StatusBadRequest)
98 l.Error("invalid repo format", "components", components)
99 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
100 return
101 }
102 repoOwner := components[0]
103 repoName := components[1]
104
105 resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
106
107 repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner)
108 if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
109 l.Error("Error resolving handle", "handle", repoOwner, "err", err)
110 w.WriteHeader(http.StatusInternalServerError)
111 fmt.Fprintf(w, "error resolving handle: invalid handle\n")
112 return
113 }
114 repoOwnerDid := repoOwnerIdent.DID.String()
115
116 qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
117
118 if gitCommand == "git-receive-pack" {
119 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
120 if err != nil || !ok {
121 w.WriteHeader(http.StatusForbidden)
122 fmt.Fprint(w, repo)
123 return
124 }
125 }
126 if gitCommand == "pijul-protocol" {
127 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
128 if err != nil || !ok {
129 w.WriteHeader(http.StatusForbidden)
130 fmt.Fprint(w, repo)
131 return
132 }
133 }
134
135 w.WriteHeader(http.StatusOK)
136 fmt.Fprint(w, qualifiedRepo)
137}
138
139type PushOptions struct {
140 skipCi bool
141 verboseCi bool
142}
143
144func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
145 l := h.l.With("handler", "PostReceiveHook")
146
147 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
148 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
149 if err != nil {
150 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
151 return
152 }
153
154 parts := strings.SplitN(gitRelativeDir, "/", 2)
155 if len(parts) != 2 {
156 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
157 return
158 }
159 repoDid := parts[0]
160 repoName := parts[1]
161
162 gitUserDid := r.Header.Get("X-Git-User-Did")
163
164 lines, err := git.ParsePostReceive(r.Body)
165 if err != nil {
166 l.Error("failed to parse post-receive payload", "err", err)
167 // non-fatal
168 }
169
170 // extract any push options
171 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
172 pushOptions := PushOptions{}
173 for _, option := range pushOptionsRaw {
174 if option == "skip-ci" || option == "ci-skip" {
175 pushOptions.skipCi = true
176 }
177 if option == "verbose-ci" || option == "ci-verbose" {
178 pushOptions.verboseCi = true
179 }
180 }
181
182 resp := hook.HookResponse{
183 Messages: make([]string, 0),
184 }
185
186 for _, line := range lines {
187 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
188 if err != nil {
189 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
190 // non-fatal
191 }
192
193 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
194 if err != nil {
195 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
196 // non-fatal
197 }
198
199 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
200 if err != nil {
201 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
202 // non-fatal
203 }
204 }
205
206 writeJSON(w, resp)
207}
208
209func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
210 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
211 if err != nil {
212 return err
213 }
214
215 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
216 if err != nil {
217 return err
218 }
219
220 gr, err := git.Open(repoPath, line.Ref)
221 if err != nil {
222 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
223 }
224
225 var errs error
226 meta, err := gr.RefUpdateMeta(line)
227 errs = errors.Join(errs, err)
228
229 metaRecord := meta.AsRecord()
230
231 refUpdate := tangled.GitRefUpdate{
232 OldSha: line.OldSha.String(),
233 NewSha: line.NewSha.String(),
234 Ref: line.Ref,
235 CommitterDid: gitUserDid,
236 RepoDid: repoDid,
237 RepoName: repoName,
238 Meta: &metaRecord,
239 }
240 eventJson, err := json.Marshal(refUpdate)
241 if err != nil {
242 return err
243 }
244
245 event := db.Event{
246 Rkey: TID(),
247 Nsid: tangled.GitRefUpdateNSID,
248 EventJson: string(eventJson),
249 }
250
251 return errors.Join(errs, h.db.InsertEvent(event, h.n))
252}
253
254func (h *InternalHandle) triggerPipeline(
255 clientMsgs *[]string,
256 line git.PostReceiveLine,
257 gitUserDid string,
258 repoDid string,
259 repoName string,
260 pushOptions PushOptions,
261) error {
262 if pushOptions.skipCi {
263 return nil
264 }
265
266 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
267 if err != nil {
268 return err
269 }
270
271 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
272 if err != nil {
273 return err
274 }
275
276 gr, err := git.Open(repoPath, line.Ref)
277 if err != nil {
278 return err
279 }
280
281 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
282 if err != nil {
283 return err
284 }
285
286 var pipeline workflow.RawPipeline
287 for _, e := range workflowDir {
288 if !e.IsFile() {
289 continue
290 }
291
292 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
293 contents, err := gr.RawContent(fpath)
294 if err != nil {
295 continue
296 }
297
298 pipeline = append(pipeline, workflow.RawWorkflow{
299 Name: e.Name,
300 Contents: contents,
301 })
302 }
303
304 trigger := tangled.Pipeline_PushTriggerData{
305 Ref: line.Ref,
306 OldSha: line.OldSha.String(),
307 NewSha: line.NewSha.String(),
308 }
309
310 compiler := workflow.Compiler{
311 Trigger: tangled.Pipeline_TriggerMetadata{
312 Kind: string(workflow.TriggerKindPush),
313 Push: &trigger,
314 Repo: &tangled.Pipeline_TriggerRepo{
315 Did: repoDid,
316 Knot: h.c.Server.Hostname,
317 Repo: repoName,
318 },
319 },
320 }
321
322 cp := compiler.Compile(compiler.Parse(pipeline))
323 eventJson, err := json.Marshal(cp)
324 if err != nil {
325 return err
326 }
327
328 for _, e := range compiler.Diagnostics.Errors {
329 *clientMsgs = append(*clientMsgs, e.String())
330 }
331
332 if pushOptions.verboseCi {
333 if compiler.Diagnostics.IsEmpty() {
334 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
335 }
336
337 for _, w := range compiler.Diagnostics.Warnings {
338 *clientMsgs = append(*clientMsgs, w.String())
339 }
340 }
341
342 // do not run empty pipelines
343 if cp.Workflows == nil {
344 return nil
345 }
346
347 event := db.Event{
348 Rkey: TID(),
349 Nsid: tangled.PipelineNSID,
350 EventJson: string(eventJson),
351 }
352
353 return h.db.InsertEvent(event, h.n)
354}
355
356func (h *InternalHandle) emitCompareLink(
357 clientMsgs *[]string,
358 line git.PostReceiveLine,
359 repoDid string,
360 repoName string,
361) error {
362 // this is a second push to a branch, don't reply with the link again
363 if !line.OldSha.IsZero() {
364 return nil
365 }
366
367 // the ref was not updated to a new hash, don't reply with the link
368 //
369 // NOTE: do we need this?
370 if line.NewSha.String() == line.OldSha.String() {
371 return nil
372 }
373
374 pushedRef := plumbing.ReferenceName(line.Ref)
375
376 userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
377 user := repoDid
378 if err == nil {
379 user = userIdent.Handle.String()
380 }
381
382 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
383 if err != nil {
384 return err
385 }
386
387 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
388 if err != nil {
389 return err
390 }
391
392 gr, err := git.PlainOpen(repoPath)
393 if err != nil {
394 return err
395 }
396
397 defaultBranch, err := gr.FindMainBranch()
398 if err != nil {
399 return err
400 }
401
402 // pushing to default branch
403 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
404 return nil
405 }
406
407 // pushing a tag, don't prompt the user the open a PR
408 if pushedRef.IsTag() {
409 return nil
410 }
411
412 ZWS := "\u200B"
413 *clientMsgs = append(*clientMsgs, ZWS)
414 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
415 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
416 *clientMsgs = append(*clientMsgs, ZWS)
417 return nil
418}
419
420func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
421 r := chi.NewRouter()
422 l := log.FromContext(ctx)
423 l = log.SubLogger(l, "internal")
424 res := idresolver.DefaultResolver(c.Server.PlcUrl)
425
426 h := InternalHandle{
427 db,
428 c,
429 e,
430 l,
431 n,
432 res,
433 }
434
435 r.Get("/push-allowed", h.PushAllowed)
436 r.Get("/keys", h.InternalKeys)
437 r.Get("/guard", h.Guard)
438 r.Post("/hooks/post-receive", h.PostReceiveHook)
439 r.Mount("/debug", middleware.Profiler())
440
441 return r
442}