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