forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 "math/rand/v2" 10 "net/http" 11 "path" 12 "slices" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/bluesky-social/indigo/atproto/identity" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 securejoin "github.com/cyphar/filepath-securejoin" 20 "github.com/go-chi/chi/v5" 21 "tangled.sh/tangled.sh/core/api/tangled" 22 "tangled.sh/tangled.sh/core/appview/auth" 23 "tangled.sh/tangled.sh/core/appview/db" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/types" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 lexutil "github.com/bluesky-social/indigo/lex/util" 29) 30 31func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 32 ref := chi.URLParam(r, "ref") 33 f, err := fullyResolvedRepo(r) 34 if err != nil { 35 log.Println("failed to fully resolve repo", err) 36 return 37 } 38 39 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 40 if err != nil { 41 log.Printf("failed to create unsigned client for %s", f.Knot) 42 s.pages.Error503(w) 43 return 44 } 45 46 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref) 47 if err != nil { 48 s.pages.Error503(w) 49 log.Println("failed to reach knotserver", err) 50 return 51 } 52 defer resp.Body.Close() 53 54 body, err := io.ReadAll(resp.Body) 55 if err != nil { 56 log.Printf("Error reading response body: %v", err) 57 return 58 } 59 60 var result types.RepoIndexResponse 61 err = json.Unmarshal(body, &result) 62 if err != nil { 63 log.Printf("Error unmarshalling response body: %v", err) 64 return 65 } 66 67 tagMap := make(map[string][]string) 68 for _, tag := range result.Tags { 69 hash := tag.Hash 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 emails := uniqueEmails(result.Commits) 79 80 user := s.auth.GetUser(r) 81 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 82 LoggedInUser: user, 83 RepoInfo: f.RepoInfo(s, user), 84 TagMap: tagMap, 85 RepoIndexResponse: result, 86 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 87 }) 88 return 89} 90 91func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 92 f, err := fullyResolvedRepo(r) 93 if err != nil { 94 log.Println("failed to fully resolve repo", err) 95 return 96 } 97 98 page := 1 99 if r.URL.Query().Get("page") != "" { 100 page, err = strconv.Atoi(r.URL.Query().Get("page")) 101 if err != nil { 102 page = 1 103 } 104 } 105 106 ref := chi.URLParam(r, "ref") 107 108 protocol := "http" 109 if !s.config.Dev { 110 protocol = "https" 111 } 112 113 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/log/%s?page=%d&per_page=30", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, page)) 114 if err != nil { 115 log.Println("failed to reach knotserver", err) 116 return 117 } 118 119 body, err := io.ReadAll(resp.Body) 120 if err != nil { 121 log.Printf("error reading response body: %v", err) 122 return 123 } 124 125 var repolog types.RepoLogResponse 126 err = json.Unmarshal(body, &repolog) 127 if err != nil { 128 log.Println("failed to parse json response", err) 129 return 130 } 131 132 user := s.auth.GetUser(r) 133 s.pages.RepoLog(w, pages.RepoLogParams{ 134 LoggedInUser: user, 135 RepoInfo: f.RepoInfo(s, user), 136 RepoLogResponse: repolog, 137 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 138 }) 139 return 140} 141 142func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 143 f, err := fullyResolvedRepo(r) 144 if err != nil { 145 log.Println("failed to get repo and knot", err) 146 w.WriteHeader(http.StatusBadRequest) 147 return 148 } 149 150 user := s.auth.GetUser(r) 151 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 152 RepoInfo: f.RepoInfo(s, user), 153 }) 154 return 155} 156 157func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 158 f, err := fullyResolvedRepo(r) 159 if err != nil { 160 log.Println("failed to get repo and knot", err) 161 w.WriteHeader(http.StatusBadRequest) 162 return 163 } 164 165 repoAt := f.RepoAt 166 rkey := repoAt.RecordKey().String() 167 if rkey == "" { 168 log.Println("invalid aturi for repo", err) 169 w.WriteHeader(http.StatusInternalServerError) 170 return 171 } 172 173 user := s.auth.GetUser(r) 174 175 switch r.Method { 176 case http.MethodGet: 177 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 178 RepoInfo: f.RepoInfo(s, user), 179 }) 180 return 181 case http.MethodPut: 182 user := s.auth.GetUser(r) 183 newDescription := r.FormValue("description") 184 client, _ := s.auth.AuthorizedClient(r) 185 186 // optimistic update 187 err = db.UpdateDescription(s.db, string(repoAt), newDescription) 188 if err != nil { 189 log.Println("failed to perferom update-description query", err) 190 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 191 return 192 } 193 194 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 195 // 196 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 197 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey) 198 if err != nil { 199 // failed to get record 200 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 201 return 202 } 203 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 204 Collection: tangled.RepoNSID, 205 Repo: user.Did, 206 Rkey: rkey, 207 SwapRecord: ex.Cid, 208 Record: &lexutil.LexiconTypeDecoder{ 209 Val: &tangled.Repo{ 210 Knot: f.Knot, 211 Name: f.RepoName, 212 Owner: user.Did, 213 AddedAt: &f.AddedAt, 214 Description: &newDescription, 215 }, 216 }, 217 }) 218 219 if err != nil { 220 log.Println("failed to perferom update-description query", err) 221 // failed to get record 222 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 223 return 224 } 225 226 newRepoInfo := f.RepoInfo(s, user) 227 newRepoInfo.Description = newDescription 228 229 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 230 RepoInfo: newRepoInfo, 231 }) 232 return 233 } 234} 235 236func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 237 f, err := fullyResolvedRepo(r) 238 if err != nil { 239 log.Println("failed to fully resolve repo", err) 240 return 241 } 242 ref := chi.URLParam(r, "ref") 243 protocol := "http" 244 if !s.config.Dev { 245 protocol = "https" 246 } 247 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 248 if err != nil { 249 log.Println("failed to reach knotserver", err) 250 return 251 } 252 253 body, err := io.ReadAll(resp.Body) 254 if err != nil { 255 log.Printf("Error reading response body: %v", err) 256 return 257 } 258 259 var result types.RepoCommitResponse 260 err = json.Unmarshal(body, &result) 261 if err != nil { 262 log.Println("failed to parse response:", err) 263 return 264 } 265 266 user := s.auth.GetUser(r) 267 s.pages.RepoCommit(w, pages.RepoCommitParams{ 268 LoggedInUser: user, 269 RepoInfo: f.RepoInfo(s, user), 270 RepoCommitResponse: result, 271 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 272 }) 273 return 274} 275 276func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 277 f, err := fullyResolvedRepo(r) 278 if err != nil { 279 log.Println("failed to fully resolve repo", err) 280 return 281 } 282 283 ref := chi.URLParam(r, "ref") 284 treePath := chi.URLParam(r, "*") 285 protocol := "http" 286 if !s.config.Dev { 287 protocol = "https" 288 } 289 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 290 if err != nil { 291 log.Println("failed to reach knotserver", err) 292 return 293 } 294 295 body, err := io.ReadAll(resp.Body) 296 if err != nil { 297 log.Printf("Error reading response body: %v", err) 298 return 299 } 300 301 var result types.RepoTreeResponse 302 err = json.Unmarshal(body, &result) 303 if err != nil { 304 log.Println("failed to parse response:", err) 305 return 306 } 307 308 user := s.auth.GetUser(r) 309 310 var breadcrumbs [][]string 311 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 312 if treePath != "" { 313 for idx, elem := range strings.Split(treePath, "/") { 314 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 315 } 316 } 317 318 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath) 319 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath) 320 321 s.pages.RepoTree(w, pages.RepoTreeParams{ 322 LoggedInUser: user, 323 BreadCrumbs: breadcrumbs, 324 BaseTreeLink: baseTreeLink, 325 BaseBlobLink: baseBlobLink, 326 RepoInfo: f.RepoInfo(s, user), 327 RepoTreeResponse: result, 328 }) 329 return 330} 331 332func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 333 f, err := fullyResolvedRepo(r) 334 if err != nil { 335 log.Println("failed to get repo and knot", err) 336 return 337 } 338 339 protocol := "http" 340 if !s.config.Dev { 341 protocol = "https" 342 } 343 344 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName)) 345 if err != nil { 346 log.Println("failed to reach knotserver", err) 347 return 348 } 349 350 body, err := io.ReadAll(resp.Body) 351 if err != nil { 352 log.Printf("Error reading response body: %v", err) 353 return 354 } 355 356 var result types.RepoTagsResponse 357 err = json.Unmarshal(body, &result) 358 if err != nil { 359 log.Println("failed to parse response:", err) 360 return 361 } 362 363 user := s.auth.GetUser(r) 364 s.pages.RepoTags(w, pages.RepoTagsParams{ 365 LoggedInUser: user, 366 RepoInfo: f.RepoInfo(s, user), 367 RepoTagsResponse: result, 368 }) 369 return 370} 371 372func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 373 f, err := fullyResolvedRepo(r) 374 if err != nil { 375 log.Println("failed to get repo and knot", err) 376 return 377 } 378 379 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 380 if err != nil { 381 log.Println("failed to create unsigned client", err) 382 return 383 } 384 385 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 386 if err != nil { 387 log.Println("failed to reach knotserver", err) 388 return 389 } 390 391 body, err := io.ReadAll(resp.Body) 392 if err != nil { 393 log.Printf("Error reading response body: %v", err) 394 return 395 } 396 397 var result types.RepoBranchesResponse 398 err = json.Unmarshal(body, &result) 399 if err != nil { 400 log.Println("failed to parse response:", err) 401 return 402 } 403 404 user := s.auth.GetUser(r) 405 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 406 LoggedInUser: user, 407 RepoInfo: f.RepoInfo(s, user), 408 RepoBranchesResponse: result, 409 }) 410 return 411} 412 413func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 414 f, err := fullyResolvedRepo(r) 415 if err != nil { 416 log.Println("failed to get repo and knot", err) 417 return 418 } 419 420 ref := chi.URLParam(r, "ref") 421 filePath := chi.URLParam(r, "*") 422 protocol := "http" 423 if !s.config.Dev { 424 protocol = "https" 425 } 426 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 427 if err != nil { 428 log.Println("failed to reach knotserver", err) 429 return 430 } 431 432 body, err := io.ReadAll(resp.Body) 433 if err != nil { 434 log.Printf("Error reading response body: %v", err) 435 return 436 } 437 438 var result types.RepoBlobResponse 439 err = json.Unmarshal(body, &result) 440 if err != nil { 441 log.Println("failed to parse response:", err) 442 return 443 } 444 445 var breadcrumbs [][]string 446 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 447 if filePath != "" { 448 for idx, elem := range strings.Split(filePath, "/") { 449 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 450 } 451 } 452 453 user := s.auth.GetUser(r) 454 s.pages.RepoBlob(w, pages.RepoBlobParams{ 455 LoggedInUser: user, 456 RepoInfo: f.RepoInfo(s, user), 457 RepoBlobResponse: result, 458 BreadCrumbs: breadcrumbs, 459 }) 460 return 461} 462 463func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 464 f, err := fullyResolvedRepo(r) 465 if err != nil { 466 log.Println("failed to get repo and knot", err) 467 return 468 } 469 470 ref := chi.URLParam(r, "ref") 471 filePath := chi.URLParam(r, "*") 472 473 protocol := "http" 474 if !s.config.Dev { 475 protocol = "https" 476 } 477 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 478 if err != nil { 479 log.Println("failed to reach knotserver", err) 480 return 481 } 482 483 body, err := io.ReadAll(resp.Body) 484 if err != nil { 485 log.Printf("Error reading response body: %v", err) 486 return 487 } 488 489 var result types.RepoBlobResponse 490 err = json.Unmarshal(body, &result) 491 if err != nil { 492 log.Println("failed to parse response:", err) 493 return 494 } 495 496 if result.IsBinary { 497 w.Header().Set("Content-Type", "application/octet-stream") 498 w.Write(body) 499 return 500 } 501 502 w.Header().Set("Content-Type", "text/plain") 503 w.Write([]byte(result.Contents)) 504 return 505} 506 507func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 508 f, err := fullyResolvedRepo(r) 509 if err != nil { 510 log.Println("failed to get repo and knot", err) 511 return 512 } 513 514 collaborator := r.FormValue("collaborator") 515 if collaborator == "" { 516 http.Error(w, "malformed form", http.StatusBadRequest) 517 return 518 } 519 520 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 521 if err != nil { 522 w.Write([]byte("failed to resolve collaborator did to a handle")) 523 return 524 } 525 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 526 527 // TODO: create an atproto record for this 528 529 secret, err := db.GetRegistrationKey(s.db, f.Knot) 530 if err != nil { 531 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 532 return 533 } 534 535 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 536 if err != nil { 537 log.Println("failed to create client to ", f.Knot) 538 return 539 } 540 541 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 542 if err != nil { 543 log.Printf("failed to make request to %s: %s", f.Knot, err) 544 return 545 } 546 547 if ksResp.StatusCode != http.StatusNoContent { 548 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 549 return 550 } 551 552 tx, err := s.db.BeginTx(r.Context(), nil) 553 if err != nil { 554 log.Println("failed to start tx") 555 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 556 return 557 } 558 defer func() { 559 tx.Rollback() 560 err = s.enforcer.E.LoadPolicy() 561 if err != nil { 562 log.Println("failed to rollback policies") 563 } 564 }() 565 566 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 567 if err != nil { 568 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 569 return 570 } 571 572 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 573 if err != nil { 574 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 575 return 576 } 577 578 err = tx.Commit() 579 if err != nil { 580 log.Println("failed to commit changes", err) 581 http.Error(w, err.Error(), http.StatusInternalServerError) 582 return 583 } 584 585 err = s.enforcer.E.SavePolicy() 586 if err != nil { 587 log.Println("failed to update ACLs", err) 588 http.Error(w, err.Error(), http.StatusInternalServerError) 589 return 590 } 591 592 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 593 594} 595 596func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 597 f, err := fullyResolvedRepo(r) 598 if err != nil { 599 log.Println("failed to get repo and knot", err) 600 return 601 } 602 603 switch r.Method { 604 case http.MethodGet: 605 // for now, this is just pubkeys 606 user := s.auth.GetUser(r) 607 repoCollaborators, err := f.Collaborators(r.Context(), s) 608 if err != nil { 609 log.Println("failed to get collaborators", err) 610 } 611 612 isCollaboratorInviteAllowed := false 613 if user != nil { 614 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 615 if err == nil && ok { 616 isCollaboratorInviteAllowed = true 617 } 618 } 619 620 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 621 LoggedInUser: user, 622 RepoInfo: f.RepoInfo(s, user), 623 Collaborators: repoCollaborators, 624 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 625 }) 626 } 627} 628 629type FullyResolvedRepo struct { 630 Knot string 631 OwnerId identity.Identity 632 RepoName string 633 RepoAt syntax.ATURI 634 Description string 635 AddedAt string 636} 637 638func (f *FullyResolvedRepo) OwnerDid() string { 639 return f.OwnerId.DID.String() 640} 641 642func (f *FullyResolvedRepo) OwnerHandle() string { 643 return f.OwnerId.Handle.String() 644} 645 646func (f *FullyResolvedRepo) OwnerSlashRepo() string { 647 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 648 return p 649} 650 651func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 652 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 653 if err != nil { 654 return nil, err 655 } 656 657 var collaborators []pages.Collaborator 658 for _, item := range repoCollaborators { 659 // currently only two roles: owner and member 660 var role string 661 if item[3] == "repo:owner" { 662 role = "owner" 663 } else if item[3] == "repo:collaborator" { 664 role = "collaborator" 665 } else { 666 continue 667 } 668 669 did := item[0] 670 671 c := pages.Collaborator{ 672 Did: did, 673 Handle: "", 674 Role: role, 675 } 676 collaborators = append(collaborators, c) 677 } 678 679 // populate all collborators with handles 680 identsToResolve := make([]string, len(collaborators)) 681 for i, collab := range collaborators { 682 identsToResolve[i] = collab.Did 683 } 684 685 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 686 for i, resolved := range resolvedIdents { 687 if resolved != nil { 688 collaborators[i].Handle = resolved.Handle.String() 689 } 690 } 691 692 return collaborators, nil 693} 694 695func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { 696 isStarred := false 697 if u != nil { 698 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 699 } 700 701 starCount, err := db.GetStarCount(s.db, f.RepoAt) 702 if err != nil { 703 log.Println("failed to get star count for ", f.RepoAt) 704 } 705 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 706 if err != nil { 707 log.Println("failed to get issue count for ", f.RepoAt) 708 } 709 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 710 if err != nil { 711 log.Println("failed to get issue count for ", f.RepoAt) 712 } 713 714 knot := f.Knot 715 if knot == "knot1.tangled.sh" { 716 knot = "tangled.sh" 717 } 718 719 return pages.RepoInfo{ 720 OwnerDid: f.OwnerDid(), 721 OwnerHandle: f.OwnerHandle(), 722 Name: f.RepoName, 723 RepoAt: f.RepoAt, 724 Description: f.Description, 725 IsStarred: isStarred, 726 Knot: knot, 727 Roles: RolesInRepo(s, u, f), 728 Stats: db.RepoStats{ 729 StarCount: starCount, 730 IssueCount: issueCount, 731 PullCount: pullCount, 732 }, 733 } 734} 735 736func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 737 user := s.auth.GetUser(r) 738 f, err := fullyResolvedRepo(r) 739 if err != nil { 740 log.Println("failed to get repo and knot", err) 741 return 742 } 743 744 issueId := chi.URLParam(r, "issue") 745 issueIdInt, err := strconv.Atoi(issueId) 746 if err != nil { 747 http.Error(w, "bad issue id", http.StatusBadRequest) 748 log.Println("failed to parse issue id", err) 749 return 750 } 751 752 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 753 if err != nil { 754 log.Println("failed to get issue and comments", err) 755 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 756 return 757 } 758 759 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 760 if err != nil { 761 log.Println("failed to resolve issue owner", err) 762 } 763 764 identsToResolve := make([]string, len(comments)) 765 for i, comment := range comments { 766 identsToResolve[i] = comment.OwnerDid 767 } 768 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 769 didHandleMap := make(map[string]string) 770 for _, identity := range resolvedIds { 771 if !identity.Handle.IsInvalidHandle() { 772 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 773 } else { 774 didHandleMap[identity.DID.String()] = identity.DID.String() 775 } 776 } 777 778 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 779 LoggedInUser: user, 780 RepoInfo: f.RepoInfo(s, user), 781 Issue: *issue, 782 Comments: comments, 783 784 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 785 DidHandleMap: didHandleMap, 786 }) 787 788} 789 790func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 791 user := s.auth.GetUser(r) 792 f, err := fullyResolvedRepo(r) 793 if err != nil { 794 log.Println("failed to get repo and knot", err) 795 return 796 } 797 798 issueId := chi.URLParam(r, "issue") 799 issueIdInt, err := strconv.Atoi(issueId) 800 if err != nil { 801 http.Error(w, "bad issue id", http.StatusBadRequest) 802 log.Println("failed to parse issue id", err) 803 return 804 } 805 806 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 807 if err != nil { 808 log.Println("failed to get issue", err) 809 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 810 return 811 } 812 813 collaborators, err := f.Collaborators(r.Context(), s) 814 if err != nil { 815 log.Println("failed to fetch repo collaborators: %w", err) 816 } 817 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 818 return user.Did == collab.Did 819 }) 820 isIssueOwner := user.Did == issue.OwnerDid 821 822 // TODO: make this more granular 823 if isIssueOwner || isCollaborator { 824 825 closed := tangled.RepoIssueStateClosed 826 827 client, _ := s.auth.AuthorizedClient(r) 828 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 829 Collection: tangled.RepoIssueStateNSID, 830 Repo: user.Did, 831 Rkey: s.TID(), 832 Record: &lexutil.LexiconTypeDecoder{ 833 Val: &tangled.RepoIssueState{ 834 Issue: issue.IssueAt, 835 State: &closed, 836 }, 837 }, 838 }) 839 840 if err != nil { 841 log.Println("failed to update issue state", err) 842 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 843 return 844 } 845 846 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 847 if err != nil { 848 log.Println("failed to close issue", err) 849 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 850 return 851 } 852 853 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 854 return 855 } else { 856 log.Println("user is not permitted to close issue") 857 http.Error(w, "for biden", http.StatusUnauthorized) 858 return 859 } 860} 861 862func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 863 user := s.auth.GetUser(r) 864 f, err := fullyResolvedRepo(r) 865 if err != nil { 866 log.Println("failed to get repo and knot", err) 867 return 868 } 869 870 issueId := chi.URLParam(r, "issue") 871 issueIdInt, err := strconv.Atoi(issueId) 872 if err != nil { 873 http.Error(w, "bad issue id", http.StatusBadRequest) 874 log.Println("failed to parse issue id", err) 875 return 876 } 877 878 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 879 if err != nil { 880 log.Println("failed to get issue", err) 881 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 882 return 883 } 884 885 collaborators, err := f.Collaborators(r.Context(), s) 886 if err != nil { 887 log.Println("failed to fetch repo collaborators: %w", err) 888 } 889 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 890 return user.Did == collab.Did 891 }) 892 isIssueOwner := user.Did == issue.OwnerDid 893 894 if isCollaborator || isIssueOwner { 895 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 896 if err != nil { 897 log.Println("failed to reopen issue", err) 898 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 899 return 900 } 901 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 902 return 903 } else { 904 log.Println("user is not the owner of the repo") 905 http.Error(w, "forbidden", http.StatusUnauthorized) 906 return 907 } 908} 909 910func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 911 user := s.auth.GetUser(r) 912 f, err := fullyResolvedRepo(r) 913 if err != nil { 914 log.Println("failed to get repo and knot", err) 915 return 916 } 917 918 issueId := chi.URLParam(r, "issue") 919 issueIdInt, err := strconv.Atoi(issueId) 920 if err != nil { 921 http.Error(w, "bad issue id", http.StatusBadRequest) 922 log.Println("failed to parse issue id", err) 923 return 924 } 925 926 switch r.Method { 927 case http.MethodPost: 928 body := r.FormValue("body") 929 if body == "" { 930 s.pages.Notice(w, "issue", "Body is required") 931 return 932 } 933 934 commentId := rand.IntN(1000000) 935 936 err := db.NewComment(s.db, &db.Comment{ 937 OwnerDid: user.Did, 938 RepoAt: f.RepoAt, 939 Issue: issueIdInt, 940 CommentId: commentId, 941 Body: body, 942 }) 943 if err != nil { 944 log.Println("failed to create comment", err) 945 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 946 return 947 } 948 949 createdAt := time.Now().Format(time.RFC3339) 950 commentIdInt64 := int64(commentId) 951 ownerDid := user.Did 952 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 953 if err != nil { 954 log.Println("failed to get issue at", err) 955 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 956 return 957 } 958 959 atUri := f.RepoAt.String() 960 client, _ := s.auth.AuthorizedClient(r) 961 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 962 Collection: tangled.RepoIssueCommentNSID, 963 Repo: user.Did, 964 Rkey: s.TID(), 965 Record: &lexutil.LexiconTypeDecoder{ 966 Val: &tangled.RepoIssueComment{ 967 Repo: &atUri, 968 Issue: issueAt, 969 CommentId: &commentIdInt64, 970 Owner: &ownerDid, 971 Body: &body, 972 CreatedAt: &createdAt, 973 }, 974 }, 975 }) 976 if err != nil { 977 log.Println("failed to create comment", err) 978 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 979 return 980 } 981 982 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 983 return 984 } 985} 986 987func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 988 params := r.URL.Query() 989 state := params.Get("state") 990 isOpen := true 991 switch state { 992 case "open": 993 isOpen = true 994 case "closed": 995 isOpen = false 996 default: 997 isOpen = true 998 } 999 1000 user := s.auth.GetUser(r) 1001 f, err := fullyResolvedRepo(r) 1002 if err != nil { 1003 log.Println("failed to get repo and knot", err) 1004 return 1005 } 1006 1007 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 1008 if err != nil { 1009 log.Println("failed to get issues", err) 1010 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1011 return 1012 } 1013 1014 identsToResolve := make([]string, len(issues)) 1015 for i, issue := range issues { 1016 identsToResolve[i] = issue.OwnerDid 1017 } 1018 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1019 didHandleMap := make(map[string]string) 1020 for _, identity := range resolvedIds { 1021 if !identity.Handle.IsInvalidHandle() { 1022 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1023 } else { 1024 didHandleMap[identity.DID.String()] = identity.DID.String() 1025 } 1026 } 1027 1028 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1029 LoggedInUser: s.auth.GetUser(r), 1030 RepoInfo: f.RepoInfo(s, user), 1031 Issues: issues, 1032 DidHandleMap: didHandleMap, 1033 FilteringByOpen: isOpen, 1034 }) 1035 return 1036} 1037 1038func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1039 user := s.auth.GetUser(r) 1040 1041 f, err := fullyResolvedRepo(r) 1042 if err != nil { 1043 log.Println("failed to get repo and knot", err) 1044 return 1045 } 1046 1047 switch r.Method { 1048 case http.MethodGet: 1049 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1050 LoggedInUser: user, 1051 RepoInfo: f.RepoInfo(s, user), 1052 }) 1053 case http.MethodPost: 1054 title := r.FormValue("title") 1055 body := r.FormValue("body") 1056 1057 if title == "" || body == "" { 1058 s.pages.Notice(w, "issues", "Title and body are required") 1059 return 1060 } 1061 1062 tx, err := s.db.BeginTx(r.Context(), nil) 1063 if err != nil { 1064 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1065 return 1066 } 1067 1068 err = db.NewIssue(tx, &db.Issue{ 1069 RepoAt: f.RepoAt, 1070 Title: title, 1071 Body: body, 1072 OwnerDid: user.Did, 1073 }) 1074 if err != nil { 1075 log.Println("failed to create issue", err) 1076 s.pages.Notice(w, "issues", "Failed to create issue.") 1077 return 1078 } 1079 1080 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1081 if err != nil { 1082 log.Println("failed to get issue id", err) 1083 s.pages.Notice(w, "issues", "Failed to create issue.") 1084 return 1085 } 1086 1087 client, _ := s.auth.AuthorizedClient(r) 1088 atUri := f.RepoAt.String() 1089 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1090 Collection: tangled.RepoIssueNSID, 1091 Repo: user.Did, 1092 Rkey: s.TID(), 1093 Record: &lexutil.LexiconTypeDecoder{ 1094 Val: &tangled.RepoIssue{ 1095 Repo: atUri, 1096 Title: title, 1097 Body: &body, 1098 Owner: user.Did, 1099 IssueId: int64(issueId), 1100 }, 1101 }, 1102 }) 1103 if err != nil { 1104 log.Println("failed to create issue", err) 1105 s.pages.Notice(w, "issues", "Failed to create issue.") 1106 return 1107 } 1108 1109 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1110 if err != nil { 1111 log.Println("failed to set issue at", err) 1112 s.pages.Notice(w, "issues", "Failed to create issue.") 1113 return 1114 } 1115 1116 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1117 return 1118 } 1119}