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