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