forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

+259
appview/db/issues.go
··· 237 237 } 238 238 239 239 sort.Slice(issues, func(i, j int) bool { 240 + if issues[i].Created.Equal(issues[j].Created) { 241 + // Tiebreaker: use issue_id for stable sort 242 + return issues[i].IssueId > issues[j].IssueId 243 + } 240 244 return issues[i].Created.After(issues[j].Created) 241 245 }) 242 246 ··· 490 494 491 495 return count, nil 492 496 } 497 + 498 + func SearchIssues(e Execer, page pagination.Page, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]models.Issue, error) { 499 + var conditions []string 500 + var args []any 501 + 502 + for _, filter := range filters { 503 + conditions = append(conditions, filter.Condition()) 504 + args = append(args, filter.Arg()...) 505 + } 506 + 507 + if text != "" { 508 + searchPattern := "%" + text + "%" 509 + conditions = append(conditions, "(title like ? or body like ?)") 510 + args = append(args, searchPattern, searchPattern) 511 + } 512 + 513 + whereClause := "" 514 + if len(conditions) > 0 { 515 + whereClause = " where " + strings.Join(conditions, " and ") 516 + } 517 + 518 + pLower := FilterGte("row_num", page.Offset+1) 519 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 520 + args = append(args, pLower.Arg()...) 521 + args = append(args, pUpper.Arg()...) 522 + paginationClause := " where " + pLower.Condition() + " and " + pUpper.Condition() 523 + 524 + query := fmt.Sprintf( 525 + ` 526 + select * from ( 527 + select 528 + id, 529 + did, 530 + rkey, 531 + repo_at, 532 + issue_id, 533 + title, 534 + body, 535 + open, 536 + created, 537 + edited, 538 + deleted, 539 + row_number() over (order by created desc) as row_num 540 + from 541 + issues 542 + %s 543 + ) ranked_issues 544 + %s 545 + `, 546 + whereClause, 547 + paginationClause, 548 + ) 549 + 550 + rows, err := e.Query(query, args...) 551 + if err != nil { 552 + return nil, fmt.Errorf("failed to query issues: %w", err) 553 + } 554 + defer rows.Close() 555 + 556 + issueMap := make(map[string]*models.Issue) 557 + for rows.Next() { 558 + var issue models.Issue 559 + var createdAt string 560 + var editedAt, deletedAt sql.Null[string] 561 + var rowNum int64 562 + 563 + err := rows.Scan( 564 + &issue.Id, 565 + &issue.Did, 566 + &issue.Rkey, 567 + &issue.RepoAt, 568 + &issue.IssueId, 569 + &issue.Title, 570 + &issue.Body, 571 + &issue.Open, 572 + &createdAt, 573 + &editedAt, 574 + &deletedAt, 575 + &rowNum, 576 + ) 577 + if err != nil { 578 + return nil, fmt.Errorf("failed to scan issue: %w", err) 579 + } 580 + 581 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 582 + issue.Created = t 583 + } 584 + if editedAt.Valid { 585 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 586 + issue.Edited = &t 587 + } 588 + } 589 + if deletedAt.Valid { 590 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 591 + issue.Deleted = &t 592 + } 593 + } 594 + 595 + atUri := issue.AtUri().String() 596 + issueMap[atUri] = &issue 597 + } 598 + 599 + repoAts := make([]string, 0, len(issueMap)) 600 + for _, issue := range issueMap { 601 + repoAts = append(repoAts, string(issue.RepoAt)) 602 + } 603 + 604 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 605 + if err != nil { 606 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 607 + } 608 + 609 + repoMap := make(map[string]*models.Repo) 610 + for i := range repos { 611 + repoMap[string(repos[i].RepoAt())] = &repos[i] 612 + } 613 + 614 + for issueAt, i := range issueMap { 615 + if r, ok := repoMap[string(i.RepoAt)]; ok { 616 + i.Repo = r 617 + } else { 618 + delete(issueMap, issueAt) 619 + } 620 + } 621 + 622 + issueAts := slices.Collect(maps.Keys(issueMap)) 623 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 624 + if err != nil { 625 + return nil, fmt.Errorf("failed to query comments: %w", err) 626 + } 627 + for i := range comments { 628 + issueAt := comments[i].IssueAt 629 + if issue, ok := issueMap[issueAt]; ok { 630 + issue.Comments = append(issue.Comments, comments[i]) 631 + } 632 + } 633 + 634 + allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 635 + if err != nil { 636 + return nil, fmt.Errorf("failed to query labels: %w", err) 637 + } 638 + for issueAt, labels := range allLabels { 639 + if issue, ok := issueMap[issueAt.String()]; ok { 640 + issue.Labels = labels 641 + } 642 + } 643 + 644 + reactionCounts := make(map[string]int) 645 + if len(issueAts) > 0 { 646 + reactionArgs := make([]any, len(issueAts)) 647 + for i, v := range issueAts { 648 + reactionArgs[i] = v 649 + } 650 + rows, err := e.Query(` 651 + select thread_at, count(*) as total 652 + from reactions 653 + where thread_at in (`+strings.Repeat("?,", len(issueAts)-1)+"?"+`) 654 + group by thread_at 655 + `, reactionArgs...) 656 + if err == nil { 657 + defer rows.Close() 658 + for rows.Next() { 659 + var threadAt string 660 + var count int 661 + if err := rows.Scan(&threadAt, &count); err == nil { 662 + reactionCounts[threadAt] = count 663 + } 664 + } 665 + } 666 + } 667 + 668 + if len(labels) > 0 { 669 + if len(issueMap) > 0 { 670 + var repoAt string 671 + for _, issue := range issueMap { 672 + repoAt = string(issue.RepoAt) 673 + break 674 + } 675 + 676 + repo, err := GetRepoByAtUri(e, repoAt) 677 + if err == nil && len(repo.Labels) > 0 { 678 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 679 + if err == nil { 680 + labelNameToUri := make(map[string]string) 681 + for _, def := range labelDefs { 682 + labelNameToUri[def.Name] = def.AtUri().String() 683 + } 684 + 685 + for issueAt, issue := range issueMap { 686 + hasAllLabels := true 687 + for _, labelName := range labels { 688 + labelUri, found := labelNameToUri[labelName] 689 + if !found { 690 + hasAllLabels = false 691 + break 692 + } 693 + if !issue.Labels.ContainsLabel(labelUri) { 694 + hasAllLabels = false 695 + break 696 + } 697 + } 698 + if !hasAllLabels { 699 + delete(issueMap, issueAt) 700 + } 701 + } 702 + } 703 + } 704 + } 705 + } 706 + 707 + var issues []models.Issue 708 + for _, i := range issueMap { 709 + i.ReactionCount = reactionCounts[i.AtUri().String()] 710 + issues = append(issues, *i) 711 + } 712 + 713 + sort.Slice(issues, func(i, j int) bool { 714 + var less bool 715 + 716 + switch sortBy { 717 + case "comments": 718 + if len(issues[i].Comments) == len(issues[j].Comments) { 719 + // Tiebreaker: use issue_id for stable sort 720 + less = issues[i].IssueId > issues[j].IssueId 721 + } else { 722 + less = len(issues[i].Comments) > len(issues[j].Comments) 723 + } 724 + case "reactions": 725 + iCount := reactionCounts[issues[i].AtUri().String()] 726 + jCount := reactionCounts[issues[j].AtUri().String()] 727 + if iCount == jCount { 728 + // Tiebreaker: use issue_id for stable sort 729 + less = issues[i].IssueId > issues[j].IssueId 730 + } else { 731 + less = iCount > jCount 732 + } 733 + case "created": 734 + fallthrough 735 + default: 736 + if issues[i].Created.Equal(issues[j].Created) { 737 + // Tiebreaker: use issue_id for stable sort 738 + less = issues[i].IssueId > issues[j].IssueId 739 + } else { 740 + less = issues[i].Created.After(issues[j].Created) 741 + } 742 + } 743 + 744 + if sortOrder == "asc" { 745 + return !less 746 + } 747 + return less 748 + }) 749 + 750 + return issues, nil 751 + }
+34
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 "tangled.org/core/appview/models" 8 10 ) 9 11 ··· 82 84 83 85 return nil 84 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+198 -2
appview/db/pulls.go
··· 246 246 // collect pull source for all pulls that need it 247 247 var sourceAts []syntax.ATURI 248 248 for _, p := range pulls { 249 - if p.PullSource.RepoAt != nil { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 250 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 251 } 252 252 } ··· 259 259 sourceRepoMap[r.RepoAt()] = &r 260 260 } 261 261 for _, p := range pulls { 262 - if p.PullSource.RepoAt != nil { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 263 if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 264 p.PullSource.Repo = sourceRepo 265 265 } ··· 736 736 737 737 return pulls, nil 738 738 } 739 + 740 + func SearchPulls(e Execer, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]*models.Pull, error) { 741 + var conditions []string 742 + var args []any 743 + 744 + for _, filter := range filters { 745 + conditions = append(conditions, filter.Condition()) 746 + args = append(args, filter.Arg()...) 747 + } 748 + 749 + if text != "" { 750 + searchPattern := "%" + text + "%" 751 + conditions = append(conditions, "title like ?") 752 + args = append(args, searchPattern) 753 + } 754 + 755 + whereClause := "" 756 + if len(conditions) > 0 { 757 + whereClause = " where " + strings.Join(conditions, " and ") 758 + } 759 + 760 + query := fmt.Sprintf(` 761 + select 762 + id, 763 + owner_did, 764 + pull_id, 765 + title, 766 + body, 767 + target_branch, 768 + repo_at, 769 + rkey, 770 + state, 771 + source_branch, 772 + source_repo_at, 773 + stack_id, 774 + change_id, 775 + parent_change_id, 776 + created 777 + from pulls 778 + %s 779 + order by created desc 780 + `, whereClause) 781 + 782 + rows, err := e.Query(query, args...) 783 + if err != nil { 784 + return nil, fmt.Errorf("failed to query pulls: %w", err) 785 + } 786 + defer rows.Close() 787 + 788 + pullMap := make(map[string]*models.Pull) 789 + for rows.Next() { 790 + var pull models.Pull 791 + var createdAt string 792 + var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.Null[string] 793 + 794 + err := rows.Scan( 795 + &pull.ID, 796 + &pull.OwnerDid, 797 + &pull.PullId, 798 + &pull.Title, 799 + &pull.Body, 800 + &pull.TargetBranch, 801 + &pull.RepoAt, 802 + &pull.Rkey, 803 + &pull.State, 804 + &sourceBranch, 805 + &sourceRepoAt, 806 + &stackId, 807 + &changeId, 808 + &parentChangeId, 809 + &createdAt, 810 + ) 811 + if err != nil { 812 + return nil, fmt.Errorf("failed to scan pull: %w", err) 813 + } 814 + 815 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 816 + pull.Created = t 817 + } 818 + 819 + if sourceBranch.Valid || sourceRepoAt.Valid { 820 + pull.PullSource = &models.PullSource{} 821 + if sourceBranch.Valid { 822 + pull.PullSource.Branch = sourceBranch.V 823 + } 824 + if sourceRepoAt.Valid { 825 + uri := syntax.ATURI(sourceRepoAt.V) 826 + pull.PullSource.RepoAt = &uri 827 + } 828 + } 829 + 830 + if stackId.Valid { 831 + pull.StackId = stackId.V 832 + } 833 + if changeId.Valid { 834 + pull.ChangeId = changeId.V 835 + } 836 + if parentChangeId.Valid { 837 + pull.ParentChangeId = parentChangeId.V 838 + } 839 + 840 + pullAt := pull.PullAt().String() 841 + pullMap[pullAt] = &pull 842 + } 843 + 844 + // Load submissions and labels 845 + for _, pull := range pullMap { 846 + submissionsMap, err := GetPullSubmissions(e, FilterEq("pull_at", pull.PullAt().String())) 847 + if err != nil { 848 + return nil, fmt.Errorf("failed to query submissions: %w", err) 849 + } 850 + if subs, ok := submissionsMap[pull.PullAt()]; ok { 851 + pull.Submissions = subs 852 + } 853 + } 854 + 855 + // Collect labels 856 + pullAts := slices.Collect(maps.Keys(pullMap)) 857 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 858 + if err != nil { 859 + return nil, fmt.Errorf("failed to query labels: %w", err) 860 + } 861 + for pullAt, labels := range allLabels { 862 + if pull, ok := pullMap[pullAt.String()]; ok { 863 + pull.Labels = labels 864 + } 865 + } 866 + 867 + // Filter by labels if specified 868 + if len(labels) > 0 { 869 + if len(pullMap) > 0 { 870 + var repoAt string 871 + for _, pull := range pullMap { 872 + repoAt = string(pull.RepoAt) 873 + break 874 + } 875 + 876 + repo, err := GetRepoByAtUri(e, repoAt) 877 + if err == nil && len(repo.Labels) > 0 { 878 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 879 + if err == nil { 880 + labelNameToUri := make(map[string]string) 881 + for _, def := range labelDefs { 882 + labelNameToUri[def.Name] = def.AtUri().String() 883 + } 884 + 885 + for pullAt, pull := range pullMap { 886 + hasAllLabels := true 887 + for _, labelName := range labels { 888 + labelUri, found := labelNameToUri[labelName] 889 + if !found { 890 + hasAllLabels = false 891 + break 892 + } 893 + if !pull.Labels.ContainsLabel(labelUri) { 894 + hasAllLabels = false 895 + break 896 + } 897 + } 898 + if !hasAllLabels { 899 + delete(pullMap, pullAt) 900 + } 901 + } 902 + } 903 + } 904 + } 905 + } 906 + 907 + var pulls []*models.Pull 908 + for _, p := range pullMap { 909 + pulls = append(pulls, p) 910 + } 911 + 912 + sort.Slice(pulls, func(i, j int) bool { 913 + var less bool 914 + 915 + switch sortBy { 916 + case "created": 917 + fallthrough 918 + default: 919 + if pulls[i].Created.Equal(pulls[j].Created) { 920 + // Tiebreaker: use pull_id for stable sort 921 + less = pulls[i].PullId > pulls[j].PullId 922 + } else { 923 + less = pulls[i].Created.After(pulls[j].Created) 924 + } 925 + } 926 + 927 + if sortOrder == "asc" { 928 + return !less 929 + } 930 + return less 931 + }) 932 + 933 + return pulls, nil 934 + }
+34 -7
appview/db/reaction.go
··· 62 62 return count, nil 63 63 } 64 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 67 81 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 71 91 } 72 - countMap[kind] = count 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 73 99 } 74 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 75 102 } 76 103 77 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+15
appview/db/repos.go
··· 372 372 repo.Description = "" 373 373 } 374 374 375 + // Load labels for this repo 376 + rows, err := e.Query(`select label_at from repo_labels where repo_at = ?`, atUri) 377 + if err != nil { 378 + return nil, fmt.Errorf("failed to load repo labels: %w", err) 379 + } 380 + defer rows.Close() 381 + 382 + for rows.Next() { 383 + var labelAt string 384 + if err := rows.Scan(&labelAt); err != nil { 385 + continue 386 + } 387 + repo.Labels = append(repo.Labels, labelAt) 388 + } 389 + 375 390 return &repo, nil 376 391 } 377 392
+46 -14
appview/issues/issues.go
··· 12 12 "time" 13 13 14 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 lexutil "github.com/bluesky-social/indigo/lex/util" 17 18 "github.com/go-chi/chi/v5" ··· 25 26 "tangled.org/core/appview/pages" 26 27 "tangled.org/core/appview/pagination" 27 28 "tangled.org/core/appview/reporesolver" 29 + "tangled.org/core/appview/search" 28 30 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 31 "tangled.org/core/idresolver" 31 32 tlog "tangled.org/core/log" 32 33 "tangled.org/core/tid" ··· 83 84 return 84 85 } 85 86 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 87 88 if err != nil { 88 89 l.Error("failed to get issue reactions", "err", err) 89 90 } ··· 115 116 Issue: issue, 116 117 CommentList: issue.CommentList(), 117 118 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 119 + Reactions: reactionMap, 119 120 UserReacted: userReactions, 120 121 LabelDefs: defs, 121 122 }) ··· 166 167 return 167 168 } 168 169 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 171 if err != nil { 171 172 l.Error("failed to get record", "err", err) 172 173 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 174 return 174 175 } 175 176 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 178 Collection: tangled.RepoIssueNSID, 178 179 Repo: user.Did, 179 180 Rkey: newIssue.Rkey, ··· 241 242 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 243 return 243 244 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 246 Collection: tangled.RepoIssueNSID, 246 247 Repo: issue.Did, 247 248 Rkey: issue.Rkey, ··· 408 409 } 409 410 410 411 // create a record first 411 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 412 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 412 413 Collection: tangled.RepoIssueCommentNSID, 413 414 Repo: comment.Did, 414 415 Rkey: comment.Rkey, ··· 559 560 // rkey is optional, it was introduced later 560 561 if newComment.Rkey != "" { 561 562 // update the record on pds 562 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 564 if err != nil { 564 565 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 566 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 567 return 567 568 } 568 569 569 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 570 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 570 571 Collection: tangled.RepoIssueCommentNSID, 571 572 Repo: user.Did, 572 573 Rkey: newComment.Rkey, ··· 733 734 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 735 return 735 736 } 736 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 737 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 737 738 Collection: tangled.RepoIssueCommentNSID, 738 739 Repo: user.Did, 739 740 Rkey: comment.Rkey, ··· 759 760 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 760 761 params := r.URL.Query() 761 762 state := params.Get("state") 763 + searchQuery := params.Get("q") 764 + sortBy := params.Get("sort_by") 765 + sortOrder := params.Get("sort_order") 766 + 767 + // Use for template (preserve empty values) 768 + templateSortBy := sortBy 769 + templateSortOrder := sortOrder 770 + 771 + // Default sort values for queries 772 + if sortBy == "" { 773 + sortBy = "created" 774 + } 775 + if sortOrder == "" { 776 + sortOrder = "desc" 777 + } 778 + 762 779 isOpen := true 763 780 switch state { 764 781 case "open": ··· 786 803 if isOpen { 787 804 openVal = 1 788 805 } 789 - issues, err := db.GetIssuesPaginated( 806 + 807 + var issues []models.Issue 808 + 809 + // Parse the search query (even if empty, to handle label filters) 810 + query := search.Parse(searchQuery) 811 + 812 + // Always use search function to handle sorting 813 + issues, err = db.SearchIssues( 790 814 rp.db, 791 815 page, 816 + query.Text, 817 + query.Labels, 818 + sortBy, 819 + sortOrder, 792 820 db.FilterEq("repo_at", f.RepoAt()), 793 821 db.FilterEq("open", openVal), 794 822 ) 823 + 795 824 if err != nil { 796 825 log.Println("failed to get issues", err) 797 826 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 821 850 LabelDefs: defs, 822 851 FilteringByOpen: isOpen, 823 852 Page: page, 853 + SearchQuery: searchQuery, 854 + SortBy: templateSortBy, 855 + SortOrder: templateSortOrder, 824 856 }) 825 857 } 826 858 ··· 865 897 rp.pages.Notice(w, "issues", "Failed to create issue.") 866 898 return 867 899 } 868 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 900 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 869 901 Collection: tangled.RepoIssueNSID, 870 902 Repo: user.Did, 871 903 Rkey: issue.Rkey, ··· 923 955 // this is used to rollback changes made to the PDS 924 956 // 925 957 // it is a no-op if the provided ATURI is empty 926 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 958 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 927 959 if aturi == "" { 928 960 return nil 929 961 } ··· 934 966 repo := parsed.Authority().String() 935 967 rkey := parsed.RecordKey().String() 936 968 937 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 969 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 938 970 Collection: collection, 939 971 Repo: repo, 940 972 Rkey: rkey,
+6 -6
appview/knots/knots.go
··· 185 185 return 186 186 } 187 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 189 var exCid *string 190 190 if ex != nil { 191 191 exCid = ex.Cid 192 192 } 193 193 194 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 196 Collection: tangled.KnotNSID, 197 197 Repo: user.Did, 198 198 Rkey: domain, ··· 323 323 return 324 324 } 325 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 327 Collection: tangled.KnotNSID, 328 328 Repo: user.Did, 329 329 Rkey: domain, ··· 431 431 return 432 432 } 433 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 435 var exCid *string 436 436 if ex != nil { 437 437 exCid = ex.Cid 438 438 } 439 439 440 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 442 Collection: tangled.KnotNSID, 443 443 Repo: user.Did, 444 444 Rkey: domain, ··· 555 555 556 556 rkey := tid.TID() 557 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 559 Collection: tangled.KnotMemberNSID, 560 560 Repo: user.Did, 561 561 Rkey: rkey,
+9 -9
appview/labels/labels.go
··· 9 9 "net/http" 10 10 "time" 11 11 12 - comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/go-chi/chi/v5" 16 - 17 12 "tangled.org/core/api/tangled" 18 13 "tangled.org/core/appview/db" 19 14 "tangled.org/core/appview/middleware" ··· 21 16 "tangled.org/core/appview/oauth" 22 17 "tangled.org/core/appview/pages" 23 18 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 19 "tangled.org/core/log" 26 20 "tangled.org/core/rbac" 27 21 "tangled.org/core/tid" 22 + 23 + comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + atpclient "github.com/bluesky-social/indigo/atproto/client" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + "github.com/go-chi/chi/v5" 28 28 ) 29 29 30 30 type Labels struct { ··· 196 196 return 197 197 } 198 198 199 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.LabelOpNSID, 201 201 Repo: did, 202 202 Rkey: rkey, ··· 252 252 // this is used to rollback changes made to the PDS 253 253 // 254 254 // it is a no-op if the provided ATURI is empty 255 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 256 if aturi == "" { 257 257 return nil 258 258 } ··· 263 263 repo := parsed.Authority().String() 264 264 rkey := parsed.RecordKey().String() 265 265 266 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 267 Collection: collection, 268 268 Repo: repo, 269 269 Rkey: rkey,
+5 -14
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 - return func(next http.Handler) http.Handler { 48 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 - _, _, _ = mw.oauth.GetSession(r) 50 - next.ServeHTTP(w, r) 51 - }) 52 - } 53 - } 54 - 55 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 56 47 return func(next http.Handler) http.Handler { 57 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 49 returnURL := "/" ··· 72 63 } 73 64 } 74 65 75 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 76 67 if err != nil { 77 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 78 69 redirectFunc(w, r) 79 70 return 80 71 } 81 72 82 - if !auth { 83 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 84 75 redirectFunc(w, r) 85 76 return 86 77 }
+4 -3
appview/models/issue.go
··· 24 24 25 25 // optionally, populate this when querying for reverse mappings 26 26 // like comment counts, parent repo etc. 27 - Comments []IssueComment 28 - Labels LabelState 29 - Repo *Repo 27 + Comments []IssueComment 28 + ReactionCount int 29 + Labels LabelState 30 + Repo *Repo 30 31 } 31 32 32 33 func (i *Issue) AtUri() syntax.ATURI {
+14 -13
appview/models/label.go
··· 461 461 return result 462 462 } 463 463 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 464 472 func DefaultLabelDefs() []string { 465 - rkeys := []string{ 466 - "wontfix", 467 - "duplicate", 468 - "assignee", 469 - "good-first-issue", 470 - "documentation", 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 471 479 } 472 - 473 - defs := make([]string, len(rkeys)) 474 - for i, r := range rkeys { 475 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 476 - } 477 - 478 - return defs 479 480 } 480 481 481 482 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+5
appview/models/reaction.go
··· 55 55 Rkey string 56 56 Kind ReactionKind 57 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+5
appview/models/repo.go
··· 86 86 RepoAt syntax.ATURI 87 87 LabelAt syntax.ATURI 88 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+18 -20
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 - "fmt" 5 4 "log" 6 5 "net/http" 7 6 "strconv" ··· 31 30 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 31 r := chi.NewRouter() 33 32 34 - r.Use(middleware.AuthMiddleware(n.oauth)) 35 - 36 - r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 - 38 33 r.Get("/count", n.getUnreadCount) 39 - r.Post("/{id}/read", n.markRead) 40 - r.Post("/read-all", n.markAllRead) 41 - r.Delete("/{id}", n.deleteNotification) 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(middleware.AuthMiddleware(n.oauth)) 37 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 38 + r.Post("/{id}/read", n.markRead) 39 + r.Post("/read-all", n.markAllRead) 40 + r.Delete("/{id}", n.deleteNotification) 41 + }) 42 42 43 43 return r 44 44 } 45 45 46 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 - userDid := n.oauth.GetDid(r) 47 + user := n.oauth.GetUser(r) 48 48 49 49 page, ok := r.Context().Value("page").(pagination.Page) 50 50 if !ok { ··· 54 54 55 55 total, err := db.CountNotifications( 56 56 n.db, 57 - db.FilterEq("recipient_did", userDid), 57 + db.FilterEq("recipient_did", user.Did), 58 58 ) 59 59 if err != nil { 60 60 log.Println("failed to get total notifications:", err) ··· 65 65 notifications, err := db.GetNotificationsWithEntities( 66 66 n.db, 67 67 page, 68 - db.FilterEq("recipient_did", userDid), 68 + db.FilterEq("recipient_did", user.Did), 69 69 ) 70 70 if err != nil { 71 71 log.Println("failed to get notifications:", err) ··· 73 73 return 74 74 } 75 75 76 - err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 76 + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 77 if err != nil { 78 78 log.Println("failed to mark notifications as read:", err) 79 79 } 80 80 81 81 unreadCount := 0 82 82 83 - user := n.oauth.GetUser(r) 84 - if user == nil { 85 - http.Error(w, "Failed to get user", http.StatusInternalServerError) 86 - return 87 - } 88 - 89 - fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 83 + n.pages.Notifications(w, pages.NotificationsParams{ 90 84 LoggedInUser: user, 91 85 Notifications: notifications, 92 86 UnreadCount: unreadCount, 93 87 Page: page, 94 88 Total: total, 95 - })) 89 + }) 96 90 } 97 91 98 92 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 93 user := n.oauth.GetUser(r) 94 + if user == nil { 95 + return 96 + } 97 + 100 98 count, err := db.CountNotifications( 101 99 n.db, 102 100 db.FilterEq("recipient_did", user.Did),
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
+2 -1
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 SessionRefreshJwt = "refreshJwt"
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - tangled "tangled.org/core/api/tangled" 20 - sessioncache "tangled.org/core/appview/cache/session" 21 - "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 23 - "tangled.org/core/appview/middleware" 24 - "tangled.org/core/appview/oauth" 25 - "tangled.org/core/appview/oauth/client" 26 - "tangled.org/core/appview/pages" 27 - "tangled.org/core/consts" 28 - "tangled.org/core/idresolver" 29 - "tangled.org/core/rbac" 30 - "tangled.org/core/tid" 31 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
+65
appview/oauth/handler.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + func (o *OAuth) Router() http.Handler { 13 + r := chi.NewRouter() 14 + 15 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 16 + r.Get("/oauth/jwks.json", o.jwks) 17 + r.Get("/oauth/callback", o.callback) 18 + return r 19 + } 20 + 21 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 + doc := o.ClientApp.Config.ClientMetadata() 23 + doc.JWKSURI = &o.JwksUri 24 + 25 + w.Header().Set("Content-Type", "application/json") 26 + if err := json.NewEncoder(w).Encode(doc); err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + } 31 + 32 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 + jwks := o.Config.OAuth.Jwks 34 + pubKey, err := pubKeyFromJwk(jwks) 35 + if err != nil { 36 + log.Printf("error parsing public key: %v", err) 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + response := map[string]any{ 42 + "keys": []jwk.Key{pubKey}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.WriteHeader(http.StatusOK) 47 + json.NewEncoder(w).Encode(response) 48 + } 49 + 50 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 + ctx := r.Context() 52 + 53 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + if err := o.SaveSession(w, r, sessData); err != nil { 60 + http.Error(w, err.Error(), http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/", http.StatusFound) 65 + }
+107 -202
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 6 "net/http" 7 - "net/url" 8 7 "time" 9 8 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + xrpc "github.com/bluesky-social/indigo/xrpc" 11 14 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 13 16 "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/oauth/client" 15 - xrpc "tangled.org/core/appview/xrpcclient" 16 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 18 17 ) 19 18 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 19 + func New(config *config.Config) (*OAuth, error) { 20 + 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 25 23 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 24 + if config.Core.Dev { 25 + clientUri = "http://127.0.0.1:3000" 26 + callbackUri := clientUri + "/oauth/callback" 27 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 28 + } else { 29 + clientUri = config.Core.AppviewHost 30 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 31 + callbackUri := clientUri + "/oauth/callback" 32 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 31 33 } 34 + 35 + jwksUri := clientUri + "/oauth/jwks.json" 36 + 37 + authStore, err := NewRedisStore(config.Redis.ToURL()) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 + 44 + return &OAuth{ 45 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 + Config: config, 47 + SessStore: sessStore, 48 + JwksUri: jwksUri, 49 + }, nil 32 50 } 33 51 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 36 57 } 37 58 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 60 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 61 + userSession, err := o.SessStore.Get(r, SessionName) 41 62 if err != nil { 42 63 return err 43 64 } 44 65 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 48 69 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 70 + return userSession.Save(r, w) 71 + } 72 + 73 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 74 + userSession, err := o.SessStore.Get(r, SessionName) 50 75 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 76 + return nil, fmt.Errorf("error getting user session: %w", err) 52 77 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 65 80 } 66 81 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 82 + d := userSession.Values[SessionDid].(string) 83 + sessDid, err := syntax.ParseDID(d) 84 + if err != nil { 85 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 74 86 } 75 87 76 - did := userSession.Values[SessionDid].(string) 88 + sessId := userSession.Values[SessionId].(string) 77 89 78 - err = o.sess.DeleteSession(r.Context(), did) 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 91 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 93 } 82 94 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 95 + return clientSess, nil 86 96 } 87 97 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 98 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 99 + userSession, err := o.SessStore.Get(r, SessionName) 100 + if err != nil { 101 + return fmt.Errorf("error getting user session: %w", err) 92 102 } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 - if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 100 105 } 101 106 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 103 109 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 111 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 112 - self := o.ClientMetadata() 113 + sessId := userSession.Values[SessionId].(string) 113 114 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 119 117 120 - if err != nil { 121 - return nil, false, err 122 - } 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 123 121 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 122 + return errors.Join(err1, err2) 123 + } 128 124 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 125 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 + k, err := jwk.ParseKey([]byte(jwks)) 127 + if err != nil { 128 + return nil, err 129 + } 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 140 133 } 141 - 142 - return session, auth, nil 134 + return pubKey, nil 143 135 } 144 136 145 137 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 138 + Did string 139 + Pds string 149 140 } 150 141 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 153 144 154 - if err != nil || clientSession.IsNew { 145 + if err != nil || sess.IsNew { 155 146 return nil 156 147 } 157 148 158 149 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 162 152 } 163 153 } 164 154 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 170 158 } 171 159 172 - return clientSession.Values[SessionDid].(string) 160 + return "" 173 161 } 174 162 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 177 165 if err != nil { 178 166 return nil, fmt.Errorf("error getting session: %w", err) 179 167 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 168 + return session.APIClient(), nil 208 169 } 209 170 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 171 // this is a higher level abstraction on ServerGetServiceAuth 213 172 type ServiceClientOpts struct { 214 173 service string ··· 259 218 return scheme + s.service 260 219 } 261 220 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 222 opts := ServiceClientOpts{} 264 223 for _, o := range os { 265 224 o(&opts) 266 225 } 267 226 268 - authorizedClient, err := o.AuthorizedClient(r) 227 + client, err := o.AuthorizedClient(r) 269 228 if err != nil { 270 229 return nil, err 271 230 } ··· 276 235 opts.exp = sixty 277 236 } 278 237 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 239 if err != nil { 281 240 return nil, err 282 241 } 283 242 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 286 245 AccessJwt: resp.Token, 287 246 }, 288 247 Host: opts.Host(), ··· 291 250 }, 292 251 }, nil 293 252 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
+147
appview/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+23 -2
appview/pages/pages.go
··· 306 306 LoggedInUser *oauth.User 307 307 Timeline []models.TimelineEvent 308 308 Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 309 310 } 310 311 311 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 312 313 return p.execute("timeline/timeline", w, params) 314 + } 315 + 316 + type GoodFirstIssuesParams struct { 317 + LoggedInUser *oauth.User 318 + Issues []models.Issue 319 + RepoGroups []*models.RepoGroup 320 + LabelDefs map[string]*models.LabelDefinition 321 + GfiLabel *models.LabelDefinition 322 + Page pagination.Page 323 + } 324 + 325 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 326 + return p.execute("goodfirstissues/index", w, params) 313 327 } 314 328 315 329 type UserProfileSettingsParams struct { ··· 955 969 LabelDefs map[string]*models.LabelDefinition 956 970 Page pagination.Page 957 971 FilteringByOpen bool 972 + SearchQuery string 973 + SortBy string 974 + SortOrder string 958 975 } 959 976 960 977 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 971 988 LabelDefs map[string]*models.LabelDefinition 972 989 973 990 OrderedReactionKinds []models.ReactionKind 974 - Reactions map[models.ReactionKind]int 991 + Reactions map[models.ReactionKind]models.ReactionDisplayData 975 992 UserReacted map[models.ReactionKind]bool 976 993 } 977 994 ··· 996 1013 ThreadAt syntax.ATURI 997 1014 Kind models.ReactionKind 998 1015 Count int 1016 + Users []string 999 1017 IsReacted bool 1000 1018 } 1001 1019 ··· 1087 1105 Stacks map[string]models.Stack 1088 1106 Pipelines map[string]models.Pipeline 1089 1107 LabelDefs map[string]*models.LabelDefinition 1108 + SearchQuery string 1109 + SortBy string 1110 + SortOrder string 1090 1111 } 1091 1112 1092 1113 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1124 1145 Pipelines map[string]models.Pipeline 1125 1146 1126 1147 OrderedReactionKinds []models.ReactionKind 1127 - Reactions map[models.ReactionKind]int 1148 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1128 1149 UserReacted map[models.ReactionKind]bool 1129 1150 1130 1151 LabelDefs map[string]*models.LabelDefinition
+167
appview/pages/templates/goodfirstissues/index.html
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 26 28 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 29 + <header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 30 29 31 {{ if .LoggedInUser }} 30 32 <div id="upgrade-banner" ··· 38 40 {{ end }} 39 41 40 42 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <main class="col-span-1 md:col-span-8"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 44 47 {{ block "content" . }}{{ end }} 45 48 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 50 53 {{ block "contentAfter" . }}{{ end }} 51 54 </main> 52 - {{ end }} 55 + {{ end }} 56 + </div> 53 57 </div> 54 58 {{ end }} 55 59 56 60 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 58 62 {{ template "layouts/fragments/footer" . }} 59 63 </footer> 60 64 {{ end }}
+87 -34
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 2 + <div class="w-full p-8"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 + 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 10 33 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 19 46 </div> 20 47 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 27 51 </div> 52 + </div> 28 53 29 - <div class="flex flex-col gap-1"> 30 - <div class="{{ $headerStyle }}">social</div> 31 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 32 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 33 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 34 64 </div> 35 65 36 - <div class="flex flex-col gap-1"> 37 - <div class="{{ $headerStyle }}">contact</div> 38 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 39 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 40 93 </div> 41 - </div> 42 94 43 - <div class="text-center lg:text-right flex-shrink-0"> 44 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 45 98 </div> 46 99 </div> 47 100 </div>
+2 -2
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 51 51 <summary 52 52 class="cursor-pointer list-none flex items-center gap-1" 53 53 > 54 - {{ $user := didOrHandle .Did .Handle }} 54 + {{ $user := .Did }} 55 55 <img 56 56 src="{{ tinyAvatar $user }}" 57 57 alt=""
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+1 -1
appview/pages/templates/repo/fragments/participants.html
··· 1 1 {{ define "repo/fragments/participants" }} 2 2 {{ $all := . }} 3 3 {{ $ps := take $all 5 }} 4 - <div class="px-6 md:px-0"> 4 + <div class="px-2 md:px-0"> 5 5 <div class="py-1 flex items-center text-sm"> 6 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 7 <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+185
appview/pages/templates/repo/fragments/searchBar.html
··· 1 + {{ define "repo/fragments/searchBar" }} 2 + <div class="flex gap-2 items-center w-full"> 3 + <form class="flex-grow flex gap-2" method="get" action=""> 4 + <div class="flex-grow flex items-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800"> 5 + <input 6 + type="text" 7 + name="q" 8 + value="{{ .SearchQuery }}" 9 + placeholder="Search {{ .Placeholder }}... (e.g., 'has:enhancement fix bug')" 10 + class="flex-grow px-4 py-2 bg-transparent dark:text-white focus:outline-none" 11 + /> 12 + <button type="submit" class="px-3 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"> 13 + {{ i "search" "w-5 h-5" }} 14 + </button> 15 + </div> 16 + 17 + <!-- Keep state filter in search --> 18 + {{ if .State }} 19 + <input type="hidden" name="state" value="{{ .State }}" /> 20 + {{ end }} 21 + 22 + <!-- Sort options --> 23 + {{ $sortBy := .SortBy }} 24 + {{ $sortOrder := .SortOrder }} 25 + {{ $defaultSortBy := "created" }} 26 + {{ $defaultSortOrder := "desc" }} 27 + {{ if not $sortBy }} 28 + {{ $sortBy = $defaultSortBy }} 29 + {{ end }} 30 + {{ if not $sortOrder }} 31 + {{ $sortOrder = $defaultSortOrder }} 32 + {{ end }} 33 + <input type="hidden" name="sort_by" value="{{ $sortBy }}" id="sortByInput" /> 34 + <input type="hidden" name="sort_order" value="{{ $sortOrder }}" id="sortOrderInput" /> 35 + 36 + <details class="relative dropdown-menu" id="sortDropdown"> 37 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 38 + {{ i "arrow-down-up" "w-4 h-4" }} 39 + <span> 40 + {{ if .SortBy }} 41 + {{ if eq $sortBy "created" }}Created{{ else if eq $sortBy "comments" }}Comments{{ else if eq $sortBy "reactions" }}Reactions{{ end }} 42 + {{ else }} 43 + Sort 44 + {{ end }} 45 + </span> 46 + {{ i "chevron-down" "w-4 h-4" }} 47 + </summary> 48 + <div class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10"> 49 + <div class="p-3"> 50 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Sort by</div> 51 + <div class="space-y-1 mb-3"> 52 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="created"> 53 + {{ if eq $sortBy "created" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 54 + <span class="text-sm dark:text-gray-200">Created</span> 55 + </div> 56 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="comments"> 57 + {{ if eq $sortBy "comments" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 58 + <span class="text-sm dark:text-gray-200">Comments</span> 59 + </div> 60 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="reactions"> 61 + {{ if eq $sortBy "reactions" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 62 + <span class="text-sm dark:text-gray-200">Reactions</span> 63 + </div> 64 + </div> 65 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300 pt-2 border-t border-gray-200 dark:border-gray-600">Order</div> 66 + <div class="space-y-1"> 67 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="desc"> 68 + {{ if eq $sortOrder "desc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 69 + <span class="text-sm dark:text-gray-200">Descending</span> 70 + </div> 71 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="asc"> 72 + {{ if eq $sortOrder "asc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 73 + <span class="text-sm dark:text-gray-200">Ascending</span> 74 + </div> 75 + </div> 76 + </div> 77 + </div> 78 + </details> 79 + 80 + <!-- Label filter dropdown --> 81 + <details class="relative dropdown-menu" id="labelDropdown"> 82 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 83 + {{ i "tag" "w-4 h-4" }} 84 + <span>label</span> 85 + {{ i "chevron-down" "w-4 h-4" }} 86 + </summary> 87 + <div class="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10 max-h-96 overflow-y-auto"> 88 + <div class="p-3"> 89 + <div class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Filter by label</div> 90 + <div class="space-y-2"> 91 + {{ range $uri, $def := .LabelDefs }} 92 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded label-option" data-label-name="{{ $def.Name }}"> 93 + <span class="label-checkbox-icon w-4 h-4"></span> 94 + <span class="flex-grow text-sm dark:text-gray-200"> 95 + {{ template "labels/fragments/label" (dict "def" $def "val" "" "withPrefix" false) }} 96 + </span> 97 + </div> 98 + {{ end }} 99 + </div> 100 + </div> 101 + </div> 102 + </details> 103 + </form> 104 + </div> 105 + 106 + <script> 107 + (function() { 108 + // Handle label filter changes 109 + const labelOptions = document.querySelectorAll('.label-option'); 110 + const searchInput = document.querySelector('input[name="q"]'); 111 + 112 + // Initialize checkmarks based on current query 113 + const currentQuery = searchInput.value; 114 + labelOptions.forEach(option => { 115 + const labelName = option.getAttribute('data-label-name'); 116 + const hasFilter = 'has:' + labelName; 117 + const iconSpan = option.querySelector('.label-checkbox-icon'); 118 + 119 + if (currentQuery.includes(hasFilter)) { 120 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 121 + } 122 + }); 123 + 124 + labelOptions.forEach(option => { 125 + option.addEventListener('click', function() { 126 + const labelName = this.getAttribute('data-label-name'); 127 + let currentQuery = searchInput.value; 128 + const hasFilter = 'has:' + labelName; 129 + const iconSpan = this.querySelector('.label-checkbox-icon'); 130 + const isChecked = currentQuery.includes(hasFilter); 131 + 132 + if (isChecked) { 133 + // Remove has: filter 134 + currentQuery = currentQuery.replace(hasFilter, '').replace(/\s+/g, ' '); 135 + searchInput.value = currentQuery.trim(); 136 + iconSpan.innerHTML = ''; 137 + } else { 138 + // Add has: filter if not already present 139 + currentQuery = currentQuery.trim() + ' ' + hasFilter; 140 + searchInput.value = currentQuery.trim(); 141 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 142 + } 143 + 144 + form.submit(); 145 + }); 146 + }); 147 + 148 + // Handle sort option changes 149 + const sortByOptions = document.querySelectorAll('.sort-by-option'); 150 + const sortOrderOptions = document.querySelectorAll('.sort-order-option'); 151 + const sortByInput = document.getElementById('sortByInput'); 152 + const sortOrderInput = document.getElementById('sortOrderInput'); 153 + const form = searchInput.closest('form'); 154 + 155 + sortByOptions.forEach(option => { 156 + option.addEventListener('click', function() { 157 + sortByInput.value = this.getAttribute('data-value'); 158 + form.submit(); 159 + }); 160 + }); 161 + 162 + sortOrderOptions.forEach(option => { 163 + option.addEventListener('click', function() { 164 + sortOrderInput.value = this.getAttribute('data-value'); 165 + form.submit(); 166 + }); 167 + }); 168 + 169 + // Make dropdowns mutually exclusive - close others when one opens 170 + const dropdowns = document.querySelectorAll('.dropdown-menu'); 171 + dropdowns.forEach(dropdown => { 172 + dropdown.addEventListener('toggle', function(e) { 173 + if (this.open) { 174 + // Close all other dropdowns 175 + dropdowns.forEach(other => { 176 + if (other !== this && other.open) { 177 + other.open = false; 178 + } 179 + }); 180 + } 181 + }); 182 + }); 183 + })(); 184 + </script> 185 + {{ end }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+65
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ if gt .ReactionCount 0 }} 46 + <span class="before:content-['·']"> 47 + {{ $s := "s" }} 48 + {{ if eq .ReactionCount 1 }} 49 + {{ $s = "" }} 50 + {{ end }} 51 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .ReactionCount }} reaction{{$s}}</a> 52 + </span> 53 + {{ end }} 54 + 55 + {{ $state := .Labels }} 56 + {{ range $k, $d := $.LabelDefs }} 57 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 58 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 59 + {{ end }} 60 + {{ end }} 61 + </div> 62 + </div> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+4 -2
appview/pages/templates/repo/issues/issue.html
··· 110 110 <div class="flex items-center gap-2"> 111 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 113 114 {{ 114 115 template "repo/fragments/reaction" 115 116 (dict 116 117 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 + "Count" $reactionData.Count 118 119 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 120 122 }} 121 123 {{ end }} 122 124 </div>
+32 -55
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 11 + <div class="flex justify-between items-center gap-4 mb-4"> 12 12 <div class="flex gap-4"> 13 13 <a 14 14 href="?state=open" ··· 33 33 <span>new</span> 34 34 </a> 35 35 </div> 36 + 37 + {{ $state := "open" }} 38 + {{ if not .FilteringByOpen }} 39 + {{ $state = "closed" }} 40 + {{ end }} 41 + 42 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "issues" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 36 43 <div class="error" id="issues"></div> 37 44 {{ end }} 38 45 39 46 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 47 + <div class="mt-2"> 48 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 49 </div> 93 50 {{ block "pagination" . }} {{ end }} 94 51 {{ end }} ··· 102 59 103 60 {{ if gt .Page.Offset 0 }} 104 61 {{ $prev := .Page.Previous }} 62 + {{ $prevUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $prev.Offset $prev.Limit }} 63 + {{ if .SearchQuery }} 64 + {{ $prevUrl = printf "%s&q=%s" $prevUrl .SearchQuery }} 65 + {{ end }} 66 + {{ if .SortBy }} 67 + {{ $prevUrl = printf "%s&sort_by=%s" $prevUrl .SortBy }} 68 + {{ end }} 69 + {{ if .SortOrder }} 70 + {{ $prevUrl = printf "%s&sort_order=%s" $prevUrl .SortOrder }} 71 + {{ end }} 105 72 <a 106 73 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 107 74 hx-boost="true" 108 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 75 + href = "{{ $prevUrl }}" 109 76 > 110 77 {{ i "chevron-left" "w-4 h-4" }} 111 78 previous ··· 116 83 117 84 {{ if eq (len .Issues) .Page.Limit }} 118 85 {{ $next := .Page.Next }} 86 + {{ $nextUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $next.Offset $next.Limit }} 87 + {{ if .SearchQuery }} 88 + {{ $nextUrl = printf "%s&q=%s" $nextUrl .SearchQuery }} 89 + {{ end }} 90 + {{ if .SortBy }} 91 + {{ $nextUrl = printf "%s&sort_by=%s" $nextUrl .SortBy }} 92 + {{ end }} 93 + {{ if .SortOrder }} 94 + {{ $nextUrl = printf "%s&sort_order=%s" $nextUrl .SortOrder }} 95 + {{ end }} 119 96 <a 120 97 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 121 98 hx-boost="true" 122 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 99 + href = "{{ $nextUrl }}" 123 100 > 124 101 next 125 102 {{ i "chevron-right" "w-4 h-4" }}
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 66 <div class="flex items-center gap-2 mt-2"> 67 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 69 70 {{ 70 71 template "repo/fragments/reaction" 71 72 (dict 72 73 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 + "Count" $reactionData.Count 74 75 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 76 78 }} 77 79 {{ end }} 78 80 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+7 -3
appview/pages/templates/repo/pulls/pull.html
··· 189 189 {{ if $.LoggedInUser }} 190 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 191 191 {{ else }} 192 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 193 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 194 - <a href="/login" class="underline">login</a> to join the discussion 192 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 193 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 194 + sign up 195 + </a> 196 + <span class="text-gray-500 dark:text-gray-400">or</span> 197 + <a href="/login" class="underline">login</a> 198 + to add to the discussion 195 199 </div> 196 200 {{ end }} 197 201 </div>
+10 -1
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center"> 11 + <div class="flex justify-between items-center mb-4"> 12 12 <div class="flex gap-4"> 13 13 <a 14 14 href="?state=open" ··· 40 40 <span>new</span> 41 41 </a> 42 42 </div> 43 + 44 + {{ $state := "open" }} 45 + {{ if .FilteringBy.IsMerged }} 46 + {{ $state = "merged" }} 47 + {{ else if .FilteringBy.IsClosed }} 48 + {{ $state = "closed" }} 49 + {{ end }} 50 + 51 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "pulls" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 43 52 <div class="error" id="pulls"></div> 44 53 {{ end }} 45 54
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+1
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head>
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 13 14
+2 -1
appview/pipelines/pipelines.go
··· 48 48 ) *Pipelines { 49 49 logger := log.New("pipelines") 50 50 51 - return &Pipelines{oauth: oauth, 51 + return &Pipelines{ 52 + oauth: oauth, 52 53 repoResolver: repoResolver, 53 54 pages: pages, 54 55 idResolver: idResolver,
+42 -12
appview/pulls/pulls.go
··· 21 21 "tangled.org/core/appview/pages" 22 22 "tangled.org/core/appview/pages/markup" 23 23 "tangled.org/core/appview/reporesolver" 24 + "tangled.org/core/appview/search" 24 25 "tangled.org/core/appview/xrpcclient" 25 26 "tangled.org/core/idresolver" 26 27 "tangled.org/core/patchutil" ··· 189 190 m[p.Sha] = p 190 191 } 191 192 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 193 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 193 194 if err != nil { 194 195 log.Println("failed to get pull reactions") 195 196 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 227 228 Pipelines: m, 228 229 229 230 OrderedReactionKinds: models.OrderedReactionKinds, 230 - Reactions: reactionCountMap, 231 + Reactions: reactionMap, 231 232 UserReacted: userReactions, 232 233 233 234 LabelDefs: defs, ··· 492 493 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 493 494 user := s.oauth.GetUser(r) 494 495 params := r.URL.Query() 496 + searchQuery := params.Get("q") 497 + sortBy := params.Get("sort_by") 498 + sortOrder := params.Get("sort_order") 499 + 500 + templateSortBy := sortBy 501 + templateSortOrder := sortOrder 502 + 503 + if sortBy == "" { 504 + sortBy = "created" 505 + } 506 + if sortOrder == "" { 507 + sortOrder = "desc" 508 + } 495 509 496 510 state := models.PullOpen 497 511 switch params.Get("state") { ··· 507 521 return 508 522 } 509 523 510 - pulls, err := db.GetPulls( 524 + var pulls []*models.Pull 525 + 526 + query := search.Parse(searchQuery) 527 + 528 + pulls, err = db.SearchPulls( 511 529 s.db, 530 + query.Text, 531 + query.Labels, 532 + sortBy, 533 + sortOrder, 512 534 db.FilterEq("repo_at", f.RepoAt()), 513 535 db.FilterEq("state", state), 514 536 ) 537 + 515 538 if err != nil { 516 539 log.Println("failed to get pulls", err) 517 540 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") ··· 599 622 FilteringBy: state, 600 623 Stacks: stacks, 601 624 Pipelines: m, 625 + SearchQuery: searchQuery, 626 + SortBy: templateSortBy, 627 + SortOrder: templateSortOrder, 602 628 }) 603 629 } 604 630 ··· 665 691 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 666 692 return 667 693 } 668 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 694 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 669 695 Collection: tangled.RepoPullCommentNSID, 670 696 Repo: user.Did, 671 697 Rkey: tid.TID(), ··· 1093 1119 1094 1120 // We've already checked earlier if it's diff-based and title is empty, 1095 1121 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1096 - if title == "" { 1122 + if title == "" || body == "" { 1097 1123 formatPatches, err := patchutil.ExtractPatches(patch) 1098 1124 if err != nil { 1099 1125 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1104 1130 return 1105 1131 } 1106 1132 1107 - title = formatPatches[0].Title 1108 - body = formatPatches[0].Body 1133 + if title == "" { 1134 + title = formatPatches[0].Title 1135 + } 1136 + if body == "" { 1137 + body = formatPatches[0].Body 1138 + } 1109 1139 } 1110 1140 1111 1141 rkey := tid.TID() ··· 1138 1168 return 1139 1169 } 1140 1170 1141 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1171 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1142 1172 Collection: tangled.RepoPullNSID, 1143 1173 Repo: user.Did, 1144 1174 Rkey: rkey, ··· 1235 1265 } 1236 1266 writes = append(writes, &write) 1237 1267 } 1238 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1268 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1239 1269 Repo: user.Did, 1240 1270 Writes: writes, 1241 1271 }) ··· 1766 1796 return 1767 1797 } 1768 1798 1769 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1799 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1770 1800 if err != nil { 1771 1801 // failed to get record 1772 1802 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1789 1819 } 1790 1820 } 1791 1821 1792 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1822 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1793 1823 Collection: tangled.RepoPullNSID, 1794 1824 Repo: user.Did, 1795 1825 Rkey: pull.Rkey, ··· 2061 2091 return 2062 2092 } 2063 2093 2064 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2094 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2065 2095 Repo: user.Did, 2066 2096 Writes: writes, 2067 2097 })
+11 -10
appview/repo/artifact.go
··· 10 10 "net/url" 11 11 "time" 12 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/dustin/go-humanize" 17 - "github.com/go-chi/chi/v5" 18 - "github.com/go-git/go-git/v5/plumbing" 19 - "github.com/ipfs/go-cid" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview/db" 22 15 "tangled.org/core/appview/models" ··· 25 18 "tangled.org/core/appview/xrpcclient" 26 19 "tangled.org/core/tid" 27 20 "tangled.org/core/types" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 + "github.com/dustin/go-humanize" 26 + "github.com/go-chi/chi/v5" 27 + "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/ipfs/go-cid" 28 29 ) 29 30 30 31 // TODO: proper statuses here on early exit ··· 60 61 return 61 62 } 62 63 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 64 65 if err != nil { 65 66 log.Println("failed to upload blob", err) 66 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 73 rkey := tid.TID() 73 74 createdAt := time.Now() 74 75 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 76 77 Collection: tangled.RepoArtifactNSID, 77 78 Repo: user.Did, 78 79 Rkey: rkey, ··· 249 250 return 250 251 } 251 252 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 253 254 Collection: tangled.RepoArtifactNSID, 254 255 Repo: user.Did, 255 256 Rkey: artifact.Rkey,
+12 -1
appview/repo/index.go
··· 200 200 }) 201 201 } 202 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 203 209 // update appview's cache 204 - err = db.InsertRepoLanguages(rp.db, langs) 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 205 211 if err != nil { 206 212 // non-fatal 207 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 208 219 } 209 220 } 210 221
+28 -35
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 20 "tangled.org/core/api/tangled" 24 21 "tangled.org/core/appview/commitverify" 25 22 "tangled.org/core/appview/config" ··· 40 37 "tangled.org/core/types" 41 38 "tangled.org/core/xrpc/serviceauth" 42 39 40 + comatproto "github.com/bluesky-social/indigo/api/atproto" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 43 + lexutil "github.com/bluesky-social/indigo/lex/util" 44 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 43 45 securejoin "github.com/cyphar/filepath-securejoin" 44 46 "github.com/go-chi/chi/v5" 45 47 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 48 ) 49 49 50 50 type Repo struct { ··· 307 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 308 // 309 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 311 if err != nil { 312 312 // failed to get record 313 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 314 return 315 315 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 317 Collection: tangled.RepoNSID, 318 318 Repo: newRepo.Did, 319 319 Rkey: newRepo.Rkey, ··· 863 863 user := rp.oauth.GetUser(r) 864 864 l := rp.logger.With("handler", "EditSpindle") 865 865 l = l.With("did", user.Did) 866 - l = l.With("handle", user.Handle) 867 866 868 867 errorId := "operation-error" 869 868 fail := func(msg string, err error) { ··· 916 915 return 917 916 } 918 917 919 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 918 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 920 919 if err != nil { 921 920 fail("Failed to update spindle, no record found on PDS.", err) 922 921 return 923 922 } 924 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 923 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 925 924 Collection: tangled.RepoNSID, 926 925 Repo: newRepo.Did, 927 926 Rkey: newRepo.Rkey, ··· 951 950 user := rp.oauth.GetUser(r) 952 951 l := rp.logger.With("handler", "AddLabel") 953 952 l = l.With("did", user.Did) 954 - l = l.With("handle", user.Handle) 955 953 956 954 f, err := rp.repoResolver.Resolve(r) 957 955 if err != nil { ··· 1020 1018 1021 1019 // emit a labelRecord 1022 1020 labelRecord := label.AsRecord() 1023 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1021 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1024 1022 Collection: tangled.LabelDefinitionNSID, 1025 1023 Repo: label.Did, 1026 1024 Rkey: label.Rkey, ··· 1043 1041 newRepo.Labels = append(newRepo.Labels, aturi) 1044 1042 repoRecord := newRepo.AsRecord() 1045 1043 1046 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1044 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1047 1045 if err != nil { 1048 1046 fail("Failed to update labels, no record found on PDS.", err) 1049 1047 return 1050 1048 } 1051 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1049 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1052 1050 Collection: tangled.RepoNSID, 1053 1051 Repo: newRepo.Did, 1054 1052 Rkey: newRepo.Rkey, ··· 1111 1109 user := rp.oauth.GetUser(r) 1112 1110 l := rp.logger.With("handler", "DeleteLabel") 1113 1111 l = l.With("did", user.Did) 1114 - l = l.With("handle", user.Handle) 1115 1112 1116 1113 f, err := rp.repoResolver.Resolve(r) 1117 1114 if err != nil { ··· 1141 1138 } 1142 1139 1143 1140 // delete label record from PDS 1144 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1141 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1145 1142 Collection: tangled.LabelDefinitionNSID, 1146 1143 Repo: label.Did, 1147 1144 Rkey: label.Rkey, ··· 1163 1160 newRepo.Labels = updated 1164 1161 repoRecord := newRepo.AsRecord() 1165 1162 1166 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1163 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1167 1164 if err != nil { 1168 1165 fail("Failed to update labels, no record found on PDS.", err) 1169 1166 return 1170 1167 } 1171 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1168 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1172 1169 Collection: tangled.RepoNSID, 1173 1170 Repo: newRepo.Did, 1174 1171 Rkey: newRepo.Rkey, ··· 1220 1217 user := rp.oauth.GetUser(r) 1221 1218 l := rp.logger.With("handler", "SubscribeLabel") 1222 1219 l = l.With("did", user.Did) 1223 - l = l.With("handle", user.Handle) 1224 1220 1225 1221 f, err := rp.repoResolver.Resolve(r) 1226 1222 if err != nil { ··· 1261 1257 return 1262 1258 } 1263 1259 1264 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1260 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1265 1261 if err != nil { 1266 1262 fail("Failed to update labels, no record found on PDS.", err) 1267 1263 return 1268 1264 } 1269 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1265 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1270 1266 Collection: tangled.RepoNSID, 1271 1267 Repo: newRepo.Did, 1272 1268 Rkey: newRepo.Rkey, ··· 1307 1303 user := rp.oauth.GetUser(r) 1308 1304 l := rp.logger.With("handler", "UnsubscribeLabel") 1309 1305 l = l.With("did", user.Did) 1310 - l = l.With("handle", user.Handle) 1311 1306 1312 1307 f, err := rp.repoResolver.Resolve(r) 1313 1308 if err != nil { ··· 1350 1345 return 1351 1346 } 1352 1347 1353 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1348 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1354 1349 if err != nil { 1355 1350 fail("Failed to update labels, no record found on PDS.", err) 1356 1351 return 1357 1352 } 1358 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1353 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1359 1354 Collection: tangled.RepoNSID, 1360 1355 Repo: newRepo.Did, 1361 1356 Rkey: newRepo.Rkey, ··· 1479 1474 user := rp.oauth.GetUser(r) 1480 1475 l := rp.logger.With("handler", "AddCollaborator") 1481 1476 l = l.With("did", user.Did) 1482 - l = l.With("handle", user.Handle) 1483 1477 1484 1478 f, err := rp.repoResolver.Resolve(r) 1485 1479 if err != nil { ··· 1526 1520 currentUser := rp.oauth.GetUser(r) 1527 1521 rkey := tid.TID() 1528 1522 createdAt := time.Now() 1529 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1523 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1530 1524 Collection: tangled.RepoCollaboratorNSID, 1531 1525 Repo: currentUser.Did, 1532 1526 Rkey: rkey, ··· 1617 1611 } 1618 1612 1619 1613 // remove record from pds 1620 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1614 + atpClient, err := rp.oauth.AuthorizedClient(r) 1621 1615 if err != nil { 1622 1616 log.Println("failed to get authorized client", err) 1623 1617 return 1624 1618 } 1625 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1619 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1626 1620 Collection: tangled.RepoNSID, 1627 1621 Repo: user.Did, 1628 1622 Rkey: f.Rkey, ··· 1764 1758 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1765 1759 user := rp.oauth.GetUser(r) 1766 1760 l := rp.logger.With("handler", "Secrets") 1767 - l = l.With("handle", user.Handle) 1768 1761 l = l.With("did", user.Did) 1769 1762 1770 1763 f, err := rp.repoResolver.Resolve(r) ··· 2179 2172 } 2180 2173 record := repo.AsRecord() 2181 2174 2182 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2175 + atpClient, err := rp.oauth.AuthorizedClient(r) 2183 2176 if err != nil { 2184 2177 l.Error("failed to create xrpcclient", "err", err) 2185 2178 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2186 2179 return 2187 2180 } 2188 2181 2189 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2182 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2190 2183 Collection: tangled.RepoNSID, 2191 2184 Repo: user.Did, 2192 2185 Rkey: rkey, ··· 2218 2211 rollback := func() { 2219 2212 err1 := tx.Rollback() 2220 2213 err2 := rp.enforcer.E.LoadPolicy() 2221 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2214 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2222 2215 2223 2216 // ignore txn complete errors, this is okay 2224 2217 if errors.Is(err1, sql.ErrTxDone) { ··· 2291 2284 aturi = "" 2292 2285 2293 2286 rp.notifier.NewRepo(r.Context(), repo) 2294 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2287 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2295 2288 } 2296 2289 } 2297 2290 2298 2291 // this is used to rollback changes made to the PDS 2299 2292 // 2300 2293 // it is a no-op if the provided ATURI is empty 2301 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2294 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2302 2295 if aturi == "" { 2303 2296 return nil 2304 2297 } ··· 2309 2302 repo := parsed.Authority().String() 2310 2303 rkey := parsed.RecordKey().String() 2311 2304 2312 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2305 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2313 2306 Collection: collection, 2314 2307 Repo: repo, 2315 2308 Rkey: rkey,
+63
appview/search/query.go
··· 1 + package search 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + // Query represents a parsed search query 8 + type Query struct { 9 + // Text search terms (anything that's not a has: filter) 10 + Text string 11 + // Label filters from has:labelname syntax 12 + Labels []string 13 + } 14 + 15 + // Parse parses a search query string into a Query struct 16 + // Syntax: 17 + // - "has:enhancement" adds a label filter 18 + // - Other text becomes part of the text search 19 + func Parse(queryStr string) Query { 20 + q := Query{ 21 + Labels: []string{}, 22 + } 23 + 24 + // Split query into tokens 25 + tokens := strings.Fields(queryStr) 26 + var textParts []string 27 + 28 + for _, token := range tokens { 29 + // Check if it's a has: filter 30 + if strings.HasPrefix(token, "has:") { 31 + label := strings.TrimPrefix(token, "has:") 32 + if label != "" { 33 + q.Labels = append(q.Labels, label) 34 + } 35 + } else { 36 + // It's a text search term 37 + textParts = append(textParts, token) 38 + } 39 + } 40 + 41 + q.Text = strings.Join(textParts, " ") 42 + return q 43 + } 44 + 45 + // String converts a Query back to a query string 46 + func (q Query) String() string { 47 + var parts []string 48 + 49 + if q.Text != "" { 50 + parts = append(parts, q.Text) 51 + } 52 + 53 + for _, label := range q.Labels { 54 + parts = append(parts, "has:"+label) 55 + } 56 + 57 + return strings.Join(parts, " ") 58 + } 59 + 60 + // HasFilters returns true if the query has any search filters 61 + func (q Query) HasFilters() bool { 62 + return q.Text != "" || len(q.Labels) > 0 63 + }
+2 -2
appview/settings/settings.go
··· 470 470 } 471 471 472 472 // store in pds too 473 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 474 474 Collection: tangled.PublicKeyNSID, 475 475 Repo: did, 476 476 Rkey: rkey, ··· 527 527 528 528 if rkey != "" { 529 529 // remove from pds too 530 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 531 531 Collection: tangled.PublicKeyNSID, 532 532 Repo: did, 533 533 Rkey: rkey,
+1 -3
appview/signup/signup.go
··· 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/pages" 22 22 "tangled.org/core/appview/state/userutil" 23 - "tangled.org/core/appview/xrpcclient" 24 23 "tangled.org/core/idresolver" 25 24 ) 26 25 ··· 29 28 db *db.DB 30 29 cf *dns.Cloudflare 31 30 posthog posthog.Client 32 - xrpc *xrpcclient.Client 33 31 idResolver *idresolver.Resolver 34 32 pages *pages.Pages 35 33 l *slog.Logger ··· 133 131 noticeId := "signup-msg" 134 132 135 133 if err := s.validateCaptcha(cfToken, r); err != nil { 136 - s.l.Warn("turnstile validation failed", "error", err) 134 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 137 135 s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 136 return 139 137 }
+5 -5
appview/spindles/spindles.go
··· 189 189 return 190 190 } 191 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 193 var exCid *string 194 194 if ex != nil { 195 195 exCid = ex.Cid 196 196 } 197 197 198 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.SpindleNSID, 201 201 Repo: user.Did, 202 202 Rkey: instance, ··· 332 332 return 333 333 } 334 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 336 Collection: tangled.SpindleNSID, 337 337 Repo: user.Did, 338 338 Rkey: instance, ··· 542 542 return 543 543 } 544 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 546 Collection: tangled.SpindleMemberNSID, 547 547 Repo: user.Did, 548 548 Rkey: rkey, ··· 683 683 } 684 684 685 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 687 Collection: tangled.SpindleMemberNSID, 688 688 Repo: user.Did, 689 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 43 case http.MethodPost: 44 44 createdAt := time.Now().Format(time.RFC3339) 45 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 47 Collection: tangled.GraphFollowNSID, 48 48 Repo: currentUser.Did, 49 49 Rkey: rkey, ··· 88 88 return 89 89 } 90 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 92 Collection: tangled.GraphFollowNSID, 93 93 Repo: currentUser.Did, 94 94 Rkey: follow.Rkey,
+151
appview/state/gfi.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+14 -1
appview/state/knotstream.go
··· 172 172 }) 173 173 } 174 174 175 - return db.InsertRepoLanguages(d, langs) 175 + tx, err := d.Begin() 176 + if err != nil { 177 + return err 178 + } 179 + defer tx.Rollback() 180 + 181 + // update appview's cache 182 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 183 + if err != nil { 184 + fmt.Printf("failed; %s\n", err) 185 + // non-fatal 186 + } 187 + 188 + return tx.Commit() 176 189 } 177 190 178 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+63
appview/state/login.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "tangled.org/core/appview/pages" 10 + ) 11 + 12 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 13 + switch r.Method { 14 + case http.MethodGet: 15 + returnURL := r.URL.Query().Get("return_url") 16 + s.pages.Login(w, pages.LoginParams{ 17 + ReturnUrl: returnURL, 18 + }) 19 + case http.MethodPost: 20 + handle := r.FormValue("handle") 21 + 22 + // when users copy their handle from bsky.app, it tends to have these characters around it: 23 + // 24 + // @nelind.dk: 25 + // \u202a ensures that the handle is always rendered left to right and 26 + // \u202c reverts that so the rest of the page renders however it should 27 + handle = strings.TrimPrefix(handle, "\u202a") 28 + handle = strings.TrimSuffix(handle, "\u202c") 29 + 30 + // `@` is harmless 31 + handle = strings.TrimPrefix(handle, "@") 32 + 33 + // basic handle validation 34 + if !strings.Contains(handle, ".") { 35 + log.Println("invalid handle format", "raw", handle) 36 + s.pages.Notice( 37 + w, 38 + "login-msg", 39 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 40 + ) 41 + return 42 + } 43 + 44 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + s.pages.HxRedirect(w, redirectURL) 51 + } 52 + } 53 + 54 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 55 + err := s.oauth.DeleteSession(w, r) 56 + if err != nil { 57 + log.Println("failed to logout", "err", err) 58 + } else { 59 + log.Println("logged out successfully") 60 + } 61 + 62 + s.pages.HxRedirect(w, "/login") 63 + }
+2 -2
appview/state/profile.go
··· 634 634 vanityStats = append(vanityStats, string(v.Kind)) 635 635 } 636 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 638 var cid *string 639 639 if ex != nil { 640 640 cid = ex.Cid 641 641 } 642 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 644 Collection: tangled.ActorProfileNSID, 645 645 Repo: user.Did, 646 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" ··· 47 47 case http.MethodPost: 48 48 createdAt := time.Now().Format(time.RFC3339) 49 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 52 Repo: currentUser.Did, 53 53 Rkey: rkey, ··· 70 70 return 71 71 } 72 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions for ", subjectUri) 76 76 } 77 77 78 78 log.Println("created atproto record: ", resp.Uri) ··· 80 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 81 ThreadAt: subjectUri, 82 82 Kind: reactionKind, 83 - Count: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 84 85 IsReacted: true, 85 86 }) 86 87 ··· 92 93 return 93 94 } 94 95 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 97 Collection: tangled.FeedReactionNSID, 97 98 Repo: currentUser.Did, 98 99 Rkey: reaction.Rkey, ··· 109 110 // this is not an issue, the firehose event might have already done this 110 111 } 111 112 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 113 114 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 115 116 return 116 117 } 117 118 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 120 ThreadAt: subjectUri, 120 121 Kind: reactionKind, 121 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 122 124 IsReacted: false, 123 125 }) 124 126
+8 -10
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 8 "tangled.org/core/appview/issues" 10 9 "tangled.org/core/appview/knots" 11 10 "tangled.org/core/appview/labels" 12 11 "tangled.org/core/appview/middleware" 13 12 "tangled.org/core/appview/notifications" 14 - oauthhandler "tangled.org/core/appview/oauth/handler" 15 13 "tangled.org/core/appview/pipelines" 16 14 "tangled.org/core/appview/pulls" 17 15 "tangled.org/core/appview/repo" ··· 34 32 s.pages, 35 33 ) 36 34 37 - router.Use(middleware.TryRefreshSession()) 38 35 router.Get("/favicon.svg", s.Favicon) 39 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 40 38 41 39 userRouter := s.UserRouter(&middleware) 42 40 standardRouter := s.StandardRouter(&middleware) ··· 122 120 // special-case handler for serving tangled.org/core 123 121 r.Get("/core", s.Core()) 124 122 123 + r.Get("/login", s.Login) 124 + r.Post("/login", s.Login) 125 + r.Post("/logout", s.Logout) 126 + 125 127 r.Route("/repo", func(r chi.Router) { 126 128 r.Route("/new", func(r chi.Router) { 127 129 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 131 133 // r.Post("/import", s.ImportRepo) 132 134 }) 133 135 136 + r.Get("/goodfirstissues", s.GoodFirstIssues) 137 + 134 138 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 135 139 r.Post("/", s.Follow) 136 140 r.Delete("/", s.Follow) ··· 161 165 r.Mount("/notifications", s.NotificationsRouter(mw)) 162 166 163 167 r.Mount("/signup", s.SignupRouter()) 164 - r.Mount("/", s.OAuthRouter()) 168 + r.Mount("/", s.oauth.Router()) 165 169 166 170 r.Get("/keys/{user}", s.Keys) 167 171 r.Get("/terms", s.TermsOfService) ··· 186 190 187 191 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 188 192 } 189 - } 190 - 191 - func (s *State) OAuthRouter() http.Handler { 192 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 193 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 194 - return oauth.Router() 195 193 } 196 194 197 195 func (s *State) SettingsRouter() http.Handler {
+2 -2
appview/state/star.go
··· 40 40 case http.MethodPost: 41 41 createdAt := time.Now().Format(time.RFC3339) 42 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 45 Repo: currentUser.Did, 46 46 Rkey: rkey, ··· 92 92 return 93 93 } 94 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+50 -18
appview/state/state.go
··· 11 11 "strings" 12 12 "time" 13 13 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 14 "tangled.org/core/api/tangled" 21 15 "tangled.org/core/appview" 22 16 "tangled.org/core/appview/cache" ··· 38 32 tlog "tangled.org/core/log" 39 33 "tangled.org/core/rbac" 40 34 "tangled.org/core/tid" 35 + 36 + comatproto "github.com/bluesky-social/indigo/api/atproto" 37 + atpclient "github.com/bluesky-social/indigo/atproto/client" 38 + "github.com/bluesky-social/indigo/atproto/syntax" 39 + lexutil "github.com/bluesky-social/indigo/lex/util" 40 + securejoin "github.com/cyphar/filepath-securejoin" 41 + "github.com/go-chi/chi/v5" 42 + "github.com/posthog/posthog-go" 41 43 ) 42 44 43 45 type State struct { ··· 75 77 res = idresolver.DefaultResolver() 76 78 } 77 79 78 - pgs := pages.NewPages(config, res) 80 + pages := pages.NewPages(config, res) 79 81 cache := cache.New(config.Redis.Addr) 80 82 sess := session.New(cache) 81 - oauth := oauth.NewOAuth(config, sess) 83 + oauth2, err := oauth.New(config) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 86 + } 82 87 validator := validator.New(d, res, enforcer) 83 88 84 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 162 167 state := &State{ 163 168 d, 164 169 notifier, 165 - oauth, 170 + oauth2, 166 171 enforcer, 167 - pgs, 172 + pages, 168 173 sess, 169 174 res, 170 175 posthog, ··· 198 203 s.pages.Favicon(w) 199 204 } 200 205 206 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 207 + const manifestJson = `{ 208 + "name": "tangled", 209 + "description": "tightly-knit social coding.", 210 + "icons": [ 211 + { 212 + "src": "/favicon.svg", 213 + "sizes": "144x144" 214 + } 215 + ], 216 + "start_url": "/", 217 + "id": "org.tangled", 218 + 219 + "display": "standalone", 220 + "background_color": "#111827", 221 + "theme_color": "#111827" 222 + }` 223 + 224 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 225 + w.Header().Set("Content-Type", "application/json") 226 + w.Write([]byte(manifestJson)) 227 + } 228 + 201 229 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 202 230 user := s.oauth.GetUser(r) 203 231 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 247 275 return 248 276 } 249 277 278 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 279 + if err != nil { 280 + // non-fatal 281 + } 282 + 250 283 s.pages.Timeline(w, pages.TimelineParams{ 251 284 LoggedInUser: user, 252 285 Timeline: timeline, 253 286 Repos: repos, 287 + GfiLabel: gfiLabel, 254 288 }) 255 289 } 256 290 ··· 262 296 263 297 l := s.logger.With("handler", "UpgradeBanner") 264 298 l = l.With("did", user.Did) 265 - l = l.With("handle", user.Handle) 266 299 267 300 regs, err := db.GetRegistrations( 268 301 s.db, ··· 402 435 403 436 user := s.oauth.GetUser(r) 404 437 l = l.With("did", user.Did) 405 - l = l.With("handle", user.Handle) 406 438 407 439 // form validation 408 440 domain := r.FormValue("domain") ··· 466 498 } 467 499 record := repo.AsRecord() 468 500 469 - xrpcClient, err := s.oauth.AuthorizedClient(r) 501 + atpClient, err := s.oauth.AuthorizedClient(r) 470 502 if err != nil { 471 503 l.Info("PDS write failed", "err", err) 472 504 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 473 505 return 474 506 } 475 507 476 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 508 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 477 509 Collection: tangled.RepoNSID, 478 510 Repo: user.Did, 479 511 Rkey: rkey, ··· 505 537 rollback := func() { 506 538 err1 := tx.Rollback() 507 539 err2 := s.enforcer.E.LoadPolicy() 508 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 540 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 509 541 510 542 // ignore txn complete errors, this is okay 511 543 if errors.Is(err1, sql.ErrTxDone) { ··· 578 610 aturi = "" 579 611 580 612 s.notifier.NewRepo(r.Context(), repo) 581 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 613 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 582 614 } 583 615 } 584 616 585 617 // this is used to rollback changes made to the PDS 586 618 // 587 619 // it is a no-op if the provided ATURI is empty 588 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 620 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 589 621 if aturi == "" { 590 622 return nil 591 623 } ··· 596 628 repo := parsed.Authority().String() 597 629 rkey := parsed.RecordKey().String() 598 630 599 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 631 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 600 632 Collection: collection, 601 633 Repo: repo, 602 634 Rkey: rkey,
+9 -7
appview/strings/strings.go
··· 22 22 "github.com/bluesky-social/indigo/api/atproto" 23 23 "github.com/bluesky-social/indigo/atproto/identity" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 29 ) 28 30 29 31 type Strings struct { ··· 254 256 } 255 257 256 258 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 260 if err != nil { 259 261 fail("Failed to updated existing record.", err) 260 262 return 261 263 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 263 265 Collection: tangled.StringNSID, 264 266 Repo: entry.Did.String(), 265 267 Rkey: entry.Rkey, ··· 284 286 s.Notifier.EditString(r.Context(), &entry) 285 287 286 288 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 288 290 } 289 291 290 292 } ··· 336 338 return 337 339 } 338 340 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 340 342 Collection: tangled.StringNSID, 341 343 Repo: user.Did, 342 344 Rkey: string.Rkey, ··· 360 362 s.Notifier.NewString(r.Context(), &string) 361 363 362 364 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 364 366 } 365 367 } 366 368 ··· 403 405 404 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 407 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 407 409 } 408 410 409 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 19 13 ErrXrpcFailed = errors.New("xrpc request failed") 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 16 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error {
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 2 3 3 package main 4 4
+1 -1
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 25 26 26 ```yaml 27 27 when:
+4 -4
go.mod
··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 40 40 github.com/urfave/cli/v3 v3.3.3 41 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 43 + github.com/yuin/goldmark v1.7.13 44 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 46 47 golang.org/x/net v0.42.0 47 48 golang.org/x/sync v0.16.0 48 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 50 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 51 52 ) 52 53 53 54 require ( ··· 168 169 go.uber.org/atomic v1.11.0 // indirect 169 170 go.uber.org/multierr v1.11.0 // indirect 170 171 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 172 golang.org/x/sys v0.34.0 // indirect 173 173 golang.org/x/text v0.27.0 // indirect 174 174 golang.org/x/time v0.12.0 // indirect
+6 -4
go.sum
··· 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 436 438 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 439 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 440 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 442 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 443 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 444 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 445 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= ··· 652 654 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 655 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 656 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 h1:EpQ9MT09jSf4Zjs1+yFvB4CD/fBkFdx8UaDJDwO1Jk8= 658 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5/go.mod h1:BQFGoN2V+h5KtgKsQgWU73R55ILdDy/R5RZTrZi6wog= 657 659 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 660 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
+1 -1
nix/gomod2nix.toml
··· 527 527 [mod."lukechampine.com/blake3"] 528 528 version = "v1.4.1" 529 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 530 + [mod."tangled.org/anirudh.fi/atproto-oauth"] 531 531 version = "v0.0.0-20250724194903-28e660378cb1" 532 532 hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="