Monorepo for Tangled
at master 501 lines 13 kB view raw
1package knotserver 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net/http" 9 "os" 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", "Guard") 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 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 91 l.Info("command components", "components", components) 92 93 var rbacResource string 94 var diskRelative string 95 96 switch { 97 case len(components) == 1 && strings.HasPrefix(components[0], "did:"): 98 repoDid := components[0] 99 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 100 if lookupErr != nil { 101 w.WriteHeader(http.StatusNotFound) 102 l.Error("repo DID not found", "repoDid", repoDid, "err", lookupErr) 103 fmt.Fprintln(w, "repo not found") 104 return 105 } 106 rbacResource = repoDid 107 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 108 if relErr != nil { 109 w.WriteHeader(http.StatusInternalServerError) 110 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 111 fmt.Fprintln(w, "internal error") 112 return 113 } 114 diskRelative = rel 115 116 case len(components) == 2: 117 repoOwner := components[0] 118 resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl) 119 repoOwnerIdent, resolveErr := resolver.ResolveIdent(r.Context(), repoOwner) 120 if resolveErr != nil || repoOwnerIdent.Handle.IsInvalidHandle() { 121 l.Error("Error resolving handle", "handle", repoOwner, "err", resolveErr) 122 w.WriteHeader(http.StatusInternalServerError) 123 fmt.Fprintf(w, "error resolving handle: invalid handle\n") 124 return 125 } 126 ownerDid := repoOwnerIdent.DID.String() 127 repoName := components[1] 128 repoDid, didErr := h.db.GetRepoDid(ownerDid, repoName) 129 var repoPath string 130 if didErr == nil { 131 var lookupErr error 132 repoPath, _, _, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 133 if lookupErr != nil { 134 w.WriteHeader(http.StatusNotFound) 135 l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr) 136 fmt.Fprintln(w, "repo not found") 137 return 138 } 139 rbacResource = repoDid 140 } else { 141 legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid, repoName)) 142 if joinErr != nil { 143 w.WriteHeader(http.StatusNotFound) 144 fmt.Fprintln(w, "repo not found") 145 return 146 } 147 if _, statErr := os.Stat(legacyPath); statErr != nil { 148 w.WriteHeader(http.StatusNotFound) 149 l.Error("repo not found on disk (legacy)", "owner", ownerDid, "name", repoName) 150 fmt.Fprintln(w, "repo not found") 151 return 152 } 153 repoPath = legacyPath 154 rbacResource = ownerDid + "/" + repoName 155 } 156 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 157 if relErr != nil { 158 w.WriteHeader(http.StatusInternalServerError) 159 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 160 fmt.Fprintln(w, "internal error") 161 return 162 } 163 diskRelative = rel 164 165 default: 166 w.WriteHeader(http.StatusBadRequest) 167 l.Error("invalid repo format", "components", components) 168 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>") 169 return 170 } 171 172 if gitCommand == "git-receive-pack" { 173 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource) 174 if err != nil || !ok { 175 w.WriteHeader(http.StatusForbidden) 176 fmt.Fprint(w, repo) 177 return 178 } 179 } 180 181 w.WriteHeader(http.StatusOK) 182 fmt.Fprint(w, diskRelative) 183} 184 185type PushOptions struct { 186 skipCi bool 187 verboseCi bool 188} 189 190func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 191 l := h.l.With("handler", "PostReceiveHook") 192 193 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 194 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 195 if err != nil { 196 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 197 w.WriteHeader(http.StatusInternalServerError) 198 return 199 } 200 201 var repoDid string 202 var ownerDid, repoName string 203 204 if strings.HasPrefix(gitRelativeDir, "did:") { 205 repoDid = gitRelativeDir 206 var err error 207 ownerDid, repoName, err = h.db.GetRepoKeyOwner(repoDid) 208 if err != nil { 209 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err) 210 w.WriteHeader(http.StatusBadRequest) 211 return 212 } 213 } else { 214 components := strings.SplitN(gitRelativeDir, "/", 2) 215 if len(components) != 2 { 216 l.Error("invalid git dir, expected repo DID or owner/repo", "gitRelativeDir", gitRelativeDir) 217 w.WriteHeader(http.StatusBadRequest) 218 return 219 } 220 ownerDid = components[0] 221 repoName = components[1] 222 var didErr error 223 repoDid, didErr = h.db.GetRepoDid(ownerDid, repoName) 224 if didErr != nil { 225 l.Error("failed to resolve repo DID from legacy path", "gitRelativeDir", gitRelativeDir, "err", didErr) 226 w.WriteHeader(http.StatusBadRequest) 227 return 228 } 229 } 230 231 gitUserDid := r.Header.Get("X-Git-User-Did") 232 233 lines, err := git.ParsePostReceive(r.Body) 234 if err != nil { 235 l.Error("failed to parse post-receive payload", "err", err) 236 // non-fatal 237 } 238 239 // extract any push options 240 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 241 pushOptions := PushOptions{} 242 for _, option := range pushOptionsRaw { 243 if option == "skip-ci" || option == "ci-skip" { 244 pushOptions.skipCi = true 245 } 246 if option == "verbose-ci" || option == "ci-verbose" { 247 pushOptions.verboseCi = true 248 } 249 } 250 251 resp := hook.HookResponse{ 252 Messages: make([]string, 0), 253 } 254 255 for _, line := range lines { 256 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid) 257 if err != nil { 258 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 259 } 260 261 err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName, repoDid) 262 if err != nil { 263 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 264 } 265 266 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions) 267 if err != nil { 268 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 269 } 270 } 271 272 writeJSON(w, resp) 273} 274 275func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error { 276 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 277 if resolveErr != nil { 278 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 279 } 280 281 gr, err := git.Open(repoPath, line.Ref) 282 if err != nil { 283 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 284 } 285 286 meta, err := gr.RefUpdateMeta(line) 287 if err != nil { 288 return fmt.Errorf("failed to get ref update metadata: %w", err) 289 } 290 291 metaRecord := meta.AsRecord() 292 293 refUpdate := tangled.GitRefUpdate{ 294 OldSha: line.OldSha.String(), 295 NewSha: line.NewSha.String(), 296 Ref: line.Ref, 297 CommitterDid: gitUserDid, 298 OwnerDid: &ownerDid, 299 RepoName: repoName, 300 RepoDid: &repoDid, 301 Meta: &metaRecord, 302 } 303 304 eventJson, err := json.Marshal(refUpdate) 305 if err != nil { 306 return err 307 } 308 309 event := db.Event{ 310 Rkey: TID(), 311 Nsid: tangled.GitRefUpdateNSID, 312 EventJson: string(eventJson), 313 } 314 315 return h.db.InsertEvent(event, h.n) 316} 317 318func (h *InternalHandle) triggerPipeline( 319 clientMsgs *[]string, 320 line git.PostReceiveLine, 321 gitUserDid string, 322 ownerDid string, 323 repoName string, 324 repoDid string, 325 pushOptions PushOptions, 326) error { 327 if pushOptions.skipCi { 328 return nil 329 } 330 331 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 332 if resolveErr != nil { 333 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 334 } 335 336 gr, err := git.Open(repoPath, line.Ref) 337 if err != nil { 338 return err 339 } 340 341 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 342 if err != nil { 343 return err 344 } 345 346 var pipeline workflow.RawPipeline 347 for _, e := range workflowDir { 348 if !e.IsFile() { 349 continue 350 } 351 352 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 353 contents, err := gr.RawContent(fpath) 354 if err != nil { 355 continue 356 } 357 358 pipeline = append(pipeline, workflow.RawWorkflow{ 359 Name: e.Name, 360 Contents: contents, 361 }) 362 } 363 364 trigger := tangled.Pipeline_PushTriggerData{ 365 Ref: line.Ref, 366 OldSha: line.OldSha.String(), 367 NewSha: line.NewSha.String(), 368 } 369 370 triggerRepo := &tangled.Pipeline_TriggerRepo{ 371 Did: ownerDid, 372 Knot: h.c.Server.Hostname, 373 Repo: &repoName, 374 RepoDid: &repoDid, 375 } 376 377 compiler := workflow.Compiler{ 378 Trigger: tangled.Pipeline_TriggerMetadata{ 379 Kind: string(workflow.TriggerKindPush), 380 Push: &trigger, 381 Repo: triggerRepo, 382 }, 383 } 384 385 cp := compiler.Compile(compiler.Parse(pipeline)) 386 eventJson, err := json.Marshal(cp) 387 if err != nil { 388 return err 389 } 390 391 for _, e := range compiler.Diagnostics.Errors { 392 *clientMsgs = append(*clientMsgs, e.String()) 393 } 394 395 if pushOptions.verboseCi { 396 if compiler.Diagnostics.IsEmpty() { 397 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 398 } 399 400 for _, w := range compiler.Diagnostics.Warnings { 401 *clientMsgs = append(*clientMsgs, w.String()) 402 } 403 } 404 405 // do not run empty pipelines 406 if cp.Workflows == nil { 407 return nil 408 } 409 410 event := db.Event{ 411 Rkey: TID(), 412 Nsid: tangled.PipelineNSID, 413 EventJson: string(eventJson), 414 } 415 416 return h.db.InsertEvent(event, h.n) 417} 418 419func (h *InternalHandle) emitCompareLink( 420 clientMsgs *[]string, 421 line git.PostReceiveLine, 422 ownerDid string, 423 repoName string, 424 repoDid string, 425) error { 426 // this is a second push to a branch, don't reply with the link again 427 if !line.OldSha.IsZero() { 428 return nil 429 } 430 431 // the ref was not updated to a new hash, don't reply with the link 432 // 433 // NOTE: do we need this? 434 if line.NewSha.String() == line.OldSha.String() { 435 return nil 436 } 437 438 pushedRef := plumbing.ReferenceName(line.Ref) 439 440 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 441 user := ownerDid 442 if err == nil { 443 user = userIdent.Handle.String() 444 } 445 446 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 447 if resolveErr != nil { 448 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 449 } 450 451 gr, err := git.PlainOpen(repoPath) 452 if err != nil { 453 return err 454 } 455 456 defaultBranch, err := gr.FindMainBranch() 457 if err != nil { 458 return err 459 } 460 461 // pushing to default branch 462 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 463 return nil 464 } 465 466 // pushing a tag, don't prompt the user the open a PR 467 if pushedRef.IsTag() { 468 return nil 469 } 470 471 ZWS := "\u200B" 472 *clientMsgs = append(*clientMsgs, ZWS) 473 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 474 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 475 *clientMsgs = append(*clientMsgs, ZWS) 476 return nil 477} 478 479func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 480 r := chi.NewRouter() 481 l := log.FromContext(ctx) 482 l = log.SubLogger(l, "internal") 483 res := idresolver.DefaultResolver(c.Server.PlcUrl) 484 485 h := InternalHandle{ 486 db, 487 c, 488 e, 489 l, 490 n, 491 res, 492 } 493 494 r.Get("/push-allowed", h.PushAllowed) 495 r.Get("/keys", h.InternalKeys) 496 r.Get("/guard", h.Guard) 497 r.Post("/hooks/post-receive", h.PostReceiveHook) 498 r.Mount("/debug", middleware.Profiler()) 499 500 return r 501}