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: user.Did, 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 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1154 if err != nil { 1155 log.Println("failed to get issue", err) 1156 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1157 return 1158 } 1159 1160 collaborators, err := f.Collaborators(r.Context(), s) 1161 if err != nil { 1162 log.Println("failed to fetch repo collaborators: %w", err) 1163 } 1164 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1165 return user.Did == collab.Did 1166 }) 1167 isIssueOwner := user.Did == issue.OwnerDid 1168 1169 if isCollaborator || isIssueOwner { 1170 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1171 if err != nil { 1172 log.Println("failed to reopen issue", err) 1173 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1174 return 1175 } 1176 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1177 return 1178 } else { 1179 log.Println("user is not the owner of the repo") 1180 http.Error(w, "forbidden", http.StatusUnauthorized) 1181 return 1182 } 1183} 1184 1185func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1186 user := s.auth.GetUser(r) 1187 f, err := fullyResolvedRepo(r) 1188 if err != nil { 1189 log.Println("failed to get repo and knot", err) 1190 return 1191 } 1192 1193 issueId := chi.URLParam(r, "issue") 1194 issueIdInt, err := strconv.Atoi(issueId) 1195 if err != nil { 1196 http.Error(w, "bad issue id", http.StatusBadRequest) 1197 log.Println("failed to parse issue id", err) 1198 return 1199 } 1200 1201 switch r.Method { 1202 case http.MethodPost: 1203 body := r.FormValue("body") 1204 if body == "" { 1205 s.pages.Notice(w, "issue", "Body is required") 1206 return 1207 } 1208 1209 commentId := rand.IntN(1000000) 1210 1211 err := db.NewComment(s.db, &db.Comment{ 1212 OwnerDid: user.Did, 1213 RepoAt: f.RepoAt, 1214 Issue: issueIdInt, 1215 CommentId: commentId, 1216 Body: body, 1217 }) 1218 if err != nil { 1219 log.Println("failed to create comment", err) 1220 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1221 return 1222 } 1223 1224 createdAt := time.Now().Format(time.RFC3339) 1225 commentIdInt64 := int64(commentId) 1226 ownerDid := user.Did 1227 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1228 if err != nil { 1229 log.Println("failed to get issue at", err) 1230 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1231 return 1232 } 1233 1234 atUri := f.RepoAt.String() 1235 client, _ := s.auth.AuthorizedClient(r) 1236 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1237 Collection: tangled.RepoIssueCommentNSID, 1238 Repo: user.Did, 1239 Rkey: s.TID(), 1240 Record: &lexutil.LexiconTypeDecoder{ 1241 Val: &tangled.RepoIssueComment{ 1242 Repo: &atUri, 1243 Issue: issueAt, 1244 CommentId: &commentIdInt64, 1245 Owner: &ownerDid, 1246 Body: &body, 1247 CreatedAt: &createdAt, 1248 }, 1249 }, 1250 }) 1251 if err != nil { 1252 log.Println("failed to create comment", err) 1253 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1254 return 1255 } 1256 1257 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1258 return 1259 } 1260} 1261 1262func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1263 params := r.URL.Query() 1264 state := params.Get("state") 1265 isOpen := true 1266 switch state { 1267 case "open": 1268 isOpen = true 1269 case "closed": 1270 isOpen = false 1271 default: 1272 isOpen = true 1273 } 1274 1275 user := s.auth.GetUser(r) 1276 f, err := fullyResolvedRepo(r) 1277 if err != nil { 1278 log.Println("failed to get repo and knot", err) 1279 return 1280 } 1281 1282 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 1283 if err != nil { 1284 log.Println("failed to get issues", err) 1285 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1286 return 1287 } 1288 1289 identsToResolve := make([]string, len(issues)) 1290 for i, issue := range issues { 1291 identsToResolve[i] = issue.OwnerDid 1292 } 1293 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1294 didHandleMap := make(map[string]string) 1295 for _, identity := range resolvedIds { 1296 if !identity.Handle.IsInvalidHandle() { 1297 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1298 } else { 1299 didHandleMap[identity.DID.String()] = identity.DID.String() 1300 } 1301 } 1302 1303 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1304 LoggedInUser: s.auth.GetUser(r), 1305 RepoInfo: f.RepoInfo(s, user), 1306 Issues: issues, 1307 DidHandleMap: didHandleMap, 1308 FilteringByOpen: isOpen, 1309 }) 1310 return 1311} 1312 1313func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1314 user := s.auth.GetUser(r) 1315 1316 f, err := fullyResolvedRepo(r) 1317 if err != nil { 1318 log.Println("failed to get repo and knot", err) 1319 return 1320 } 1321 1322 switch r.Method { 1323 case http.MethodGet: 1324 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1325 LoggedInUser: user, 1326 RepoInfo: f.RepoInfo(s, user), 1327 }) 1328 case http.MethodPost: 1329 title := r.FormValue("title") 1330 body := r.FormValue("body") 1331 1332 if title == "" || body == "" { 1333 s.pages.Notice(w, "issues", "Title and body are required") 1334 return 1335 } 1336 1337 tx, err := s.db.BeginTx(r.Context(), nil) 1338 if err != nil { 1339 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1340 return 1341 } 1342 1343 err = db.NewIssue(tx, &db.Issue{ 1344 RepoAt: f.RepoAt, 1345 Title: title, 1346 Body: body, 1347 OwnerDid: user.Did, 1348 }) 1349 if err != nil { 1350 log.Println("failed to create issue", err) 1351 s.pages.Notice(w, "issues", "Failed to create issue.") 1352 return 1353 } 1354 1355 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1356 if err != nil { 1357 log.Println("failed to get issue id", err) 1358 s.pages.Notice(w, "issues", "Failed to create issue.") 1359 return 1360 } 1361 1362 client, _ := s.auth.AuthorizedClient(r) 1363 atUri := f.RepoAt.String() 1364 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1365 Collection: tangled.RepoIssueNSID, 1366 Repo: user.Did, 1367 Rkey: s.TID(), 1368 Record: &lexutil.LexiconTypeDecoder{ 1369 Val: &tangled.RepoIssue{ 1370 Repo: atUri, 1371 Title: title, 1372 Body: &body, 1373 Owner: user.Did, 1374 IssueId: int64(issueId), 1375 }, 1376 }, 1377 }) 1378 if err != nil { 1379 log.Println("failed to create issue", err) 1380 s.pages.Notice(w, "issues", "Failed to create issue.") 1381 return 1382 } 1383 1384 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1385 if err != nil { 1386 log.Println("failed to set issue at", err) 1387 s.pages.Notice(w, "issues", "Failed to create issue.") 1388 return 1389 } 1390 1391 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1392 return 1393 } 1394} 1395 1396func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 1397 user := s.auth.GetUser(r) 1398 params := r.URL.Query() 1399 1400 state := db.PullOpen 1401 switch params.Get("state") { 1402 case "closed": 1403 state = db.PullClosed 1404 case "merged": 1405 state = db.PullMerged 1406 } 1407 1408 f, err := fullyResolvedRepo(r) 1409 if err != nil { 1410 log.Println("failed to get repo and knot", err) 1411 return 1412 } 1413 1414 pulls, err := db.GetPulls(s.db, f.RepoAt, state) 1415 if err != nil { 1416 log.Println("failed to get pulls", err) 1417 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 1418 return 1419 } 1420 1421 identsToResolve := make([]string, len(pulls)) 1422 for i, pull := range pulls { 1423 identsToResolve[i] = pull.OwnerDid 1424 } 1425 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1426 didHandleMap := make(map[string]string) 1427 for _, identity := range resolvedIds { 1428 if !identity.Handle.IsInvalidHandle() { 1429 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1430 } else { 1431 didHandleMap[identity.DID.String()] = identity.DID.String() 1432 } 1433 } 1434 1435 s.pages.RepoPulls(w, pages.RepoPullsParams{ 1436 LoggedInUser: s.auth.GetUser(r), 1437 RepoInfo: f.RepoInfo(s, user), 1438 Pulls: pulls, 1439 DidHandleMap: didHandleMap, 1440 FilteringBy: state, 1441 }) 1442 return 1443} 1444 1445func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1446 user := s.auth.GetUser(r) 1447 f, err := fullyResolvedRepo(r) 1448 if err != nil { 1449 log.Println("failed to resolve repo:", err) 1450 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1451 return 1452 } 1453 1454 pull, ok := r.Context().Value("pull").(*db.Pull) 1455 if !ok { 1456 log.Println("failed to get pull") 1457 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1458 return 1459 } 1460 1461 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1462 if err != nil { 1463 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1464 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1465 return 1466 } 1467 1468 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1469 if err != nil { 1470 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1471 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1472 return 1473 } 1474 1475 // Merge the pull request 1476 resp, err := ksClient.Merge([]byte(pull.Patch), user.Did, f.RepoName, pull.TargetBranch) 1477 if err != nil { 1478 log.Printf("failed to merge pull request: %s", err) 1479 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1480 return 1481 } 1482 1483 if resp.StatusCode == http.StatusOK { 1484 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1485 if err != nil { 1486 log.Printf("failed to update pull request status in database: %s", err) 1487 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1488 return 1489 } 1490 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1491 } else { 1492 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1493 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1494 } 1495} 1496 1497func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 1498 user := s.auth.GetUser(r) 1499 f, err := fullyResolvedRepo(r) 1500 if err != nil { 1501 log.Println("failed to get repo and knot", err) 1502 return 1503 } 1504 1505 pullId := chi.URLParam(r, "pull") 1506 pullIdInt, err := strconv.Atoi(pullId) 1507 if err != nil { 1508 http.Error(w, "bad pull id", http.StatusBadRequest) 1509 log.Println("failed to parse pull id", err) 1510 return 1511 } 1512 1513 switch r.Method { 1514 case http.MethodPost: 1515 body := r.FormValue("body") 1516 if body == "" { 1517 s.pages.Notice(w, "pull", "Comment body is required") 1518 return 1519 } 1520 1521 // Start a transaction 1522 tx, err := s.db.BeginTx(r.Context(), nil) 1523 if err != nil { 1524 log.Println("failed to start transaction", err) 1525 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1526 return 1527 } 1528 defer tx.Rollback() // Will be ignored if we commit 1529 1530 commentId := rand.IntN(1000000) 1531 createdAt := time.Now().Format(time.RFC3339) 1532 commentIdInt64 := int64(commentId) 1533 ownerDid := user.Did 1534 1535 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pullIdInt) 1536 if err != nil { 1537 log.Println("failed to get pull at", err) 1538 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1539 return 1540 } 1541 1542 atUri := f.RepoAt.String() 1543 client, _ := s.auth.AuthorizedClient(r) 1544 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1545 Collection: tangled.RepoPullCommentNSID, 1546 Repo: user.Did, 1547 Rkey: s.TID(), 1548 Record: &lexutil.LexiconTypeDecoder{ 1549 Val: &tangled.RepoPullComment{ 1550 Repo: &atUri, 1551 Pull: pullAt, 1552 CommentId: &commentIdInt64, 1553 Owner: &ownerDid, 1554 Body: &body, 1555 CreatedAt: &createdAt, 1556 }, 1557 }, 1558 }) 1559 if err != nil { 1560 log.Println("failed to create pull comment", err) 1561 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1562 return 1563 } 1564 1565 // Create the pull comment in the database with the commentAt field 1566 err = db.NewPullComment(tx, &db.PullComment{ 1567 OwnerDid: user.Did, 1568 RepoAt: f.RepoAt.String(), 1569 CommentId: commentId, 1570 PullId: pullIdInt, 1571 Body: body, 1572 CommentAt: atResp.Uri, 1573 }) 1574 if err != nil { 1575 log.Println("failed to create pull comment", err) 1576 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1577 return 1578 } 1579 1580 // Commit the transaction 1581 if err = tx.Commit(); err != nil { 1582 log.Println("failed to commit transaction", err) 1583 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 1584 return 1585 } 1586 1587 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pullIdInt, commentId)) 1588 return 1589 } 1590} 1591 1592func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1593 user := s.auth.GetUser(r) 1594 1595 f, err := fullyResolvedRepo(r) 1596 if err != nil { 1597 log.Println("malformed middleware") 1598 return 1599 } 1600 1601 pull, ok := r.Context().Value("pull").(*db.Pull) 1602 if !ok { 1603 log.Println("failed to get pull") 1604 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1605 return 1606 } 1607 1608 // auth filter: only owner or collaborators can close 1609 roles := RolesInRepo(s, user, f) 1610 isCollaborator := roles.IsCollaborator() 1611 isPullAuthor := user.Did == pull.OwnerDid 1612 isCloseAllowed := isCollaborator || isPullAuthor 1613 if !isCloseAllowed { 1614 log.Println("failed to close pull") 1615 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1616 return 1617 } 1618 1619 // Start a transaction 1620 tx, err := s.db.BeginTx(r.Context(), nil) 1621 if err != nil { 1622 log.Println("failed to start transaction", err) 1623 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1624 return 1625 } 1626 1627 // Close the pull in the database 1628 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1629 if err != nil { 1630 log.Println("failed to close pull", err) 1631 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1632 return 1633 } 1634 1635 // Commit the transaction 1636 if err = tx.Commit(); err != nil { 1637 log.Println("failed to commit transaction", err) 1638 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1639 return 1640 } 1641 1642 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1643 return 1644} 1645 1646func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1647 user := s.auth.GetUser(r) 1648 1649 f, err := fullyResolvedRepo(r) 1650 if err != nil { 1651 log.Println("failed to resolve repo", err) 1652 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1653 return 1654 } 1655 1656 pull, ok := r.Context().Value("pull").(*db.Pull) 1657 if !ok { 1658 log.Println("failed to get pull") 1659 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1660 return 1661 } 1662 1663 // auth filter: only owner or collaborators can close 1664 roles := RolesInRepo(s, user, f) 1665 isCollaborator := roles.IsCollaborator() 1666 isPullAuthor := user.Did == pull.OwnerDid 1667 isCloseAllowed := isCollaborator || isPullAuthor 1668 if !isCloseAllowed { 1669 log.Println("failed to close pull") 1670 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1671 return 1672 } 1673 1674 // Start a transaction 1675 tx, err := s.db.BeginTx(r.Context(), nil) 1676 if err != nil { 1677 log.Println("failed to start transaction", err) 1678 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1679 return 1680 } 1681 1682 // Reopen the pull in the database 1683 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1684 if err != nil { 1685 log.Println("failed to reopen pull", err) 1686 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1687 return 1688 } 1689 1690 // Commit the transaction 1691 if err = tx.Commit(); err != nil { 1692 log.Println("failed to commit transaction", err) 1693 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1694 return 1695 } 1696 1697 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1698 return 1699} 1700 1701func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 1702 repoName := chi.URLParam(r, "repo") 1703 knot, ok := r.Context().Value("knot").(string) 1704 if !ok { 1705 log.Println("malformed middleware") 1706 return nil, fmt.Errorf("malformed middleware") 1707 } 1708 id, ok := r.Context().Value("resolvedId").(identity.Identity) 1709 if !ok { 1710 log.Println("malformed middleware") 1711 return nil, fmt.Errorf("malformed middleware") 1712 } 1713 1714 repoAt, ok := r.Context().Value("repoAt").(string) 1715 if !ok { 1716 log.Println("malformed middleware") 1717 return nil, fmt.Errorf("malformed middleware") 1718 } 1719 1720 parsedRepoAt, err := syntax.ParseATURI(repoAt) 1721 if err != nil { 1722 log.Println("malformed repo at-uri") 1723 return nil, fmt.Errorf("malformed middleware") 1724 } 1725 1726 // pass through values from the middleware 1727 description, ok := r.Context().Value("repoDescription").(string) 1728 addedAt, ok := r.Context().Value("repoAddedAt").(string) 1729 1730 return &FullyResolvedRepo{ 1731 Knot: knot, 1732 OwnerId: id, 1733 RepoName: repoName, 1734 RepoAt: parsedRepoAt, 1735 Description: description, 1736 AddedAt: addedAt, 1737 }, nil 1738} 1739 1740func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 1741 if u != nil { 1742 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 1743 return pages.RolesInRepo{r} 1744 } else { 1745 return pages.RolesInRepo{} 1746 } 1747}