forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log" 11 mathrand "math/rand/v2" 12 "net/http" 13 "path" 14 "slices" 15 "strconv" 16 "strings" 17 "time" 18 19 "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/knotclient" 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/appview/pages/markup" 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 "tangled.sh/tangled.sh/core/appview/pagination" 28 "tangled.sh/tangled.sh/core/types" 29 30 "github.com/bluesky-social/indigo/atproto/data" 31 "github.com/bluesky-social/indigo/atproto/identity" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 securejoin "github.com/cyphar/filepath-securejoin" 34 "github.com/go-chi/chi/v5" 35 "github.com/go-git/go-git/v5/plumbing" 36 "github.com/posthog/posthog-go" 37 38 comatproto "github.com/bluesky-social/indigo/api/atproto" 39 lexutil "github.com/bluesky-social/indigo/lex/util" 40) 41 42func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 43 ref := chi.URLParam(r, "ref") 44 f, err := s.fullyResolvedRepo(r) 45 if err != nil { 46 log.Println("failed to fully resolve repo", err) 47 return 48 } 49 50 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 51 if err != nil { 52 log.Printf("failed to create unsigned client for %s", f.Knot) 53 s.pages.Error503(w) 54 return 55 } 56 57 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref) 58 if err != nil { 59 s.pages.Error503(w) 60 log.Println("failed to reach knotserver", err) 61 return 62 } 63 defer resp.Body.Close() 64 65 body, err := io.ReadAll(resp.Body) 66 if err != nil { 67 log.Printf("Error reading response body: %v", err) 68 return 69 } 70 71 var result types.RepoIndexResponse 72 err = json.Unmarshal(body, &result) 73 if err != nil { 74 log.Printf("Error unmarshalling response body: %v", err) 75 return 76 } 77 78 tagMap := make(map[string][]string) 79 for _, tag := range result.Tags { 80 hash := tag.Hash 81 if tag.Tag != nil { 82 hash = tag.Tag.Target.String() 83 } 84 tagMap[hash] = append(tagMap[hash], tag.Name) 85 } 86 87 for _, branch := range result.Branches { 88 hash := branch.Hash 89 tagMap[hash] = append(tagMap[hash], branch.Name) 90 } 91 92 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 93 if a.Name == result.Ref { 94 return -1 95 } 96 if a.IsDefault { 97 return -1 98 } 99 if b.IsDefault { 100 return 1 101 } 102 if a.Commit != nil { 103 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 104 return 1 105 } else { 106 return -1 107 } 108 } 109 return strings.Compare(a.Name, b.Name) * -1 110 }) 111 112 commitCount := len(result.Commits) 113 branchCount := len(result.Branches) 114 tagCount := len(result.Tags) 115 fileCount := len(result.Files) 116 117 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 118 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 119 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 120 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 121 122 emails := uniqueEmails(commitsTrunc) 123 124 user := s.oauth.GetUser(r) 125 repoInfo := f.RepoInfo(s, user) 126 127 secret, err := db.GetRegistrationKey(s.db, f.Knot) 128 if err != nil { 129 log.Printf("failed to get registration key for %s: %s", f.Knot, err) 130 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 131 } 132 133 signedClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 134 if err != nil { 135 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 136 return 137 } 138 139 var forkInfo *types.ForkInfo 140 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 141 forkInfo, err = getForkInfo(repoInfo, s, f, user, signedClient) 142 if err != nil { 143 log.Printf("Failed to fetch fork information: %v", err) 144 return 145 } 146 } 147 148 repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref) 149 if err != nil { 150 log.Printf("failed to compute language percentages: %s", err) 151 // non-fatal 152 } 153 154 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 155 LoggedInUser: user, 156 RepoInfo: repoInfo, 157 TagMap: tagMap, 158 RepoIndexResponse: result, 159 CommitsTrunc: commitsTrunc, 160 TagsTrunc: tagsTrunc, 161 ForkInfo: forkInfo, 162 BranchesTrunc: branchesTrunc, 163 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 164 Languages: repoLanguages, 165 }) 166 return 167} 168 169func getForkInfo( 170 repoInfo repoinfo.RepoInfo, 171 s *State, 172 f *FullyResolvedRepo, 173 user *oauth.User, 174 signedClient *knotclient.SignedClient, 175) (*types.ForkInfo, error) { 176 if user == nil { 177 return nil, nil 178 } 179 180 forkInfo := types.ForkInfo{ 181 IsFork: repoInfo.Source != nil, 182 Status: types.UpToDate, 183 } 184 185 if !forkInfo.IsFork { 186 forkInfo.IsFork = false 187 return &forkInfo, nil 188 } 189 190 us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, s.config.Core.Dev) 191 if err != nil { 192 log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 193 return nil, err 194 } 195 196 resp, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 197 if err != nil { 198 log.Println("failed to reach knotserver", err) 199 return nil, err 200 } 201 202 body, err := io.ReadAll(resp.Body) 203 if err != nil { 204 log.Printf("Error reading forkResponse forkBody: %v", err) 205 return nil, err 206 } 207 208 var result types.RepoBranchesResponse 209 err = json.Unmarshal(body, &result) 210 if err != nil { 211 log.Println("failed to parse forkResponse:", err) 212 return nil, err 213 } 214 215 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 216 return branch.Name == f.Ref 217 }) { 218 forkInfo.Status = types.MissingBranch 219 return &forkInfo, nil 220 } 221 222 newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 223 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 224 log.Printf("failed to update tracking branch: %s", err) 225 return nil, err 226 } 227 228 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 229 230 var status types.AncestorCheckResponse 231 forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 232 if err != nil { 233 log.Printf("failed to check if fork is ahead/behind: %s", err) 234 return nil, err 235 } 236 237 if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 238 log.Printf("failed to decode fork status: %s", err) 239 return nil, err 240 } 241 242 forkInfo.Status = status.Status 243 return &forkInfo, nil 244} 245 246func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 247 f, err := s.fullyResolvedRepo(r) 248 if err != nil { 249 log.Println("failed to fully resolve repo", err) 250 return 251 } 252 253 page := 1 254 if r.URL.Query().Get("page") != "" { 255 page, err = strconv.Atoi(r.URL.Query().Get("page")) 256 if err != nil { 257 page = 1 258 } 259 } 260 261 ref := chi.URLParam(r, "ref") 262 263 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 264 if err != nil { 265 log.Println("failed to create unsigned client", err) 266 return 267 } 268 269 resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 270 if err != nil { 271 log.Println("failed to reach knotserver", err) 272 return 273 } 274 275 body, err := io.ReadAll(resp.Body) 276 if err != nil { 277 log.Printf("error reading response body: %v", err) 278 return 279 } 280 281 var repolog types.RepoLogResponse 282 err = json.Unmarshal(body, &repolog) 283 if err != nil { 284 log.Println("failed to parse json response", err) 285 return 286 } 287 288 result, err := us.Tags(f.OwnerDid(), f.RepoName) 289 if err != nil { 290 log.Println("failed to reach knotserver", err) 291 return 292 } 293 294 tagMap := make(map[string][]string) 295 for _, tag := range result.Tags { 296 hash := tag.Hash 297 if tag.Tag != nil { 298 hash = tag.Tag.Target.String() 299 } 300 tagMap[hash] = append(tagMap[hash], tag.Name) 301 } 302 303 user := s.oauth.GetUser(r) 304 s.pages.RepoLog(w, pages.RepoLogParams{ 305 LoggedInUser: user, 306 TagMap: tagMap, 307 RepoInfo: f.RepoInfo(s, user), 308 RepoLogResponse: repolog, 309 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 310 }) 311 return 312} 313 314func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 315 f, err := s.fullyResolvedRepo(r) 316 if err != nil { 317 log.Println("failed to get repo and knot", err) 318 w.WriteHeader(http.StatusBadRequest) 319 return 320 } 321 322 user := s.oauth.GetUser(r) 323 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 324 RepoInfo: f.RepoInfo(s, user), 325 }) 326 return 327} 328 329func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 330 f, err := s.fullyResolvedRepo(r) 331 if err != nil { 332 log.Println("failed to get repo and knot", err) 333 w.WriteHeader(http.StatusBadRequest) 334 return 335 } 336 337 repoAt := f.RepoAt 338 rkey := repoAt.RecordKey().String() 339 if rkey == "" { 340 log.Println("invalid aturi for repo", err) 341 w.WriteHeader(http.StatusInternalServerError) 342 return 343 } 344 345 user := s.oauth.GetUser(r) 346 347 switch r.Method { 348 case http.MethodGet: 349 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 350 RepoInfo: f.RepoInfo(s, user), 351 }) 352 return 353 case http.MethodPut: 354 user := s.oauth.GetUser(r) 355 newDescription := r.FormValue("description") 356 client, err := s.oauth.AuthorizedClient(r) 357 if err != nil { 358 log.Println("failed to get client") 359 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 360 return 361 } 362 363 // optimistic update 364 err = db.UpdateDescription(s.db, string(repoAt), newDescription) 365 if err != nil { 366 log.Println("failed to perferom update-description query", err) 367 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 368 return 369 } 370 371 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 372 // 373 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 374 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 375 if err != nil { 376 // failed to get record 377 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 378 return 379 } 380 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 381 Collection: tangled.RepoNSID, 382 Repo: user.Did, 383 Rkey: rkey, 384 SwapRecord: ex.Cid, 385 Record: &lexutil.LexiconTypeDecoder{ 386 Val: &tangled.Repo{ 387 Knot: f.Knot, 388 Name: f.RepoName, 389 Owner: user.Did, 390 CreatedAt: f.CreatedAt, 391 Description: &newDescription, 392 }, 393 }, 394 }) 395 396 if err != nil { 397 log.Println("failed to perferom update-description query", err) 398 // failed to get record 399 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 400 return 401 } 402 403 newRepoInfo := f.RepoInfo(s, user) 404 newRepoInfo.Description = newDescription 405 406 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 407 RepoInfo: newRepoInfo, 408 }) 409 return 410 } 411} 412 413func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 414 f, err := s.fullyResolvedRepo(r) 415 if err != nil { 416 log.Println("failed to fully resolve repo", err) 417 return 418 } 419 ref := chi.URLParam(r, "ref") 420 protocol := "http" 421 if !s.config.Core.Dev { 422 protocol = "https" 423 } 424 425 if !plumbing.IsHash(ref) { 426 s.pages.Error404(w) 427 return 428 } 429 430 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 431 if err != nil { 432 log.Println("failed to reach knotserver", err) 433 return 434 } 435 436 body, err := io.ReadAll(resp.Body) 437 if err != nil { 438 log.Printf("Error reading response body: %v", err) 439 return 440 } 441 442 var result types.RepoCommitResponse 443 err = json.Unmarshal(body, &result) 444 if err != nil { 445 log.Println("failed to parse response:", err) 446 return 447 } 448 449 user := s.oauth.GetUser(r) 450 s.pages.RepoCommit(w, pages.RepoCommitParams{ 451 LoggedInUser: user, 452 RepoInfo: f.RepoInfo(s, user), 453 RepoCommitResponse: result, 454 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 455 }) 456 return 457} 458 459func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 460 f, err := s.fullyResolvedRepo(r) 461 if err != nil { 462 log.Println("failed to fully resolve repo", err) 463 return 464 } 465 466 ref := chi.URLParam(r, "ref") 467 treePath := chi.URLParam(r, "*") 468 protocol := "http" 469 if !s.config.Core.Dev { 470 protocol = "https" 471 } 472 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 473 if err != nil { 474 log.Println("failed to reach knotserver", err) 475 return 476 } 477 478 body, err := io.ReadAll(resp.Body) 479 if err != nil { 480 log.Printf("Error reading response body: %v", err) 481 return 482 } 483 484 var result types.RepoTreeResponse 485 err = json.Unmarshal(body, &result) 486 if err != nil { 487 log.Println("failed to parse response:", err) 488 return 489 } 490 491 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 492 // so we can safely redirect to the "parent" (which is the same file). 493 if len(result.Files) == 0 && result.Parent == treePath { 494 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 495 return 496 } 497 498 user := s.oauth.GetUser(r) 499 500 var breadcrumbs [][]string 501 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 502 if treePath != "" { 503 for idx, elem := range strings.Split(treePath, "/") { 504 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 505 } 506 } 507 508 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 509 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 510 511 s.pages.RepoTree(w, pages.RepoTreeParams{ 512 LoggedInUser: user, 513 BreadCrumbs: breadcrumbs, 514 BaseTreeLink: baseTreeLink, 515 BaseBlobLink: baseBlobLink, 516 RepoInfo: f.RepoInfo(s, user), 517 RepoTreeResponse: result, 518 }) 519 return 520} 521 522func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 523 f, err := s.fullyResolvedRepo(r) 524 if err != nil { 525 log.Println("failed to get repo and knot", err) 526 return 527 } 528 529 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 530 if err != nil { 531 log.Println("failed to create unsigned client", err) 532 return 533 } 534 535 result, err := us.Tags(f.OwnerDid(), f.RepoName) 536 if err != nil { 537 log.Println("failed to reach knotserver", err) 538 return 539 } 540 541 artifacts, err := db.GetArtifact(s.db, db.FilterEq("repo_at", f.RepoAt)) 542 if err != nil { 543 log.Println("failed grab artifacts", err) 544 return 545 } 546 547 // convert artifacts to map for easy UI building 548 artifactMap := make(map[plumbing.Hash][]db.Artifact) 549 for _, a := range artifacts { 550 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 551 } 552 553 var danglingArtifacts []db.Artifact 554 for _, a := range artifacts { 555 found := false 556 for _, t := range result.Tags { 557 if t.Tag != nil { 558 if t.Tag.Hash == a.Tag { 559 found = true 560 } 561 } 562 } 563 564 if !found { 565 danglingArtifacts = append(danglingArtifacts, a) 566 } 567 } 568 569 user := s.oauth.GetUser(r) 570 s.pages.RepoTags(w, pages.RepoTagsParams{ 571 LoggedInUser: user, 572 RepoInfo: f.RepoInfo(s, user), 573 RepoTagsResponse: *result, 574 ArtifactMap: artifactMap, 575 DanglingArtifacts: danglingArtifacts, 576 }) 577 return 578} 579 580func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 581 f, err := s.fullyResolvedRepo(r) 582 if err != nil { 583 log.Println("failed to get repo and knot", err) 584 return 585 } 586 587 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 588 if err != nil { 589 log.Println("failed to create unsigned client", err) 590 return 591 } 592 593 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 594 if err != nil { 595 log.Println("failed to reach knotserver", err) 596 return 597 } 598 599 body, err := io.ReadAll(resp.Body) 600 if err != nil { 601 log.Printf("Error reading response body: %v", err) 602 return 603 } 604 605 var result types.RepoBranchesResponse 606 err = json.Unmarshal(body, &result) 607 if err != nil { 608 log.Println("failed to parse response:", err) 609 return 610 } 611 612 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 613 if a.IsDefault { 614 return -1 615 } 616 if b.IsDefault { 617 return 1 618 } 619 if a.Commit != nil { 620 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 621 return 1 622 } else { 623 return -1 624 } 625 } 626 return strings.Compare(a.Name, b.Name) * -1 627 }) 628 629 user := s.oauth.GetUser(r) 630 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 631 LoggedInUser: user, 632 RepoInfo: f.RepoInfo(s, user), 633 RepoBranchesResponse: result, 634 }) 635 return 636} 637 638func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 639 f, err := s.fullyResolvedRepo(r) 640 if err != nil { 641 log.Println("failed to get repo and knot", err) 642 return 643 } 644 645 ref := chi.URLParam(r, "ref") 646 filePath := chi.URLParam(r, "*") 647 protocol := "http" 648 if !s.config.Core.Dev { 649 protocol = "https" 650 } 651 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 652 if err != nil { 653 log.Println("failed to reach knotserver", err) 654 return 655 } 656 657 body, err := io.ReadAll(resp.Body) 658 if err != nil { 659 log.Printf("Error reading response body: %v", err) 660 return 661 } 662 663 var result types.RepoBlobResponse 664 err = json.Unmarshal(body, &result) 665 if err != nil { 666 log.Println("failed to parse response:", err) 667 return 668 } 669 670 var breadcrumbs [][]string 671 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 672 if filePath != "" { 673 for idx, elem := range strings.Split(filePath, "/") { 674 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 675 } 676 } 677 678 showRendered := false 679 renderToggle := false 680 681 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 682 renderToggle = true 683 showRendered = r.URL.Query().Get("code") != "true" 684 } 685 686 user := s.oauth.GetUser(r) 687 s.pages.RepoBlob(w, pages.RepoBlobParams{ 688 LoggedInUser: user, 689 RepoInfo: f.RepoInfo(s, user), 690 RepoBlobResponse: result, 691 BreadCrumbs: breadcrumbs, 692 ShowRendered: showRendered, 693 RenderToggle: renderToggle, 694 }) 695 return 696} 697 698func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 699 f, err := s.fullyResolvedRepo(r) 700 if err != nil { 701 log.Println("failed to get repo and knot", err) 702 return 703 } 704 705 ref := chi.URLParam(r, "ref") 706 filePath := chi.URLParam(r, "*") 707 708 protocol := "http" 709 if !s.config.Core.Dev { 710 protocol = "https" 711 } 712 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 713 if err != nil { 714 log.Println("failed to reach knotserver", err) 715 return 716 } 717 718 body, err := io.ReadAll(resp.Body) 719 if err != nil { 720 log.Printf("Error reading response body: %v", err) 721 return 722 } 723 724 var result types.RepoBlobResponse 725 err = json.Unmarshal(body, &result) 726 if err != nil { 727 log.Println("failed to parse response:", err) 728 return 729 } 730 731 if result.IsBinary { 732 w.Header().Set("Content-Type", "application/octet-stream") 733 w.Write(body) 734 return 735 } 736 737 w.Header().Set("Content-Type", "text/plain") 738 w.Write([]byte(result.Contents)) 739 return 740} 741 742func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 743 f, err := s.fullyResolvedRepo(r) 744 if err != nil { 745 log.Println("failed to get repo and knot", err) 746 return 747 } 748 749 collaborator := r.FormValue("collaborator") 750 if collaborator == "" { 751 http.Error(w, "malformed form", http.StatusBadRequest) 752 return 753 } 754 755 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 756 if err != nil { 757 w.Write([]byte("failed to resolve collaborator did to a handle")) 758 return 759 } 760 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 761 762 // TODO: create an atproto record for this 763 764 secret, err := db.GetRegistrationKey(s.db, f.Knot) 765 if err != nil { 766 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 767 return 768 } 769 770 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 771 if err != nil { 772 log.Println("failed to create client to ", f.Knot) 773 return 774 } 775 776 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 777 if err != nil { 778 log.Printf("failed to make request to %s: %s", f.Knot, err) 779 return 780 } 781 782 if ksResp.StatusCode != http.StatusNoContent { 783 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 784 return 785 } 786 787 tx, err := s.db.BeginTx(r.Context(), nil) 788 if err != nil { 789 log.Println("failed to start tx") 790 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 791 return 792 } 793 defer func() { 794 tx.Rollback() 795 err = s.enforcer.E.LoadPolicy() 796 if err != nil { 797 log.Println("failed to rollback policies") 798 } 799 }() 800 801 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 802 if err != nil { 803 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 804 return 805 } 806 807 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 808 if err != nil { 809 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 810 return 811 } 812 813 err = tx.Commit() 814 if err != nil { 815 log.Println("failed to commit changes", err) 816 http.Error(w, err.Error(), http.StatusInternalServerError) 817 return 818 } 819 820 err = s.enforcer.E.SavePolicy() 821 if err != nil { 822 log.Println("failed to update ACLs", err) 823 http.Error(w, err.Error(), http.StatusInternalServerError) 824 return 825 } 826 827 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 828 829} 830 831func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 832 user := s.oauth.GetUser(r) 833 834 f, err := s.fullyResolvedRepo(r) 835 if err != nil { 836 log.Println("failed to get repo and knot", err) 837 return 838 } 839 840 // remove record from pds 841 xrpcClient, err := s.oauth.AuthorizedClient(r) 842 if err != nil { 843 log.Println("failed to get authorized client", err) 844 return 845 } 846 repoRkey := f.RepoAt.RecordKey().String() 847 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 848 Collection: tangled.RepoNSID, 849 Repo: user.Did, 850 Rkey: repoRkey, 851 }) 852 if err != nil { 853 log.Printf("failed to delete record: %s", err) 854 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 855 return 856 } 857 log.Println("removed repo record ", f.RepoAt.String()) 858 859 secret, err := db.GetRegistrationKey(s.db, f.Knot) 860 if err != nil { 861 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 862 return 863 } 864 865 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 866 if err != nil { 867 log.Println("failed to create client to ", f.Knot) 868 return 869 } 870 871 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 872 if err != nil { 873 log.Printf("failed to make request to %s: %s", f.Knot, err) 874 return 875 } 876 877 if ksResp.StatusCode != http.StatusNoContent { 878 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 879 } else { 880 log.Println("removed repo from knot ", f.Knot) 881 } 882 883 tx, err := s.db.BeginTx(r.Context(), nil) 884 if err != nil { 885 log.Println("failed to start tx") 886 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 887 return 888 } 889 defer func() { 890 tx.Rollback() 891 err = s.enforcer.E.LoadPolicy() 892 if err != nil { 893 log.Println("failed to rollback policies") 894 } 895 }() 896 897 // remove collaborator RBAC 898 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 899 if err != nil { 900 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 901 return 902 } 903 for _, c := range repoCollaborators { 904 did := c[0] 905 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 906 } 907 log.Println("removed collaborators") 908 909 // remove repo RBAC 910 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 911 if err != nil { 912 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 913 return 914 } 915 916 // remove repo from db 917 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 918 if err != nil { 919 s.pages.Notice(w, "settings-delete", "Failed to update appview") 920 return 921 } 922 log.Println("removed repo from db") 923 924 err = tx.Commit() 925 if err != nil { 926 log.Println("failed to commit changes", err) 927 http.Error(w, err.Error(), http.StatusInternalServerError) 928 return 929 } 930 931 err = s.enforcer.E.SavePolicy() 932 if err != nil { 933 log.Println("failed to update ACLs", err) 934 http.Error(w, err.Error(), http.StatusInternalServerError) 935 return 936 } 937 938 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 939} 940 941func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 942 f, err := s.fullyResolvedRepo(r) 943 if err != nil { 944 log.Println("failed to get repo and knot", err) 945 return 946 } 947 948 branch := r.FormValue("branch") 949 if branch == "" { 950 http.Error(w, "malformed form", http.StatusBadRequest) 951 return 952 } 953 954 secret, err := db.GetRegistrationKey(s.db, f.Knot) 955 if err != nil { 956 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 957 return 958 } 959 960 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 961 if err != nil { 962 log.Println("failed to create client to ", f.Knot) 963 return 964 } 965 966 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 967 if err != nil { 968 log.Printf("failed to make request to %s: %s", f.Knot, err) 969 return 970 } 971 972 if ksResp.StatusCode != http.StatusNoContent { 973 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 974 return 975 } 976 977 w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 978} 979 980func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 981 f, err := s.fullyResolvedRepo(r) 982 if err != nil { 983 log.Println("failed to get repo and knot", err) 984 return 985 } 986 987 switch r.Method { 988 case http.MethodGet: 989 // for now, this is just pubkeys 990 user := s.oauth.GetUser(r) 991 repoCollaborators, err := f.Collaborators(r.Context(), s) 992 if err != nil { 993 log.Println("failed to get collaborators", err) 994 } 995 996 isCollaboratorInviteAllowed := false 997 if user != nil { 998 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 999 if err == nil && ok { 1000 isCollaboratorInviteAllowed = true 1001 } 1002 } 1003 1004 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1005 if err != nil { 1006 log.Println("failed to create unsigned client", err) 1007 return 1008 } 1009 1010 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1011 if err != nil { 1012 log.Println("failed to reach knotserver", err) 1013 return 1014 } 1015 defer resp.Body.Close() 1016 1017 body, err := io.ReadAll(resp.Body) 1018 if err != nil { 1019 log.Printf("Error reading response body: %v", err) 1020 } 1021 1022 var result types.RepoBranchesResponse 1023 err = json.Unmarshal(body, &result) 1024 if err != nil { 1025 log.Println("failed to parse response:", err) 1026 } 1027 1028 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 1029 LoggedInUser: user, 1030 RepoInfo: f.RepoInfo(s, user), 1031 Collaborators: repoCollaborators, 1032 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1033 Branches: result.Branches, 1034 }) 1035 } 1036} 1037 1038type FullyResolvedRepo struct { 1039 Knot string 1040 OwnerId identity.Identity 1041 RepoName string 1042 RepoAt syntax.ATURI 1043 Description string 1044 CreatedAt string 1045 Ref string 1046 CurrentDir string 1047} 1048 1049func (f *FullyResolvedRepo) OwnerDid() string { 1050 return f.OwnerId.DID.String() 1051} 1052 1053func (f *FullyResolvedRepo) OwnerHandle() string { 1054 return f.OwnerId.Handle.String() 1055} 1056 1057func (f *FullyResolvedRepo) OwnerSlashRepo() string { 1058 handle := f.OwnerId.Handle 1059 1060 var p string 1061 if handle != "" && !handle.IsInvalidHandle() { 1062 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 1063 } else { 1064 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1065 } 1066 1067 return p 1068} 1069 1070func (f *FullyResolvedRepo) DidSlashRepo() string { 1071 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1072 return p 1073} 1074 1075func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 1076 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1077 if err != nil { 1078 return nil, err 1079 } 1080 1081 var collaborators []pages.Collaborator 1082 for _, item := range repoCollaborators { 1083 // currently only two roles: owner and member 1084 var role string 1085 if item[3] == "repo:owner" { 1086 role = "owner" 1087 } else if item[3] == "repo:collaborator" { 1088 role = "collaborator" 1089 } else { 1090 continue 1091 } 1092 1093 did := item[0] 1094 1095 c := pages.Collaborator{ 1096 Did: did, 1097 Handle: "", 1098 Role: role, 1099 } 1100 collaborators = append(collaborators, c) 1101 } 1102 1103 // populate all collborators with handles 1104 identsToResolve := make([]string, len(collaborators)) 1105 for i, collab := range collaborators { 1106 identsToResolve[i] = collab.Did 1107 } 1108 1109 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 1110 for i, resolved := range resolvedIdents { 1111 if resolved != nil { 1112 collaborators[i].Handle = resolved.Handle.String() 1113 } 1114 } 1115 1116 return collaborators, nil 1117} 1118 1119func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { 1120 isStarred := false 1121 if u != nil { 1122 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 1123 } 1124 1125 starCount, err := db.GetStarCount(s.db, f.RepoAt) 1126 if err != nil { 1127 log.Println("failed to get star count for ", f.RepoAt) 1128 } 1129 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 1130 if err != nil { 1131 log.Println("failed to get issue count for ", f.RepoAt) 1132 } 1133 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 1134 if err != nil { 1135 log.Println("failed to get issue count for ", f.RepoAt) 1136 } 1137 source, err := db.GetRepoSource(s.db, f.RepoAt) 1138 if errors.Is(err, sql.ErrNoRows) { 1139 source = "" 1140 } else if err != nil { 1141 log.Println("failed to get repo source for ", f.RepoAt, err) 1142 } 1143 1144 var sourceRepo *db.Repo 1145 if source != "" { 1146 sourceRepo, err = db.GetRepoByAtUri(s.db, source) 1147 if err != nil { 1148 log.Println("failed to get repo by at uri", err) 1149 } 1150 } 1151 1152 var sourceHandle *identity.Identity 1153 if sourceRepo != nil { 1154 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 1155 if err != nil { 1156 log.Println("failed to resolve source repo", err) 1157 } 1158 } 1159 1160 knot := f.Knot 1161 var disableFork bool 1162 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 1163 if err != nil { 1164 log.Printf("failed to create unsigned client for %s: %v", knot, err) 1165 } else { 1166 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1167 if err != nil { 1168 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1169 } else { 1170 defer resp.Body.Close() 1171 body, err := io.ReadAll(resp.Body) 1172 if err != nil { 1173 log.Printf("error reading branch response body: %v", err) 1174 } else { 1175 var branchesResp types.RepoBranchesResponse 1176 if err := json.Unmarshal(body, &branchesResp); err != nil { 1177 log.Printf("error parsing branch response: %v", err) 1178 } else { 1179 disableFork = false 1180 } 1181 1182 if len(branchesResp.Branches) == 0 { 1183 disableFork = true 1184 } 1185 } 1186 } 1187 } 1188 1189 repoInfo := repoinfo.RepoInfo{ 1190 OwnerDid: f.OwnerDid(), 1191 OwnerHandle: f.OwnerHandle(), 1192 Name: f.RepoName, 1193 RepoAt: f.RepoAt, 1194 Description: f.Description, 1195 Ref: f.Ref, 1196 IsStarred: isStarred, 1197 Knot: knot, 1198 Roles: RolesInRepo(s, u, f), 1199 Stats: db.RepoStats{ 1200 StarCount: starCount, 1201 IssueCount: issueCount, 1202 PullCount: pullCount, 1203 }, 1204 DisableFork: disableFork, 1205 CurrentDir: f.CurrentDir, 1206 } 1207 1208 if sourceRepo != nil { 1209 repoInfo.Source = sourceRepo 1210 repoInfo.SourceHandle = sourceHandle.Handle.String() 1211 } 1212 1213 return repoInfo 1214} 1215 1216func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1217 user := s.oauth.GetUser(r) 1218 f, err := s.fullyResolvedRepo(r) 1219 if err != nil { 1220 log.Println("failed to get repo and knot", err) 1221 return 1222 } 1223 1224 issueId := chi.URLParam(r, "issue") 1225 issueIdInt, err := strconv.Atoi(issueId) 1226 if err != nil { 1227 http.Error(w, "bad issue id", http.StatusBadRequest) 1228 log.Println("failed to parse issue id", err) 1229 return 1230 } 1231 1232 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 1233 if err != nil { 1234 log.Println("failed to get issue and comments", err) 1235 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1236 return 1237 } 1238 1239 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 1240 if err != nil { 1241 log.Println("failed to resolve issue owner", err) 1242 } 1243 1244 identsToResolve := make([]string, len(comments)) 1245 for i, comment := range comments { 1246 identsToResolve[i] = comment.OwnerDid 1247 } 1248 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1249 didHandleMap := make(map[string]string) 1250 for _, identity := range resolvedIds { 1251 if !identity.Handle.IsInvalidHandle() { 1252 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1253 } else { 1254 didHandleMap[identity.DID.String()] = identity.DID.String() 1255 } 1256 } 1257 1258 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1259 LoggedInUser: user, 1260 RepoInfo: f.RepoInfo(s, user), 1261 Issue: *issue, 1262 Comments: comments, 1263 1264 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1265 DidHandleMap: didHandleMap, 1266 }) 1267 1268} 1269 1270func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1271 user := s.oauth.GetUser(r) 1272 f, err := s.fullyResolvedRepo(r) 1273 if err != nil { 1274 log.Println("failed to get repo and knot", err) 1275 return 1276 } 1277 1278 issueId := chi.URLParam(r, "issue") 1279 issueIdInt, err := strconv.Atoi(issueId) 1280 if err != nil { 1281 http.Error(w, "bad issue id", http.StatusBadRequest) 1282 log.Println("failed to parse issue id", err) 1283 return 1284 } 1285 1286 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1287 if err != nil { 1288 log.Println("failed to get issue", err) 1289 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1290 return 1291 } 1292 1293 collaborators, err := f.Collaborators(r.Context(), s) 1294 if err != nil { 1295 log.Println("failed to fetch repo collaborators: %w", err) 1296 } 1297 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1298 return user.Did == collab.Did 1299 }) 1300 isIssueOwner := user.Did == issue.OwnerDid 1301 1302 // TODO: make this more granular 1303 if isIssueOwner || isCollaborator { 1304 1305 closed := tangled.RepoIssueStateClosed 1306 1307 client, err := s.oauth.AuthorizedClient(r) 1308 if err != nil { 1309 log.Println("failed to get authorized client", err) 1310 return 1311 } 1312 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1313 Collection: tangled.RepoIssueStateNSID, 1314 Repo: user.Did, 1315 Rkey: appview.TID(), 1316 Record: &lexutil.LexiconTypeDecoder{ 1317 Val: &tangled.RepoIssueState{ 1318 Issue: issue.IssueAt, 1319 State: closed, 1320 }, 1321 }, 1322 }) 1323 1324 if err != nil { 1325 log.Println("failed to update issue state", err) 1326 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1327 return 1328 } 1329 1330 err = db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1331 if err != nil { 1332 log.Println("failed to close issue", err) 1333 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1334 return 1335 } 1336 1337 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1338 return 1339 } else { 1340 log.Println("user is not permitted to close issue") 1341 http.Error(w, "for biden", http.StatusUnauthorized) 1342 return 1343 } 1344} 1345 1346func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1347 user := s.oauth.GetUser(r) 1348 f, err := s.fullyResolvedRepo(r) 1349 if err != nil { 1350 log.Println("failed to get repo and knot", err) 1351 return 1352 } 1353 1354 issueId := chi.URLParam(r, "issue") 1355 issueIdInt, err := strconv.Atoi(issueId) 1356 if err != nil { 1357 http.Error(w, "bad issue id", http.StatusBadRequest) 1358 log.Println("failed to parse issue id", err) 1359 return 1360 } 1361 1362 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1363 if err != nil { 1364 log.Println("failed to get issue", err) 1365 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1366 return 1367 } 1368 1369 collaborators, err := f.Collaborators(r.Context(), s) 1370 if err != nil { 1371 log.Println("failed to fetch repo collaborators: %w", err) 1372 } 1373 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1374 return user.Did == collab.Did 1375 }) 1376 isIssueOwner := user.Did == issue.OwnerDid 1377 1378 if isCollaborator || isIssueOwner { 1379 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1380 if err != nil { 1381 log.Println("failed to reopen issue", err) 1382 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1383 return 1384 } 1385 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1386 return 1387 } else { 1388 log.Println("user is not the owner of the repo") 1389 http.Error(w, "forbidden", http.StatusUnauthorized) 1390 return 1391 } 1392} 1393 1394func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1395 user := s.oauth.GetUser(r) 1396 f, err := s.fullyResolvedRepo(r) 1397 if err != nil { 1398 log.Println("failed to get repo and knot", err) 1399 return 1400 } 1401 1402 issueId := chi.URLParam(r, "issue") 1403 issueIdInt, err := strconv.Atoi(issueId) 1404 if err != nil { 1405 http.Error(w, "bad issue id", http.StatusBadRequest) 1406 log.Println("failed to parse issue id", err) 1407 return 1408 } 1409 1410 switch r.Method { 1411 case http.MethodPost: 1412 body := r.FormValue("body") 1413 if body == "" { 1414 s.pages.Notice(w, "issue", "Body is required") 1415 return 1416 } 1417 1418 commentId := mathrand.IntN(1000000) 1419 rkey := appview.TID() 1420 1421 err := db.NewIssueComment(s.db, &db.Comment{ 1422 OwnerDid: user.Did, 1423 RepoAt: f.RepoAt, 1424 Issue: issueIdInt, 1425 CommentId: commentId, 1426 Body: body, 1427 Rkey: rkey, 1428 }) 1429 if err != nil { 1430 log.Println("failed to create comment", err) 1431 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1432 return 1433 } 1434 1435 createdAt := time.Now().Format(time.RFC3339) 1436 commentIdInt64 := int64(commentId) 1437 ownerDid := user.Did 1438 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1439 if err != nil { 1440 log.Println("failed to get issue at", err) 1441 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1442 return 1443 } 1444 1445 atUri := f.RepoAt.String() 1446 client, err := s.oauth.AuthorizedClient(r) 1447 if err != nil { 1448 log.Println("failed to get authorized client", err) 1449 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1450 return 1451 } 1452 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1453 Collection: tangled.RepoIssueCommentNSID, 1454 Repo: user.Did, 1455 Rkey: rkey, 1456 Record: &lexutil.LexiconTypeDecoder{ 1457 Val: &tangled.RepoIssueComment{ 1458 Repo: &atUri, 1459 Issue: issueAt, 1460 CommentId: &commentIdInt64, 1461 Owner: &ownerDid, 1462 Body: body, 1463 CreatedAt: createdAt, 1464 }, 1465 }, 1466 }) 1467 if err != nil { 1468 log.Println("failed to create comment", err) 1469 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1470 return 1471 } 1472 1473 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1474 return 1475 } 1476} 1477 1478func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1479 user := s.oauth.GetUser(r) 1480 f, err := s.fullyResolvedRepo(r) 1481 if err != nil { 1482 log.Println("failed to get repo and knot", err) 1483 return 1484 } 1485 1486 issueId := chi.URLParam(r, "issue") 1487 issueIdInt, err := strconv.Atoi(issueId) 1488 if err != nil { 1489 http.Error(w, "bad issue id", http.StatusBadRequest) 1490 log.Println("failed to parse issue id", err) 1491 return 1492 } 1493 1494 commentId := chi.URLParam(r, "comment_id") 1495 commentIdInt, err := strconv.Atoi(commentId) 1496 if err != nil { 1497 http.Error(w, "bad comment id", http.StatusBadRequest) 1498 log.Println("failed to parse issue id", err) 1499 return 1500 } 1501 1502 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1503 if err != nil { 1504 log.Println("failed to get issue", err) 1505 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1506 return 1507 } 1508 1509 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1510 if err != nil { 1511 http.Error(w, "bad comment id", http.StatusBadRequest) 1512 return 1513 } 1514 1515 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1516 if err != nil { 1517 log.Println("failed to resolve did") 1518 return 1519 } 1520 1521 didHandleMap := make(map[string]string) 1522 if !identity.Handle.IsInvalidHandle() { 1523 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1524 } else { 1525 didHandleMap[identity.DID.String()] = identity.DID.String() 1526 } 1527 1528 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1529 LoggedInUser: user, 1530 RepoInfo: f.RepoInfo(s, user), 1531 DidHandleMap: didHandleMap, 1532 Issue: issue, 1533 Comment: comment, 1534 }) 1535} 1536 1537func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1538 user := s.oauth.GetUser(r) 1539 f, err := s.fullyResolvedRepo(r) 1540 if err != nil { 1541 log.Println("failed to get repo and knot", err) 1542 return 1543 } 1544 1545 issueId := chi.URLParam(r, "issue") 1546 issueIdInt, err := strconv.Atoi(issueId) 1547 if err != nil { 1548 http.Error(w, "bad issue id", http.StatusBadRequest) 1549 log.Println("failed to parse issue id", err) 1550 return 1551 } 1552 1553 commentId := chi.URLParam(r, "comment_id") 1554 commentIdInt, err := strconv.Atoi(commentId) 1555 if err != nil { 1556 http.Error(w, "bad comment id", http.StatusBadRequest) 1557 log.Println("failed to parse issue id", err) 1558 return 1559 } 1560 1561 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1562 if err != nil { 1563 log.Println("failed to get issue", err) 1564 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1565 return 1566 } 1567 1568 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1569 if err != nil { 1570 http.Error(w, "bad comment id", http.StatusBadRequest) 1571 return 1572 } 1573 1574 if comment.OwnerDid != user.Did { 1575 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1576 return 1577 } 1578 1579 switch r.Method { 1580 case http.MethodGet: 1581 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1582 LoggedInUser: user, 1583 RepoInfo: f.RepoInfo(s, user), 1584 Issue: issue, 1585 Comment: comment, 1586 }) 1587 case http.MethodPost: 1588 // extract form value 1589 newBody := r.FormValue("body") 1590 client, err := s.oauth.AuthorizedClient(r) 1591 if err != nil { 1592 log.Println("failed to get authorized client", err) 1593 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1594 return 1595 } 1596 rkey := comment.Rkey 1597 1598 // optimistic update 1599 edited := time.Now() 1600 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1601 if err != nil { 1602 log.Println("failed to perferom update-description query", err) 1603 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1604 return 1605 } 1606 1607 // rkey is optional, it was introduced later 1608 if comment.Rkey != "" { 1609 // update the record on pds 1610 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1611 if err != nil { 1612 // failed to get record 1613 log.Println(err, rkey) 1614 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1615 return 1616 } 1617 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1618 record, _ := data.UnmarshalJSON(value) 1619 1620 repoAt := record["repo"].(string) 1621 issueAt := record["issue"].(string) 1622 createdAt := record["createdAt"].(string) 1623 commentIdInt64 := int64(commentIdInt) 1624 1625 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1626 Collection: tangled.RepoIssueCommentNSID, 1627 Repo: user.Did, 1628 Rkey: rkey, 1629 SwapRecord: ex.Cid, 1630 Record: &lexutil.LexiconTypeDecoder{ 1631 Val: &tangled.RepoIssueComment{ 1632 Repo: &repoAt, 1633 Issue: issueAt, 1634 CommentId: &commentIdInt64, 1635 Owner: &comment.OwnerDid, 1636 Body: newBody, 1637 CreatedAt: createdAt, 1638 }, 1639 }, 1640 }) 1641 if err != nil { 1642 log.Println(err) 1643 } 1644 } 1645 1646 // optimistic update for htmx 1647 didHandleMap := map[string]string{ 1648 user.Did: user.Handle, 1649 } 1650 comment.Body = newBody 1651 comment.Edited = &edited 1652 1653 // return new comment body with htmx 1654 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1655 LoggedInUser: user, 1656 RepoInfo: f.RepoInfo(s, user), 1657 DidHandleMap: didHandleMap, 1658 Issue: issue, 1659 Comment: comment, 1660 }) 1661 return 1662 1663 } 1664 1665} 1666 1667func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1668 user := s.oauth.GetUser(r) 1669 f, err := s.fullyResolvedRepo(r) 1670 if err != nil { 1671 log.Println("failed to get repo and knot", err) 1672 return 1673 } 1674 1675 issueId := chi.URLParam(r, "issue") 1676 issueIdInt, err := strconv.Atoi(issueId) 1677 if err != nil { 1678 http.Error(w, "bad issue id", http.StatusBadRequest) 1679 log.Println("failed to parse issue id", err) 1680 return 1681 } 1682 1683 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1684 if err != nil { 1685 log.Println("failed to get issue", err) 1686 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1687 return 1688 } 1689 1690 commentId := chi.URLParam(r, "comment_id") 1691 commentIdInt, err := strconv.Atoi(commentId) 1692 if err != nil { 1693 http.Error(w, "bad comment id", http.StatusBadRequest) 1694 log.Println("failed to parse issue id", err) 1695 return 1696 } 1697 1698 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1699 if err != nil { 1700 http.Error(w, "bad comment id", http.StatusBadRequest) 1701 return 1702 } 1703 1704 if comment.OwnerDid != user.Did { 1705 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1706 return 1707 } 1708 1709 if comment.Deleted != nil { 1710 http.Error(w, "comment already deleted", http.StatusBadRequest) 1711 return 1712 } 1713 1714 // optimistic deletion 1715 deleted := time.Now() 1716 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1717 if err != nil { 1718 log.Println("failed to delete comment") 1719 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1720 return 1721 } 1722 1723 // delete from pds 1724 if comment.Rkey != "" { 1725 client, err := s.oauth.AuthorizedClient(r) 1726 if err != nil { 1727 log.Println("failed to get authorized client", err) 1728 s.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1729 return 1730 } 1731 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1732 Collection: tangled.GraphFollowNSID, 1733 Repo: user.Did, 1734 Rkey: comment.Rkey, 1735 }) 1736 if err != nil { 1737 log.Println(err) 1738 } 1739 } 1740 1741 // optimistic update for htmx 1742 didHandleMap := map[string]string{ 1743 user.Did: user.Handle, 1744 } 1745 comment.Body = "" 1746 comment.Deleted = &deleted 1747 1748 // htmx fragment of comment after deletion 1749 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1750 LoggedInUser: user, 1751 RepoInfo: f.RepoInfo(s, user), 1752 DidHandleMap: didHandleMap, 1753 Issue: issue, 1754 Comment: comment, 1755 }) 1756 return 1757} 1758 1759func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1760 params := r.URL.Query() 1761 state := params.Get("state") 1762 isOpen := true 1763 switch state { 1764 case "open": 1765 isOpen = true 1766 case "closed": 1767 isOpen = false 1768 default: 1769 isOpen = true 1770 } 1771 1772 page, ok := r.Context().Value("page").(pagination.Page) 1773 if !ok { 1774 log.Println("failed to get page") 1775 page = pagination.FirstPage() 1776 } 1777 1778 user := s.oauth.GetUser(r) 1779 f, err := s.fullyResolvedRepo(r) 1780 if err != nil { 1781 log.Println("failed to get repo and knot", err) 1782 return 1783 } 1784 1785 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1786 if err != nil { 1787 log.Println("failed to get issues", err) 1788 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1789 return 1790 } 1791 1792 identsToResolve := make([]string, len(issues)) 1793 for i, issue := range issues { 1794 identsToResolve[i] = issue.OwnerDid 1795 } 1796 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1797 didHandleMap := make(map[string]string) 1798 for _, identity := range resolvedIds { 1799 if !identity.Handle.IsInvalidHandle() { 1800 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1801 } else { 1802 didHandleMap[identity.DID.String()] = identity.DID.String() 1803 } 1804 } 1805 1806 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1807 LoggedInUser: s.oauth.GetUser(r), 1808 RepoInfo: f.RepoInfo(s, user), 1809 Issues: issues, 1810 DidHandleMap: didHandleMap, 1811 FilteringByOpen: isOpen, 1812 Page: page, 1813 }) 1814 return 1815} 1816 1817func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1818 user := s.oauth.GetUser(r) 1819 1820 f, err := s.fullyResolvedRepo(r) 1821 if err != nil { 1822 log.Println("failed to get repo and knot", err) 1823 return 1824 } 1825 1826 switch r.Method { 1827 case http.MethodGet: 1828 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1829 LoggedInUser: user, 1830 RepoInfo: f.RepoInfo(s, user), 1831 }) 1832 case http.MethodPost: 1833 title := r.FormValue("title") 1834 body := r.FormValue("body") 1835 1836 if title == "" || body == "" { 1837 s.pages.Notice(w, "issues", "Title and body are required") 1838 return 1839 } 1840 1841 tx, err := s.db.BeginTx(r.Context(), nil) 1842 if err != nil { 1843 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1844 return 1845 } 1846 1847 err = db.NewIssue(tx, &db.Issue{ 1848 RepoAt: f.RepoAt, 1849 Title: title, 1850 Body: body, 1851 OwnerDid: user.Did, 1852 }) 1853 if err != nil { 1854 log.Println("failed to create issue", err) 1855 s.pages.Notice(w, "issues", "Failed to create issue.") 1856 return 1857 } 1858 1859 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1860 if err != nil { 1861 log.Println("failed to get issue id", err) 1862 s.pages.Notice(w, "issues", "Failed to create issue.") 1863 return 1864 } 1865 1866 client, err := s.oauth.AuthorizedClient(r) 1867 if err != nil { 1868 log.Println("failed to get authorized client", err) 1869 s.pages.Notice(w, "issues", "Failed to create issue.") 1870 return 1871 } 1872 atUri := f.RepoAt.String() 1873 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1874 Collection: tangled.RepoIssueNSID, 1875 Repo: user.Did, 1876 Rkey: appview.TID(), 1877 Record: &lexutil.LexiconTypeDecoder{ 1878 Val: &tangled.RepoIssue{ 1879 Repo: atUri, 1880 Title: title, 1881 Body: &body, 1882 Owner: user.Did, 1883 IssueId: int64(issueId), 1884 }, 1885 }, 1886 }) 1887 if err != nil { 1888 log.Println("failed to create issue", err) 1889 s.pages.Notice(w, "issues", "Failed to create issue.") 1890 return 1891 } 1892 1893 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1894 if err != nil { 1895 log.Println("failed to set issue at", err) 1896 s.pages.Notice(w, "issues", "Failed to create issue.") 1897 return 1898 } 1899 1900 if !s.config.Core.Dev { 1901 err = s.posthog.Enqueue(posthog.Capture{ 1902 DistinctId: user.Did, 1903 Event: "new_issue", 1904 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 1905 }) 1906 if err != nil { 1907 log.Println("failed to enqueue posthog event:", err) 1908 } 1909 } 1910 1911 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1912 return 1913 } 1914} 1915 1916func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1917 user := s.oauth.GetUser(r) 1918 f, err := s.fullyResolvedRepo(r) 1919 if err != nil { 1920 log.Printf("failed to resolve source repo: %v", err) 1921 return 1922 } 1923 1924 switch r.Method { 1925 case http.MethodPost: 1926 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1927 if err != nil { 1928 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1929 return 1930 } 1931 1932 client, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1933 if err != nil { 1934 s.pages.Notice(w, "repo", "Failed to reach knot server.") 1935 return 1936 } 1937 1938 var uri string 1939 if s.config.Core.Dev { 1940 uri = "http" 1941 } else { 1942 uri = "https" 1943 } 1944 forkName := fmt.Sprintf("%s", f.RepoName) 1945 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1946 1947 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1948 if err != nil { 1949 s.pages.Notice(w, "repo", "Failed to sync repository fork.") 1950 return 1951 } 1952 1953 s.pages.HxRefresh(w) 1954 return 1955 } 1956} 1957 1958func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1959 user := s.oauth.GetUser(r) 1960 f, err := s.fullyResolvedRepo(r) 1961 if err != nil { 1962 log.Printf("failed to resolve source repo: %v", err) 1963 return 1964 } 1965 1966 switch r.Method { 1967 case http.MethodGet: 1968 user := s.oauth.GetUser(r) 1969 knots, err := s.enforcer.GetDomainsForUser(user.Did) 1970 if err != nil { 1971 s.pages.Notice(w, "repo", "Invalid user account.") 1972 return 1973 } 1974 1975 s.pages.ForkRepo(w, pages.ForkRepoParams{ 1976 LoggedInUser: user, 1977 Knots: knots, 1978 RepoInfo: f.RepoInfo(s, user), 1979 }) 1980 1981 case http.MethodPost: 1982 1983 knot := r.FormValue("knot") 1984 if knot == "" { 1985 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1986 return 1987 } 1988 1989 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1990 if err != nil || !ok { 1991 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1992 return 1993 } 1994 1995 forkName := fmt.Sprintf("%s", f.RepoName) 1996 1997 // this check is *only* to see if the forked repo name already exists 1998 // in the user's account. 1999 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 2000 if err != nil { 2001 if errors.Is(err, sql.ErrNoRows) { 2002 // no existing repo with this name found, we can use the name as is 2003 } else { 2004 log.Println("error fetching existing repo from db", err) 2005 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2006 return 2007 } 2008 } else if existingRepo != nil { 2009 // repo with this name already exists, append random string 2010 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2011 } 2012 secret, err := db.GetRegistrationKey(s.db, knot) 2013 if err != nil { 2014 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 2015 return 2016 } 2017 2018 client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev) 2019 if err != nil { 2020 s.pages.Notice(w, "repo", "Failed to reach knot server.") 2021 return 2022 } 2023 2024 var uri string 2025 if s.config.Core.Dev { 2026 uri = "http" 2027 } else { 2028 uri = "https" 2029 } 2030 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 2031 sourceAt := f.RepoAt.String() 2032 2033 rkey := appview.TID() 2034 repo := &db.Repo{ 2035 Did: user.Did, 2036 Name: forkName, 2037 Knot: knot, 2038 Rkey: rkey, 2039 Source: sourceAt, 2040 } 2041 2042 tx, err := s.db.BeginTx(r.Context(), nil) 2043 if err != nil { 2044 log.Println(err) 2045 s.pages.Notice(w, "repo", "Failed to save repository information.") 2046 return 2047 } 2048 defer func() { 2049 tx.Rollback() 2050 err = s.enforcer.E.LoadPolicy() 2051 if err != nil { 2052 log.Println("failed to rollback policies") 2053 } 2054 }() 2055 2056 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 2057 if err != nil { 2058 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 2059 return 2060 } 2061 2062 switch resp.StatusCode { 2063 case http.StatusConflict: 2064 s.pages.Notice(w, "repo", "A repository with that name already exists.") 2065 return 2066 case http.StatusInternalServerError: 2067 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 2068 case http.StatusNoContent: 2069 // continue 2070 } 2071 2072 xrpcClient, err := s.oauth.AuthorizedClient(r) 2073 if err != nil { 2074 log.Println("failed to get authorized client", err) 2075 s.pages.Notice(w, "repo", "Failed to create repository.") 2076 return 2077 } 2078 2079 createdAt := time.Now().Format(time.RFC3339) 2080 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2081 Collection: tangled.RepoNSID, 2082 Repo: user.Did, 2083 Rkey: rkey, 2084 Record: &lexutil.LexiconTypeDecoder{ 2085 Val: &tangled.Repo{ 2086 Knot: repo.Knot, 2087 Name: repo.Name, 2088 CreatedAt: createdAt, 2089 Owner: user.Did, 2090 Source: &sourceAt, 2091 }}, 2092 }) 2093 if err != nil { 2094 log.Printf("failed to create record: %s", err) 2095 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 2096 return 2097 } 2098 log.Println("created repo record: ", atresp.Uri) 2099 2100 repo.AtUri = atresp.Uri 2101 err = db.AddRepo(tx, repo) 2102 if err != nil { 2103 log.Println(err) 2104 s.pages.Notice(w, "repo", "Failed to save repository information.") 2105 return 2106 } 2107 2108 // acls 2109 p, _ := securejoin.SecureJoin(user.Did, forkName) 2110 err = s.enforcer.AddRepo(user.Did, knot, p) 2111 if err != nil { 2112 log.Println(err) 2113 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2114 return 2115 } 2116 2117 err = tx.Commit() 2118 if err != nil { 2119 log.Println("failed to commit changes", err) 2120 http.Error(w, err.Error(), http.StatusInternalServerError) 2121 return 2122 } 2123 2124 err = s.enforcer.E.SavePolicy() 2125 if err != nil { 2126 log.Println("failed to update ACLs", err) 2127 http.Error(w, err.Error(), http.StatusInternalServerError) 2128 return 2129 } 2130 2131 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2132 return 2133 } 2134}