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