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