forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package state 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 "math/rand/v2" 10 "net/http" 11 "path" 12 "slices" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/bluesky-social/indigo/atproto/identity" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 securejoin "github.com/cyphar/filepath-securejoin" 20 "github.com/go-chi/chi/v5" 21 "github.com/sotangled/tangled/api/tangled" 22 "github.com/sotangled/tangled/appview/auth" 23 "github.com/sotangled/tangled/appview/db" 24 "github.com/sotangled/tangled/appview/pages" 25 "github.com/sotangled/tangled/types" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 lexutil "github.com/bluesky-social/indigo/lex/util" 29) 30 31func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 32 ref := chi.URLParam(r, "ref") 33 f, err := fullyResolvedRepo(r) 34 if err != nil { 35 log.Println("failed to fully resolve repo", err) 36 return 37 } 38 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: user.Did, 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 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1081 if err != nil { 1082 log.Println("failed to get issue", err) 1083 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1084 return 1085 } 1086 1087 collaborators, err := f.Collaborators(r.Context(), s) 1088 if err != nil { 1089 log.Println("failed to fetch repo collaborators: %w", err) 1090 } 1091 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1092 return user.Did == collab.Did 1093 }) 1094 isIssueOwner := user.Did == issue.OwnerDid 1095 1096 if isCollaborator || isIssueOwner { 1097 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1098 if err != nil { 1099 log.Println("failed to reopen issue", err) 1100 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1101 return 1102 } 1103 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1104 return 1105 } else { 1106 log.Println("user is not the owner of the repo") 1107 http.Error(w, "forbidden", http.StatusUnauthorized) 1108 return 1109 } 1110} 1111 1112func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1113 user := s.auth.GetUser(r) 1114 f, err := fullyResolvedRepo(r) 1115 if err != nil { 1116 log.Println("failed to get repo and knot", err) 1117 return 1118 } 1119 1120 issueId := chi.URLParam(r, "issue") 1121 issueIdInt, err := strconv.Atoi(issueId) 1122 if err != nil { 1123 http.Error(w, "bad issue id", http.StatusBadRequest) 1124 log.Println("failed to parse issue id", err) 1125 return 1126 } 1127 1128 switch r.Method { 1129 case http.MethodPost: 1130 body := r.FormValue("body") 1131 if body == "" { 1132 s.pages.Notice(w, "issue", "Body is required") 1133 return 1134 } 1135 1136 commentId := rand.IntN(1000000) 1137 1138 err := db.NewComment(s.db, &db.Comment{ 1139 OwnerDid: user.Did, 1140 RepoAt: f.RepoAt, 1141 Issue: issueIdInt, 1142 CommentId: commentId, 1143 Body: body, 1144 }) 1145 if err != nil { 1146 log.Println("failed to create comment", err) 1147 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1148 return 1149 } 1150 1151 createdAt := time.Now().Format(time.RFC3339) 1152 commentIdInt64 := int64(commentId) 1153 ownerDid := user.Did 1154 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1155 if err != nil { 1156 log.Println("failed to get issue at", err) 1157 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1158 return 1159 } 1160 1161 atUri := f.RepoAt.String() 1162 client, _ := s.auth.AuthorizedClient(r) 1163 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1164 Collection: tangled.RepoIssueCommentNSID, 1165 Repo: user.Did, 1166 Rkey: s.TID(), 1167 Record: &lexutil.LexiconTypeDecoder{ 1168 Val: &tangled.RepoIssueComment{ 1169 Repo: &atUri, 1170 Issue: issueAt, 1171 CommentId: &commentIdInt64, 1172 Owner: &ownerDid, 1173 Body: &body, 1174 CreatedAt: &createdAt, 1175 }, 1176 }, 1177 }) 1178 if err != nil { 1179 log.Println("failed to create comment", err) 1180 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1181 return 1182 } 1183 1184 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1185 return 1186 } 1187} 1188 1189func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1190 params := r.URL.Query() 1191 state := params.Get("state") 1192 isOpen := true 1193 switch state { 1194 case "open": 1195 isOpen = true 1196 case "closed": 1197 isOpen = false 1198 default: 1199 isOpen = true 1200 } 1201 1202 user := s.auth.GetUser(r) 1203 f, err := fullyResolvedRepo(r) 1204 if err != nil { 1205 log.Println("failed to get repo and knot", err) 1206 return 1207 } 1208 1209 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 1210 if err != nil { 1211 log.Println("failed to get issues", err) 1212 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1213 return 1214 } 1215 1216 identsToResolve := make([]string, len(issues)) 1217 for i, issue := range issues { 1218 identsToResolve[i] = issue.OwnerDid 1219 } 1220 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1221 didHandleMap := make(map[string]string) 1222 for _, identity := range resolvedIds { 1223 if !identity.Handle.IsInvalidHandle() { 1224 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1225 } else { 1226 didHandleMap[identity.DID.String()] = identity.DID.String() 1227 } 1228 } 1229 1230 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1231 LoggedInUser: s.auth.GetUser(r), 1232 RepoInfo: f.RepoInfo(s, user), 1233 Issues: issues, 1234 DidHandleMap: didHandleMap, 1235 FilteringByOpen: isOpen, 1236 }) 1237 return 1238} 1239 1240func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1241 user := s.auth.GetUser(r) 1242 1243 f, err := fullyResolvedRepo(r) 1244 if err != nil { 1245 log.Println("failed to get repo and knot", err) 1246 return 1247 } 1248 1249 switch r.Method { 1250 case http.MethodGet: 1251 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1252 LoggedInUser: user, 1253 RepoInfo: f.RepoInfo(s, user), 1254 }) 1255 case http.MethodPost: 1256 title := r.FormValue("title") 1257 body := r.FormValue("body") 1258 1259 if title == "" || body == "" { 1260 s.pages.Notice(w, "issues", "Title and body are required") 1261 return 1262 } 1263 1264 tx, err := s.db.BeginTx(r.Context(), nil) 1265 if err != nil { 1266 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1267 return 1268 } 1269 1270 err = db.NewIssue(tx, &db.Issue{ 1271 RepoAt: f.RepoAt, 1272 Title: title, 1273 Body: body, 1274 OwnerDid: user.Did, 1275 }) 1276 if err != nil { 1277 log.Println("failed to create issue", err) 1278 s.pages.Notice(w, "issues", "Failed to create issue.") 1279 return 1280 } 1281 1282 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1283 if err != nil { 1284 log.Println("failed to get issue id", err) 1285 s.pages.Notice(w, "issues", "Failed to create issue.") 1286 return 1287 } 1288 1289 client, _ := s.auth.AuthorizedClient(r) 1290 atUri := f.RepoAt.String() 1291 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1292 Collection: tangled.RepoIssueNSID, 1293 Repo: user.Did, 1294 Rkey: s.TID(), 1295 Record: &lexutil.LexiconTypeDecoder{ 1296 Val: &tangled.RepoIssue{ 1297 Repo: atUri, 1298 Title: title, 1299 Body: &body, 1300 Owner: user.Did, 1301 IssueId: int64(issueId), 1302 }, 1303 }, 1304 }) 1305 if err != nil { 1306 log.Println("failed to create issue", err) 1307 s.pages.Notice(w, "issues", "Failed to create issue.") 1308 return 1309 } 1310 1311 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1312 if err != nil { 1313 log.Println("failed to set issue at", err) 1314 s.pages.Notice(w, "issues", "Failed to create issue.") 1315 return 1316 } 1317 1318 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1319 return 1320 } 1321} 1322 1323func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 1324 user := s.auth.GetUser(r) 1325 f, err := fullyResolvedRepo(r) 1326 if err != nil { 1327 log.Println("failed to get repo and knot", err) 1328 return 1329 } 1330 1331 pulls, err := db.GetPulls(s.db, f.RepoAt) 1332 if err != nil { 1333 log.Println("failed to get pulls", err) 1334 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 1335 return 1336 } 1337 1338 identsToResolve := make([]string, len(pulls)) 1339 for i, pull := range pulls { 1340 identsToResolve[i] = pull.OwnerDid 1341 } 1342 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1343 didHandleMap := make(map[string]string) 1344 for _, identity := range resolvedIds { 1345 if !identity.Handle.IsInvalidHandle() { 1346 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1347 } else { 1348 didHandleMap[identity.DID.String()] = identity.DID.String() 1349 } 1350 } 1351 1352 s.pages.RepoPulls(w, pages.RepoPullsParams{ 1353 LoggedInUser: s.auth.GetUser(r), 1354 RepoInfo: f.RepoInfo(s, user), 1355 Pulls: pulls, 1356 DidHandleMap: didHandleMap, 1357 }) 1358 return 1359} 1360 1361func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 1362 repoName := chi.URLParam(r, "repo") 1363 knot, ok := r.Context().Value("knot").(string) 1364 if !ok { 1365 log.Println("malformed middleware") 1366 return nil, fmt.Errorf("malformed middleware") 1367 } 1368 id, ok := r.Context().Value("resolvedId").(identity.Identity) 1369 if !ok { 1370 log.Println("malformed middleware") 1371 return nil, fmt.Errorf("malformed middleware") 1372 } 1373 1374 repoAt, ok := r.Context().Value("repoAt").(string) 1375 if !ok { 1376 log.Println("malformed middleware") 1377 return nil, fmt.Errorf("malformed middleware") 1378 } 1379 1380 parsedRepoAt, err := syntax.ParseATURI(repoAt) 1381 if err != nil { 1382 log.Println("malformed repo at-uri") 1383 return nil, fmt.Errorf("malformed middleware") 1384 } 1385 1386 // pass through values from the middleware 1387 description, ok := r.Context().Value("repoDescription").(string) 1388 addedAt, ok := r.Context().Value("repoAddedAt").(string) 1389 1390 return &FullyResolvedRepo{ 1391 Knot: knot, 1392 OwnerId: id, 1393 RepoName: repoName, 1394 RepoAt: parsedRepoAt, 1395 Description: description, 1396 AddedAt: addedAt, 1397 }, nil 1398} 1399 1400func rolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 1401 if u != nil { 1402 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 1403 return pages.RolesInRepo{r} 1404 } else { 1405 return pages.RolesInRepo{} 1406 } 1407}