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