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