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