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