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 protocol := "http" 39 if !s.config.Dev { 40 protocol = "https" 41 } 42 43 var reqUrl string 44 if ref != "" { 45 reqUrl = fmt.Sprintf("%s://%s/%s/%s/tree/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref) 46 } else { 47 reqUrl = fmt.Sprintf("%s://%s/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName) 48 } 49 50 resp, err := http.Get(reqUrl) 51 if err != nil { 52 s.pages.Error503(w) 53 log.Println("failed to reach knotserver", err) 54 return 55 } 56 defer resp.Body.Close() 57 58 body, err := io.ReadAll(resp.Body) 59 if err != nil { 60 log.Printf("Error reading response body: %v", err) 61 return 62 } 63 64 var result types.RepoIndexResponse 65 err = json.Unmarshal(body, &result) 66 if err != nil { 67 log.Printf("Error unmarshalling response body: %v", err) 68 return 69 } 70 71 tagMap := make(map[string][]string) 72 for _, tag := range result.Tags { 73 hash := tag.Hash 74 tagMap[hash] = append(tagMap[hash], tag.Name) 75 } 76 77 for _, branch := range result.Branches { 78 hash := branch.Hash 79 tagMap[hash] = append(tagMap[hash], branch.Name) 80 } 81 82 user := s.auth.GetUser(r) 83 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 84 LoggedInUser: user, 85 RepoInfo: f.RepoInfo(s, user), 86 TagMap: tagMap, 87 RepoIndexResponse: result, 88 }) 89 90 return 91} 92 93func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 94 f, err := fullyResolvedRepo(r) 95 if err != nil { 96 log.Println("failed to fully resolve repo", err) 97 return 98 } 99 100 page := 1 101 if r.URL.Query().Get("page") != "" { 102 page, err = strconv.Atoi(r.URL.Query().Get("page")) 103 if err != nil { 104 page = 1 105 } 106 } 107 108 ref := chi.URLParam(r, "ref") 109 110 protocol := "http" 111 if !s.config.Dev { 112 protocol = "https" 113 } 114 115 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)) 116 if err != nil { 117 log.Println("failed to reach knotserver", err) 118 return 119 } 120 121 body, err := io.ReadAll(resp.Body) 122 if err != nil { 123 log.Printf("error reading response body: %v", err) 124 return 125 } 126 127 var repolog types.RepoLogResponse 128 err = json.Unmarshal(body, &repolog) 129 if err != nil { 130 log.Println("failed to parse json response", err) 131 return 132 } 133 134 user := s.auth.GetUser(r) 135 s.pages.RepoLog(w, pages.RepoLogParams{ 136 LoggedInUser: user, 137 RepoInfo: f.RepoInfo(s, user), 138 RepoLogResponse: repolog, 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 }) 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 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/branches", f.Knot, f.OwnerDid(), f.RepoName)) 380 if err != nil { 381 log.Println("failed to reach knotserver", err) 382 return 383 } 384 385 body, err := io.ReadAll(resp.Body) 386 if err != nil { 387 log.Printf("Error reading response body: %v", err) 388 return 389 } 390 391 var result types.RepoBranchesResponse 392 err = json.Unmarshal(body, &result) 393 if err != nil { 394 log.Println("failed to parse response:", err) 395 return 396 } 397 398 user := s.auth.GetUser(r) 399 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 400 LoggedInUser: user, 401 RepoInfo: f.RepoInfo(s, user), 402 RepoBranchesResponse: result, 403 }) 404 return 405} 406 407func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 408 f, err := fullyResolvedRepo(r) 409 if err != nil { 410 log.Println("failed to get repo and knot", err) 411 return 412 } 413 414 ref := chi.URLParam(r, "ref") 415 filePath := chi.URLParam(r, "*") 416 protocol := "http" 417 if !s.config.Dev { 418 protocol = "https" 419 } 420 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 421 if err != nil { 422 log.Println("failed to reach knotserver", err) 423 return 424 } 425 426 body, err := io.ReadAll(resp.Body) 427 if err != nil { 428 log.Printf("Error reading response body: %v", err) 429 return 430 } 431 432 var result types.RepoBlobResponse 433 err = json.Unmarshal(body, &result) 434 if err != nil { 435 log.Println("failed to parse response:", err) 436 return 437 } 438 439 var breadcrumbs [][]string 440 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 441 if filePath != "" { 442 for idx, elem := range strings.Split(filePath, "/") { 443 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 444 } 445 } 446 447 user := s.auth.GetUser(r) 448 s.pages.RepoBlob(w, pages.RepoBlobParams{ 449 LoggedInUser: user, 450 RepoInfo: f.RepoInfo(s, user), 451 RepoBlobResponse: result, 452 BreadCrumbs: breadcrumbs, 453 }) 454 return 455} 456 457func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 458 f, err := fullyResolvedRepo(r) 459 if err != nil { 460 log.Println("failed to get repo and knot", err) 461 return 462 } 463 464 collaborator := r.FormValue("collaborator") 465 if collaborator == "" { 466 http.Error(w, "malformed form", http.StatusBadRequest) 467 return 468 } 469 470 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 471 if err != nil { 472 w.Write([]byte("failed to resolve collaborator did to a handle")) 473 return 474 } 475 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 476 477 // TODO: create an atproto record for this 478 479 secret, err := db.GetRegistrationKey(s.db, f.Knot) 480 if err != nil { 481 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 482 return 483 } 484 485 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 486 if err != nil { 487 log.Println("failed to create client to ", f.Knot) 488 return 489 } 490 491 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 492 if err != nil { 493 log.Printf("failed to make request to %s: %s", f.Knot, err) 494 return 495 } 496 497 if ksResp.StatusCode != http.StatusNoContent { 498 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 499 return 500 } 501 502 tx, err := s.db.BeginTx(r.Context(), nil) 503 if err != nil { 504 log.Println("failed to start tx") 505 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 506 return 507 } 508 defer func() { 509 tx.Rollback() 510 err = s.enforcer.E.LoadPolicy() 511 if err != nil { 512 log.Println("failed to rollback policies") 513 } 514 }() 515 516 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 517 if err != nil { 518 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 519 return 520 } 521 522 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 523 if err != nil { 524 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 525 return 526 } 527 528 err = tx.Commit() 529 if err != nil { 530 log.Println("failed to commit changes", err) 531 http.Error(w, err.Error(), http.StatusInternalServerError) 532 return 533 } 534 535 err = s.enforcer.E.SavePolicy() 536 if err != nil { 537 log.Println("failed to update ACLs", err) 538 http.Error(w, err.Error(), http.StatusInternalServerError) 539 return 540 } 541 542 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 543 544} 545 546func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 547 f, err := fullyResolvedRepo(r) 548 if err != nil { 549 log.Println("failed to get repo and knot", err) 550 return 551 } 552 553 switch r.Method { 554 case http.MethodGet: 555 // for now, this is just pubkeys 556 user := s.auth.GetUser(r) 557 repoCollaborators, err := f.Collaborators(r.Context(), s) 558 if err != nil { 559 log.Println("failed to get collaborators", err) 560 } 561 562 isCollaboratorInviteAllowed := false 563 if user != nil { 564 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 565 if err == nil && ok { 566 isCollaboratorInviteAllowed = true 567 } 568 } 569 570 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 571 LoggedInUser: user, 572 RepoInfo: f.RepoInfo(s, user), 573 Collaborators: repoCollaborators, 574 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 575 }) 576 } 577} 578 579type FullyResolvedRepo struct { 580 Knot string 581 OwnerId identity.Identity 582 RepoName string 583 RepoAt syntax.ATURI 584 Description string 585 AddedAt string 586} 587 588func (f *FullyResolvedRepo) OwnerDid() string { 589 return f.OwnerId.DID.String() 590} 591 592func (f *FullyResolvedRepo) OwnerHandle() string { 593 return f.OwnerId.Handle.String() 594} 595 596func (f *FullyResolvedRepo) OwnerSlashRepo() string { 597 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 598 return p 599} 600 601func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 602 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 603 if err != nil { 604 return nil, err 605 } 606 607 var collaborators []pages.Collaborator 608 for _, item := range repoCollaborators { 609 // currently only two roles: owner and member 610 var role string 611 if item[3] == "repo:owner" { 612 role = "owner" 613 } else if item[3] == "repo:collaborator" { 614 role = "collaborator" 615 } else { 616 continue 617 } 618 619 did := item[0] 620 621 c := pages.Collaborator{ 622 Did: did, 623 Handle: "", 624 Role: role, 625 } 626 collaborators = append(collaborators, c) 627 } 628 629 // populate all collborators with handles 630 identsToResolve := make([]string, len(collaborators)) 631 for i, collab := range collaborators { 632 identsToResolve[i] = collab.Did 633 } 634 635 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 636 for i, resolved := range resolvedIdents { 637 if resolved != nil { 638 collaborators[i].Handle = resolved.Handle.String() 639 } 640 } 641 642 return collaborators, nil 643} 644 645func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { 646 isStarred := false 647 if u != nil { 648 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 649 } 650 651 starCount, err := db.GetStarCount(s.db, f.RepoAt) 652 if err != nil { 653 log.Println("failed to get star count for ", f.RepoAt) 654 } 655 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 656 if err != nil { 657 log.Println("failed to get issue count for ", f.RepoAt) 658 } 659 660 knot := f.Knot 661 if knot == "knot1.tangled.sh" { 662 knot = "tangled.sh" 663 } 664 665 return pages.RepoInfo{ 666 OwnerDid: f.OwnerDid(), 667 OwnerHandle: f.OwnerHandle(), 668 Name: f.RepoName, 669 RepoAt: f.RepoAt, 670 Description: f.Description, 671 IsStarred: isStarred, 672 Knot: knot, 673 Roles: rolesInRepo(s, u, f), 674 Stats: db.RepoStats{ 675 StarCount: starCount, 676 IssueCount: issueCount, 677 }, 678 } 679} 680 681func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 682 user := s.auth.GetUser(r) 683 f, err := fullyResolvedRepo(r) 684 if err != nil { 685 log.Println("failed to get repo and knot", err) 686 return 687 } 688 689 issueId := chi.URLParam(r, "issue") 690 issueIdInt, err := strconv.Atoi(issueId) 691 if err != nil { 692 http.Error(w, "bad issue id", http.StatusBadRequest) 693 log.Println("failed to parse issue id", err) 694 return 695 } 696 697 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 698 if err != nil { 699 log.Println("failed to get issue and comments", err) 700 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 701 return 702 } 703 704 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 705 if err != nil { 706 log.Println("failed to resolve issue owner", err) 707 } 708 709 identsToResolve := make([]string, len(comments)) 710 for i, comment := range comments { 711 identsToResolve[i] = comment.OwnerDid 712 } 713 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 714 didHandleMap := make(map[string]string) 715 for _, identity := range resolvedIds { 716 if !identity.Handle.IsInvalidHandle() { 717 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 718 } else { 719 didHandleMap[identity.DID.String()] = identity.DID.String() 720 } 721 } 722 723 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 724 LoggedInUser: user, 725 RepoInfo: f.RepoInfo(s, user), 726 Issue: *issue, 727 Comments: comments, 728 729 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 730 DidHandleMap: didHandleMap, 731 }) 732 733} 734 735func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 736 user := s.auth.GetUser(r) 737 f, err := fullyResolvedRepo(r) 738 if err != nil { 739 log.Println("failed to get repo and knot", err) 740 return 741 } 742 743 issueId := chi.URLParam(r, "issue") 744 issueIdInt, err := strconv.Atoi(issueId) 745 if err != nil { 746 http.Error(w, "bad issue id", http.StatusBadRequest) 747 log.Println("failed to parse issue id", err) 748 return 749 } 750 751 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 752 if err != nil { 753 log.Println("failed to get issue", err) 754 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 755 return 756 } 757 758 collaborators, err := f.Collaborators(r.Context(), s) 759 if err != nil { 760 log.Println("failed to fetch repo collaborators: %w", err) 761 } 762 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 763 return user.Did == collab.Did 764 }) 765 isIssueOwner := user.Did == issue.OwnerDid 766 767 // TODO: make this more granular 768 if isIssueOwner || isCollaborator { 769 770 closed := tangled.RepoIssueStateClosed 771 772 client, _ := s.auth.AuthorizedClient(r) 773 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 774 Collection: tangled.RepoIssueStateNSID, 775 Repo: issue.OwnerDid, 776 Rkey: s.TID(), 777 Record: &lexutil.LexiconTypeDecoder{ 778 Val: &tangled.RepoIssueState{ 779 Issue: issue.IssueAt, 780 State: &closed, 781 }, 782 }, 783 }) 784 785 if err != nil { 786 log.Println("failed to update issue state", err) 787 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 788 return 789 } 790 791 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 792 if err != nil { 793 log.Println("failed to close issue", err) 794 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 795 return 796 } 797 798 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 799 return 800 } else { 801 log.Println("user is not permitted to close issue") 802 http.Error(w, "for biden", http.StatusUnauthorized) 803 return 804 } 805} 806 807func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 808 user := s.auth.GetUser(r) 809 f, err := fullyResolvedRepo(r) 810 if err != nil { 811 log.Println("failed to get repo and knot", err) 812 return 813 } 814 815 issueId := chi.URLParam(r, "issue") 816 issueIdInt, err := strconv.Atoi(issueId) 817 if err != nil { 818 http.Error(w, "bad issue id", http.StatusBadRequest) 819 log.Println("failed to parse issue id", err) 820 return 821 } 822 823 if user.Did == f.OwnerDid() { 824 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 825 if err != nil { 826 log.Println("failed to reopen issue", err) 827 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 828 return 829 } 830 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 831 return 832 } else { 833 log.Println("user is not the owner of the repo") 834 http.Error(w, "forbidden", http.StatusUnauthorized) 835 return 836 } 837} 838 839func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 840 user := s.auth.GetUser(r) 841 f, err := fullyResolvedRepo(r) 842 if err != nil { 843 log.Println("failed to get repo and knot", err) 844 return 845 } 846 847 issueId := chi.URLParam(r, "issue") 848 issueIdInt, err := strconv.Atoi(issueId) 849 if err != nil { 850 http.Error(w, "bad issue id", http.StatusBadRequest) 851 log.Println("failed to parse issue id", err) 852 return 853 } 854 855 switch r.Method { 856 case http.MethodPost: 857 body := r.FormValue("body") 858 if body == "" { 859 s.pages.Notice(w, "issue", "Body is required") 860 return 861 } 862 863 commentId := rand.IntN(1000000) 864 865 err := db.NewComment(s.db, &db.Comment{ 866 OwnerDid: user.Did, 867 RepoAt: f.RepoAt, 868 Issue: issueIdInt, 869 CommentId: commentId, 870 Body: body, 871 }) 872 if err != nil { 873 log.Println("failed to create comment", err) 874 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 875 return 876 } 877 878 createdAt := time.Now().Format(time.RFC3339) 879 commentIdInt64 := int64(commentId) 880 ownerDid := user.Did 881 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 882 if err != nil { 883 log.Println("failed to get issue at", err) 884 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 885 return 886 } 887 888 atUri := f.RepoAt.String() 889 client, _ := s.auth.AuthorizedClient(r) 890 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 891 Collection: tangled.RepoIssueCommentNSID, 892 Repo: user.Did, 893 Rkey: s.TID(), 894 Record: &lexutil.LexiconTypeDecoder{ 895 Val: &tangled.RepoIssueComment{ 896 Repo: &atUri, 897 Issue: issueAt, 898 CommentId: &commentIdInt64, 899 Owner: &ownerDid, 900 Body: &body, 901 CreatedAt: &createdAt, 902 }, 903 }, 904 }) 905 if err != nil { 906 log.Println("failed to create comment", err) 907 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 908 return 909 } 910 911 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 912 return 913 } 914} 915 916func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 917 params := r.URL.Query() 918 state := params.Get("state") 919 isOpen := true 920 switch state { 921 case "open": 922 isOpen = true 923 case "closed": 924 isOpen = false 925 default: 926 isOpen = true 927 } 928 929 user := s.auth.GetUser(r) 930 f, err := fullyResolvedRepo(r) 931 if err != nil { 932 log.Println("failed to get repo and knot", err) 933 return 934 } 935 936 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 937 if err != nil { 938 log.Println("failed to get issues", err) 939 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 940 return 941 } 942 943 identsToResolve := make([]string, len(issues)) 944 for i, issue := range issues { 945 identsToResolve[i] = issue.OwnerDid 946 } 947 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 948 didHandleMap := make(map[string]string) 949 for _, identity := range resolvedIds { 950 if !identity.Handle.IsInvalidHandle() { 951 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 952 } else { 953 didHandleMap[identity.DID.String()] = identity.DID.String() 954 } 955 } 956 957 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 958 LoggedInUser: s.auth.GetUser(r), 959 RepoInfo: f.RepoInfo(s, user), 960 Issues: issues, 961 DidHandleMap: didHandleMap, 962 FilteringByOpen: isOpen, 963 }) 964 return 965} 966 967func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 968 user := s.auth.GetUser(r) 969 970 f, err := fullyResolvedRepo(r) 971 if err != nil { 972 log.Println("failed to get repo and knot", err) 973 return 974 } 975 976 switch r.Method { 977 case http.MethodGet: 978 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 979 LoggedInUser: user, 980 RepoInfo: f.RepoInfo(s, user), 981 }) 982 case http.MethodPost: 983 title := r.FormValue("title") 984 body := r.FormValue("body") 985 986 if title == "" || body == "" { 987 s.pages.Notice(w, "issues", "Title and body are required") 988 return 989 } 990 991 tx, err := s.db.BeginTx(r.Context(), nil) 992 if err != nil { 993 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 994 return 995 } 996 997 err = db.NewIssue(tx, &db.Issue{ 998 RepoAt: f.RepoAt, 999 Title: title, 1000 Body: body, 1001 OwnerDid: user.Did, 1002 }) 1003 if err != nil { 1004 log.Println("failed to create issue", err) 1005 s.pages.Notice(w, "issues", "Failed to create issue.") 1006 return 1007 } 1008 1009 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1010 if err != nil { 1011 log.Println("failed to get issue id", err) 1012 s.pages.Notice(w, "issues", "Failed to create issue.") 1013 return 1014 } 1015 1016 client, _ := s.auth.AuthorizedClient(r) 1017 atUri := f.RepoAt.String() 1018 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1019 Collection: tangled.RepoIssueNSID, 1020 Repo: user.Did, 1021 Rkey: s.TID(), 1022 Record: &lexutil.LexiconTypeDecoder{ 1023 Val: &tangled.RepoIssue{ 1024 Repo: atUri, 1025 Title: title, 1026 Body: &body, 1027 Owner: user.Did, 1028 IssueId: int64(issueId), 1029 }, 1030 }, 1031 }) 1032 if err != nil { 1033 log.Println("failed to create issue", err) 1034 s.pages.Notice(w, "issues", "Failed to create issue.") 1035 return 1036 } 1037 1038 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1039 if err != nil { 1040 log.Println("failed to set issue at", err) 1041 s.pages.Notice(w, "issues", "Failed to create issue.") 1042 return 1043 } 1044 1045 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1046 return 1047 } 1048} 1049 1050func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 1051 user := s.auth.GetUser(r) 1052 f, err := fullyResolvedRepo(r) 1053 if err != nil { 1054 log.Println("failed to get repo and knot", err) 1055 return 1056 } 1057 1058 switch r.Method { 1059 case http.MethodGet: 1060 s.pages.RepoPulls(w, pages.RepoPullsParams{ 1061 LoggedInUser: user, 1062 RepoInfo: f.RepoInfo(s, user), 1063 }) 1064 } 1065} 1066 1067func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 1068 repoName := chi.URLParam(r, "repo") 1069 knot, ok := r.Context().Value("knot").(string) 1070 if !ok { 1071 log.Println("malformed middleware") 1072 return nil, fmt.Errorf("malformed middleware") 1073 } 1074 id, ok := r.Context().Value("resolvedId").(identity.Identity) 1075 if !ok { 1076 log.Println("malformed middleware") 1077 return nil, fmt.Errorf("malformed middleware") 1078 } 1079 1080 repoAt, ok := r.Context().Value("repoAt").(string) 1081 if !ok { 1082 log.Println("malformed middleware") 1083 return nil, fmt.Errorf("malformed middleware") 1084 } 1085 1086 parsedRepoAt, err := syntax.ParseATURI(repoAt) 1087 if err != nil { 1088 log.Println("malformed repo at-uri") 1089 return nil, fmt.Errorf("malformed middleware") 1090 } 1091 1092 // pass through values from the middleware 1093 description, ok := r.Context().Value("repoDescription").(string) 1094 addedAt, ok := r.Context().Value("repoAddedAt").(string) 1095 1096 return &FullyResolvedRepo{ 1097 Knot: knot, 1098 OwnerId: id, 1099 RepoName: repoName, 1100 RepoAt: parsedRepoAt, 1101 Description: description, 1102 AddedAt: addedAt, 1103 }, nil 1104} 1105 1106func rolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 1107 if u != nil { 1108 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 1109 return pages.RolesInRepo{r} 1110 } else { 1111 return pages.RolesInRepo{} 1112 } 1113}