forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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: user.Did, 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 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 831 if err != nil { 832 log.Println("failed to get issue", err) 833 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 834 return 835 } 836 837 collaborators, err := f.Collaborators(r.Context(), s) 838 if err != nil { 839 log.Println("failed to fetch repo collaborators: %w", err) 840 } 841 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 842 return user.Did == collab.Did 843 }) 844 isIssueOwner := user.Did == issue.OwnerDid 845 846 if isCollaborator || isIssueOwner { 847 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 848 if err != nil { 849 log.Println("failed to reopen issue", err) 850 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 851 return 852 } 853 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 854 return 855 } else { 856 log.Println("user is not the owner of the repo") 857 http.Error(w, "forbidden", http.StatusUnauthorized) 858 return 859 } 860} 861 862func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 863 user := s.auth.GetUser(r) 864 f, err := fullyResolvedRepo(r) 865 if err != nil { 866 log.Println("failed to get repo and knot", err) 867 return 868 } 869 870 issueId := chi.URLParam(r, "issue") 871 issueIdInt, err := strconv.Atoi(issueId) 872 if err != nil { 873 http.Error(w, "bad issue id", http.StatusBadRequest) 874 log.Println("failed to parse issue id", err) 875 return 876 } 877 878 switch r.Method { 879 case http.MethodPost: 880 body := r.FormValue("body") 881 if body == "" { 882 s.pages.Notice(w, "issue", "Body is required") 883 return 884 } 885 886 commentId := rand.IntN(1000000) 887 888 err := db.NewComment(s.db, &db.Comment{ 889 OwnerDid: user.Did, 890 RepoAt: f.RepoAt, 891 Issue: issueIdInt, 892 CommentId: commentId, 893 Body: body, 894 }) 895 if err != nil { 896 log.Println("failed to create comment", err) 897 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 898 return 899 } 900 901 createdAt := time.Now().Format(time.RFC3339) 902 commentIdInt64 := int64(commentId) 903 ownerDid := user.Did 904 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 905 if err != nil { 906 log.Println("failed to get issue at", err) 907 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 908 return 909 } 910 911 atUri := f.RepoAt.String() 912 client, _ := s.auth.AuthorizedClient(r) 913 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 914 Collection: tangled.RepoIssueCommentNSID, 915 Repo: user.Did, 916 Rkey: s.TID(), 917 Record: &lexutil.LexiconTypeDecoder{ 918 Val: &tangled.RepoIssueComment{ 919 Repo: &atUri, 920 Issue: issueAt, 921 CommentId: &commentIdInt64, 922 Owner: &ownerDid, 923 Body: &body, 924 CreatedAt: &createdAt, 925 }, 926 }, 927 }) 928 if err != nil { 929 log.Println("failed to create comment", err) 930 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 931 return 932 } 933 934 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 935 return 936 } 937} 938 939func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 940 params := r.URL.Query() 941 state := params.Get("state") 942 isOpen := true 943 switch state { 944 case "open": 945 isOpen = true 946 case "closed": 947 isOpen = false 948 default: 949 isOpen = true 950 } 951 952 user := s.auth.GetUser(r) 953 f, err := fullyResolvedRepo(r) 954 if err != nil { 955 log.Println("failed to get repo and knot", err) 956 return 957 } 958 959 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 960 if err != nil { 961 log.Println("failed to get issues", err) 962 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 963 return 964 } 965 966 identsToResolve := make([]string, len(issues)) 967 for i, issue := range issues { 968 identsToResolve[i] = issue.OwnerDid 969 } 970 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 971 didHandleMap := make(map[string]string) 972 for _, identity := range resolvedIds { 973 if !identity.Handle.IsInvalidHandle() { 974 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 975 } else { 976 didHandleMap[identity.DID.String()] = identity.DID.String() 977 } 978 } 979 980 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 981 LoggedInUser: s.auth.GetUser(r), 982 RepoInfo: f.RepoInfo(s, user), 983 Issues: issues, 984 DidHandleMap: didHandleMap, 985 FilteringByOpen: isOpen, 986 }) 987 return 988} 989 990func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 991 user := s.auth.GetUser(r) 992 993 f, err := fullyResolvedRepo(r) 994 if err != nil { 995 log.Println("failed to get repo and knot", err) 996 return 997 } 998 999 switch r.Method { 1000 case http.MethodGet: 1001 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1002 LoggedInUser: user, 1003 RepoInfo: f.RepoInfo(s, user), 1004 }) 1005 case http.MethodPost: 1006 title := r.FormValue("title") 1007 body := r.FormValue("body") 1008 1009 if title == "" || body == "" { 1010 s.pages.Notice(w, "issues", "Title and body are required") 1011 return 1012 } 1013 1014 tx, err := s.db.BeginTx(r.Context(), nil) 1015 if err != nil { 1016 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1017 return 1018 } 1019 1020 err = db.NewIssue(tx, &db.Issue{ 1021 RepoAt: f.RepoAt, 1022 Title: title, 1023 Body: body, 1024 OwnerDid: user.Did, 1025 }) 1026 if err != nil { 1027 log.Println("failed to create issue", err) 1028 s.pages.Notice(w, "issues", "Failed to create issue.") 1029 return 1030 } 1031 1032 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1033 if err != nil { 1034 log.Println("failed to get issue id", err) 1035 s.pages.Notice(w, "issues", "Failed to create issue.") 1036 return 1037 } 1038 1039 client, _ := s.auth.AuthorizedClient(r) 1040 atUri := f.RepoAt.String() 1041 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1042 Collection: tangled.RepoIssueNSID, 1043 Repo: user.Did, 1044 Rkey: s.TID(), 1045 Record: &lexutil.LexiconTypeDecoder{ 1046 Val: &tangled.RepoIssue{ 1047 Repo: atUri, 1048 Title: title, 1049 Body: &body, 1050 Owner: user.Did, 1051 IssueId: int64(issueId), 1052 }, 1053 }, 1054 }) 1055 if err != nil { 1056 log.Println("failed to create issue", err) 1057 s.pages.Notice(w, "issues", "Failed to create issue.") 1058 return 1059 } 1060 1061 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1062 if err != nil { 1063 log.Println("failed to set issue at", err) 1064 s.pages.Notice(w, "issues", "Failed to create issue.") 1065 return 1066 } 1067 1068 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1069 return 1070 } 1071} 1072 1073func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 1074 repoName := chi.URLParam(r, "repo") 1075 knot, ok := r.Context().Value("knot").(string) 1076 if !ok { 1077 log.Println("malformed middleware") 1078 return nil, fmt.Errorf("malformed middleware") 1079 } 1080 id, ok := r.Context().Value("resolvedId").(identity.Identity) 1081 if !ok { 1082 log.Println("malformed middleware") 1083 return nil, fmt.Errorf("malformed middleware") 1084 } 1085 1086 repoAt, ok := r.Context().Value("repoAt").(string) 1087 if !ok { 1088 log.Println("malformed middleware") 1089 return nil, fmt.Errorf("malformed middleware") 1090 } 1091 1092 parsedRepoAt, err := syntax.ParseATURI(repoAt) 1093 if err != nil { 1094 log.Println("malformed repo at-uri") 1095 return nil, fmt.Errorf("malformed middleware") 1096 } 1097 1098 // pass through values from the middleware 1099 description, ok := r.Context().Value("repoDescription").(string) 1100 addedAt, ok := r.Context().Value("repoAddedAt").(string) 1101 1102 return &FullyResolvedRepo{ 1103 Knot: knot, 1104 OwnerId: id, 1105 RepoName: repoName, 1106 RepoAt: parsedRepoAt, 1107 Description: description, 1108 AddedAt: addedAt, 1109 }, nil 1110} 1111 1112func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 1113 if u != nil { 1114 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 1115 return pages.RolesInRepo{r} 1116 } else { 1117 return pages.RolesInRepo{} 1118 } 1119}