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