forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 "math/rand/v2" 10 "net/http" 11 "path" 12 "slices" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/bluesky-social/indigo/atproto/identity" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 securejoin "github.com/cyphar/filepath-securejoin" 20 "github.com/go-chi/chi/v5" 21 "github.com/sotangled/tangled/api/tangled" 22 "github.com/sotangled/tangled/appview/auth" 23 "github.com/sotangled/tangled/appview/db" 24 "github.com/sotangled/tangled/appview/pages" 25 "github.com/sotangled/tangled/types" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 lexutil "github.com/bluesky-social/indigo/lex/util" 29) 30 31func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 32 ref := chi.URLParam(r, "ref") 33 f, err := fullyResolvedRepo(r) 34 if err != nil { 35 log.Println("failed to fully resolve repo", err) 36 return 37 } 38 39 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 40 if err != nil { 41 log.Printf("failed to create unsigned client for %s", f.Knot) 42 s.pages.Error503(w) 43 return 44 } 45 46 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref) 47 if err != nil { 48 s.pages.Error503(w) 49 log.Println("failed to reach knotserver", err) 50 return 51 } 52 defer resp.Body.Close() 53 54 body, err := io.ReadAll(resp.Body) 55 if err != nil { 56 log.Printf("Error reading response body: %v", err) 57 return 58 } 59 60 var result types.RepoIndexResponse 61 err = json.Unmarshal(body, &result) 62 if err != nil { 63 log.Printf("Error unmarshalling response body: %v", err) 64 return 65 } 66 67 tagMap := make(map[string][]string) 68 for _, tag := range result.Tags { 69 hash := tag.Hash 70 tagMap[hash] = append(tagMap[hash], tag.Name) 71 } 72 73 for _, branch := range result.Branches { 74 hash := branch.Hash 75 tagMap[hash] = append(tagMap[hash], branch.Name) 76 } 77 78 user := s.auth.GetUser(r) 79 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 80 LoggedInUser: user, 81 RepoInfo: f.RepoInfo(s, user), 82 TagMap: tagMap, 83 RepoIndexResponse: result, 84 }) 85 86 return 87} 88 89func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 90 f, err := fullyResolvedRepo(r) 91 if err != nil { 92 log.Println("failed to fully resolve repo", err) 93 return 94 } 95 96 page := 1 97 if r.URL.Query().Get("page") != "" { 98 page, err = strconv.Atoi(r.URL.Query().Get("page")) 99 if err != nil { 100 page = 1 101 } 102 } 103 104 ref := chi.URLParam(r, "ref") 105 106 protocol := "http" 107 if !s.config.Dev { 108 protocol = "https" 109 } 110 111 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/log/%s?page=%d&per_page=30", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, page)) 112 if err != nil { 113 log.Println("failed to reach knotserver", err) 114 return 115 } 116 117 body, err := io.ReadAll(resp.Body) 118 if err != nil { 119 log.Printf("error reading response body: %v", err) 120 return 121 } 122 123 var repolog types.RepoLogResponse 124 err = json.Unmarshal(body, &repolog) 125 if err != nil { 126 log.Println("failed to parse json response", err) 127 return 128 } 129 130 user := s.auth.GetUser(r) 131 s.pages.RepoLog(w, pages.RepoLogParams{ 132 LoggedInUser: user, 133 RepoInfo: f.RepoInfo(s, user), 134 RepoLogResponse: repolog, 135 }) 136 return 137} 138 139func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 140 f, err := fullyResolvedRepo(r) 141 if err != nil { 142 log.Println("failed to get repo and knot", err) 143 w.WriteHeader(http.StatusBadRequest) 144 return 145 } 146 147 user := s.auth.GetUser(r) 148 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 149 RepoInfo: f.RepoInfo(s, user), 150 }) 151 return 152} 153 154func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 155 f, err := fullyResolvedRepo(r) 156 if err != nil { 157 log.Println("failed to get repo and knot", err) 158 w.WriteHeader(http.StatusBadRequest) 159 return 160 } 161 162 repoAt := f.RepoAt 163 rkey := repoAt.RecordKey().String() 164 if rkey == "" { 165 log.Println("invalid aturi for repo", err) 166 w.WriteHeader(http.StatusInternalServerError) 167 return 168 } 169 170 user := s.auth.GetUser(r) 171 172 switch r.Method { 173 case http.MethodGet: 174 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 175 RepoInfo: f.RepoInfo(s, user), 176 }) 177 return 178 case http.MethodPut: 179 user := s.auth.GetUser(r) 180 newDescription := r.FormValue("description") 181 client, _ := s.auth.AuthorizedClient(r) 182 183 // optimistic update 184 err = db.UpdateDescription(s.db, string(repoAt), newDescription) 185 if err != nil { 186 log.Println("failed to perferom update-description query", err) 187 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 188 return 189 } 190 191 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 192 // 193 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 194 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey) 195 if err != nil { 196 // failed to get record 197 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 198 return 199 } 200 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 201 Collection: tangled.RepoNSID, 202 Repo: user.Did, 203 Rkey: rkey, 204 SwapRecord: ex.Cid, 205 Record: &lexutil.LexiconTypeDecoder{ 206 Val: &tangled.Repo{ 207 Knot: f.Knot, 208 Name: f.RepoName, 209 Owner: user.Did, 210 AddedAt: &f.AddedAt, 211 Description: &newDescription, 212 }, 213 }, 214 }) 215 216 if err != nil { 217 log.Println("failed to perferom update-description query", err) 218 // failed to get record 219 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 220 return 221 } 222 223 newRepoInfo := f.RepoInfo(s, user) 224 newRepoInfo.Description = newDescription 225 226 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 227 RepoInfo: newRepoInfo, 228 }) 229 return 230 } 231} 232 233func (s *State) EditPatch(w http.ResponseWriter, r *http.Request) { 234 user := s.auth.GetUser(r) 235 236 patch := r.FormValue("patch") 237 if patch == "" { 238 s.pages.Notice(w, "pull-error", "Patch is required.") 239 return 240 } 241 242 pull, ok := r.Context().Value("pull").(*db.Pull) 243 if !ok { 244 log.Println("failed to get pull") 245 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 246 return 247 } 248 249 if pull.OwnerDid != user.Did { 250 log.Println("failed to edit pull information") 251 s.pages.Notice(w, "pull-error", "Unauthorized") 252 return 253 } 254 255 f, err := fullyResolvedRepo(r) 256 if err != nil { 257 log.Println("failed to get repo and knot", err) 258 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 259 return 260 } 261 262 // Start a transaction for database operations 263 tx, err := s.db.BeginTx(r.Context(), nil) 264 if err != nil { 265 log.Println("failed to start transaction", err) 266 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 267 return 268 } 269 270 // Set up deferred rollback that will be overridden by commit if successful 271 defer tx.Rollback() 272 273 // Update patch in the database within transaction 274 err = db.EditPatch(tx, f.RepoAt, pull.PullId, patch) 275 if err != nil { 276 log.Println("failed to update patch", err) 277 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 278 return 279 } 280 281 // Update the atproto record 282 client, _ := s.auth.AuthorizedClient(r) 283 pullAt := pull.PullAt 284 285 // Get the existing record first 286 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pullAt.RecordKey().String()) 287 if err != nil { 288 log.Println("failed to get existing pull record", err) 289 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 290 return 291 } 292 293 // Update the record 294 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 295 Collection: tangled.RepoPullNSID, 296 Repo: user.Did, 297 Rkey: pullAt.RecordKey().String(), 298 SwapRecord: ex.Cid, 299 Record: &lexutil.LexiconTypeDecoder{ 300 Val: &tangled.RepoPull{ 301 Title: pull.Title, 302 PullId: int64(pull.PullId), 303 TargetRepo: string(f.RepoAt), 304 TargetBranch: pull.TargetBranch, 305 Patch: patch, 306 }, 307 }, 308 }) 309 310 if err != nil { 311 log.Println("failed to update pull record in atproto", err) 312 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 313 return 314 } 315 316 // Commit the transaction now that both operations have succeeded 317 err = tx.Commit() 318 if err != nil { 319 log.Println("failed to commit transaction", err) 320 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 321 return 322 } 323 324 targetBranch := pull.TargetBranch 325 326 // Perform merge check 327 secret, err := db.GetRegistrationKey(s.db, f.Knot) 328 if err != nil { 329 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 330 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 331 return 332 } 333 334 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 335 if err != nil { 336 log.Printf("failed to create signed client for %s", f.Knot) 337 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 338 return 339 } 340 341 resp, err := ksClient.MergeCheck([]byte(patch), user.Did, f.RepoName, targetBranch) 342 if err != nil { 343 log.Println("failed to check mergeability", err) 344 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 345 return 346 } 347 348 respBody, err := io.ReadAll(resp.Body) 349 if err != nil { 350 log.Println("failed to read knotserver response body") 351 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 352 return 353 } 354 355 var mergeCheckResponse types.MergeCheckResponse 356 err = json.Unmarshal(respBody, &mergeCheckResponse) 357 if err != nil { 358 log.Println("failed to unmarshal merge check response", err) 359 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 360 return 361 } 362 363 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 364 return 365} 366 367func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 368 user := s.auth.GetUser(r) 369 f, err := fullyResolvedRepo(r) 370 if err != nil { 371 log.Println("failed to get repo and knot", err) 372 return 373 } 374 375 switch r.Method { 376 case http.MethodGet: 377 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 378 if err != nil { 379 log.Printf("failed to create unsigned client for %s", f.Knot) 380 s.pages.Error503(w) 381 return 382 } 383 384 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 385 if err != nil { 386 log.Println("failed to reach knotserver", err) 387 return 388 } 389 390 body, err := io.ReadAll(resp.Body) 391 if err != nil { 392 log.Printf("Error reading response body: %v", err) 393 return 394 } 395 396 var result types.RepoBranchesResponse 397 err = json.Unmarshal(body, &result) 398 if err != nil { 399 log.Println("failed to parse response:", err) 400 return 401 } 402 403 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 404 LoggedInUser: user, 405 RepoInfo: f.RepoInfo(s, user), 406 Branches: result.Branches, 407 }) 408 case http.MethodPost: 409 title := r.FormValue("title") 410 body := r.FormValue("body") 411 targetBranch := r.FormValue("targetBranch") 412 patch := r.FormValue("patch") 413 414 if title == "" || body == "" || patch == "" || targetBranch == "" { 415 s.pages.Notice(w, "pull", "Title, body and patch diff are required.") 416 return 417 } 418 419 tx, err := s.db.BeginTx(r.Context(), nil) 420 if err != nil { 421 log.Println("failed to start tx") 422 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 423 return 424 } 425 426 defer func() { 427 tx.Rollback() 428 err = s.enforcer.E.LoadPolicy() 429 if err != nil { 430 log.Println("failed to rollback policies") 431 } 432 }() 433 434 err = db.NewPull(tx, &db.Pull{ 435 Title: title, 436 Body: body, 437 TargetBranch: targetBranch, 438 Patch: patch, 439 OwnerDid: user.Did, 440 RepoAt: f.RepoAt, 441 }) 442 if err != nil { 443 log.Println("failed to create pull request", err) 444 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 445 return 446 } 447 client, _ := s.auth.AuthorizedClient(r) 448 pullId, err := db.NextPullId(s.db, f.RepoAt) 449 if err != nil { 450 log.Println("failed to get pull id", err) 451 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 452 return 453 } 454 455 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 456 Collection: tangled.RepoPullNSID, 457 Repo: user.Did, 458 Rkey: s.TID(), 459 Record: &lexutil.LexiconTypeDecoder{ 460 Val: &tangled.RepoPull{ 461 Title: title, 462 PullId: int64(pullId), 463 TargetRepo: string(f.RepoAt), 464 TargetBranch: targetBranch, 465 Patch: patch, 466 }, 467 }, 468 }) 469 470 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 471 if err != nil { 472 log.Println("failed to get pull id", err) 473 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 474 return 475 } 476 477 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 478 return 479 } 480} 481 482func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 483 user := s.auth.GetUser(r) 484 f, err := fullyResolvedRepo(r) 485 if err != nil { 486 log.Println("failed to get repo and knot", err) 487 return 488 } 489 490 pull, ok1 := r.Context().Value("pull").(*db.Pull) 491 comments, ok2 := r.Context().Value("pull_comments").([]db.PullComment) 492 if !ok1 || !ok2 { 493 log.Println("failed to get pull") 494 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 495 return 496 } 497 498 identsToResolve := make([]string, len(comments)) 499 for i, comment := range comments { 500 identsToResolve[i] = comment.OwnerDid 501 } 502 identsToResolve = append(identsToResolve, pull.OwnerDid) 503 504 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 505 didHandleMap := make(map[string]string) 506 for _, identity := range resolvedIds { 507 if !identity.Handle.IsInvalidHandle() { 508 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 509 } else { 510 didHandleMap[identity.DID.String()] = identity.DID.String() 511 } 512 } 513 514 var mergeCheckResponse types.MergeCheckResponse 515 516 // Only perform merge check if the pull request is not already merged 517 if pull.State != db.PullMerged { 518 secret, err := db.GetRegistrationKey(s.db, f.Knot) 519 if err != nil { 520 log.Printf("failed to get registration key for %s", f.Knot) 521 s.pages.Notice(w, "pull", "Failed to load pull request. Try again later.") 522 return 523 } 524 525 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 526 if err == nil { 527 resp, err := ksClient.MergeCheck([]byte(pull.Patch), pull.OwnerDid, f.RepoName, pull.TargetBranch) 528 if err != nil { 529 log.Println("failed to check for mergeability:", err) 530 } else { 531 respBody, err := io.ReadAll(resp.Body) 532 if err != nil { 533 log.Println("failed to read merge check response body") 534 } else { 535 err = json.Unmarshal(respBody, &mergeCheckResponse) 536 if err != nil { 537 log.Println("failed to unmarshal merge check response", err) 538 } 539 } 540 } 541 } else { 542 log.Printf("failed to setup signed client for %s; ignoring...", f.Knot) 543 } 544 } 545 546 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 547 LoggedInUser: user, 548 RepoInfo: f.RepoInfo(s, user), 549 Pull: *pull, 550 Comments: comments, 551 DidHandleMap: didHandleMap, 552 MergeCheck: mergeCheckResponse, 553 }) 554} 555 556func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 557 f, err := fullyResolvedRepo(r) 558 if err != nil { 559 log.Println("failed to fully resolve repo", err) 560 return 561 } 562 ref := chi.URLParam(r, "ref") 563 protocol := "http" 564 if !s.config.Dev { 565 protocol = "https" 566 } 567 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 568 if err != nil { 569 log.Println("failed to reach knotserver", err) 570 return 571 } 572 573 body, err := io.ReadAll(resp.Body) 574 if err != nil { 575 log.Printf("Error reading response body: %v", err) 576 return 577 } 578 579 var result types.RepoCommitResponse 580 err = json.Unmarshal(body, &result) 581 if err != nil { 582 log.Println("failed to parse response:", err) 583 return 584 } 585 586 user := s.auth.GetUser(r) 587 s.pages.RepoCommit(w, pages.RepoCommitParams{ 588 LoggedInUser: user, 589 RepoInfo: f.RepoInfo(s, user), 590 RepoCommitResponse: result, 591 }) 592 return 593} 594 595func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 596 f, err := fullyResolvedRepo(r) 597 if err != nil { 598 log.Println("failed to fully resolve repo", err) 599 return 600 } 601 602 ref := chi.URLParam(r, "ref") 603 treePath := chi.URLParam(r, "*") 604 protocol := "http" 605 if !s.config.Dev { 606 protocol = "https" 607 } 608 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 609 if err != nil { 610 log.Println("failed to reach knotserver", err) 611 return 612 } 613 614 body, err := io.ReadAll(resp.Body) 615 if err != nil { 616 log.Printf("Error reading response body: %v", err) 617 return 618 } 619 620 var result types.RepoTreeResponse 621 err = json.Unmarshal(body, &result) 622 if err != nil { 623 log.Println("failed to parse response:", err) 624 return 625 } 626 627 user := s.auth.GetUser(r) 628 629 var breadcrumbs [][]string 630 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 631 if treePath != "" { 632 for idx, elem := range strings.Split(treePath, "/") { 633 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 634 } 635 } 636 637 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath) 638 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath) 639 640 s.pages.RepoTree(w, pages.RepoTreeParams{ 641 LoggedInUser: user, 642 BreadCrumbs: breadcrumbs, 643 BaseTreeLink: baseTreeLink, 644 BaseBlobLink: baseBlobLink, 645 RepoInfo: f.RepoInfo(s, user), 646 RepoTreeResponse: result, 647 }) 648 return 649} 650 651func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 652 f, err := fullyResolvedRepo(r) 653 if err != nil { 654 log.Println("failed to get repo and knot", err) 655 return 656 } 657 658 protocol := "http" 659 if !s.config.Dev { 660 protocol = "https" 661 } 662 663 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName)) 664 if err != nil { 665 log.Println("failed to reach knotserver", err) 666 return 667 } 668 669 body, err := io.ReadAll(resp.Body) 670 if err != nil { 671 log.Printf("Error reading response body: %v", err) 672 return 673 } 674 675 var result types.RepoTagsResponse 676 err = json.Unmarshal(body, &result) 677 if err != nil { 678 log.Println("failed to parse response:", err) 679 return 680 } 681 682 user := s.auth.GetUser(r) 683 s.pages.RepoTags(w, pages.RepoTagsParams{ 684 LoggedInUser: user, 685 RepoInfo: f.RepoInfo(s, user), 686 RepoTagsResponse: result, 687 }) 688 return 689} 690 691func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 692 f, err := fullyResolvedRepo(r) 693 if err != nil { 694 log.Println("failed to get repo and knot", err) 695 return 696 } 697 698 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 699 if err != nil { 700 log.Println("failed to create unsigned client", err) 701 return 702 } 703 704 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 705 if err != nil { 706 log.Println("failed to reach knotserver", err) 707 return 708 } 709 710 body, err := io.ReadAll(resp.Body) 711 if err != nil { 712 log.Printf("Error reading response body: %v", err) 713 return 714 } 715 716 var result types.RepoBranchesResponse 717 err = json.Unmarshal(body, &result) 718 if err != nil { 719 log.Println("failed to parse response:", err) 720 return 721 } 722 723 user := s.auth.GetUser(r) 724 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 725 LoggedInUser: user, 726 RepoInfo: f.RepoInfo(s, user), 727 RepoBranchesResponse: result, 728 }) 729 return 730} 731 732func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 733 f, err := fullyResolvedRepo(r) 734 if err != nil { 735 log.Println("failed to get repo and knot", err) 736 return 737 } 738 739 ref := chi.URLParam(r, "ref") 740 filePath := chi.URLParam(r, "*") 741 protocol := "http" 742 if !s.config.Dev { 743 protocol = "https" 744 } 745 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 746 if err != nil { 747 log.Println("failed to reach knotserver", err) 748 return 749 } 750 751 body, err := io.ReadAll(resp.Body) 752 if err != nil { 753 log.Printf("Error reading response body: %v", err) 754 return 755 } 756 757 var result types.RepoBlobResponse 758 err = json.Unmarshal(body, &result) 759 if err != nil { 760 log.Println("failed to parse response:", err) 761 return 762 } 763 764 var breadcrumbs [][]string 765 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 766 if filePath != "" { 767 for idx, elem := range strings.Split(filePath, "/") { 768 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 769 } 770 } 771 772 user := s.auth.GetUser(r) 773 s.pages.RepoBlob(w, pages.RepoBlobParams{ 774 LoggedInUser: user, 775 RepoInfo: f.RepoInfo(s, user), 776 RepoBlobResponse: result, 777 BreadCrumbs: breadcrumbs, 778 }) 779 return 780} 781 782func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 783 f, err := fullyResolvedRepo(r) 784 if err != nil { 785 log.Println("failed to get repo and knot", err) 786 return 787 } 788 789 collaborator := r.FormValue("collaborator") 790 if collaborator == "" { 791 http.Error(w, "malformed form", http.StatusBadRequest) 792 return 793 } 794 795 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 796 if err != nil { 797 w.Write([]byte("failed to resolve collaborator did to a handle")) 798 return 799 } 800 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 801 802 // TODO: create an atproto record for this 803 804 secret, err := db.GetRegistrationKey(s.db, f.Knot) 805 if err != nil { 806 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 807 return 808 } 809 810 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 811 if err != nil { 812 log.Println("failed to create client to ", f.Knot) 813 return 814 } 815 816 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 817 if err != nil { 818 log.Printf("failed to make request to %s: %s", f.Knot, err) 819 return 820 } 821 822 if ksResp.StatusCode != http.StatusNoContent { 823 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 824 return 825 } 826 827 tx, err := s.db.BeginTx(r.Context(), nil) 828 if err != nil { 829 log.Println("failed to start tx") 830 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 831 return 832 } 833 defer func() { 834 tx.Rollback() 835 err = s.enforcer.E.LoadPolicy() 836 if err != nil { 837 log.Println("failed to rollback policies") 838 } 839 }() 840 841 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 842 if err != nil { 843 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 844 return 845 } 846 847 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 848 if err != nil { 849 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 850 return 851 } 852 853 err = tx.Commit() 854 if err != nil { 855 log.Println("failed to commit changes", err) 856 http.Error(w, err.Error(), http.StatusInternalServerError) 857 return 858 } 859 860 err = s.enforcer.E.SavePolicy() 861 if err != nil { 862 log.Println("failed to update ACLs", err) 863 http.Error(w, err.Error(), http.StatusInternalServerError) 864 return 865 } 866 867 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 868 869} 870 871func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 872 f, err := fullyResolvedRepo(r) 873 if err != nil { 874 log.Println("failed to get repo and knot", err) 875 return 876 } 877 878 switch r.Method { 879 case http.MethodGet: 880 // for now, this is just pubkeys 881 user := s.auth.GetUser(r) 882 repoCollaborators, err := f.Collaborators(r.Context(), s) 883 if err != nil { 884 log.Println("failed to get collaborators", err) 885 } 886 887 isCollaboratorInviteAllowed := false 888 if user != nil { 889 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 890 if err == nil && ok { 891 isCollaboratorInviteAllowed = true 892 } 893 } 894 895 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 896 LoggedInUser: user, 897 RepoInfo: f.RepoInfo(s, user), 898 Collaborators: repoCollaborators, 899 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 900 }) 901 } 902} 903 904type FullyResolvedRepo struct { 905 Knot string 906 OwnerId identity.Identity 907 RepoName string 908 RepoAt syntax.ATURI 909 Description string 910 AddedAt string 911} 912 913func (f *FullyResolvedRepo) OwnerDid() string { 914 return f.OwnerId.DID.String() 915} 916 917func (f *FullyResolvedRepo) OwnerHandle() string { 918 return f.OwnerId.Handle.String() 919} 920 921func (f *FullyResolvedRepo) OwnerSlashRepo() string { 922 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 923 return p 924} 925 926func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 927 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 928 if err != nil { 929 return nil, err 930 } 931 932 var collaborators []pages.Collaborator 933 for _, item := range repoCollaborators { 934 // currently only two roles: owner and member 935 var role string 936 if item[3] == "repo:owner" { 937 role = "owner" 938 } else if item[3] == "repo:collaborator" { 939 role = "collaborator" 940 } else { 941 continue 942 } 943 944 did := item[0] 945 946 c := pages.Collaborator{ 947 Did: did, 948 Handle: "", 949 Role: role, 950 } 951 collaborators = append(collaborators, c) 952 } 953 954 // populate all collborators with handles 955 identsToResolve := make([]string, len(collaborators)) 956 for i, collab := range collaborators { 957 identsToResolve[i] = collab.Did 958 } 959 960 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 961 for i, resolved := range resolvedIdents { 962 if resolved != nil { 963 collaborators[i].Handle = resolved.Handle.String() 964 } 965 } 966 967 return collaborators, nil 968} 969 970func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { 971 isStarred := false 972 if u != nil { 973 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 974 } 975 976 starCount, err := db.GetStarCount(s.db, f.RepoAt) 977 if err != nil { 978 log.Println("failed to get star count for ", f.RepoAt) 979 } 980 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 981 if err != nil { 982 log.Println("failed to get issue count for ", f.RepoAt) 983 } 984 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 985 if err != nil { 986 log.Println("failed to get issue count for ", f.RepoAt) 987 } 988 989 knot := f.Knot 990 if knot == "knot1.tangled.sh" { 991 knot = "tangled.sh" 992 } 993 994 return pages.RepoInfo{ 995 OwnerDid: f.OwnerDid(), 996 OwnerHandle: f.OwnerHandle(), 997 Name: f.RepoName, 998 RepoAt: f.RepoAt, 999 Description: f.Description, 1000 IsStarred: isStarred, 1001 Knot: knot, 1002 Roles: RolesInRepo(s, u, f), 1003 Stats: db.RepoStats{ 1004 StarCount: starCount, 1005 IssueCount: issueCount, 1006 PullCount: pullCount, 1007 }, 1008 } 1009} 1010 1011func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1012 user := s.auth.GetUser(r) 1013 f, err := fullyResolvedRepo(r) 1014 if err != nil { 1015 log.Println("failed to get repo and knot", err) 1016 return 1017 } 1018 1019 issueId := chi.URLParam(r, "issue") 1020 issueIdInt, err := strconv.Atoi(issueId) 1021 if err != nil { 1022 http.Error(w, "bad issue id", http.StatusBadRequest) 1023 log.Println("failed to parse issue id", err) 1024 return 1025 } 1026 1027 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 1028 if err != nil { 1029 log.Println("failed to get issue and comments", err) 1030 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1031 return 1032 } 1033 1034 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 1035 if err != nil { 1036 log.Println("failed to resolve issue owner", err) 1037 } 1038 1039 identsToResolve := make([]string, len(comments)) 1040 for i, comment := range comments { 1041 identsToResolve[i] = comment.OwnerDid 1042 } 1043 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1044 didHandleMap := make(map[string]string) 1045 for _, identity := range resolvedIds { 1046 if !identity.Handle.IsInvalidHandle() { 1047 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1048 } else { 1049 didHandleMap[identity.DID.String()] = identity.DID.String() 1050 } 1051 } 1052 1053 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1054 LoggedInUser: user, 1055 RepoInfo: f.RepoInfo(s, user), 1056 Issue: *issue, 1057 Comments: comments, 1058 1059 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1060 DidHandleMap: didHandleMap, 1061 }) 1062 1063} 1064 1065func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1066 user := s.auth.GetUser(r) 1067 f, err := fullyResolvedRepo(r) 1068 if err != nil { 1069 log.Println("failed to get repo and knot", err) 1070 return 1071 } 1072 1073 issueId := chi.URLParam(r, "issue") 1074 issueIdInt, err := strconv.Atoi(issueId) 1075 if err != nil { 1076 http.Error(w, "bad issue id", http.StatusBadRequest) 1077 log.Println("failed to parse issue id", err) 1078 return 1079 } 1080 1081 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1082 if err != nil { 1083 log.Println("failed to get issue", err) 1084 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1085 return 1086 } 1087 1088 collaborators, err := f.Collaborators(r.Context(), s) 1089 if err != nil { 1090 log.Println("failed to fetch repo collaborators: %w", err) 1091 } 1092 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1093 return user.Did == collab.Did 1094 }) 1095 isIssueOwner := user.Did == issue.OwnerDid 1096 1097 // TODO: make this more granular 1098 if isIssueOwner || isCollaborator { 1099 1100 closed := tangled.RepoIssueStateClosed 1101 1102 client, _ := s.auth.AuthorizedClient(r) 1103 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1104 Collection: tangled.RepoIssueStateNSID, 1105 Repo: issue.OwnerDid, 1106 Rkey: s.TID(), 1107 Record: &lexutil.LexiconTypeDecoder{ 1108 Val: &tangled.RepoIssueState{ 1109 Issue: issue.IssueAt, 1110 State: &closed, 1111 }, 1112 }, 1113 }) 1114 1115 if err != nil { 1116 log.Println("failed to update issue state", err) 1117 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1118 return 1119 } 1120 1121 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1122 if err != nil { 1123 log.Println("failed to close issue", err) 1124 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1125 return 1126 } 1127 1128 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1129 return 1130 } else { 1131 log.Println("user is not permitted to close issue") 1132 http.Error(w, "for biden", http.StatusUnauthorized) 1133 return 1134 } 1135} 1136 1137func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1138 user := s.auth.GetUser(r) 1139 f, err := fullyResolvedRepo(r) 1140 if err != nil { 1141 log.Println("failed to get repo and knot", err) 1142 return 1143 } 1144 1145 issueId := chi.URLParam(r, "issue") 1146 issueIdInt, err := strconv.Atoi(issueId) 1147 if err != nil { 1148 http.Error(w, "bad issue id", http.StatusBadRequest) 1149 log.Println("failed to parse issue id", err) 1150 return 1151 } 1152 1153 if user.Did == f.OwnerDid() { 1154 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1155 if err != nil { 1156 log.Println("failed to reopen issue", err) 1157 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1158 return 1159 } 1160 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1161 return 1162 } else { 1163 log.Println("user is not the owner of the repo") 1164 http.Error(w, "forbidden", http.StatusUnauthorized) 1165 return 1166 } 1167} 1168 1169func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1170 user := s.auth.GetUser(r) 1171 f, err := fullyResolvedRepo(r) 1172 if err != nil { 1173 log.Println("failed to get repo and knot", err) 1174 return 1175 } 1176 1177 issueId := chi.URLParam(r, "issue") 1178 issueIdInt, err := strconv.Atoi(issueId) 1179 if err != nil { 1180 http.Error(w, "bad issue id", http.StatusBadRequest) 1181 log.Println("failed to parse issue id", err) 1182 return 1183 } 1184 1185 switch r.Method { 1186 case http.MethodPost: 1187 body := r.FormValue("body") 1188 if body == "" { 1189 s.pages.Notice(w, "issue", "Body is required") 1190 return 1191 } 1192 1193 commentId := rand.IntN(1000000) 1194 1195 err := db.NewComment(s.db, &db.Comment{ 1196 OwnerDid: user.Did, 1197 RepoAt: f.RepoAt, 1198 Issue: issueIdInt, 1199 CommentId: commentId, 1200 Body: body, 1201 }) 1202 if err != nil { 1203 log.Println("failed to create comment", err) 1204 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1205 return 1206 } 1207 1208 createdAt := time.Now().Format(time.RFC3339) 1209 commentIdInt64 := int64(commentId) 1210 ownerDid := user.Did 1211 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1212 if err != nil { 1213 log.Println("failed to get issue at", err) 1214 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1215 return 1216 } 1217 1218 atUri := f.RepoAt.String() 1219 client, _ := s.auth.AuthorizedClient(r) 1220 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1221 Collection: tangled.RepoIssueCommentNSID, 1222 Repo: user.Did, 1223 Rkey: s.TID(), 1224 Record: &lexutil.LexiconTypeDecoder{ 1225 Val: &tangled.RepoIssueComment{ 1226 Repo: &atUri, 1227 Issue: issueAt, 1228 CommentId: &commentIdInt64, 1229 Owner: &ownerDid, 1230 Body: &body, 1231 CreatedAt: &createdAt, 1232 }, 1233 }, 1234 }) 1235 if err != nil { 1236 log.Println("failed to create comment", err) 1237 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1238 return 1239 } 1240 1241 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1242 return 1243 } 1244} 1245 1246func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1247 params := r.URL.Query() 1248 state := params.Get("state") 1249 isOpen := true 1250 switch state { 1251 case "open": 1252 isOpen = true 1253 case "closed": 1254 isOpen = false 1255 default: 1256 isOpen = true 1257 } 1258 1259 user := s.auth.GetUser(r) 1260 f, err := fullyResolvedRepo(r) 1261 if err != nil { 1262 log.Println("failed to get repo and knot", err) 1263 return 1264 } 1265 1266 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 1267 if err != nil { 1268 log.Println("failed to get issues", err) 1269 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1270 return 1271 } 1272 1273 identsToResolve := make([]string, len(issues)) 1274 for i, issue := range issues { 1275 identsToResolve[i] = issue.OwnerDid 1276 } 1277 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1278 didHandleMap := make(map[string]string) 1279 for _, identity := range resolvedIds { 1280 if !identity.Handle.IsInvalidHandle() { 1281 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1282 } else { 1283 didHandleMap[identity.DID.String()] = identity.DID.String() 1284 } 1285 } 1286 1287 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1288 LoggedInUser: s.auth.GetUser(r), 1289 RepoInfo: f.RepoInfo(s, user), 1290 Issues: issues, 1291 DidHandleMap: didHandleMap, 1292 FilteringByOpen: isOpen, 1293 }) 1294 return 1295} 1296 1297func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1298 user := s.auth.GetUser(r) 1299 1300 f, err := fullyResolvedRepo(r) 1301 if err != nil { 1302 log.Println("failed to get repo and knot", err) 1303 return 1304 } 1305 1306 switch r.Method { 1307 case http.MethodGet: 1308 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1309 LoggedInUser: user, 1310 RepoInfo: f.RepoInfo(s, user), 1311 }) 1312 case http.MethodPost: 1313 title := r.FormValue("title") 1314 body := r.FormValue("body") 1315 1316 if title == "" || body == "" { 1317 s.pages.Notice(w, "issues", "Title and body are required") 1318 return 1319 } 1320 1321 tx, err := s.db.BeginTx(r.Context(), nil) 1322 if err != nil { 1323 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1324 return 1325 } 1326 1327 err = db.NewIssue(tx, &db.Issue{ 1328 RepoAt: f.RepoAt, 1329 Title: title, 1330 Body: body, 1331 OwnerDid: user.Did, 1332 }) 1333 if err != nil { 1334 log.Println("failed to create issue", err) 1335 s.pages.Notice(w, "issues", "Failed to create issue.") 1336 return 1337 } 1338 1339 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1340 if err != nil { 1341 log.Println("failed to get issue id", err) 1342 s.pages.Notice(w, "issues", "Failed to create issue.") 1343 return 1344 } 1345 1346 client, _ := s.auth.AuthorizedClient(r) 1347 atUri := f.RepoAt.String() 1348 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1349 Collection: tangled.RepoIssueNSID, 1350 Repo: user.Did, 1351 Rkey: s.TID(), 1352 Record: &lexutil.LexiconTypeDecoder{ 1353 Val: &tangled.RepoIssue{ 1354 Repo: atUri, 1355 Title: title, 1356 Body: &body, 1357 Owner: user.Did, 1358 IssueId: int64(issueId), 1359 }, 1360 }, 1361 }) 1362 if err != nil { 1363 log.Println("failed to create issue", err) 1364 s.pages.Notice(w, "issues", "Failed to create issue.") 1365 return 1366 } 1367 1368 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1369 if err != nil { 1370 log.Println("failed to set issue at", err) 1371 s.pages.Notice(w, "issues", "Failed to create issue.") 1372 return 1373 } 1374 1375 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1376 return 1377 } 1378} 1379 1380func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 1381 user := s.auth.GetUser(r) 1382 params := r.URL.Query() 1383 1384 state := db.PullOpen 1385 switch params.Get("state") { 1386 case "closed": 1387 state = db.PullClosed 1388 case "merged": 1389 state = db.PullMerged 1390 } 1391 1392 f, err := fullyResolvedRepo(r) 1393 if err != nil { 1394 log.Println("failed to get repo and knot", err) 1395 return 1396 } 1397 1398 pulls, err := db.GetPulls(s.db, f.RepoAt, state) 1399 if err != nil { 1400 log.Println("failed to get pulls", err) 1401 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 1402 return 1403 } 1404 1405 identsToResolve := make([]string, len(pulls)) 1406 for i, pull := range pulls { 1407 identsToResolve[i] = pull.OwnerDid 1408 } 1409 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1410 didHandleMap := make(map[string]string) 1411 for _, identity := range resolvedIds { 1412 if !identity.Handle.IsInvalidHandle() { 1413 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1414 } else { 1415 didHandleMap[identity.DID.String()] = identity.DID.String() 1416 } 1417 } 1418 1419 s.pages.RepoPulls(w, pages.RepoPullsParams{ 1420 LoggedInUser: s.auth.GetUser(r), 1421 RepoInfo: f.RepoInfo(s, user), 1422 Pulls: pulls, 1423 DidHandleMap: didHandleMap, 1424 FilteringBy: state, 1425 }) 1426 return 1427} 1428 1429func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1430 user := s.auth.GetUser(r) 1431 f, err := fullyResolvedRepo(r) 1432 if err != nil { 1433 log.Println("failed to resolve repo:", err) 1434 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1435 return 1436 } 1437 1438 pull, ok := r.Context().Value("pull").(*db.Pull) 1439 if !ok { 1440 log.Println("failed to get pull") 1441 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1442 return 1443 } 1444 1445 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1446 if err != nil { 1447 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1448 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1449 return 1450 } 1451 1452 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1453 if err != nil { 1454 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1455 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1456 return 1457 } 1458 1459 // Merge the pull request 1460 resp, err := ksClient.Merge([]byte(pull.Patch), user.Did, f.RepoName, pull.TargetBranch) 1461 if err != nil { 1462 log.Printf("failed to merge pull request: %s", err) 1463 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1464 return 1465 } 1466 1467 if resp.StatusCode == http.StatusOK { 1468 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1469 if err != nil { 1470 log.Printf("failed to update pull request status in database: %s", err) 1471 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1472 return 1473 } 1474 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1475 } else { 1476 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1477 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1478 } 1479} 1480 1481func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 1482 user := s.auth.GetUser(r) 1483 f, err := fullyResolvedRepo(r) 1484 if err != nil { 1485 log.Println("failed to get repo and knot", err) 1486 return 1487 } 1488 1489 pullId := chi.URLParam(r, "pull") 1490 pullIdInt, err := strconv.Atoi(pullId) 1491 if err != nil { 1492 http.Error(w, "bad pull id", http.StatusBadRequest) 1493 log.Println("failed to parse pull id", err) 1494 return 1495 } 1496 1497 switch r.Method { 1498 case http.MethodPost: 1499 body := r.FormValue("body") 1500 if body == "" { 1501 s.pages.Notice(w, "pull", "Comment body is required") 1502 return 1503 } 1504 1505 // Start a transaction 1506 tx, err := s.db.BeginTx(r.Context(), nil) 1507 if err != nil { 1508 log.Println("failed to start transaction", err) 1509 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1510 return 1511 } 1512 defer tx.Rollback() // Will be ignored if we commit 1513 1514 commentId := rand.IntN(1000000) 1515 createdAt := time.Now().Format(time.RFC3339) 1516 commentIdInt64 := int64(commentId) 1517 ownerDid := user.Did 1518 1519 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pullIdInt) 1520 if err != nil { 1521 log.Println("failed to get pull at", err) 1522 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1523 return 1524 } 1525 1526 atUri := f.RepoAt.String() 1527 client, _ := s.auth.AuthorizedClient(r) 1528 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1529 Collection: tangled.RepoPullCommentNSID, 1530 Repo: user.Did, 1531 Rkey: s.TID(), 1532 Record: &lexutil.LexiconTypeDecoder{ 1533 Val: &tangled.RepoPullComment{ 1534 Repo: &atUri, 1535 Pull: pullAt, 1536 CommentId: &commentIdInt64, 1537 Owner: &ownerDid, 1538 Body: &body, 1539 CreatedAt: &createdAt, 1540 }, 1541 }, 1542 }) 1543 if err != nil { 1544 log.Println("failed to create pull comment", err) 1545 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1546 return 1547 } 1548 1549 // Create the pull comment in the database with the commentAt field 1550 err = db.NewPullComment(tx, &db.PullComment{ 1551 OwnerDid: user.Did, 1552 RepoAt: f.RepoAt.String(), 1553 CommentId: commentId, 1554 PullId: pullIdInt, 1555 Body: body, 1556 CommentAt: atResp.Uri, 1557 }) 1558 if err != nil { 1559 log.Println("failed to create pull comment", err) 1560 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1561 return 1562 } 1563 1564 // Commit the transaction 1565 if err = tx.Commit(); err != nil { 1566 log.Println("failed to commit transaction", err) 1567 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1568 return 1569 } 1570 1571 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pullIdInt, commentId)) 1572 return 1573 } 1574} 1575 1576func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1577 user := s.auth.GetUser(r) 1578 1579 f, err := fullyResolvedRepo(r) 1580 if err != nil { 1581 log.Println("malformed middleware") 1582 return 1583 } 1584 1585 pull, ok := r.Context().Value("pull").(*db.Pull) 1586 if !ok { 1587 log.Println("failed to get pull") 1588 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1589 return 1590 } 1591 1592 // auth filter: only owner or collaborators can close 1593 roles := RolesInRepo(s, user, f) 1594 isCollaborator := roles.IsCollaborator() 1595 isPullAuthor := user.Did == pull.OwnerDid 1596 isCloseAllowed := isCollaborator || isPullAuthor 1597 if !isCloseAllowed { 1598 log.Println("failed to close pull") 1599 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1600 return 1601 } 1602 1603 // Start a transaction 1604 tx, err := s.db.BeginTx(r.Context(), nil) 1605 if err != nil { 1606 log.Println("failed to start transaction", err) 1607 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1608 return 1609 } 1610 1611 // Close the pull in the database 1612 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1613 if err != nil { 1614 log.Println("failed to close pull", err) 1615 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1616 return 1617 } 1618 1619 // Commit the transaction 1620 if err = tx.Commit(); err != nil { 1621 log.Println("failed to commit transaction", err) 1622 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1623 return 1624 } 1625 1626 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1627 return 1628} 1629 1630func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1631 user := s.auth.GetUser(r) 1632 1633 f, err := fullyResolvedRepo(r) 1634 if err != nil { 1635 log.Println("failed to resolve repo", err) 1636 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1637 return 1638 } 1639 1640 pull, ok := r.Context().Value("pull").(*db.Pull) 1641 if !ok { 1642 log.Println("failed to get pull") 1643 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1644 return 1645 } 1646 1647 // auth filter: only owner or collaborators can close 1648 roles := RolesInRepo(s, user, f) 1649 isCollaborator := roles.IsCollaborator() 1650 isPullAuthor := user.Did == pull.OwnerDid 1651 isCloseAllowed := isCollaborator || isPullAuthor 1652 if !isCloseAllowed { 1653 log.Println("failed to close pull") 1654 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1655 return 1656 } 1657 1658 // Start a transaction 1659 tx, err := s.db.BeginTx(r.Context(), nil) 1660 if err != nil { 1661 log.Println("failed to start transaction", err) 1662 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1663 return 1664 } 1665 1666 // Reopen the pull in the database 1667 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1668 if err != nil { 1669 log.Println("failed to reopen pull", err) 1670 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1671 return 1672 } 1673 1674 // Commit the transaction 1675 if err = tx.Commit(); err != nil { 1676 log.Println("failed to commit transaction", err) 1677 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1678 return 1679 } 1680 1681 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1682 return 1683} 1684 1685func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 1686 repoName := chi.URLParam(r, "repo") 1687 knot, ok := r.Context().Value("knot").(string) 1688 if !ok { 1689 log.Println("malformed middleware") 1690 return nil, fmt.Errorf("malformed middleware") 1691 } 1692 id, ok := r.Context().Value("resolvedId").(identity.Identity) 1693 if !ok { 1694 log.Println("malformed middleware") 1695 return nil, fmt.Errorf("malformed middleware") 1696 } 1697 1698 repoAt, ok := r.Context().Value("repoAt").(string) 1699 if !ok { 1700 log.Println("malformed middleware") 1701 return nil, fmt.Errorf("malformed middleware") 1702 } 1703 1704 parsedRepoAt, err := syntax.ParseATURI(repoAt) 1705 if err != nil { 1706 log.Println("malformed repo at-uri") 1707 return nil, fmt.Errorf("malformed middleware") 1708 } 1709 1710 // pass through values from the middleware 1711 description, ok := r.Context().Value("repoDescription").(string) 1712 addedAt, ok := r.Context().Value("repoAddedAt").(string) 1713 1714 return &FullyResolvedRepo{ 1715 Knot: knot, 1716 OwnerId: id, 1717 RepoName: repoName, 1718 RepoAt: parsedRepoAt, 1719 Description: description, 1720 AddedAt: addedAt, 1721 }, nil 1722} 1723 1724func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 1725 if u != nil { 1726 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 1727 return pages.RolesInRepo{r} 1728 } else { 1729 return pages.RolesInRepo{} 1730 } 1731}