forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log" 11 mathrand "math/rand/v2" 12 "net/http" 13 "path" 14 "slices" 15 "strconv" 16 "strings" 17 "time" 18 19 "github.com/bluesky-social/indigo/atproto/data" 20 "github.com/bluesky-social/indigo/atproto/identity" 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 securejoin "github.com/cyphar/filepath-securejoin" 23 "github.com/go-chi/chi/v5" 24 "github.com/go-git/go-git/v5/plumbing" 25 "tangled.sh/tangled.sh/core/api/tangled" 26 "tangled.sh/tangled.sh/core/appview" 27 "tangled.sh/tangled.sh/core/appview/auth" 28 "tangled.sh/tangled.sh/core/appview/db" 29 "tangled.sh/tangled.sh/core/appview/pages" 30 "tangled.sh/tangled.sh/core/appview/pages/markup" 31 "tangled.sh/tangled.sh/core/appview/pagination" 32 "tangled.sh/tangled.sh/core/types" 33 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 35 lexutil "github.com/bluesky-social/indigo/lex/util" 36) 37 38func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 39 ref := chi.URLParam(r, "ref") 40 f, err := s.fullyResolvedRepo(r) 41 if err != nil { 42 log.Println("failed to fully resolve repo", err) 43 return 44 } 45 46 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 47 if err != nil { 48 log.Printf("failed to create unsigned client for %s", f.Knot) 49 s.pages.Error503(w) 50 return 51 } 52 53 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref) 54 if err != nil { 55 s.pages.Error503(w) 56 log.Println("failed to reach knotserver", err) 57 return 58 } 59 defer resp.Body.Close() 60 61 body, err := io.ReadAll(resp.Body) 62 if err != nil { 63 log.Printf("Error reading response body: %v", err) 64 return 65 } 66 67 var result types.RepoIndexResponse 68 err = json.Unmarshal(body, &result) 69 if err != nil { 70 log.Printf("Error unmarshalling response body: %v", err) 71 return 72 } 73 74 tagMap := make(map[string][]string) 75 for _, tag := range result.Tags { 76 hash := tag.Hash 77 if tag.Tag != nil { 78 hash = tag.Tag.Target.String() 79 } 80 tagMap[hash] = append(tagMap[hash], tag.Name) 81 } 82 83 for _, branch := range result.Branches { 84 hash := branch.Hash 85 tagMap[hash] = append(tagMap[hash], branch.Name) 86 } 87 88 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 89 if a.Name == result.Ref { 90 return -1 91 } 92 if a.IsDefault { 93 return -1 94 } 95 if a.Commit != nil { 96 if a.Commit.Author.When.Before(b.Commit.Author.When) { 97 return 1 98 } else { 99 return -1 100 } 101 } 102 return strings.Compare(a.Name, b.Name) * -1 103 }) 104 105 commitCount := len(result.Commits) 106 branchCount := len(result.Branches) 107 tagCount := len(result.Tags) 108 fileCount := len(result.Files) 109 110 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 111 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 112 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 113 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 114 115 emails := uniqueEmails(commitsTrunc) 116 117 user := s.auth.GetUser(r) 118 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 119 LoggedInUser: user, 120 RepoInfo: f.RepoInfo(s, user), 121 TagMap: tagMap, 122 RepoIndexResponse: result, 123 CommitsTrunc: commitsTrunc, 124 TagsTrunc: tagsTrunc, 125 BranchesTrunc: branchesTrunc, 126 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 127 }) 128 return 129} 130 131func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 132 f, err := s.fullyResolvedRepo(r) 133 if err != nil { 134 log.Println("failed to fully resolve repo", err) 135 return 136 } 137 138 page := 1 139 if r.URL.Query().Get("page") != "" { 140 page, err = strconv.Atoi(r.URL.Query().Get("page")) 141 if err != nil { 142 page = 1 143 } 144 } 145 146 ref := chi.URLParam(r, "ref") 147 148 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 149 if err != nil { 150 log.Println("failed to create unsigned client", err) 151 return 152 } 153 154 resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 155 if err != nil { 156 log.Println("failed to reach knotserver", err) 157 return 158 } 159 160 body, err := io.ReadAll(resp.Body) 161 if err != nil { 162 log.Printf("error reading response body: %v", err) 163 return 164 } 165 166 var repolog types.RepoLogResponse 167 err = json.Unmarshal(body, &repolog) 168 if err != nil { 169 log.Println("failed to parse json response", err) 170 return 171 } 172 173 resp, err = us.Tags(f.OwnerDid(), f.RepoName) 174 if err != nil { 175 log.Println("failed to reach knotserver", err) 176 return 177 } 178 179 body, err = io.ReadAll(resp.Body) 180 if err != nil { 181 log.Printf("error reading response body: %v", err) 182 return 183 } 184 185 var result types.RepoTagsResponse 186 err = json.Unmarshal(body, &result) 187 if err != nil { 188 log.Printf("Error unmarshalling response body: %v", err) 189 return 190 } 191 192 tagMap := make(map[string][]string) 193 for _, tag := range result.Tags { 194 hash := tag.Hash 195 if tag.Tag != nil { 196 hash = tag.Tag.Target.String() 197 } 198 tagMap[hash] = append(tagMap[hash], tag.Name) 199 } 200 201 user := s.auth.GetUser(r) 202 s.pages.RepoLog(w, pages.RepoLogParams{ 203 LoggedInUser: user, 204 TagMap: tagMap, 205 RepoInfo: f.RepoInfo(s, user), 206 RepoLogResponse: repolog, 207 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 208 }) 209 return 210} 211 212func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 213 f, err := s.fullyResolvedRepo(r) 214 if err != nil { 215 log.Println("failed to get repo and knot", err) 216 w.WriteHeader(http.StatusBadRequest) 217 return 218 } 219 220 user := s.auth.GetUser(r) 221 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 222 RepoInfo: f.RepoInfo(s, user), 223 }) 224 return 225} 226 227func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 228 f, err := s.fullyResolvedRepo(r) 229 if err != nil { 230 log.Println("failed to get repo and knot", err) 231 w.WriteHeader(http.StatusBadRequest) 232 return 233 } 234 235 repoAt := f.RepoAt 236 rkey := repoAt.RecordKey().String() 237 if rkey == "" { 238 log.Println("invalid aturi for repo", err) 239 w.WriteHeader(http.StatusInternalServerError) 240 return 241 } 242 243 user := s.auth.GetUser(r) 244 245 switch r.Method { 246 case http.MethodGet: 247 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 248 RepoInfo: f.RepoInfo(s, user), 249 }) 250 return 251 case http.MethodPut: 252 user := s.auth.GetUser(r) 253 newDescription := r.FormValue("description") 254 client, _ := s.auth.AuthorizedClient(r) 255 256 // optimistic update 257 err = db.UpdateDescription(s.db, string(repoAt), newDescription) 258 if err != nil { 259 log.Println("failed to perferom update-description query", err) 260 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 261 return 262 } 263 264 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 265 // 266 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 267 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey) 268 if err != nil { 269 // failed to get record 270 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 271 return 272 } 273 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 274 Collection: tangled.RepoNSID, 275 Repo: user.Did, 276 Rkey: rkey, 277 SwapRecord: ex.Cid, 278 Record: &lexutil.LexiconTypeDecoder{ 279 Val: &tangled.Repo{ 280 Knot: f.Knot, 281 Name: f.RepoName, 282 Owner: user.Did, 283 CreatedAt: f.CreatedAt, 284 Description: &newDescription, 285 }, 286 }, 287 }) 288 289 if err != nil { 290 log.Println("failed to perferom update-description query", err) 291 // failed to get record 292 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 293 return 294 } 295 296 newRepoInfo := f.RepoInfo(s, user) 297 newRepoInfo.Description = newDescription 298 299 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 300 RepoInfo: newRepoInfo, 301 }) 302 return 303 } 304} 305 306func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 307 f, err := s.fullyResolvedRepo(r) 308 if err != nil { 309 log.Println("failed to fully resolve repo", err) 310 return 311 } 312 ref := chi.URLParam(r, "ref") 313 protocol := "http" 314 if !s.config.Dev { 315 protocol = "https" 316 } 317 318 if !plumbing.IsHash(ref) { 319 s.pages.Error404(w) 320 return 321 } 322 323 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 324 if err != nil { 325 log.Println("failed to reach knotserver", err) 326 return 327 } 328 329 body, err := io.ReadAll(resp.Body) 330 if err != nil { 331 log.Printf("Error reading response body: %v", err) 332 return 333 } 334 335 var result types.RepoCommitResponse 336 err = json.Unmarshal(body, &result) 337 if err != nil { 338 log.Println("failed to parse response:", err) 339 return 340 } 341 342 user := s.auth.GetUser(r) 343 s.pages.RepoCommit(w, pages.RepoCommitParams{ 344 LoggedInUser: user, 345 RepoInfo: f.RepoInfo(s, user), 346 RepoCommitResponse: result, 347 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 348 }) 349 return 350} 351 352func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 353 f, err := s.fullyResolvedRepo(r) 354 if err != nil { 355 log.Println("failed to fully resolve repo", err) 356 return 357 } 358 359 ref := chi.URLParam(r, "ref") 360 treePath := chi.URLParam(r, "*") 361 protocol := "http" 362 if !s.config.Dev { 363 protocol = "https" 364 } 365 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 366 if err != nil { 367 log.Println("failed to reach knotserver", err) 368 return 369 } 370 371 body, err := io.ReadAll(resp.Body) 372 if err != nil { 373 log.Printf("Error reading response body: %v", err) 374 return 375 } 376 377 var result types.RepoTreeResponse 378 err = json.Unmarshal(body, &result) 379 if err != nil { 380 log.Println("failed to parse response:", err) 381 return 382 } 383 384 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 385 // so we can safely redirect to the "parent" (which is the same file). 386 if len(result.Files) == 0 && result.Parent == treePath { 387 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 388 return 389 } 390 391 user := s.auth.GetUser(r) 392 393 var breadcrumbs [][]string 394 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 395 if treePath != "" { 396 for idx, elem := range strings.Split(treePath, "/") { 397 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 398 } 399 } 400 401 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 402 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 403 404 s.pages.RepoTree(w, pages.RepoTreeParams{ 405 LoggedInUser: user, 406 BreadCrumbs: breadcrumbs, 407 BaseTreeLink: baseTreeLink, 408 BaseBlobLink: baseBlobLink, 409 RepoInfo: f.RepoInfo(s, user), 410 RepoTreeResponse: result, 411 }) 412 return 413} 414 415func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 416 f, err := s.fullyResolvedRepo(r) 417 if err != nil { 418 log.Println("failed to get repo and knot", err) 419 return 420 } 421 422 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 423 if err != nil { 424 log.Println("failed to create unsigned client", err) 425 return 426 } 427 428 resp, err := us.Tags(f.OwnerDid(), f.RepoName) 429 if err != nil { 430 log.Println("failed to reach knotserver", err) 431 return 432 } 433 434 body, err := io.ReadAll(resp.Body) 435 if err != nil { 436 log.Printf("Error reading response body: %v", err) 437 return 438 } 439 440 var result types.RepoTagsResponse 441 err = json.Unmarshal(body, &result) 442 if err != nil { 443 log.Println("failed to parse response:", err) 444 return 445 } 446 447 user := s.auth.GetUser(r) 448 s.pages.RepoTags(w, pages.RepoTagsParams{ 449 LoggedInUser: user, 450 RepoInfo: f.RepoInfo(s, user), 451 RepoTagsResponse: result, 452 }) 453 return 454} 455 456func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 457 f, err := s.fullyResolvedRepo(r) 458 if err != nil { 459 log.Println("failed to get repo and knot", err) 460 return 461 } 462 463 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 464 if err != nil { 465 log.Println("failed to create unsigned client", err) 466 return 467 } 468 469 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 470 if err != nil { 471 log.Println("failed to reach knotserver", err) 472 return 473 } 474 475 body, err := io.ReadAll(resp.Body) 476 if err != nil { 477 log.Printf("Error reading response body: %v", err) 478 return 479 } 480 481 var result types.RepoBranchesResponse 482 err = json.Unmarshal(body, &result) 483 if err != nil { 484 log.Println("failed to parse response:", err) 485 return 486 } 487 488 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 489 if a.IsDefault { 490 return -1 491 } 492 if a.Commit != nil { 493 if a.Commit.Author.When.Before(b.Commit.Author.When) { 494 return 1 495 } else { 496 return -1 497 } 498 } 499 return strings.Compare(a.Name, b.Name) * -1 500 }) 501 502 user := s.auth.GetUser(r) 503 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 504 LoggedInUser: user, 505 RepoInfo: f.RepoInfo(s, user), 506 RepoBranchesResponse: result, 507 }) 508 return 509} 510 511func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 512 f, err := s.fullyResolvedRepo(r) 513 if err != nil { 514 log.Println("failed to get repo and knot", err) 515 return 516 } 517 518 ref := chi.URLParam(r, "ref") 519 filePath := chi.URLParam(r, "*") 520 protocol := "http" 521 if !s.config.Dev { 522 protocol = "https" 523 } 524 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 525 if err != nil { 526 log.Println("failed to reach knotserver", err) 527 return 528 } 529 530 body, err := io.ReadAll(resp.Body) 531 if err != nil { 532 log.Printf("Error reading response body: %v", err) 533 return 534 } 535 536 var result types.RepoBlobResponse 537 err = json.Unmarshal(body, &result) 538 if err != nil { 539 log.Println("failed to parse response:", err) 540 return 541 } 542 543 var breadcrumbs [][]string 544 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 545 if filePath != "" { 546 for idx, elem := range strings.Split(filePath, "/") { 547 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 548 } 549 } 550 551 showRendered := false 552 renderToggle := false 553 554 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 555 renderToggle = true 556 showRendered = r.URL.Query().Get("code") != "true" 557 } 558 559 user := s.auth.GetUser(r) 560 s.pages.RepoBlob(w, pages.RepoBlobParams{ 561 LoggedInUser: user, 562 RepoInfo: f.RepoInfo(s, user), 563 RepoBlobResponse: result, 564 BreadCrumbs: breadcrumbs, 565 ShowRendered: showRendered, 566 RenderToggle: renderToggle, 567 }) 568 return 569} 570 571func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 572 f, err := s.fullyResolvedRepo(r) 573 if err != nil { 574 log.Println("failed to get repo and knot", err) 575 return 576 } 577 578 ref := chi.URLParam(r, "ref") 579 filePath := chi.URLParam(r, "*") 580 581 protocol := "http" 582 if !s.config.Dev { 583 protocol = "https" 584 } 585 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 586 if err != nil { 587 log.Println("failed to reach knotserver", err) 588 return 589 } 590 591 body, err := io.ReadAll(resp.Body) 592 if err != nil { 593 log.Printf("Error reading response body: %v", err) 594 return 595 } 596 597 var result types.RepoBlobResponse 598 err = json.Unmarshal(body, &result) 599 if err != nil { 600 log.Println("failed to parse response:", err) 601 return 602 } 603 604 if result.IsBinary { 605 w.Header().Set("Content-Type", "application/octet-stream") 606 w.Write(body) 607 return 608 } 609 610 w.Header().Set("Content-Type", "text/plain") 611 w.Write([]byte(result.Contents)) 612 return 613} 614 615func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 616 f, err := s.fullyResolvedRepo(r) 617 if err != nil { 618 log.Println("failed to get repo and knot", err) 619 return 620 } 621 622 collaborator := r.FormValue("collaborator") 623 if collaborator == "" { 624 http.Error(w, "malformed form", http.StatusBadRequest) 625 return 626 } 627 628 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 629 if err != nil { 630 w.Write([]byte("failed to resolve collaborator did to a handle")) 631 return 632 } 633 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 634 635 // TODO: create an atproto record for this 636 637 secret, err := db.GetRegistrationKey(s.db, f.Knot) 638 if err != nil { 639 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 640 return 641 } 642 643 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 644 if err != nil { 645 log.Println("failed to create client to ", f.Knot) 646 return 647 } 648 649 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 650 if err != nil { 651 log.Printf("failed to make request to %s: %s", f.Knot, err) 652 return 653 } 654 655 if ksResp.StatusCode != http.StatusNoContent { 656 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 657 return 658 } 659 660 tx, err := s.db.BeginTx(r.Context(), nil) 661 if err != nil { 662 log.Println("failed to start tx") 663 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 664 return 665 } 666 defer func() { 667 tx.Rollback() 668 err = s.enforcer.E.LoadPolicy() 669 if err != nil { 670 log.Println("failed to rollback policies") 671 } 672 }() 673 674 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 675 if err != nil { 676 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 677 return 678 } 679 680 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 681 if err != nil { 682 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 683 return 684 } 685 686 err = tx.Commit() 687 if err != nil { 688 log.Println("failed to commit changes", err) 689 http.Error(w, err.Error(), http.StatusInternalServerError) 690 return 691 } 692 693 err = s.enforcer.E.SavePolicy() 694 if err != nil { 695 log.Println("failed to update ACLs", err) 696 http.Error(w, err.Error(), http.StatusInternalServerError) 697 return 698 } 699 700 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 701 702} 703 704func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 705 user := s.auth.GetUser(r) 706 707 f, err := s.fullyResolvedRepo(r) 708 if err != nil { 709 log.Println("failed to get repo and knot", err) 710 return 711 } 712 713 // remove record from pds 714 xrpcClient, _ := s.auth.AuthorizedClient(r) 715 repoRkey := f.RepoAt.RecordKey().String() 716 _, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{ 717 Collection: tangled.RepoNSID, 718 Repo: user.Did, 719 Rkey: repoRkey, 720 }) 721 if err != nil { 722 log.Printf("failed to delete record: %s", err) 723 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 724 return 725 } 726 log.Println("removed repo record ", f.RepoAt.String()) 727 728 secret, err := db.GetRegistrationKey(s.db, f.Knot) 729 if err != nil { 730 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 731 return 732 } 733 734 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 735 if err != nil { 736 log.Println("failed to create client to ", f.Knot) 737 return 738 } 739 740 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 741 if err != nil { 742 log.Printf("failed to make request to %s: %s", f.Knot, err) 743 return 744 } 745 746 if ksResp.StatusCode != http.StatusNoContent { 747 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 748 } else { 749 log.Println("removed repo from knot ", f.Knot) 750 } 751 752 tx, err := s.db.BeginTx(r.Context(), nil) 753 if err != nil { 754 log.Println("failed to start tx") 755 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 756 return 757 } 758 defer func() { 759 tx.Rollback() 760 err = s.enforcer.E.LoadPolicy() 761 if err != nil { 762 log.Println("failed to rollback policies") 763 } 764 }() 765 766 // remove collaborator RBAC 767 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 768 if err != nil { 769 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 770 return 771 } 772 for _, c := range repoCollaborators { 773 did := c[0] 774 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 775 } 776 log.Println("removed collaborators") 777 778 // remove repo RBAC 779 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 780 if err != nil { 781 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 782 return 783 } 784 785 // remove repo from db 786 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 787 if err != nil { 788 s.pages.Notice(w, "settings-delete", "Failed to update appview") 789 return 790 } 791 log.Println("removed repo from db") 792 793 err = tx.Commit() 794 if err != nil { 795 log.Println("failed to commit changes", err) 796 http.Error(w, err.Error(), http.StatusInternalServerError) 797 return 798 } 799 800 err = s.enforcer.E.SavePolicy() 801 if err != nil { 802 log.Println("failed to update ACLs", err) 803 http.Error(w, err.Error(), http.StatusInternalServerError) 804 return 805 } 806 807 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 808} 809 810func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 811 f, err := s.fullyResolvedRepo(r) 812 if err != nil { 813 log.Println("failed to get repo and knot", err) 814 return 815 } 816 817 branch := r.FormValue("branch") 818 if branch == "" { 819 http.Error(w, "malformed form", http.StatusBadRequest) 820 return 821 } 822 823 secret, err := db.GetRegistrationKey(s.db, f.Knot) 824 if err != nil { 825 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 826 return 827 } 828 829 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 830 if err != nil { 831 log.Println("failed to create client to ", f.Knot) 832 return 833 } 834 835 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 836 if err != nil { 837 log.Printf("failed to make request to %s: %s", f.Knot, err) 838 return 839 } 840 841 if ksResp.StatusCode != http.StatusNoContent { 842 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 843 return 844 } 845 846 w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 847} 848 849func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 850 f, err := s.fullyResolvedRepo(r) 851 if err != nil { 852 log.Println("failed to get repo and knot", err) 853 return 854 } 855 856 switch r.Method { 857 case http.MethodGet: 858 // for now, this is just pubkeys 859 user := s.auth.GetUser(r) 860 repoCollaborators, err := f.Collaborators(r.Context(), s) 861 if err != nil { 862 log.Println("failed to get collaborators", err) 863 } 864 865 isCollaboratorInviteAllowed := false 866 if user != nil { 867 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 868 if err == nil && ok { 869 isCollaboratorInviteAllowed = true 870 } 871 } 872 873 var branchNames []string 874 var defaultBranch string 875 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 876 if err != nil { 877 log.Println("failed to create unsigned client", err) 878 } else { 879 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 880 if err != nil { 881 log.Println("failed to reach knotserver", err) 882 } else { 883 defer resp.Body.Close() 884 885 body, err := io.ReadAll(resp.Body) 886 if err != nil { 887 log.Printf("Error reading response body: %v", err) 888 } else { 889 var result types.RepoBranchesResponse 890 err = json.Unmarshal(body, &result) 891 if err != nil { 892 log.Println("failed to parse response:", err) 893 } else { 894 for _, branch := range result.Branches { 895 branchNames = append(branchNames, branch.Name) 896 } 897 } 898 } 899 } 900 901 defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName) 902 if err != nil { 903 log.Println("failed to reach knotserver", err) 904 } else { 905 defaultBranch = defaultBranchResp.Branch 906 } 907 } 908 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 909 LoggedInUser: user, 910 RepoInfo: f.RepoInfo(s, user), 911 Collaborators: repoCollaborators, 912 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 913 Branches: branchNames, 914 DefaultBranch: defaultBranch, 915 }) 916 } 917} 918 919type FullyResolvedRepo struct { 920 Knot string 921 OwnerId identity.Identity 922 RepoName string 923 RepoAt syntax.ATURI 924 Description string 925 CreatedAt string 926 Ref string 927} 928 929func (f *FullyResolvedRepo) OwnerDid() string { 930 return f.OwnerId.DID.String() 931} 932 933func (f *FullyResolvedRepo) OwnerHandle() string { 934 return f.OwnerId.Handle.String() 935} 936 937func (f *FullyResolvedRepo) OwnerSlashRepo() string { 938 handle := f.OwnerId.Handle 939 940 var p string 941 if handle != "" && !handle.IsInvalidHandle() { 942 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 943 } else { 944 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 945 } 946 947 return p 948} 949 950func (f *FullyResolvedRepo) DidSlashRepo() string { 951 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 952 return p 953} 954 955func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 956 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 957 if err != nil { 958 return nil, err 959 } 960 961 var collaborators []pages.Collaborator 962 for _, item := range repoCollaborators { 963 // currently only two roles: owner and member 964 var role string 965 if item[3] == "repo:owner" { 966 role = "owner" 967 } else if item[3] == "repo:collaborator" { 968 role = "collaborator" 969 } else { 970 continue 971 } 972 973 did := item[0] 974 975 c := pages.Collaborator{ 976 Did: did, 977 Handle: "", 978 Role: role, 979 } 980 collaborators = append(collaborators, c) 981 } 982 983 // populate all collborators with handles 984 identsToResolve := make([]string, len(collaborators)) 985 for i, collab := range collaborators { 986 identsToResolve[i] = collab.Did 987 } 988 989 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 990 for i, resolved := range resolvedIdents { 991 if resolved != nil { 992 collaborators[i].Handle = resolved.Handle.String() 993 } 994 } 995 996 return collaborators, nil 997} 998 999func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { 1000 isStarred := false 1001 if u != nil { 1002 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 1003 } 1004 1005 starCount, err := db.GetStarCount(s.db, f.RepoAt) 1006 if err != nil { 1007 log.Println("failed to get star count for ", f.RepoAt) 1008 } 1009 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 1010 if err != nil { 1011 log.Println("failed to get issue count for ", f.RepoAt) 1012 } 1013 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 1014 if err != nil { 1015 log.Println("failed to get issue count for ", f.RepoAt) 1016 } 1017 source, err := db.GetRepoSource(s.db, f.RepoAt) 1018 if errors.Is(err, sql.ErrNoRows) { 1019 source = "" 1020 } else if err != nil { 1021 log.Println("failed to get repo source for ", f.RepoAt, err) 1022 } 1023 1024 var sourceRepo *db.Repo 1025 if source != "" { 1026 sourceRepo, err = db.GetRepoByAtUri(s.db, source) 1027 if err != nil { 1028 log.Println("failed to get repo by at uri", err) 1029 } 1030 } 1031 1032 var sourceHandle *identity.Identity 1033 if sourceRepo != nil { 1034 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 1035 if err != nil { 1036 log.Println("failed to resolve source repo", err) 1037 } 1038 } 1039 1040 knot := f.Knot 1041 var disableFork bool 1042 us, err := NewUnsignedClient(knot, s.config.Dev) 1043 if err != nil { 1044 log.Printf("failed to create unsigned client for %s: %v", knot, err) 1045 } else { 1046 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1047 if err != nil { 1048 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1049 } else { 1050 defer resp.Body.Close() 1051 body, err := io.ReadAll(resp.Body) 1052 if err != nil { 1053 log.Printf("error reading branch response body: %v", err) 1054 } else { 1055 var branchesResp types.RepoBranchesResponse 1056 if err := json.Unmarshal(body, &branchesResp); err != nil { 1057 log.Printf("error parsing branch response: %v", err) 1058 } else { 1059 disableFork = false 1060 } 1061 1062 if len(branchesResp.Branches) == 0 { 1063 disableFork = true 1064 } 1065 } 1066 } 1067 } 1068 1069 if knot == "knot1.tangled.sh" { 1070 knot = "tangled.sh" 1071 } 1072 1073 repoInfo := pages.RepoInfo{ 1074 OwnerDid: f.OwnerDid(), 1075 OwnerHandle: f.OwnerHandle(), 1076 Name: f.RepoName, 1077 RepoAt: f.RepoAt, 1078 Description: f.Description, 1079 Ref: f.Ref, 1080 IsStarred: isStarred, 1081 Knot: knot, 1082 Roles: RolesInRepo(s, u, f), 1083 Stats: db.RepoStats{ 1084 StarCount: starCount, 1085 IssueCount: issueCount, 1086 PullCount: pullCount, 1087 }, 1088 DisableFork: disableFork, 1089 } 1090 1091 if sourceRepo != nil { 1092 repoInfo.Source = sourceRepo 1093 repoInfo.SourceHandle = sourceHandle.Handle.String() 1094 } 1095 1096 return repoInfo 1097} 1098 1099func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1100 user := s.auth.GetUser(r) 1101 f, err := s.fullyResolvedRepo(r) 1102 if err != nil { 1103 log.Println("failed to get repo and knot", err) 1104 return 1105 } 1106 1107 issueId := chi.URLParam(r, "issue") 1108 issueIdInt, err := strconv.Atoi(issueId) 1109 if err != nil { 1110 http.Error(w, "bad issue id", http.StatusBadRequest) 1111 log.Println("failed to parse issue id", err) 1112 return 1113 } 1114 1115 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 1116 if err != nil { 1117 log.Println("failed to get issue and comments", err) 1118 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1119 return 1120 } 1121 1122 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 1123 if err != nil { 1124 log.Println("failed to resolve issue owner", err) 1125 } 1126 1127 identsToResolve := make([]string, len(comments)) 1128 for i, comment := range comments { 1129 identsToResolve[i] = comment.OwnerDid 1130 } 1131 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1132 didHandleMap := make(map[string]string) 1133 for _, identity := range resolvedIds { 1134 if !identity.Handle.IsInvalidHandle() { 1135 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1136 } else { 1137 didHandleMap[identity.DID.String()] = identity.DID.String() 1138 } 1139 } 1140 1141 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1142 LoggedInUser: user, 1143 RepoInfo: f.RepoInfo(s, user), 1144 Issue: *issue, 1145 Comments: comments, 1146 1147 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1148 DidHandleMap: didHandleMap, 1149 }) 1150 1151} 1152 1153func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1154 user := s.auth.GetUser(r) 1155 f, err := s.fullyResolvedRepo(r) 1156 if err != nil { 1157 log.Println("failed to get repo and knot", err) 1158 return 1159 } 1160 1161 issueId := chi.URLParam(r, "issue") 1162 issueIdInt, err := strconv.Atoi(issueId) 1163 if err != nil { 1164 http.Error(w, "bad issue id", http.StatusBadRequest) 1165 log.Println("failed to parse issue id", err) 1166 return 1167 } 1168 1169 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1170 if err != nil { 1171 log.Println("failed to get issue", err) 1172 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1173 return 1174 } 1175 1176 collaborators, err := f.Collaborators(r.Context(), s) 1177 if err != nil { 1178 log.Println("failed to fetch repo collaborators: %w", err) 1179 } 1180 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1181 return user.Did == collab.Did 1182 }) 1183 isIssueOwner := user.Did == issue.OwnerDid 1184 1185 // TODO: make this more granular 1186 if isIssueOwner || isCollaborator { 1187 1188 closed := tangled.RepoIssueStateClosed 1189 1190 client, _ := s.auth.AuthorizedClient(r) 1191 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1192 Collection: tangled.RepoIssueStateNSID, 1193 Repo: user.Did, 1194 Rkey: appview.TID(), 1195 Record: &lexutil.LexiconTypeDecoder{ 1196 Val: &tangled.RepoIssueState{ 1197 Issue: issue.IssueAt, 1198 State: closed, 1199 }, 1200 }, 1201 }) 1202 1203 if err != nil { 1204 log.Println("failed to update issue state", err) 1205 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1206 return 1207 } 1208 1209 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1210 if err != nil { 1211 log.Println("failed to close issue", err) 1212 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1213 return 1214 } 1215 1216 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1217 return 1218 } else { 1219 log.Println("user is not permitted to close issue") 1220 http.Error(w, "for biden", http.StatusUnauthorized) 1221 return 1222 } 1223} 1224 1225func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1226 user := s.auth.GetUser(r) 1227 f, err := s.fullyResolvedRepo(r) 1228 if err != nil { 1229 log.Println("failed to get repo and knot", err) 1230 return 1231 } 1232 1233 issueId := chi.URLParam(r, "issue") 1234 issueIdInt, err := strconv.Atoi(issueId) 1235 if err != nil { 1236 http.Error(w, "bad issue id", http.StatusBadRequest) 1237 log.Println("failed to parse issue id", err) 1238 return 1239 } 1240 1241 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1242 if err != nil { 1243 log.Println("failed to get issue", err) 1244 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1245 return 1246 } 1247 1248 collaborators, err := f.Collaborators(r.Context(), s) 1249 if err != nil { 1250 log.Println("failed to fetch repo collaborators: %w", err) 1251 } 1252 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1253 return user.Did == collab.Did 1254 }) 1255 isIssueOwner := user.Did == issue.OwnerDid 1256 1257 if isCollaborator || isIssueOwner { 1258 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1259 if err != nil { 1260 log.Println("failed to reopen issue", err) 1261 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1262 return 1263 } 1264 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1265 return 1266 } else { 1267 log.Println("user is not the owner of the repo") 1268 http.Error(w, "forbidden", http.StatusUnauthorized) 1269 return 1270 } 1271} 1272 1273func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1274 user := s.auth.GetUser(r) 1275 f, err := s.fullyResolvedRepo(r) 1276 if err != nil { 1277 log.Println("failed to get repo and knot", err) 1278 return 1279 } 1280 1281 issueId := chi.URLParam(r, "issue") 1282 issueIdInt, err := strconv.Atoi(issueId) 1283 if err != nil { 1284 http.Error(w, "bad issue id", http.StatusBadRequest) 1285 log.Println("failed to parse issue id", err) 1286 return 1287 } 1288 1289 switch r.Method { 1290 case http.MethodPost: 1291 body := r.FormValue("body") 1292 if body == "" { 1293 s.pages.Notice(w, "issue", "Body is required") 1294 return 1295 } 1296 1297 commentId := mathrand.IntN(1000000) 1298 rkey := appview.TID() 1299 1300 err := db.NewIssueComment(s.db, &db.Comment{ 1301 OwnerDid: user.Did, 1302 RepoAt: f.RepoAt, 1303 Issue: issueIdInt, 1304 CommentId: commentId, 1305 Body: body, 1306 Rkey: rkey, 1307 }) 1308 if err != nil { 1309 log.Println("failed to create comment", err) 1310 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1311 return 1312 } 1313 1314 createdAt := time.Now().Format(time.RFC3339) 1315 commentIdInt64 := int64(commentId) 1316 ownerDid := user.Did 1317 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1318 if err != nil { 1319 log.Println("failed to get issue at", err) 1320 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1321 return 1322 } 1323 1324 atUri := f.RepoAt.String() 1325 client, _ := s.auth.AuthorizedClient(r) 1326 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1327 Collection: tangled.RepoIssueCommentNSID, 1328 Repo: user.Did, 1329 Rkey: rkey, 1330 Record: &lexutil.LexiconTypeDecoder{ 1331 Val: &tangled.RepoIssueComment{ 1332 Repo: &atUri, 1333 Issue: issueAt, 1334 CommentId: &commentIdInt64, 1335 Owner: &ownerDid, 1336 Body: body, 1337 CreatedAt: createdAt, 1338 }, 1339 }, 1340 }) 1341 if err != nil { 1342 log.Println("failed to create comment", err) 1343 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1344 return 1345 } 1346 1347 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1348 return 1349 } 1350} 1351 1352func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1353 user := s.auth.GetUser(r) 1354 f, err := s.fullyResolvedRepo(r) 1355 if err != nil { 1356 log.Println("failed to get repo and knot", err) 1357 return 1358 } 1359 1360 issueId := chi.URLParam(r, "issue") 1361 issueIdInt, err := strconv.Atoi(issueId) 1362 if err != nil { 1363 http.Error(w, "bad issue id", http.StatusBadRequest) 1364 log.Println("failed to parse issue id", err) 1365 return 1366 } 1367 1368 commentId := chi.URLParam(r, "comment_id") 1369 commentIdInt, err := strconv.Atoi(commentId) 1370 if err != nil { 1371 http.Error(w, "bad comment id", http.StatusBadRequest) 1372 log.Println("failed to parse issue id", err) 1373 return 1374 } 1375 1376 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1377 if err != nil { 1378 log.Println("failed to get issue", err) 1379 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1380 return 1381 } 1382 1383 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1384 if err != nil { 1385 http.Error(w, "bad comment id", http.StatusBadRequest) 1386 return 1387 } 1388 1389 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1390 if err != nil { 1391 log.Println("failed to resolve did") 1392 return 1393 } 1394 1395 didHandleMap := make(map[string]string) 1396 if !identity.Handle.IsInvalidHandle() { 1397 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1398 } else { 1399 didHandleMap[identity.DID.String()] = identity.DID.String() 1400 } 1401 1402 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1403 LoggedInUser: user, 1404 RepoInfo: f.RepoInfo(s, user), 1405 DidHandleMap: didHandleMap, 1406 Issue: issue, 1407 Comment: comment, 1408 }) 1409} 1410 1411func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1412 user := s.auth.GetUser(r) 1413 f, err := s.fullyResolvedRepo(r) 1414 if err != nil { 1415 log.Println("failed to get repo and knot", err) 1416 return 1417 } 1418 1419 issueId := chi.URLParam(r, "issue") 1420 issueIdInt, err := strconv.Atoi(issueId) 1421 if err != nil { 1422 http.Error(w, "bad issue id", http.StatusBadRequest) 1423 log.Println("failed to parse issue id", err) 1424 return 1425 } 1426 1427 commentId := chi.URLParam(r, "comment_id") 1428 commentIdInt, err := strconv.Atoi(commentId) 1429 if err != nil { 1430 http.Error(w, "bad comment id", http.StatusBadRequest) 1431 log.Println("failed to parse issue id", err) 1432 return 1433 } 1434 1435 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1436 if err != nil { 1437 log.Println("failed to get issue", err) 1438 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1439 return 1440 } 1441 1442 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1443 if err != nil { 1444 http.Error(w, "bad comment id", http.StatusBadRequest) 1445 return 1446 } 1447 1448 if comment.OwnerDid != user.Did { 1449 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1450 return 1451 } 1452 1453 switch r.Method { 1454 case http.MethodGet: 1455 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1456 LoggedInUser: user, 1457 RepoInfo: f.RepoInfo(s, user), 1458 Issue: issue, 1459 Comment: comment, 1460 }) 1461 case http.MethodPost: 1462 // extract form value 1463 newBody := r.FormValue("body") 1464 client, _ := s.auth.AuthorizedClient(r) 1465 rkey := comment.Rkey 1466 1467 // optimistic update 1468 edited := time.Now() 1469 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1470 if err != nil { 1471 log.Println("failed to perferom update-description query", err) 1472 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1473 return 1474 } 1475 1476 // rkey is optional, it was introduced later 1477 if comment.Rkey != "" { 1478 // update the record on pds 1479 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1480 if err != nil { 1481 // failed to get record 1482 log.Println(err, rkey) 1483 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1484 return 1485 } 1486 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1487 record, _ := data.UnmarshalJSON(value) 1488 1489 repoAt := record["repo"].(string) 1490 issueAt := record["issue"].(string) 1491 createdAt := record["createdAt"].(string) 1492 commentIdInt64 := int64(commentIdInt) 1493 1494 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1495 Collection: tangled.RepoIssueCommentNSID, 1496 Repo: user.Did, 1497 Rkey: rkey, 1498 SwapRecord: ex.Cid, 1499 Record: &lexutil.LexiconTypeDecoder{ 1500 Val: &tangled.RepoIssueComment{ 1501 Repo: &repoAt, 1502 Issue: issueAt, 1503 CommentId: &commentIdInt64, 1504 Owner: &comment.OwnerDid, 1505 Body: newBody, 1506 CreatedAt: createdAt, 1507 }, 1508 }, 1509 }) 1510 if err != nil { 1511 log.Println(err) 1512 } 1513 } 1514 1515 // optimistic update for htmx 1516 didHandleMap := map[string]string{ 1517 user.Did: user.Handle, 1518 } 1519 comment.Body = newBody 1520 comment.Edited = &edited 1521 1522 // return new comment body with htmx 1523 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1524 LoggedInUser: user, 1525 RepoInfo: f.RepoInfo(s, user), 1526 DidHandleMap: didHandleMap, 1527 Issue: issue, 1528 Comment: comment, 1529 }) 1530 return 1531 1532 } 1533 1534} 1535 1536func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1537 user := s.auth.GetUser(r) 1538 f, err := s.fullyResolvedRepo(r) 1539 if err != nil { 1540 log.Println("failed to get repo and knot", err) 1541 return 1542 } 1543 1544 issueId := chi.URLParam(r, "issue") 1545 issueIdInt, err := strconv.Atoi(issueId) 1546 if err != nil { 1547 http.Error(w, "bad issue id", http.StatusBadRequest) 1548 log.Println("failed to parse issue id", err) 1549 return 1550 } 1551 1552 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1553 if err != nil { 1554 log.Println("failed to get issue", err) 1555 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1556 return 1557 } 1558 1559 commentId := chi.URLParam(r, "comment_id") 1560 commentIdInt, err := strconv.Atoi(commentId) 1561 if err != nil { 1562 http.Error(w, "bad comment id", http.StatusBadRequest) 1563 log.Println("failed to parse issue id", err) 1564 return 1565 } 1566 1567 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1568 if err != nil { 1569 http.Error(w, "bad comment id", http.StatusBadRequest) 1570 return 1571 } 1572 1573 if comment.OwnerDid != user.Did { 1574 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1575 return 1576 } 1577 1578 if comment.Deleted != nil { 1579 http.Error(w, "comment already deleted", http.StatusBadRequest) 1580 return 1581 } 1582 1583 // optimistic deletion 1584 deleted := time.Now() 1585 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1586 if err != nil { 1587 log.Println("failed to delete comment") 1588 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1589 return 1590 } 1591 1592 // delete from pds 1593 if comment.Rkey != "" { 1594 client, _ := s.auth.AuthorizedClient(r) 1595 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1596 Collection: tangled.GraphFollowNSID, 1597 Repo: user.Did, 1598 Rkey: comment.Rkey, 1599 }) 1600 if err != nil { 1601 log.Println(err) 1602 } 1603 } 1604 1605 // optimistic update for htmx 1606 didHandleMap := map[string]string{ 1607 user.Did: user.Handle, 1608 } 1609 comment.Body = "" 1610 comment.Deleted = &deleted 1611 1612 // htmx fragment of comment after deletion 1613 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1614 LoggedInUser: user, 1615 RepoInfo: f.RepoInfo(s, user), 1616 DidHandleMap: didHandleMap, 1617 Issue: issue, 1618 Comment: comment, 1619 }) 1620 return 1621} 1622 1623func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1624 params := r.URL.Query() 1625 state := params.Get("state") 1626 isOpen := true 1627 switch state { 1628 case "open": 1629 isOpen = true 1630 case "closed": 1631 isOpen = false 1632 default: 1633 isOpen = true 1634 } 1635 1636 page, ok := r.Context().Value("page").(pagination.Page) 1637 if !ok { 1638 log.Println("failed to get page") 1639 page = pagination.FirstPage() 1640 } 1641 1642 user := s.auth.GetUser(r) 1643 f, err := s.fullyResolvedRepo(r) 1644 if err != nil { 1645 log.Println("failed to get repo and knot", err) 1646 return 1647 } 1648 1649 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1650 if err != nil { 1651 log.Println("failed to get issues", err) 1652 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1653 return 1654 } 1655 1656 identsToResolve := make([]string, len(issues)) 1657 for i, issue := range issues { 1658 identsToResolve[i] = issue.OwnerDid 1659 } 1660 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1661 didHandleMap := make(map[string]string) 1662 for _, identity := range resolvedIds { 1663 if !identity.Handle.IsInvalidHandle() { 1664 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1665 } else { 1666 didHandleMap[identity.DID.String()] = identity.DID.String() 1667 } 1668 } 1669 1670 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1671 LoggedInUser: s.auth.GetUser(r), 1672 RepoInfo: f.RepoInfo(s, user), 1673 Issues: issues, 1674 DidHandleMap: didHandleMap, 1675 FilteringByOpen: isOpen, 1676 Page: page, 1677 }) 1678 return 1679} 1680 1681func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1682 user := s.auth.GetUser(r) 1683 1684 f, err := s.fullyResolvedRepo(r) 1685 if err != nil { 1686 log.Println("failed to get repo and knot", err) 1687 return 1688 } 1689 1690 switch r.Method { 1691 case http.MethodGet: 1692 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1693 LoggedInUser: user, 1694 RepoInfo: f.RepoInfo(s, user), 1695 }) 1696 case http.MethodPost: 1697 title := r.FormValue("title") 1698 body := r.FormValue("body") 1699 1700 if title == "" || body == "" { 1701 s.pages.Notice(w, "issues", "Title and body are required") 1702 return 1703 } 1704 1705 tx, err := s.db.BeginTx(r.Context(), nil) 1706 if err != nil { 1707 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1708 return 1709 } 1710 1711 err = db.NewIssue(tx, &db.Issue{ 1712 RepoAt: f.RepoAt, 1713 Title: title, 1714 Body: body, 1715 OwnerDid: user.Did, 1716 }) 1717 if err != nil { 1718 log.Println("failed to create issue", err) 1719 s.pages.Notice(w, "issues", "Failed to create issue.") 1720 return 1721 } 1722 1723 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1724 if err != nil { 1725 log.Println("failed to get issue id", err) 1726 s.pages.Notice(w, "issues", "Failed to create issue.") 1727 return 1728 } 1729 1730 client, _ := s.auth.AuthorizedClient(r) 1731 atUri := f.RepoAt.String() 1732 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1733 Collection: tangled.RepoIssueNSID, 1734 Repo: user.Did, 1735 Rkey: appview.TID(), 1736 Record: &lexutil.LexiconTypeDecoder{ 1737 Val: &tangled.RepoIssue{ 1738 Repo: atUri, 1739 Title: title, 1740 Body: &body, 1741 Owner: user.Did, 1742 IssueId: int64(issueId), 1743 }, 1744 }, 1745 }) 1746 if err != nil { 1747 log.Println("failed to create issue", err) 1748 s.pages.Notice(w, "issues", "Failed to create issue.") 1749 return 1750 } 1751 1752 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1753 if err != nil { 1754 log.Println("failed to set issue at", err) 1755 s.pages.Notice(w, "issues", "Failed to create issue.") 1756 return 1757 } 1758 1759 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1760 return 1761 } 1762} 1763 1764func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1765 user := s.auth.GetUser(r) 1766 f, err := s.fullyResolvedRepo(r) 1767 if err != nil { 1768 log.Printf("failed to resolve source repo: %v", err) 1769 return 1770 } 1771 1772 switch r.Method { 1773 case http.MethodGet: 1774 user := s.auth.GetUser(r) 1775 knots, err := s.enforcer.GetDomainsForUser(user.Did) 1776 if err != nil { 1777 s.pages.Notice(w, "repo", "Invalid user account.") 1778 return 1779 } 1780 1781 s.pages.ForkRepo(w, pages.ForkRepoParams{ 1782 LoggedInUser: user, 1783 Knots: knots, 1784 RepoInfo: f.RepoInfo(s, user), 1785 }) 1786 1787 case http.MethodPost: 1788 1789 knot := r.FormValue("knot") 1790 if knot == "" { 1791 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1792 return 1793 } 1794 1795 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1796 if err != nil || !ok { 1797 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1798 return 1799 } 1800 1801 forkName := fmt.Sprintf("%s", f.RepoName) 1802 1803 // this check is *only* to see if the forked repo name already exists 1804 // in the user's account. 1805 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1806 if err != nil { 1807 if errors.Is(err, sql.ErrNoRows) { 1808 // no existing repo with this name found, we can use the name as is 1809 } else { 1810 log.Println("error fetching existing repo from db", err) 1811 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1812 return 1813 } 1814 } else if existingRepo != nil { 1815 // repo with this name already exists, append random string 1816 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1817 } 1818 secret, err := db.GetRegistrationKey(s.db, knot) 1819 if err != nil { 1820 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1821 return 1822 } 1823 1824 client, err := NewSignedClient(knot, secret, s.config.Dev) 1825 if err != nil { 1826 s.pages.Notice(w, "repo", "Failed to reach knot server.") 1827 return 1828 } 1829 1830 var uri string 1831 if s.config.Dev { 1832 uri = "http" 1833 } else { 1834 uri = "https" 1835 } 1836 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1837 sourceAt := f.RepoAt.String() 1838 1839 rkey := appview.TID() 1840 repo := &db.Repo{ 1841 Did: user.Did, 1842 Name: forkName, 1843 Knot: knot, 1844 Rkey: rkey, 1845 Source: sourceAt, 1846 } 1847 1848 tx, err := s.db.BeginTx(r.Context(), nil) 1849 if err != nil { 1850 log.Println(err) 1851 s.pages.Notice(w, "repo", "Failed to save repository information.") 1852 return 1853 } 1854 defer func() { 1855 tx.Rollback() 1856 err = s.enforcer.E.LoadPolicy() 1857 if err != nil { 1858 log.Println("failed to rollback policies") 1859 } 1860 }() 1861 1862 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1863 if err != nil { 1864 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1865 return 1866 } 1867 1868 switch resp.StatusCode { 1869 case http.StatusConflict: 1870 s.pages.Notice(w, "repo", "A repository with that name already exists.") 1871 return 1872 case http.StatusInternalServerError: 1873 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1874 case http.StatusNoContent: 1875 // continue 1876 } 1877 1878 xrpcClient, _ := s.auth.AuthorizedClient(r) 1879 1880 createdAt := time.Now().Format(time.RFC3339) 1881 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 1882 Collection: tangled.RepoNSID, 1883 Repo: user.Did, 1884 Rkey: rkey, 1885 Record: &lexutil.LexiconTypeDecoder{ 1886 Val: &tangled.Repo{ 1887 Knot: repo.Knot, 1888 Name: repo.Name, 1889 CreatedAt: createdAt, 1890 Owner: user.Did, 1891 Source: &sourceAt, 1892 }}, 1893 }) 1894 if err != nil { 1895 log.Printf("failed to create record: %s", err) 1896 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 1897 return 1898 } 1899 log.Println("created repo record: ", atresp.Uri) 1900 1901 repo.AtUri = atresp.Uri 1902 err = db.AddRepo(tx, repo) 1903 if err != nil { 1904 log.Println(err) 1905 s.pages.Notice(w, "repo", "Failed to save repository information.") 1906 return 1907 } 1908 1909 // acls 1910 p, _ := securejoin.SecureJoin(user.Did, forkName) 1911 err = s.enforcer.AddRepo(user.Did, knot, p) 1912 if err != nil { 1913 log.Println(err) 1914 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1915 return 1916 } 1917 1918 err = tx.Commit() 1919 if err != nil { 1920 log.Println("failed to commit changes", err) 1921 http.Error(w, err.Error(), http.StatusInternalServerError) 1922 return 1923 } 1924 1925 err = s.enforcer.E.SavePolicy() 1926 if err != nil { 1927 log.Println("failed to update ACLs", err) 1928 http.Error(w, err.Error(), http.StatusInternalServerError) 1929 return 1930 } 1931 1932 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1933 return 1934 } 1935}