+259
appview/db/issues.go
+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
+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
+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
+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
+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
+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
+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
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
+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
+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
+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
+5
appview/models/reaction.go
+5
appview/models/repo.go
+5
appview/models/repo.go
+18
-20
appview/notifications/notifications.go
+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
-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
+2
-1
appview/oauth/consts.go
-538
appview/oauth/handler/handler.go
-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
+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
+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
+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
+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
+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
+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
+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 }}
+2
-2
appview/pages/templates/layouts/fragments/topbar.html
+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
appview/pages/templates/repo/fragments/cloneDropdown.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/participants.html
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/login.html
+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 · tangled</title>
13
14
</head>
+1
-3
appview/pages/templates/user/settings/profile.html
+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
+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 · tangled</title>
13
14
+2
-1
appview/pipelines/pipelines.go
+2
-1
appview/pipelines/pipelines.go
+42
-12
appview/pulls/pulls.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
-1
cmd/genjwks/main.go
+1
-1
docs/spindle/pipeline.md
+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
+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
+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
+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
+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="