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