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