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