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