A vibe coded tangled fork which supports pijul.
at master 442 lines 11 kB view raw
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}