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