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