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