forked from tangled.org/core
this repo has no description
at drop-at 28 kB view raw
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) AddCollaborator(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 collaborator := r.FormValue("collaborator") 471 if collaborator == "" { 472 http.Error(w, "malformed form", http.StatusBadRequest) 473 return 474 } 475 476 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 477 if err != nil { 478 w.Write([]byte("failed to resolve collaborator did to a handle")) 479 return 480 } 481 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 482 483 // TODO: create an atproto record for this 484 485 secret, err := db.GetRegistrationKey(s.db, f.Knot) 486 if err != nil { 487 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 488 return 489 } 490 491 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 492 if err != nil { 493 log.Println("failed to create client to ", f.Knot) 494 return 495 } 496 497 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 498 if err != nil { 499 log.Printf("failed to make request to %s: %s", f.Knot, err) 500 return 501 } 502 503 if ksResp.StatusCode != http.StatusNoContent { 504 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 505 return 506 } 507 508 tx, err := s.db.BeginTx(r.Context(), nil) 509 if err != nil { 510 log.Println("failed to start tx") 511 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 512 return 513 } 514 defer func() { 515 tx.Rollback() 516 err = s.enforcer.E.LoadPolicy() 517 if err != nil { 518 log.Println("failed to rollback policies") 519 } 520 }() 521 522 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 523 if err != nil { 524 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 525 return 526 } 527 528 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 529 if err != nil { 530 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 531 return 532 } 533 534 err = tx.Commit() 535 if err != nil { 536 log.Println("failed to commit changes", err) 537 http.Error(w, err.Error(), http.StatusInternalServerError) 538 return 539 } 540 541 err = s.enforcer.E.SavePolicy() 542 if err != nil { 543 log.Println("failed to update ACLs", err) 544 http.Error(w, err.Error(), http.StatusInternalServerError) 545 return 546 } 547 548 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 549 550} 551 552func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 553 f, err := fullyResolvedRepo(r) 554 if err != nil { 555 log.Println("failed to get repo and knot", err) 556 return 557 } 558 559 switch r.Method { 560 case http.MethodGet: 561 // for now, this is just pubkeys 562 user := s.auth.GetUser(r) 563 repoCollaborators, err := f.Collaborators(r.Context(), s) 564 if err != nil { 565 log.Println("failed to get collaborators", err) 566 } 567 568 isCollaboratorInviteAllowed := false 569 if user != nil { 570 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 571 if err == nil && ok { 572 isCollaboratorInviteAllowed = true 573 } 574 } 575 576 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 577 LoggedInUser: user, 578 RepoInfo: f.RepoInfo(s, user), 579 Collaborators: repoCollaborators, 580 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 581 }) 582 } 583} 584 585type FullyResolvedRepo struct { 586 Knot string 587 OwnerId identity.Identity 588 RepoName string 589 RepoAt syntax.ATURI 590 Description string 591 AddedAt string 592} 593 594func (f *FullyResolvedRepo) OwnerDid() string { 595 return f.OwnerId.DID.String() 596} 597 598func (f *FullyResolvedRepo) OwnerHandle() string { 599 return f.OwnerId.Handle.String() 600} 601 602func (f *FullyResolvedRepo) OwnerSlashRepo() string { 603 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 604 return p 605} 606 607func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 608 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 609 if err != nil { 610 return nil, err 611 } 612 613 var collaborators []pages.Collaborator 614 for _, item := range repoCollaborators { 615 // currently only two roles: owner and member 616 var role string 617 if item[3] == "repo:owner" { 618 role = "owner" 619 } else if item[3] == "repo:collaborator" { 620 role = "collaborator" 621 } else { 622 continue 623 } 624 625 did := item[0] 626 627 c := pages.Collaborator{ 628 Did: did, 629 Handle: "", 630 Role: role, 631 } 632 collaborators = append(collaborators, c) 633 } 634 635 // populate all collborators with handles 636 identsToResolve := make([]string, len(collaborators)) 637 for i, collab := range collaborators { 638 identsToResolve[i] = collab.Did 639 } 640 641 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 642 for i, resolved := range resolvedIdents { 643 if resolved != nil { 644 collaborators[i].Handle = resolved.Handle.String() 645 } 646 } 647 648 return collaborators, nil 649} 650 651func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { 652 isStarred := false 653 if u != nil { 654 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 655 } 656 657 starCount, err := db.GetStarCount(s.db, f.RepoAt) 658 if err != nil { 659 log.Println("failed to get star count for ", f.RepoAt) 660 } 661 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 662 if err != nil { 663 log.Println("failed to get issue count for ", f.RepoAt) 664 } 665 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 666 if err != nil { 667 log.Println("failed to get issue count for ", f.RepoAt) 668 } 669 670 knot := f.Knot 671 if knot == "knot1.tangled.sh" { 672 knot = "tangled.sh" 673 } 674 675 return pages.RepoInfo{ 676 OwnerDid: f.OwnerDid(), 677 OwnerHandle: f.OwnerHandle(), 678 Name: f.RepoName, 679 RepoAt: f.RepoAt, 680 Description: f.Description, 681 IsStarred: isStarred, 682 Knot: knot, 683 Roles: RolesInRepo(s, u, f), 684 Stats: db.RepoStats{ 685 StarCount: starCount, 686 IssueCount: issueCount, 687 PullCount: pullCount, 688 }, 689 } 690} 691 692func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 693 user := s.auth.GetUser(r) 694 f, err := fullyResolvedRepo(r) 695 if err != nil { 696 log.Println("failed to get repo and knot", err) 697 return 698 } 699 700 issueId := chi.URLParam(r, "issue") 701 issueIdInt, err := strconv.Atoi(issueId) 702 if err != nil { 703 http.Error(w, "bad issue id", http.StatusBadRequest) 704 log.Println("failed to parse issue id", err) 705 return 706 } 707 708 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 709 if err != nil { 710 log.Println("failed to get issue and comments", err) 711 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 712 return 713 } 714 715 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 716 if err != nil { 717 log.Println("failed to resolve issue owner", err) 718 } 719 720 identsToResolve := make([]string, len(comments)) 721 for i, comment := range comments { 722 identsToResolve[i] = comment.OwnerDid 723 } 724 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 725 didHandleMap := make(map[string]string) 726 for _, identity := range resolvedIds { 727 if !identity.Handle.IsInvalidHandle() { 728 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 729 } else { 730 didHandleMap[identity.DID.String()] = identity.DID.String() 731 } 732 } 733 734 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 735 LoggedInUser: user, 736 RepoInfo: f.RepoInfo(s, user), 737 Issue: *issue, 738 Comments: comments, 739 740 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 741 DidHandleMap: didHandleMap, 742 }) 743 744} 745 746func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 747 user := s.auth.GetUser(r) 748 f, err := fullyResolvedRepo(r) 749 if err != nil { 750 log.Println("failed to get repo and knot", err) 751 return 752 } 753 754 issueId := chi.URLParam(r, "issue") 755 issueIdInt, err := strconv.Atoi(issueId) 756 if err != nil { 757 http.Error(w, "bad issue id", http.StatusBadRequest) 758 log.Println("failed to parse issue id", err) 759 return 760 } 761 762 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 763 if err != nil { 764 log.Println("failed to get issue", err) 765 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 766 return 767 } 768 769 collaborators, err := f.Collaborators(r.Context(), s) 770 if err != nil { 771 log.Println("failed to fetch repo collaborators: %w", err) 772 } 773 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 774 return user.Did == collab.Did 775 }) 776 isIssueOwner := user.Did == issue.OwnerDid 777 778 // TODO: make this more granular 779 if isIssueOwner || isCollaborator { 780 781 closed := tangled.RepoIssueStateClosed 782 783 client, _ := s.auth.AuthorizedClient(r) 784 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 785 Collection: tangled.RepoIssueStateNSID, 786 Repo: user.Did, 787 Rkey: s.TID(), 788 Record: &lexutil.LexiconTypeDecoder{ 789 Val: &tangled.RepoIssueState{ 790 Issue: issue.IssueAt, 791 State: &closed, 792 }, 793 }, 794 }) 795 796 if err != nil { 797 log.Println("failed to update issue state", err) 798 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 799 return 800 } 801 802 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 803 if err != nil { 804 log.Println("failed to close issue", err) 805 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 806 return 807 } 808 809 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 810 return 811 } else { 812 log.Println("user is not permitted to close issue") 813 http.Error(w, "for biden", http.StatusUnauthorized) 814 return 815 } 816} 817 818func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 819 user := s.auth.GetUser(r) 820 f, err := fullyResolvedRepo(r) 821 if err != nil { 822 log.Println("failed to get repo and knot", err) 823 return 824 } 825 826 issueId := chi.URLParam(r, "issue") 827 issueIdInt, err := strconv.Atoi(issueId) 828 if err != nil { 829 http.Error(w, "bad issue id", http.StatusBadRequest) 830 log.Println("failed to parse issue id", err) 831 return 832 } 833 834 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 835 if err != nil { 836 log.Println("failed to get issue", err) 837 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 838 return 839 } 840 841 collaborators, err := f.Collaborators(r.Context(), s) 842 if err != nil { 843 log.Println("failed to fetch repo collaborators: %w", err) 844 } 845 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 846 return user.Did == collab.Did 847 }) 848 isIssueOwner := user.Did == issue.OwnerDid 849 850 if isCollaborator || isIssueOwner { 851 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 852 if err != nil { 853 log.Println("failed to reopen issue", err) 854 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 855 return 856 } 857 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 858 return 859 } else { 860 log.Println("user is not the owner of the repo") 861 http.Error(w, "forbidden", http.StatusUnauthorized) 862 return 863 } 864} 865 866func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 867 user := s.auth.GetUser(r) 868 f, err := fullyResolvedRepo(r) 869 if err != nil { 870 log.Println("failed to get repo and knot", err) 871 return 872 } 873 874 issueId := chi.URLParam(r, "issue") 875 issueIdInt, err := strconv.Atoi(issueId) 876 if err != nil { 877 http.Error(w, "bad issue id", http.StatusBadRequest) 878 log.Println("failed to parse issue id", err) 879 return 880 } 881 882 switch r.Method { 883 case http.MethodPost: 884 body := r.FormValue("body") 885 if body == "" { 886 s.pages.Notice(w, "issue", "Body is required") 887 return 888 } 889 890 commentId := rand.IntN(1000000) 891 892 err := db.NewComment(s.db, &db.Comment{ 893 OwnerDid: user.Did, 894 RepoAt: f.RepoAt, 895 Issue: issueIdInt, 896 CommentId: commentId, 897 Body: body, 898 }) 899 if err != nil { 900 log.Println("failed to create comment", err) 901 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 902 return 903 } 904 905 createdAt := time.Now().Format(time.RFC3339) 906 commentIdInt64 := int64(commentId) 907 ownerDid := user.Did 908 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 909 if err != nil { 910 log.Println("failed to get issue at", err) 911 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 912 return 913 } 914 915 atUri := f.RepoAt.String() 916 client, _ := s.auth.AuthorizedClient(r) 917 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 918 Collection: tangled.RepoIssueCommentNSID, 919 Repo: user.Did, 920 Rkey: s.TID(), 921 Record: &lexutil.LexiconTypeDecoder{ 922 Val: &tangled.RepoIssueComment{ 923 Repo: &atUri, 924 Issue: issueAt, 925 CommentId: &commentIdInt64, 926 Owner: &ownerDid, 927 Body: &body, 928 CreatedAt: &createdAt, 929 }, 930 }, 931 }) 932 if err != nil { 933 log.Println("failed to create comment", err) 934 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 935 return 936 } 937 938 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 939 return 940 } 941} 942 943func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 944 params := r.URL.Query() 945 state := params.Get("state") 946 isOpen := true 947 switch state { 948 case "open": 949 isOpen = true 950 case "closed": 951 isOpen = false 952 default: 953 isOpen = true 954 } 955 956 user := s.auth.GetUser(r) 957 f, err := fullyResolvedRepo(r) 958 if err != nil { 959 log.Println("failed to get repo and knot", err) 960 return 961 } 962 963 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 964 if err != nil { 965 log.Println("failed to get issues", err) 966 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 967 return 968 } 969 970 identsToResolve := make([]string, len(issues)) 971 for i, issue := range issues { 972 identsToResolve[i] = issue.OwnerDid 973 } 974 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 975 didHandleMap := make(map[string]string) 976 for _, identity := range resolvedIds { 977 if !identity.Handle.IsInvalidHandle() { 978 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 979 } else { 980 didHandleMap[identity.DID.String()] = identity.DID.String() 981 } 982 } 983 984 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 985 LoggedInUser: s.auth.GetUser(r), 986 RepoInfo: f.RepoInfo(s, user), 987 Issues: issues, 988 DidHandleMap: didHandleMap, 989 FilteringByOpen: isOpen, 990 }) 991 return 992} 993 994func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 995 user := s.auth.GetUser(r) 996 997 f, err := fullyResolvedRepo(r) 998 if err != nil { 999 log.Println("failed to get repo and knot", err) 1000 return 1001 } 1002 1003 switch r.Method { 1004 case http.MethodGet: 1005 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1006 LoggedInUser: user, 1007 RepoInfo: f.RepoInfo(s, user), 1008 }) 1009 case http.MethodPost: 1010 title := r.FormValue("title") 1011 body := r.FormValue("body") 1012 1013 if title == "" || body == "" { 1014 s.pages.Notice(w, "issues", "Title and body are required") 1015 return 1016 } 1017 1018 tx, err := s.db.BeginTx(r.Context(), nil) 1019 if err != nil { 1020 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1021 return 1022 } 1023 1024 err = db.NewIssue(tx, &db.Issue{ 1025 RepoAt: f.RepoAt, 1026 Title: title, 1027 Body: body, 1028 OwnerDid: user.Did, 1029 }) 1030 if err != nil { 1031 log.Println("failed to create issue", err) 1032 s.pages.Notice(w, "issues", "Failed to create issue.") 1033 return 1034 } 1035 1036 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1037 if err != nil { 1038 log.Println("failed to get issue id", err) 1039 s.pages.Notice(w, "issues", "Failed to create issue.") 1040 return 1041 } 1042 1043 client, _ := s.auth.AuthorizedClient(r) 1044 atUri := f.RepoAt.String() 1045 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1046 Collection: tangled.RepoIssueNSID, 1047 Repo: user.Did, 1048 Rkey: s.TID(), 1049 Record: &lexutil.LexiconTypeDecoder{ 1050 Val: &tangled.RepoIssue{ 1051 Repo: atUri, 1052 Title: title, 1053 Body: &body, 1054 Owner: user.Did, 1055 IssueId: int64(issueId), 1056 }, 1057 }, 1058 }) 1059 if err != nil { 1060 log.Println("failed to create issue", err) 1061 s.pages.Notice(w, "issues", "Failed to create issue.") 1062 return 1063 } 1064 1065 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1066 if err != nil { 1067 log.Println("failed to set issue at", err) 1068 s.pages.Notice(w, "issues", "Failed to create issue.") 1069 return 1070 } 1071 1072 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1073 return 1074 } 1075}