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