Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

1package knotserver 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net" 9 "net/http" 10 "net/url" 11 "os" 12 "path" 13 "path/filepath" 14 "strings" 15 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/go-chi/chi/v5" 18 "github.com/go-chi/chi/v5/middleware" 19 "github.com/go-git/go-git/v5/plumbing" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/hook" 22 "tangled.org/core/idresolver" 23 "tangled.org/core/knotserver/config" 24 "tangled.org/core/knotserver/db" 25 "tangled.org/core/knotserver/git" 26 "tangled.org/core/log" 27 "tangled.org/core/notifier" 28 "tangled.org/core/rbac" 29 "tangled.org/core/tid" 30 "tangled.org/core/workflow" 31) 32 33type InternalHandle struct { 34 db *db.DB 35 c *config.Config 36 e *rbac.Enforcer 37 l *slog.Logger 38 n *notifier.Notifier 39 res *idresolver.Resolver 40} 41 42func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { 43 user := r.URL.Query().Get("user") 44 repo := r.URL.Query().Get("repo") 45 46 if user == "" || repo == "" { 47 w.WriteHeader(http.StatusBadRequest) 48 return 49 } 50 51 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 52 if err != nil || !ok { 53 w.WriteHeader(http.StatusForbidden) 54 return 55 } 56 57 w.WriteHeader(http.StatusNoContent) 58} 59 60func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { 61 keys, err := h.db.GetAllPublicKeys() 62 if err != nil { 63 writeError(w, err.Error(), http.StatusInternalServerError) 64 return 65 } 66 67 data := make([]map[string]interface{}, 0) 68 for _, key := range keys { 69 j := key.JSON() 70 data = append(data, j) 71 } 72 writeJSON(w, data) 73} 74 75// response in text/plain format 76// the body will be qualified repository path on success/push-denied 77// or an error message when process failed 78func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { 79 l := h.l.With("handler", "Guard") 80 81 var ( 82 incomingUser = r.URL.Query().Get("user") 83 repo = r.URL.Query().Get("repo") 84 gitCommand = r.URL.Query().Get("gitCmd") 85 ) 86 87 if incomingUser == "" || repo == "" || gitCommand == "" { 88 w.WriteHeader(http.StatusBadRequest) 89 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) 90 fmt.Fprintln(w, "invalid internal request") 91 return 92 } 93 94 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 95 l.Info("command components", "components", components) 96 97 var rbacResource string 98 var diskRelative string 99 100 switch { 101 case len(components) == 1 && strings.HasPrefix(components[0], "did:"): 102 repoDid := components[0] 103 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 104 if lookupErr != nil { 105 w.WriteHeader(http.StatusNotFound) 106 l.Error("repo DID not found", "repoDid", repoDid, "err", lookupErr) 107 fmt.Fprintln(w, "repo not found") 108 return 109 } 110 rbacResource = repoDid 111 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 112 if relErr != nil { 113 w.WriteHeader(http.StatusInternalServerError) 114 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 115 fmt.Fprintln(w, "internal error") 116 return 117 } 118 diskRelative = rel 119 120 case len(components) == 2: 121 repoOwner := components[0] 122 ownerIdent, resolveErr := h.res.ResolveAtIdentifier(r.Context(), repoOwner) 123 if resolveErr != nil { 124 l.Error("error resolving owner", "owner", repoOwner, "err", resolveErr) 125 w.WriteHeader(http.StatusInternalServerError) 126 fmt.Fprintf(w, "error resolving owner: invalid did or handle\n") 127 return 128 } 129 ownerDid := ownerIdent.DID 130 repoName := components[1] 131 repoDid, didErr := h.db.GetRepoDid(ownerDid.String(), repoName) 132 var repoPath string 133 if didErr == nil { 134 var lookupErr error 135 repoPath, _, _, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 136 if lookupErr != nil { 137 w.WriteHeader(http.StatusNotFound) 138 l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr) 139 fmt.Fprintln(w, "repo not found") 140 return 141 } 142 rbacResource = repoDid 143 } else { 144 legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid.String(), repoName)) 145 if joinErr != nil { 146 w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 147 w.WriteHeader(http.StatusNotFound) 148 fmt.Fprint(w, "repo not found\n") 149 return 150 } 151 if _, statErr := os.Stat(legacyPath); statErr != nil { 152 l.Info("legacy repo path missing, checking rename history", "owner", ownerDid, "name", repoName) 153 w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 154 w.WriteHeader(http.StatusNotFound) 155 fmt.Fprint(w, "repo not found\n") 156 return 157 } 158 repoPath = legacyPath 159 rbacResource = ownerDid.String() + "/" + repoName 160 } 161 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 162 if relErr != nil { 163 w.WriteHeader(http.StatusInternalServerError) 164 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 165 fmt.Fprintln(w, "internal error") 166 return 167 } 168 diskRelative = rel 169 170 default: 171 w.WriteHeader(http.StatusBadRequest) 172 l.Error("invalid repo format", "components", components) 173 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>") 174 return 175 } 176 177 if gitCommand == "git-receive-pack" { 178 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource) 179 if err != nil || !ok { 180 w.WriteHeader(http.StatusForbidden) 181 fmt.Fprint(w, repo) 182 return 183 } 184 } 185 186 w.WriteHeader(http.StatusOK) 187 fmt.Fprint(w, diskRelative) 188} 189 190type PushOptions struct { 191 skipCi bool 192 verboseCi bool 193} 194 195func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 196 l := h.l.With("handler", "PostReceiveHook") 197 198 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 199 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 200 if err != nil { 201 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 202 w.WriteHeader(http.StatusInternalServerError) 203 return 204 } 205 206 var repoDid string 207 var ownerDid, repoName string 208 209 if strings.HasPrefix(gitRelativeDir, "did:") { 210 repoDid = gitRelativeDir 211 var err error 212 ownerDid, repoName, err = h.db.GetRepoKeyOwner(repoDid) 213 if err != nil { 214 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err) 215 w.WriteHeader(http.StatusBadRequest) 216 return 217 } 218 } else { 219 components := strings.SplitN(gitRelativeDir, "/", 2) 220 if len(components) != 2 { 221 l.Error("invalid git dir, expected repo DID or owner/repo", "gitRelativeDir", gitRelativeDir) 222 w.WriteHeader(http.StatusBadRequest) 223 return 224 } 225 ownerDid = components[0] 226 repoName = components[1] 227 var didErr error 228 repoDid, didErr = h.db.GetRepoDid(ownerDid, repoName) 229 if didErr != nil { 230 l.Error("failed to resolve repo DID from legacy path", "gitRelativeDir", gitRelativeDir, "err", didErr) 231 w.WriteHeader(http.StatusBadRequest) 232 return 233 } 234 } 235 236 gitUserDid := r.Header.Get("X-Git-User-Did") 237 238 lines, err := git.ParsePostReceive(r.Body) 239 if err != nil { 240 l.Error("failed to parse post-receive payload", "err", err) 241 // non-fatal 242 } 243 244 // extract any push options 245 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 246 pushOptions := PushOptions{} 247 for _, option := range pushOptionsRaw { 248 if option == "skip-ci" || option == "ci-skip" { 249 pushOptions.skipCi = true 250 } 251 if option == "verbose-ci" || option == "ci-verbose" { 252 pushOptions.verboseCi = true 253 } 254 } 255 256 resp := hook.HookResponse{ 257 Messages: make([]string, 0), 258 } 259 260 for _, line := range lines { 261 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid) 262 if err != nil { 263 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 264 } 265 266 err = h.emitPullRequestLink(&resp.Messages, line, ownerDid, repoName, repoDid) 267 if err != nil { 268 l.Error("failed to reply with pull request link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 269 } 270 271 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions) 272 if err != nil { 273 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 274 } 275 } 276 277 writeJSON(w, resp) 278} 279 280func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string) error { 281 refUpdate := tangled.GitRefUpdate{ 282 OldSha: line.OldSha.String(), 283 NewSha: line.NewSha.String(), 284 Ref: line.Ref, 285 CommitterDid: gitUserDid, 286 OwnerDid: &ownerDid, 287 Repo: repoDid, 288 Meta: nil, 289 } 290 291 if !line.NewSha.IsZero() { 292 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 293 if resolveErr != nil { 294 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 295 } 296 297 gr, err := git.Open(repoPath, line.Ref) 298 if err != nil { 299 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 300 } 301 302 meta, err := gr.RefUpdateMeta(line) 303 if err != nil { 304 return fmt.Errorf("failed to get ref update metadata: %w", err) 305 } 306 307 refUpdate.Meta = new(tangled.GitRefUpdate_Meta) 308 *refUpdate.Meta = meta.AsRecord() 309 } 310 311 eventJson, err := json.Marshal(refUpdate) 312 if err != nil { 313 return err 314 } 315 316 event := db.Event{ 317 Rkey: tid.TID(), 318 Nsid: tangled.GitRefUpdateNSID, 319 EventJson: string(eventJson), 320 } 321 322 return h.db.InsertEvent(event, h.n) 323} 324 325func (h *InternalHandle) triggerPipeline( 326 clientMsgs *[]string, 327 line git.PostReceiveLine, 328 gitUserDid string, 329 ownerDid string, 330 repoName string, 331 repoDid string, 332 pushOptions PushOptions, 333) error { 334 if pushOptions.skipCi { 335 return nil 336 } 337 338 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 339 if resolveErr != nil { 340 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 341 } 342 343 gr, err := git.Open(repoPath, line.Ref) 344 if err != nil { 345 return err 346 } 347 348 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 349 if err != nil { 350 return err 351 } 352 353 var pipeline workflow.RawPipeline 354 for _, e := range workflowDir { 355 if !e.IsFile() { 356 continue 357 } 358 359 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 360 contents, err := gr.RawContent(fpath) 361 if err != nil { 362 continue 363 } 364 365 pipeline = append(pipeline, workflow.RawWorkflow{ 366 Name: e.Name, 367 Contents: contents, 368 }) 369 } 370 371 defaultBranch, _ := gr.FindMainBranch() 372 373 trigger := tangled.Pipeline_PushTriggerData{ 374 Ref: line.Ref, 375 OldSha: line.OldSha.String(), 376 NewSha: line.NewSha.String(), 377 } 378 379 triggerRepo := &tangled.Pipeline_TriggerRepo{ 380 Did: ownerDid, 381 Knot: h.c.Server.Hostname, 382 Repo: &repoName, 383 RepoDid: &repoDid, 384 DefaultBranch: defaultBranch, 385 } 386 387 compiler := workflow.Compiler{ 388 Trigger: tangled.Pipeline_TriggerMetadata{ 389 Kind: string(workflow.TriggerKindPush), 390 Push: &trigger, 391 Repo: triggerRepo, 392 }, 393 } 394 395 cp := compiler.Compile(compiler.Parse(pipeline)) 396 eventJson, err := json.Marshal(cp) 397 if err != nil { 398 return err 399 } 400 401 for _, e := range compiler.Diagnostics.Errors { 402 *clientMsgs = append(*clientMsgs, e.String()) 403 } 404 405 if pushOptions.verboseCi { 406 if compiler.Diagnostics.IsEmpty() { 407 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 408 } 409 410 for _, w := range compiler.Diagnostics.Warnings { 411 *clientMsgs = append(*clientMsgs, w.String()) 412 } 413 } 414 415 // do not run empty pipelines 416 if cp.Workflows == nil { 417 return nil 418 } 419 420 event := db.Event{ 421 Rkey: tid.TID(), 422 Nsid: tangled.PipelineNSID, 423 EventJson: string(eventJson), 424 } 425 426 if h.c.LogsAddr != "" { 427 host, port, err := net.SplitHostPort(h.c.LogsAddr) 428 if err == nil { 429 *clientMsgs = append(*clientMsgs, "→ Browse CI logs in your terminal:") 430 *clientMsgs = append(*clientMsgs, fmt.Sprintf(" ssh -t -p %s %s %s %s", port, host, repoDid, line.NewSha)) 431 } 432 } 433 434 return h.db.InsertEvent(event, h.n) 435} 436 437func (h *InternalHandle) emitPullRequestLink( 438 clientMsgs *[]string, 439 line git.PostReceiveLine, 440 ownerDid string, 441 repoName string, 442 repoDid string, 443) error { 444 if line.NewSha.IsZero() { 445 return nil 446 } 447 448 // the ref was not updated to a new hash, don't reply with the link 449 // 450 // NOTE: do we need this? 451 if line.NewSha == line.OldSha { 452 return nil 453 } 454 455 pushedRef := plumbing.ReferenceName(line.Ref) 456 if !pushedRef.IsBranch() { 457 return nil 458 } 459 460 if !line.OldSha.IsZero() { 461 return nil 462 } 463 464 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 465 if resolveErr != nil { 466 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 467 } 468 469 gr, err := git.PlainOpen(repoPath) 470 if err != nil { 471 return err 472 } 473 474 remote, err := gr.Remote() 475 if err != nil { 476 return fmt.Errorf("checking for upstream remote: %w", err) 477 } 478 479 defaultBranch, err := gr.FindMainBranch() 480 if err != nil { 481 return err 482 } 483 484 pushedBranch := pushedRef.Short() 485 486 // pushing to default branch 487 if pushedBranch == defaultBranch { 488 return nil 489 } 490 491 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 492 user := ownerDid 493 if err == nil { 494 user = userIdent.Handle.String() 495 } 496 497 pullURL, err := h.createPullURL(h.c.AppViewEndpoint, remote, user, ownerDid, repoName, pushedBranch, defaultBranch) 498 if err != nil { 499 return err 500 } 501 502 ZWS := "\u200B" 503 *clientMsgs = append(*clientMsgs, ZWS) 504 *clientMsgs = append(*clientMsgs, "→ Open pull request:") 505 *clientMsgs = append(*clientMsgs, " "+pullURL) 506 *clientMsgs = append(*clientMsgs, ZWS) 507 return nil 508} 509 510func (h *InternalHandle) createPullURL(appviewURL, remote, user, ownerDID, repoName, pushedBranch, defaultBranch string) (string, error) { 511 if remote != "" { 512 return h.createForkPullURL(appviewURL, remote, ownerDID, repoName, pushedBranch, defaultBranch) 513 } 514 515 query := url.Values{} 516 517 query.Set("source", "branch") 518 query.Set("sourceBranch", pushedBranch) 519 query.Set("targetBranch", defaultBranch) 520 521 basePath, err := url.JoinPath(appviewURL, user, repoName, "pulls", "new") 522 if err != nil { 523 return "", err 524 } 525 pullURL := basePath + "?" + query.Encode() 526 return pullURL, nil 527} 528 529func (h *InternalHandle) createForkPullURL(appviewURL, remote, ownerDID, repoName, pushedBranch, defaultBranch string) (string, error) { 530 query := url.Values{} 531 532 query.Set("fork", fmt.Sprintf("%s/%s", ownerDID, repoName)) 533 query.Set("source", "fork") 534 query.Set("sourceBranch", pushedBranch) 535 query.Set("targetBranch", defaultBranch) 536 537 repoPath, err := h.getRemoteOwnerRepoNamePath(remote) 538 if err != nil { 539 return "", err 540 } 541 542 basePath, err := url.JoinPath(appviewURL, repoPath, "pulls", "new") 543 if err != nil { 544 return "", err 545 } 546 pullURL := basePath + "?" + query.Encode() 547 return pullURL, nil 548} 549 550func (h *InternalHandle) getRemoteOwnerRepoNamePath(remote string) (string, error) { 551 u, err := url.Parse(remote) 552 if err != nil { 553 return "", fmt.Errorf("invalid remote: %w", err) 554 } 555 556 if u.Scheme != "file" { 557 return u.Path, nil 558 } 559 560 repoDid := path.Base(u.String()) 561 562 owner, name, err := h.db.GetRepoKeyOwner(repoDid) 563 if err != nil { 564 return "", err 565 } 566 567 return fmt.Sprintf("%s/%s", owner, name), nil 568} 569 570func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier, res *idresolver.Resolver) http.Handler { 571 r := chi.NewRouter() 572 l := log.FromContext(ctx) 573 l = log.SubLogger(l, "internal") 574 575 h := InternalHandle{ 576 db: db, 577 c: c, 578 e: e, 579 l: l, 580 n: n, 581 res: res, 582 } 583 584 r.Get("/push-allowed", h.PushAllowed) 585 r.Get("/keys", h.InternalKeys) 586 r.Get("/guard", h.Guard) 587 r.Post("/hooks/post-receive", h.PostReceiveHook) 588 r.Mount("/debug", middleware.Profiler()) 589 590 return r 591}