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