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