Monorepo for Tangled
at 62df028f2b0cc9f1d32515aed04d65473249c734 485 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 repoDid := gitRelativeDir 202 if !strings.HasPrefix(repoDid, "did:") { 203 l.Error("invalid git dir, expected repo DID", "gitRelativeDir", gitRelativeDir) 204 w.WriteHeader(http.StatusBadRequest) 205 return 206 } 207 208 ownerDid, repoName, err := h.db.GetRepoKeyOwner(repoDid) 209 if err != nil { 210 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err) 211 w.WriteHeader(http.StatusBadRequest) 212 return 213 } 214 215 gitUserDid := r.Header.Get("X-Git-User-Did") 216 217 lines, err := git.ParsePostReceive(r.Body) 218 if err != nil { 219 l.Error("failed to parse post-receive payload", "err", err) 220 // non-fatal 221 } 222 223 // extract any push options 224 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 225 pushOptions := PushOptions{} 226 for _, option := range pushOptionsRaw { 227 if option == "skip-ci" || option == "ci-skip" { 228 pushOptions.skipCi = true 229 } 230 if option == "verbose-ci" || option == "ci-verbose" { 231 pushOptions.verboseCi = true 232 } 233 } 234 235 resp := hook.HookResponse{ 236 Messages: make([]string, 0), 237 } 238 239 for _, line := range lines { 240 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid) 241 if err != nil { 242 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 243 } 244 245 err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName, repoDid) 246 if err != nil { 247 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 248 } 249 250 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions) 251 if err != nil { 252 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 253 } 254 } 255 256 writeJSON(w, resp) 257} 258 259func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error { 260 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 261 if resolveErr != nil { 262 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 263 } 264 265 gr, err := git.Open(repoPath, line.Ref) 266 if err != nil { 267 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 268 } 269 270 meta, err := gr.RefUpdateMeta(line) 271 if err != nil { 272 return fmt.Errorf("failed to get ref update metadata: %w", err) 273 } 274 275 metaRecord := meta.AsRecord() 276 277 refUpdate := tangled.GitRefUpdate{ 278 OldSha: line.OldSha.String(), 279 NewSha: line.NewSha.String(), 280 Ref: line.Ref, 281 CommitterDid: gitUserDid, 282 OwnerDid: ownerDid, 283 RepoName: repoName, 284 RepoDid: repoDid, 285 Meta: &metaRecord, 286 } 287 288 eventJson, err := json.Marshal(refUpdate) 289 if err != nil { 290 return err 291 } 292 293 event := db.Event{ 294 Rkey: TID(), 295 Nsid: tangled.GitRefUpdateNSID, 296 EventJson: string(eventJson), 297 } 298 299 return h.db.InsertEvent(event, h.n) 300} 301 302func (h *InternalHandle) triggerPipeline( 303 clientMsgs *[]string, 304 line git.PostReceiveLine, 305 gitUserDid string, 306 ownerDid string, 307 repoName string, 308 repoDid string, 309 pushOptions PushOptions, 310) error { 311 if pushOptions.skipCi { 312 return nil 313 } 314 315 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 316 if resolveErr != nil { 317 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 318 } 319 320 gr, err := git.Open(repoPath, line.Ref) 321 if err != nil { 322 return err 323 } 324 325 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 326 if err != nil { 327 return err 328 } 329 330 var pipeline workflow.RawPipeline 331 for _, e := range workflowDir { 332 if !e.IsFile() { 333 continue 334 } 335 336 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 337 contents, err := gr.RawContent(fpath) 338 if err != nil { 339 continue 340 } 341 342 pipeline = append(pipeline, workflow.RawWorkflow{ 343 Name: e.Name, 344 Contents: contents, 345 }) 346 } 347 348 trigger := tangled.Pipeline_PushTriggerData{ 349 Ref: line.Ref, 350 OldSha: line.OldSha.String(), 351 NewSha: line.NewSha.String(), 352 } 353 354 triggerRepo := &tangled.Pipeline_TriggerRepo{ 355 Did: ownerDid, 356 Knot: h.c.Server.Hostname, 357 Repo: repoName, 358 RepoDid: repoDid, 359 } 360 361 compiler := workflow.Compiler{ 362 Trigger: tangled.Pipeline_TriggerMetadata{ 363 Kind: string(workflow.TriggerKindPush), 364 Push: &trigger, 365 Repo: triggerRepo, 366 }, 367 } 368 369 cp := compiler.Compile(compiler.Parse(pipeline)) 370 eventJson, err := json.Marshal(cp) 371 if err != nil { 372 return err 373 } 374 375 for _, e := range compiler.Diagnostics.Errors { 376 *clientMsgs = append(*clientMsgs, e.String()) 377 } 378 379 if pushOptions.verboseCi { 380 if compiler.Diagnostics.IsEmpty() { 381 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 382 } 383 384 for _, w := range compiler.Diagnostics.Warnings { 385 *clientMsgs = append(*clientMsgs, w.String()) 386 } 387 } 388 389 // do not run empty pipelines 390 if cp.Workflows == nil { 391 return nil 392 } 393 394 event := db.Event{ 395 Rkey: TID(), 396 Nsid: tangled.PipelineNSID, 397 EventJson: string(eventJson), 398 } 399 400 return h.db.InsertEvent(event, h.n) 401} 402 403func (h *InternalHandle) emitCompareLink( 404 clientMsgs *[]string, 405 line git.PostReceiveLine, 406 ownerDid string, 407 repoName string, 408 repoDid string, 409) error { 410 // this is a second push to a branch, don't reply with the link again 411 if !line.OldSha.IsZero() { 412 return nil 413 } 414 415 // the ref was not updated to a new hash, don't reply with the link 416 // 417 // NOTE: do we need this? 418 if line.NewSha.String() == line.OldSha.String() { 419 return nil 420 } 421 422 pushedRef := plumbing.ReferenceName(line.Ref) 423 424 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 425 user := ownerDid 426 if err == nil { 427 user = userIdent.Handle.String() 428 } 429 430 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 431 if resolveErr != nil { 432 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 433 } 434 435 gr, err := git.PlainOpen(repoPath) 436 if err != nil { 437 return err 438 } 439 440 defaultBranch, err := gr.FindMainBranch() 441 if err != nil { 442 return err 443 } 444 445 // pushing to default branch 446 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 447 return nil 448 } 449 450 // pushing a tag, don't prompt the user the open a PR 451 if pushedRef.IsTag() { 452 return nil 453 } 454 455 ZWS := "\u200B" 456 *clientMsgs = append(*clientMsgs, ZWS) 457 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 458 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 459 *clientMsgs = append(*clientMsgs, ZWS) 460 return nil 461} 462 463func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 464 r := chi.NewRouter() 465 l := log.FromContext(ctx) 466 l = log.SubLogger(l, "internal") 467 res := idresolver.DefaultResolver(c.Server.PlcUrl) 468 469 h := InternalHandle{ 470 db, 471 c, 472 e, 473 l, 474 n, 475 res, 476 } 477 478 r.Get("/push-allowed", h.PushAllowed) 479 r.Get("/keys", h.InternalKeys) 480 r.Get("/guard", h.Guard) 481 r.Post("/hooks/post-receive", h.PostReceiveHook) 482 r.Mount("/debug", middleware.Profiler()) 483 484 return r 485}