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