+2
-122
api/tangled/cbor_gen.go
+2
-122
api/tangled/cbor_gen.go
···
5512
5512
}
5513
5513
5514
5514
cw := cbg.NewCborWriter(w)
5515
-
fieldCount := 7
5515
+
fieldCount := 6
5516
5516
5517
5517
if t.Body == nil {
5518
5518
fieldCount--
···
5642
5642
return err
5643
5643
}
5644
5644
5645
-
// t.IssueId (int64) (int64)
5646
-
if len("issueId") > 1000000 {
5647
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
5648
-
}
5649
-
5650
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
5651
-
return err
5652
-
}
5653
-
if _, err := cw.WriteString(string("issueId")); err != nil {
5654
-
return err
5655
-
}
5656
-
5657
-
if t.IssueId >= 0 {
5658
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
5659
-
return err
5660
-
}
5661
-
} else {
5662
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
5663
-
return err
5664
-
}
5665
-
}
5666
-
5667
5645
// t.CreatedAt (string) (string)
5668
5646
if len("createdAt") > 1000000 {
5669
5647
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
5795
5773
5796
5774
t.Title = string(sval)
5797
5775
}
5798
-
// t.IssueId (int64) (int64)
5799
-
case "issueId":
5800
-
{
5801
-
maj, extra, err := cr.ReadHeader()
5802
-
if err != nil {
5803
-
return err
5804
-
}
5805
-
var extraI int64
5806
-
switch maj {
5807
-
case cbg.MajUnsignedInt:
5808
-
extraI = int64(extra)
5809
-
if extraI < 0 {
5810
-
return fmt.Errorf("int64 positive overflow")
5811
-
}
5812
-
case cbg.MajNegativeInt:
5813
-
extraI = int64(extra)
5814
-
if extraI < 0 {
5815
-
return fmt.Errorf("int64 negative overflow")
5816
-
}
5817
-
extraI = -1 - extraI
5818
-
default:
5819
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
5820
-
}
5821
-
5822
-
t.IssueId = int64(extraI)
5823
-
}
5824
5776
// t.CreatedAt (string) (string)
5825
5777
case "createdAt":
5826
5778
···
5850
5802
}
5851
5803
5852
5804
cw := cbg.NewCborWriter(w)
5853
-
fieldCount := 7
5854
-
5855
-
if t.CommentId == nil {
5856
-
fieldCount--
5857
-
}
5805
+
fieldCount := 6
5858
5806
5859
5807
if t.Owner == nil {
5860
5808
fieldCount--
···
5997
5945
}
5998
5946
}
5999
5947
6000
-
// t.CommentId (int64) (int64)
6001
-
if t.CommentId != nil {
6002
-
6003
-
if len("commentId") > 1000000 {
6004
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
6005
-
}
6006
-
6007
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
6008
-
return err
6009
-
}
6010
-
if _, err := cw.WriteString(string("commentId")); err != nil {
6011
-
return err
6012
-
}
6013
-
6014
-
if t.CommentId == nil {
6015
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6016
-
return err
6017
-
}
6018
-
} else {
6019
-
if *t.CommentId >= 0 {
6020
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
6021
-
return err
6022
-
}
6023
-
} else {
6024
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
6025
-
return err
6026
-
}
6027
-
}
6028
-
}
6029
-
6030
-
}
6031
-
6032
5948
// t.CreatedAt (string) (string)
6033
5949
if len("createdAt") > 1000000 {
6034
5950
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6168
6084
}
6169
6085
6170
6086
t.Owner = (*string)(&sval)
6171
-
}
6172
-
}
6173
-
// t.CommentId (int64) (int64)
6174
-
case "commentId":
6175
-
{
6176
-
6177
-
b, err := cr.ReadByte()
6178
-
if err != nil {
6179
-
return err
6180
-
}
6181
-
if b != cbg.CborNull[0] {
6182
-
if err := cr.UnreadByte(); err != nil {
6183
-
return err
6184
-
}
6185
-
maj, extra, err := cr.ReadHeader()
6186
-
if err != nil {
6187
-
return err
6188
-
}
6189
-
var extraI int64
6190
-
switch maj {
6191
-
case cbg.MajUnsignedInt:
6192
-
extraI = int64(extra)
6193
-
if extraI < 0 {
6194
-
return fmt.Errorf("int64 positive overflow")
6195
-
}
6196
-
case cbg.MajNegativeInt:
6197
-
extraI = int64(extra)
6198
-
if extraI < 0 {
6199
-
return fmt.Errorf("int64 negative overflow")
6200
-
}
6201
-
extraI = -1 - extraI
6202
-
default:
6203
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6204
-
}
6205
-
6206
-
t.CommentId = (*int64)(&extraI)
6207
6087
}
6208
6088
}
6209
6089
// t.CreatedAt (string) (string)
-1
api/tangled/issuecomment.go
-1
api/tangled/issuecomment.go
···
19
19
type RepoIssueComment struct {
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
21
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
23
Issue string `json:"issue" cborgen:"issue"`
25
24
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-1
api/tangled/repoissue.go
-1
api/tangled/repoissue.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
23
Owner string `json:"owner" cborgen:"owner"`
25
24
Repo string `json:"repo" cborgen:"repo"`
26
25
Title string `json:"title" cborgen:"title"`
+41
-144
appview/db/follow.go
+41
-144
appview/db/follow.go
···
1
1
package db
2
2
3
3
import (
4
-
"fmt"
5
4
"log"
6
-
"strings"
7
5
"time"
8
6
)
9
7
···
55
53
return err
56
54
}
57
55
58
-
type FollowStats struct {
59
-
Followers int
60
-
Following int
61
-
}
62
-
63
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
56
+
func GetFollowerFollowingCount(e Execer, did string) (int, int, error) {
64
57
followers, following := 0, 0
65
58
err := e.QueryRow(
66
-
`SELECT
59
+
`SELECT
67
60
COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
68
61
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
69
62
FROM follows;`, did, did).Scan(&followers, &following)
70
63
if err != nil {
71
-
return FollowStats{}, err
64
+
return 0, 0, err
72
65
}
73
-
return FollowStats{
74
-
Followers: followers,
75
-
Following: following,
76
-
}, nil
66
+
return followers, following, nil
77
67
}
78
68
79
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
80
-
if len(dids) == 0 {
81
-
return nil, nil
82
-
}
69
+
type FollowStatus int
83
70
84
-
placeholders := make([]string, len(dids))
85
-
for i := range placeholders {
86
-
placeholders[i] = "?"
87
-
}
88
-
placeholderStr := strings.Join(placeholders, ",")
71
+
const (
72
+
IsNotFollowing FollowStatus = iota
73
+
IsFollowing
74
+
IsSelf
75
+
)
89
76
90
-
args := make([]any, len(dids)*2)
91
-
for i, did := range dids {
92
-
args[i] = did
93
-
args[i+len(dids)] = did
77
+
func (s FollowStatus) String() string {
78
+
switch s {
79
+
case IsNotFollowing:
80
+
return "IsNotFollowing"
81
+
case IsFollowing:
82
+
return "IsFollowing"
83
+
case IsSelf:
84
+
return "IsSelf"
85
+
default:
86
+
return "IsNotFollowing"
94
87
}
95
-
96
-
query := fmt.Sprintf(`
97
-
select
98
-
coalesce(f.did, g.did) as did,
99
-
coalesce(f.followers, 0) as followers,
100
-
coalesce(g.following, 0) as following
101
-
from (
102
-
select subject_did as did, count(*) as followers
103
-
from follows
104
-
where subject_did in (%s)
105
-
group by subject_did
106
-
) f
107
-
full outer join (
108
-
select user_did as did, count(*) as following
109
-
from follows
110
-
where user_did in (%s)
111
-
group by user_did
112
-
) g on f.did = g.did`,
113
-
placeholderStr, placeholderStr)
114
-
115
-
result := make(map[string]FollowStats)
116
-
117
-
rows, err := e.Query(query, args...)
118
-
if err != nil {
119
-
return nil, err
120
-
}
121
-
defer rows.Close()
88
+
}
122
89
123
-
for rows.Next() {
124
-
var did string
125
-
var followers, following int
126
-
if err := rows.Scan(&did, &followers, &following); err != nil {
127
-
return nil, err
128
-
}
129
-
result[did] = FollowStats{
130
-
Followers: followers,
131
-
Following: following,
132
-
}
90
+
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
91
+
if userDid == subjectDid {
92
+
return IsSelf
93
+
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
94
+
return IsNotFollowing
95
+
} else {
96
+
return IsFollowing
133
97
}
134
-
135
-
for _, did := range dids {
136
-
if _, exists := result[did]; !exists {
137
-
result[did] = FollowStats{
138
-
Followers: 0,
139
-
Following: 0,
140
-
}
141
-
}
142
-
}
143
-
144
-
return result, nil
145
98
}
146
99
147
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
100
+
func GetAllFollows(e Execer, limit int) ([]Follow, error) {
148
101
var follows []Follow
149
102
150
-
var conditions []string
151
-
var args []any
152
-
for _, filter := range filters {
153
-
conditions = append(conditions, filter.Condition())
154
-
args = append(args, filter.Arg()...)
155
-
}
156
-
157
-
whereClause := ""
158
-
if conditions != nil {
159
-
whereClause = " where " + strings.Join(conditions, " and ")
160
-
}
161
-
limitClause := ""
162
-
if limit > 0 {
163
-
limitClause = " limit ?"
164
-
args = append(args, limit)
165
-
}
166
-
167
-
query := fmt.Sprintf(
168
-
`select user_did, subject_did, followed_at, rkey
103
+
rows, err := e.Query(`
104
+
select user_did, subject_did, followed_at, rkey
169
105
from follows
170
-
%s
171
106
order by followed_at desc
172
-
%s
173
-
`, whereClause, limitClause)
174
-
175
-
rows, err := e.Query(query, args...)
107
+
limit ?`, limit,
108
+
)
176
109
if err != nil {
177
110
return nil, err
178
111
}
112
+
defer rows.Close()
113
+
179
114
for rows.Next() {
180
115
var follow Follow
181
116
var followedAt string
182
-
err := rows.Scan(
183
-
&follow.UserDid,
184
-
&follow.SubjectDid,
185
-
&followedAt,
186
-
&follow.Rkey,
187
-
)
188
-
if err != nil {
117
+
if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil {
189
118
return nil, err
190
119
}
120
+
191
121
followedAtTime, err := time.Parse(time.RFC3339, followedAt)
192
122
if err != nil {
193
123
log.Println("unable to determine followed at time")
···
195
125
} else {
196
126
follow.FollowedAt = followedAtTime
197
127
}
128
+
198
129
follows = append(follows, follow)
199
130
}
200
-
return follows, nil
201
-
}
202
-
203
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
204
-
return GetFollows(e, 0, FilterEq("subject_did", did))
205
-
}
206
131
207
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
208
-
return GetFollows(e, 0, FilterEq("user_did", did))
209
-
}
210
-
211
-
type FollowStatus int
212
-
213
-
const (
214
-
IsNotFollowing FollowStatus = iota
215
-
IsFollowing
216
-
IsSelf
217
-
)
218
-
219
-
func (s FollowStatus) String() string {
220
-
switch s {
221
-
case IsNotFollowing:
222
-
return "IsNotFollowing"
223
-
case IsFollowing:
224
-
return "IsFollowing"
225
-
case IsSelf:
226
-
return "IsSelf"
227
-
default:
228
-
return "IsNotFollowing"
132
+
if err := rows.Err(); err != nil {
133
+
return nil, err
229
134
}
230
-
}
231
135
232
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
233
-
if userDid == subjectDid {
234
-
return IsSelf
235
-
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
236
-
return IsNotFollowing
237
-
} else {
238
-
return IsFollowing
239
-
}
136
+
return follows, nil
240
137
}
+105
appview/db/issues.go
+105
appview/db/issues.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
mathrand "math/rand/v2"
6
7
"strings"
7
8
"time"
8
9
···
47
48
48
49
func (i *Issue) AtUri() syntax.ATURI {
49
50
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
51
+
}
52
+
53
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
54
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
55
+
if err != nil {
56
+
created = time.Now()
57
+
}
58
+
59
+
body := ""
60
+
if record.Body != nil {
61
+
body = *record.Body
62
+
}
63
+
64
+
return Issue{
65
+
RepoAt: syntax.ATURI(record.Repo),
66
+
OwnerDid: record.Owner,
67
+
Rkey: rkey,
68
+
Created: created,
69
+
Title: record.Title,
70
+
Body: body,
71
+
Open: true, // new issues are open by default
72
+
}
73
+
}
74
+
75
+
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
76
+
ownerDid := issueUri.Authority().String()
77
+
issueRkey := issueUri.RecordKey().String()
78
+
79
+
var repoAt string
80
+
var issueId int
81
+
82
+
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
83
+
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
84
+
if err != nil {
85
+
return "", 0, err
86
+
}
87
+
88
+
return syntax.ATURI(repoAt), issueId, nil
89
+
}
90
+
91
+
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
92
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
93
+
if err != nil {
94
+
created = time.Now()
95
+
}
96
+
97
+
ownerDid := did
98
+
if record.Owner != nil {
99
+
ownerDid = *record.Owner
100
+
}
101
+
102
+
issueUri, err := syntax.ParseATURI(record.Issue)
103
+
if err != nil {
104
+
return Comment{}, err
105
+
}
106
+
107
+
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
108
+
if err != nil {
109
+
return Comment{}, err
110
+
}
111
+
112
+
comment := Comment{
113
+
OwnerDid: ownerDid,
114
+
RepoAt: repoAt,
115
+
Rkey: rkey,
116
+
Body: record.Body,
117
+
Issue: issueId,
118
+
CommentId: mathrand.IntN(1000000),
119
+
Created: &created,
120
+
}
121
+
122
+
return comment, nil
50
123
}
51
124
52
125
func NewIssue(tx *sql.Tx, issue *Issue) error {
···
550
623
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
551
624
where repo_at = ? and issue_id = ? and comment_id = ?
552
625
`, repoAt, issueId, commentId)
626
+
return err
627
+
}
628
+
629
+
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
630
+
_, err := e.Exec(
631
+
`
632
+
update comments
633
+
set body = ?,
634
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
635
+
where owner_did = ? and rkey = ?
636
+
`, newBody, ownerDid, rkey)
637
+
return err
638
+
}
639
+
640
+
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
641
+
_, err := e.Exec(
642
+
`
643
+
update comments
644
+
set body = "",
645
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
646
+
where owner_did = ? and rkey = ?
647
+
`, ownerDid, rkey)
648
+
return err
649
+
}
650
+
651
+
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
652
+
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
653
+
return err
654
+
}
655
+
656
+
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
657
+
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
553
658
return err
554
659
}
555
660
+7
-2
appview/db/profile.go
+7
-2
appview/db/profile.go
···
348
348
return tx.Commit()
349
349
}
350
350
351
-
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
351
+
func GetProfiles(e Execer, filters ...filter) ([]Profile, error) {
352
352
var conditions []string
353
353
var args []any
354
354
for _, filter := range filters {
···
448
448
idxs[did] = idx + 1
449
449
}
450
450
451
-
return profileMap, nil
451
+
var profiles []Profile
452
+
for _, p := range profileMap {
453
+
profiles = append(profiles, *p)
454
+
}
455
+
456
+
return profiles, nil
452
457
}
453
458
454
459
func GetProfile(e Execer, did string) (*Profile, error) {
+22
-6
appview/db/timeline.go
+22
-6
appview/db/timeline.go
···
20
20
*FollowStats
21
21
}
22
22
23
+
type FollowStats struct {
24
+
Followers int
25
+
Following int
26
+
}
27
+
23
28
const Limit = 50
24
29
25
30
// TODO: this gathers heterogenous events from different sources and aggregates
···
132
137
}
133
138
134
139
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
135
-
follows, err := GetFollows(e, Limit)
140
+
follows, err := GetAllFollows(e, Limit)
136
141
if err != nil {
137
142
return nil, err
138
143
}
···
146
151
return nil, nil
147
152
}
148
153
154
+
profileMap := make(map[string]Profile)
149
155
profiles, err := GetProfiles(e, FilterIn("did", subjects))
150
156
if err != nil {
151
157
return nil, err
158
+
}
159
+
for _, p := range profiles {
160
+
profileMap[p.Did] = p
152
161
}
153
162
154
-
followStatMap, err := GetFollowerFollowingCounts(e, subjects)
155
-
if err != nil {
156
-
return nil, err
163
+
followStatMap := make(map[string]FollowStats)
164
+
for _, s := range subjects {
165
+
followers, following, err := GetFollowerFollowingCount(e, s)
166
+
if err != nil {
167
+
return nil, err
168
+
}
169
+
followStatMap[s] = FollowStats{
170
+
Followers: followers,
171
+
Following: following,
172
+
}
157
173
}
158
174
159
175
var events []TimelineEvent
160
176
for _, f := range follows {
161
-
profile, _ := profiles[f.SubjectDid]
177
+
profile, _ := profileMap[f.SubjectDid]
162
178
followStatMap, _ := followStatMap[f.SubjectDid]
163
179
164
180
events = append(events, TimelineEvent{
165
181
Follow: &f,
166
-
Profile: profile,
182
+
Profile: &profile,
167
183
FollowStats: &followStatMap,
168
184
EventAt: f.FollowedAt,
169
185
})
+179
-6
appview/ingester.go
+179
-6
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
"strings"
8
9
"time"
9
10
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
14
15
"tangled.sh/tangled.sh/core/api/tangled"
15
16
"tangled.sh/tangled.sh/core/appview/config"
16
17
"tangled.sh/tangled.sh/core/appview/db"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
17
19
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
20
"tangled.sh/tangled.sh/core/idresolver"
19
21
"tangled.sh/tangled.sh/core/rbac"
···
61
63
case tangled.ActorProfileNSID:
62
64
err = i.ingestProfile(e)
63
65
case tangled.SpindleMemberNSID:
64
-
err = i.ingestSpindleMember(e)
66
+
err = i.ingestSpindleMember(ctx, e)
65
67
case tangled.SpindleNSID:
66
-
err = i.ingestSpindle(e)
68
+
err = i.ingestSpindle(ctx, e)
67
69
case tangled.StringNSID:
68
70
err = i.ingestString(e)
71
+
case tangled.RepoIssueNSID:
72
+
err = i.ingestIssue(ctx, e)
73
+
case tangled.RepoIssueCommentNSID:
74
+
err = i.ingestIssueComment(e)
69
75
}
70
76
l = i.Logger.With("nsid", e.Commit.Collection)
71
77
}
···
336
342
return nil
337
343
}
338
344
339
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
345
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
340
346
did := e.Did
341
347
var err error
342
348
···
359
365
return fmt.Errorf("failed to enforce permissions: %w", err)
360
366
}
361
367
362
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
368
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
363
369
if err != nil {
364
370
return err
365
371
}
···
442
448
return nil
443
449
}
444
450
445
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
451
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
446
452
did := e.Did
447
453
var err error
448
454
···
475
481
return err
476
482
}
477
483
478
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
484
+
err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
479
485
if err != nil {
480
486
l.Error("failed to add spindle to db", "err", err, "instance", instance)
481
487
return err
···
609
615
610
616
return nil
611
617
}
618
+
619
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
620
+
did := e.Did
621
+
rkey := e.Commit.RKey
622
+
623
+
var err error
624
+
625
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
626
+
l.Info("ingesting record")
627
+
628
+
ddb, ok := i.Db.Execer.(*db.DB)
629
+
if !ok {
630
+
return fmt.Errorf("failed to index issue record, invalid db cast")
631
+
}
632
+
633
+
switch e.Commit.Operation {
634
+
case models.CommitOperationCreate:
635
+
raw := json.RawMessage(e.Commit.Record)
636
+
record := tangled.RepoIssue{}
637
+
err = json.Unmarshal(raw, &record)
638
+
if err != nil {
639
+
l.Error("invalid record", "err", err)
640
+
return err
641
+
}
642
+
643
+
issue := db.IssueFromRecord(did, rkey, record)
644
+
645
+
sanitizer := markup.NewSanitizer()
646
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
647
+
return fmt.Errorf("title is empty after HTML sanitization")
648
+
}
649
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
650
+
return fmt.Errorf("body is empty after HTML sanitization")
651
+
}
652
+
653
+
tx, err := ddb.BeginTx(ctx, nil)
654
+
if err != nil {
655
+
l.Error("failed to begin transaction", "err", err)
656
+
return err
657
+
}
658
+
659
+
err = db.NewIssue(tx, &issue)
660
+
if err != nil {
661
+
l.Error("failed to create issue", "err", err)
662
+
return err
663
+
}
664
+
665
+
return nil
666
+
667
+
case models.CommitOperationUpdate:
668
+
raw := json.RawMessage(e.Commit.Record)
669
+
record := tangled.RepoIssue{}
670
+
err = json.Unmarshal(raw, &record)
671
+
if err != nil {
672
+
l.Error("invalid record", "err", err)
673
+
return err
674
+
}
675
+
676
+
body := ""
677
+
if record.Body != nil {
678
+
body = *record.Body
679
+
}
680
+
681
+
sanitizer := markup.NewSanitizer()
682
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
683
+
return fmt.Errorf("title is empty after HTML sanitization")
684
+
}
685
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
686
+
return fmt.Errorf("body is empty after HTML sanitization")
687
+
}
688
+
689
+
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
690
+
if err != nil {
691
+
l.Error("failed to update issue", "err", err)
692
+
return err
693
+
}
694
+
695
+
return nil
696
+
697
+
case models.CommitOperationDelete:
698
+
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
699
+
l.Error("failed to delete", "err", err)
700
+
return fmt.Errorf("failed to delete issue record: %w", err)
701
+
}
702
+
703
+
return nil
704
+
}
705
+
706
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
707
+
}
708
+
709
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
710
+
did := e.Did
711
+
rkey := e.Commit.RKey
712
+
713
+
var err error
714
+
715
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
716
+
l.Info("ingesting record")
717
+
718
+
ddb, ok := i.Db.Execer.(*db.DB)
719
+
if !ok {
720
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
721
+
}
722
+
723
+
switch e.Commit.Operation {
724
+
case models.CommitOperationCreate:
725
+
raw := json.RawMessage(e.Commit.Record)
726
+
record := tangled.RepoIssueComment{}
727
+
err = json.Unmarshal(raw, &record)
728
+
if err != nil {
729
+
l.Error("invalid record", "err", err)
730
+
return err
731
+
}
732
+
733
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
734
+
if err != nil {
735
+
l.Error("failed to parse comment from record", "err", err)
736
+
return err
737
+
}
738
+
739
+
sanitizer := markup.NewSanitizer()
740
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
741
+
return fmt.Errorf("body is empty after HTML sanitization")
742
+
}
743
+
744
+
err = db.NewIssueComment(ddb, &comment)
745
+
if err != nil {
746
+
l.Error("failed to create issue comment", "err", err)
747
+
return err
748
+
}
749
+
750
+
return nil
751
+
752
+
case models.CommitOperationUpdate:
753
+
raw := json.RawMessage(e.Commit.Record)
754
+
record := tangled.RepoIssueComment{}
755
+
err = json.Unmarshal(raw, &record)
756
+
if err != nil {
757
+
l.Error("invalid record", "err", err)
758
+
return err
759
+
}
760
+
761
+
sanitizer := markup.NewSanitizer()
762
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
763
+
return fmt.Errorf("body is empty after HTML sanitization")
764
+
}
765
+
766
+
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
767
+
if err != nil {
768
+
l.Error("failed to update issue comment", "err", err)
769
+
return err
770
+
}
771
+
772
+
return nil
773
+
774
+
case models.CommitOperationDelete:
775
+
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
776
+
l.Error("failed to delete", "err", err)
777
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
778
+
}
779
+
780
+
return nil
781
+
}
782
+
783
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
784
+
}
+4
-9
appview/issues/issues.go
+4
-9
appview/issues/issues.go
···
278
278
}
279
279
280
280
createdAt := time.Now().Format(time.RFC3339)
281
-
commentIdInt64 := int64(commentId)
282
281
ownerDid := user.Did
283
282
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
284
283
if err != nil {
···
302
301
Val: &tangled.RepoIssueComment{
303
302
Repo: &atUri,
304
303
Issue: issueAt,
305
-
CommentId: &commentIdInt64,
306
304
Owner: &ownerDid,
307
305
Body: body,
308
306
CreatedAt: createdAt,
···
451
449
repoAt := record["repo"].(string)
452
450
issueAt := record["issue"].(string)
453
451
createdAt := record["createdAt"].(string)
454
-
commentIdInt64 := int64(commentIdInt)
455
452
456
453
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
457
454
Collection: tangled.RepoIssueCommentNSID,
···
462
459
Val: &tangled.RepoIssueComment{
463
460
Repo: &repoAt,
464
461
Issue: issueAt,
465
-
CommentId: &commentIdInt64,
466
462
Owner: &comment.OwnerDid,
467
463
Body: newBody,
468
464
CreatedAt: createdAt,
···
687
683
Rkey: issue.Rkey,
688
684
Record: &lexutil.LexiconTypeDecoder{
689
685
Val: &tangled.RepoIssue{
690
-
Repo: atUri,
691
-
Title: title,
692
-
Body: &body,
693
-
Owner: user.Did,
694
-
IssueId: int64(issue.IssueId),
686
+
Repo: atUri,
687
+
Title: title,
688
+
Body: &body,
689
+
Owner: user.Did,
695
690
},
696
691
},
697
692
})
+3
-3
appview/middleware/middleware.go
+3
-3
appview/middleware/middleware.go
···
217
217
if err != nil {
218
218
// invalid did or handle
219
219
log.Println("failed to resolve repo")
220
-
mw.pages.ErrorKnot404(w)
220
+
mw.pages.Error404(w)
221
221
return
222
222
}
223
223
···
234
234
f, err := mw.repoResolver.Resolve(r)
235
235
if err != nil {
236
236
log.Println("failed to fully resolve repo", err)
237
-
mw.pages.ErrorKnot404(w)
237
+
http.Error(w, "invalid repo url", http.StatusNotFound)
238
238
return
239
239
}
240
240
···
283
283
f, err := mw.repoResolver.Resolve(r)
284
284
if err != nil {
285
285
log.Println("failed to fully resolve repo", err)
286
-
mw.pages.ErrorKnot404(w)
286
+
http.Error(w, "invalid repo url", http.StatusNotFound)
287
287
return
288
288
}
289
289
-13
appview/pages/funcmap.go
-13
appview/pages/funcmap.go
···
21
21
"github.com/go-enry/go-enry/v2"
22
22
"tangled.sh/tangled.sh/core/appview/filetree"
23
23
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
-
"tangled.sh/tangled.sh/core/crypto"
25
24
)
26
25
27
26
func (p *Pages) funcMap() template.FuncMap {
···
277
276
},
278
277
"layoutCenter": func() string {
279
278
return "col-span-1 md:col-span-8 lg:col-span-6"
280
-
},
281
-
282
-
"normalizeForHtmlId": func(s string) string {
283
-
// TODO: extend this to handle other cases?
284
-
return strings.ReplaceAll(s, ":", "_")
285
-
},
286
-
"sshFingerprint": func(pubKey string) string {
287
-
fp, err := crypto.SSHFingerprint(pubKey)
288
-
if err != nil {
289
-
return "error"
290
-
}
291
-
return fp
292
279
},
293
280
}
294
281
}
+10
-64
appview/pages/pages.go
+10
-64
appview/pages/pages.go
···
306
306
return p.execute("timeline/timeline", w, params)
307
307
}
308
308
309
-
type UserProfileSettingsParams struct {
310
-
LoggedInUser *oauth.User
311
-
Tabs []map[string]any
312
-
Tab string
313
-
}
314
-
315
-
func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
316
-
return p.execute("user/settings/profile", w, params)
317
-
}
318
-
319
-
type UserKeysSettingsParams struct {
309
+
type SettingsParams struct {
320
310
LoggedInUser *oauth.User
321
311
PubKeys []db.PublicKey
322
-
Tabs []map[string]any
323
-
Tab string
324
-
}
325
-
326
-
func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
327
-
return p.execute("user/settings/keys", w, params)
328
-
}
329
-
330
-
type UserEmailsSettingsParams struct {
331
-
LoggedInUser *oauth.User
332
312
Emails []db.Email
333
-
Tabs []map[string]any
334
-
Tab string
335
313
}
336
314
337
-
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
338
-
return p.execute("user/settings/emails", w, params)
315
+
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
316
+
return p.execute("settings", w, params)
339
317
}
340
318
341
319
type KnotsParams struct {
···
430
408
return p.execute("repo/fork", w, params)
431
409
}
432
410
433
-
type ProfileHomePageParams struct {
411
+
type ProfilePageParams struct {
434
412
LoggedInUser *oauth.User
435
413
Repos []db.Repo
436
414
CollaboratingRepos []db.Repo
···
440
418
}
441
419
442
420
type ProfileCard struct {
443
-
UserDid string
444
-
UserHandle string
445
-
FollowStatus db.FollowStatus
446
-
FollowersCount int
447
-
FollowingCount int
421
+
UserDid string
422
+
UserHandle string
423
+
FollowStatus db.FollowStatus
424
+
Followers int
425
+
Following int
448
426
449
427
Profile *db.Profile
450
428
}
451
429
452
-
func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error {
430
+
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
453
431
return p.execute("user/profile", w, params)
454
432
}
455
433
···
461
439
462
440
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
463
441
return p.execute("user/repos", w, params)
464
-
}
465
-
466
-
type FollowCard struct {
467
-
UserDid string
468
-
FollowStatus db.FollowStatus
469
-
FollowersCount int
470
-
FollowingCount int
471
-
Profile *db.Profile
472
-
}
473
-
474
-
type FollowersPageParams struct {
475
-
LoggedInUser *oauth.User
476
-
Followers []FollowCard
477
-
Card ProfileCard
478
-
}
479
-
480
-
func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error {
481
-
return p.execute("user/followers", w, params)
482
-
}
483
-
484
-
type FollowingPageParams struct {
485
-
LoggedInUser *oauth.User
486
-
Following []FollowCard
487
-
Card ProfileCard
488
-
}
489
-
490
-
func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error {
491
-
return p.execute("user/following", w, params)
492
442
}
493
443
494
444
type FollowFragmentParams struct {
···
1320
1270
1321
1271
func (p *Pages) Error404(w io.Writer) error {
1322
1272
return p.execute("errors/404", w, nil)
1323
-
}
1324
-
1325
-
func (p *Pages) ErrorKnot404(w io.Writer) error {
1326
-
return p.execute("errors/knot404", w, nil)
1327
1273
}
1328
1274
1329
1275
func (p *Pages) Error503(w io.Writer) error {
+4
-24
appview/pages/templates/errors/404.html
+4
-24
appview/pages/templates/errors/404.html
···
1
1
{{ define "title" }}404 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
8
-
{{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
404 — page not found
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.
18
-
</p>
19
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
21
-
{{ i "arrow-left" "w-4 h-4" }}
22
-
go back
23
-
</a>
24
-
</div>
25
-
</div>
26
-
</div>
27
-
</div>
4
+
<h1>404 — nothing like that here!</h1>
5
+
<p>
6
+
It seems we couldn't find what you were looking for. Sorry about that!
7
+
</p>
28
8
{{ end }}
+3
-36
appview/pages/templates/errors/500.html
+3
-36
appview/pages/templates/errors/500.html
···
1
1
{{ define "title" }}500 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
500 — internal server error
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
26
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
28
-
{{ i "refresh-cw" "w-4 h-4" }}
29
-
try again
30
-
</button>
31
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
32
-
{{ i "home" "w-4 h-4" }}
33
-
back to home
34
-
</a>
35
-
</div>
36
-
</div>
37
-
</div>
38
-
</div>
39
-
{{ end }}
4
+
<h1>500 — something broke!</h1>
5
+
<p>We're working on getting service back up. Hang tight!</p>
6
+
{{ end }}
+5
-28
appview/pages/templates/errors/503.html
+5
-28
appview/pages/templates/errors/503.html
···
1
1
{{ define "title" }}503 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
8
-
{{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
503 — service unavailable
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
We were unable to reach the knot hosting this repository. The service may be temporarily unavailable.
18
-
</p>
19
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
21
-
{{ i "refresh-cw" "w-4 h-4" }}
22
-
try again
23
-
</button>
24
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
25
-
{{ i "arrow-left" "w-4 h-4" }}
26
-
back to timeline
27
-
</a>
28
-
</div>
29
-
</div>
30
-
</div>
31
-
</div>
4
+
<h1>503 — unable to reach knot</h1>
5
+
<p>
6
+
We were unable to reach the knot hosting this repository. Try again
7
+
later.
8
+
</p>
32
9
{{ end }}
-28
appview/pages/templates/errors/knot404.html
-28
appview/pages/templates/errors/knot404.html
···
1
-
{{ define "title" }}404 · tangled{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
-
<div class="mb-6">
7
-
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
8
-
{{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }}
9
-
</div>
10
-
</div>
11
-
12
-
<div class="space-y-4">
13
-
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
-
404 — repository not found
15
-
</h1>
16
-
<p class="text-gray-600 dark:text-gray-300">
17
-
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
18
-
</p>
19
-
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
21
-
{{ i "arrow-left" "w-4 h-4" }}
22
-
back to timeline
23
-
</a>
24
-
</div>
25
-
</div>
26
-
</div>
27
-
</div>
28
-
{{ end }}
+14
-22
appview/pages/templates/repo/index.html
+14
-22
appview/pages/templates/repo/index.html
···
356
356
357
357
{{ define "repoAfter" }}
358
358
{{- if or .HTMLReadme .Readme -}}
359
-
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
360
-
{{- if .ReadmeFileName -}}
361
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
362
-
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
363
-
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
364
-
</div>
365
-
{{- end -}}
366
-
<section
367
-
class="p-6 overflow-auto {{ if not .Raw }}
368
-
prose dark:prose-invert dark:[&_pre]:bg-gray-900
369
-
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
370
-
dark:[&_pre]:border dark:[&_pre]:border-gray-700
371
-
{{ end }}"
372
-
>
373
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
374
-
{{- .Readme -}}
375
-
</pre>
376
-
{{- else -}}
377
-
{{ .HTMLReadme }}
378
-
{{- end -}}</article>
379
-
</section>
380
-
</div>
359
+
<section
360
+
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }}
361
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
362
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
363
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
364
+
{{ end }}"
365
+
>
366
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
367
+
{{- .Readme -}}
368
+
</pre>
369
+
{{- else -}}
370
+
{{ .HTMLReadme }}
371
+
{{- end -}}</article>
372
+
</section>
381
373
{{- end -}}
382
374
{{ end }}
+192
appview/pages/templates/settings.html
+192
appview/pages/templates/settings.html
···
1
+
{{ define "title" }}settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="flex flex-col">
8
+
{{ block "profile" . }} {{ end }}
9
+
{{ block "keys" . }} {{ end }}
10
+
{{ block "emails" . }} {{ end }}
11
+
</div>
12
+
{{ end }}
13
+
14
+
{{ define "profile" }}
15
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2>
16
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
18
+
{{ if .LoggedInUser.Handle }}
19
+
<dt class="font-bold">handle</dt>
20
+
<dd>@{{ .LoggedInUser.Handle }}</dd>
21
+
{{ end }}
22
+
<dt class="font-bold">did</dt>
23
+
<dd>{{ .LoggedInUser.Did }}</dd>
24
+
<dt class="font-bold">pds</dt>
25
+
<dd>{{ .LoggedInUser.Pds }}</dd>
26
+
</dl>
27
+
</section>
28
+
{{ end }}
29
+
30
+
{{ define "keys" }}
31
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2>
32
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
33
+
<p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
34
+
<div id="key-list" class="flex flex-col gap-6 mb-8">
35
+
{{ range $index, $key := .PubKeys }}
36
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
37
+
<div class="flex flex-col gap-1">
38
+
<div class="inline-flex items-center gap-4">
39
+
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
+
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
+
</div>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p>
43
+
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
+
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
+
</div>
46
+
</div>
47
+
<button
48
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
+
title="Delete key"
50
+
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
51
+
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?"
52
+
>
53
+
{{ i "trash-2" "w-5 h-5" }}
54
+
<span class="hidden md:inline">delete</span>
55
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
56
+
</button>
57
+
</div>
58
+
{{ end }}
59
+
</div>
60
+
<form
61
+
hx-put="/settings/keys"
62
+
hx-indicator="#add-sshkey-spinner"
63
+
hx-swap="none"
64
+
class="max-w-2xl mb-8 space-y-4"
65
+
>
66
+
<input
67
+
type="text"
68
+
id="name"
69
+
name="name"
70
+
placeholder="key name"
71
+
required
72
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
73
+
74
+
<input
75
+
id="key"
76
+
name="key"
77
+
placeholder="ssh-rsa AAAAAA..."
78
+
required
79
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
80
+
81
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit">
82
+
<span>add key</span>
83
+
<span id="add-sshkey-spinner" class="group">
84
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
85
+
</span>
86
+
</button>
87
+
88
+
<div id="settings-keys" class="error dark:text-red-400"></div>
89
+
</form>
90
+
</section>
91
+
{{ end }}
92
+
93
+
{{ define "emails" }}
94
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
95
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
96
+
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
97
+
<div id="email-list" class="flex flex-col gap-6 mb-8">
98
+
{{ range $index, $email := .Emails }}
99
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
100
+
<div class="flex flex-col gap-2">
101
+
<div class="inline-flex items-center gap-4">
102
+
{{ i "mail" "w-3 h-3 dark:text-gray-300" }}
103
+
<p class="font-bold dark:text-white">{{ .Address }}</p>
104
+
<div class="inline-flex items-center gap-1">
105
+
{{ if .Verified }}
106
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
107
+
{{ else }}
108
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
109
+
{{ end }}
110
+
{{ if .Primary }}
111
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
112
+
{{ end }}
113
+
</div>
114
+
</div>
115
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p>
116
+
</div>
117
+
<div class="flex gap-2 items-center">
118
+
{{ if not .Verified }}
119
+
<button
120
+
class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
121
+
hx-post="/settings/emails/verify/resend"
122
+
hx-swap="none"
123
+
href="#"
124
+
hx-vals='{"email": "{{ .Address }}"}'>
125
+
{{ i "rotate-cw" "w-5 h-5" }}
126
+
<span class="hidden md:inline">resend</span>
127
+
</button>
128
+
{{ end }}
129
+
{{ if and (not .Primary) .Verified }}
130
+
<a
131
+
class="text-sm dark:text-blue-400 dark:hover:text-blue-300"
132
+
hx-post="/settings/emails/primary"
133
+
hx-swap="none"
134
+
href="#"
135
+
hx-vals='{"email": "{{ .Address }}"}'>
136
+
set as primary
137
+
</a>
138
+
{{ end }}
139
+
{{ if not .Primary }}
140
+
<form
141
+
hx-delete="/settings/emails"
142
+
hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"
143
+
hx-indicator="#delete-email-{{ $index }}-spinner"
144
+
>
145
+
<input type="hidden" name="email" value="{{ .Address }}">
146
+
<button
147
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
148
+
title="Delete email"
149
+
type="submit"
150
+
>
151
+
{{ i "trash-2" "w-5 h-5" }}
152
+
<span class="hidden md:inline">delete</span>
153
+
<span id="delete-email-{{ $index }}-spinner" class="group">
154
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
155
+
</span>
156
+
</button>
157
+
</form>
158
+
{{ end }}
159
+
</div>
160
+
</div>
161
+
{{ end }}
162
+
</div>
163
+
<form
164
+
hx-put="/settings/emails"
165
+
hx-swap="none"
166
+
class="max-w-2xl mb-8 space-y-4"
167
+
hx-indicator="#add-email-spinner"
168
+
>
169
+
<input
170
+
type="email"
171
+
id="email"
172
+
name="email"
173
+
placeholder="your@email.com"
174
+
required
175
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
176
+
>
177
+
178
+
<button
179
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center"
180
+
type="submit"
181
+
>
182
+
<span>add email</span>
183
+
<span id="add-email-spinner" class="group">
184
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
185
+
</span>
186
+
</button>
187
+
188
+
<div id="settings-emails-error" class="error dark:text-red-400"></div>
189
+
<div id="settings-emails-success" class="success dark:text-green-400"></div>
190
+
</form>
191
+
</section>
192
+
{{ end }}
+3
-3
appview/pages/templates/timeline/timeline.html
+3
-3
appview/pages/templates/timeline/timeline.html
···
171
171
{{ end }}
172
172
{{ end }}
173
173
{{ with $stat }}
174
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
174
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
175
175
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
176
+
<span id="followers">{{ .Followers }} followers</span>
177
177
<span class="select-none after:content-['ยท']"></span>
178
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
178
+
<span id="following">{{ .Following }} following</span>
179
179
</div>
180
180
{{ end }}
181
181
</div>
-30
appview/pages/templates/user/followers.html
-30
appview/pages/templates/user/followers.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "followers" . }}{{ end }}
17
-
</div>
18
-
</div>
19
-
{{ end }}
20
-
21
-
{{ define "followers" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
23
-
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
24
-
{{ range .Followers }}
25
-
{{ template "user/fragments/followCard" . }}
26
-
{{ else }}
27
-
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
28
-
{{ end }}
29
-
</div>
30
-
{{ end }}
-30
appview/pages/templates/user/following.html
-30
appview/pages/templates/user/following.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "following" . }}{{ end }}
17
-
</div>
18
-
</div>
19
-
{{ end }}
20
-
21
-
{{ define "following" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
23
-
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
24
-
{{ range .Following }}
25
-
{{ template "user/fragments/followCard" . }}
26
-
{{ else }}
27
-
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
28
-
{{ end }}
29
-
</div>
30
-
{{ end }}
+2
-2
appview/pages/templates/user/fragments/follow.html
+2
-2
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
-
<button id="{{ normalizeForHtmlId .UserDid }}"
2
+
<button id="followBtn"
3
3
class="btn mt-2 w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
···
9
9
{{ end }}
10
10
11
11
hx-trigger="click"
12
-
hx-target="#{{ normalizeForHtmlId .UserDid }}"
12
+
hx-target="#followBtn"
13
13
hx-swap="outerHTML"
14
14
>
15
15
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
-29
appview/pages/templates/user/fragments/followCard.html
-29
appview/pages/templates/user/fragments/followCard.html
···
1
-
{{ define "user/fragments/followCard" }}
2
-
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
4
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
-
</div>
8
-
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['ยท']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
19
-
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
-
{{ template "user/fragments/follow" . }}
25
-
</div>
26
-
{{ end }}
27
-
</div>
28
-
</div>
29
-
{{ end }}
+14
-17
appview/pages/templates/user/fragments/profileCard.html
+14
-17
appview/pages/templates/user/fragments/profileCard.html
···
1
1
{{ define "user/fragments/profileCard" }}
2
-
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
3
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
4
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
5
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
···
9
8
</div>
10
9
<div class="col-span-2">
11
10
<div class="flex items-center flex-row flex-nowrap gap-2">
12
-
<p title="{{ $userIdent }}"
11
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
13
12
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
14
-
{{ $userIdent }}
13
+
{{ didOrHandle .UserDid .UserHandle }}
15
14
</p>
16
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
15
+
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
17
16
</div>
18
17
19
18
<div class="md:hidden">
20
-
{{ block "followerFollowing" (list . $userIdent) }} {{ end }}
19
+
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
21
20
</div>
22
21
</div>
23
22
<div class="col-span-3 md:col-span-full">
···
30
29
{{ end }}
31
30
32
31
<div class="hidden md:block">
33
-
{{ block "followerFollowing" (list $ $userIdent) }} {{ end }}
32
+
{{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }}
34
33
</div>
35
34
36
35
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
···
43
42
{{ if .IncludeBluesky }}
44
43
<div class="flex items-center gap-2">
45
44
<span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span>
46
-
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a>
45
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a>
47
46
</div>
48
47
{{ end }}
49
48
{{ range $link := .Links }}
···
89
88
{{ end }}
90
89
91
90
{{ define "followerFollowing" }}
92
-
{{ $root := index . 0 }}
93
-
{{ $userIdent := index . 1 }}
94
-
{{ with $root }}
95
-
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
96
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
97
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
98
-
<span class="select-none after:content-['ยท']"></span>
99
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
100
-
</div>
101
-
{{ end }}
91
+
{{ $followers := index . 0 }}
92
+
{{ $following := index . 1 }}
93
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
94
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
95
+
<span id="followers">{{ $followers }} followers</span>
96
+
<span class="select-none after:content-['ยท']"></span>
97
+
<span id="following">{{ $following }} following</span>
98
+
</div>
102
99
{{ end }}
103
100
+1
-1
appview/pages/templates/user/repos.html
+1
-1
appview/pages/templates/user/repos.html
···
3
3
{{ define "extrameta" }}
4
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
5
5
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" />
7
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
8
{{ end }}
9
9
-94
appview/pages/templates/user/settings/emails.html
-94
appview/pages/templates/user/settings/emails.html
···
1
-
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Settings</p>
6
-
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
-
<div class="col-span-1">
10
-
{{ template "user/settings/fragments/sidebar" . }}
11
-
</div>
12
-
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
-
{{ template "emailSettings" . }}
14
-
</div>
15
-
</section>
16
-
</div>
17
-
{{ end }}
18
-
19
-
{{ define "emailSettings" }}
20
-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
-
<div class="col-span-1 md:col-span-2">
22
-
<h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2>
23
-
<p class="text-gray-500 dark:text-gray-400">
24
-
Commits authored using emails listed here will be associated with your Tangled profile.
25
-
</p>
26
-
</div>
27
-
<div class="col-span-1 md:col-span-1 md:justify-self-end">
28
-
{{ template "addEmailButton" . }}
29
-
</div>
30
-
</div>
31
-
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
32
-
{{ range .Emails }}
33
-
{{ template "user/settings/fragments/emailListing" (list $ .) }}
34
-
{{ else }}
35
-
<div class="flex items-center justify-center p-2 text-gray-500">
36
-
no emails added yet
37
-
</div>
38
-
{{ end }}
39
-
</div>
40
-
{{ end }}
41
-
42
-
{{ define "addEmailButton" }}
43
-
<button
44
-
class="btn flex items-center gap-2"
45
-
popovertarget="add-email-modal"
46
-
popovertargetaction="toggle">
47
-
{{ i "plus" "size-4" }}
48
-
add email
49
-
</button>
50
-
<div
51
-
id="add-email-modal"
52
-
popover
53
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
54
-
{{ template "addEmailModal" . }}
55
-
</div>
56
-
{{ end}}
57
-
58
-
{{ define "addEmailModal" }}
59
-
<form
60
-
hx-put="/settings/emails"
61
-
hx-indicator="#spinner"
62
-
hx-swap="none"
63
-
class="flex flex-col gap-2"
64
-
>
65
-
<p class="uppercase p-0">ADD EMAIL</p>
66
-
<p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p>
67
-
<input
68
-
type="email"
69
-
id="email-address"
70
-
name="email"
71
-
required
72
-
placeholder="your@email.com"
73
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
74
-
/>
75
-
<div class="flex gap-2 pt-2">
76
-
<button
77
-
type="button"
78
-
popovertarget="add-email-modal"
79
-
popovertargetaction="hide"
80
-
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
81
-
>
82
-
{{ i "x" "size-4" }} cancel
83
-
</button>
84
-
<button type="submit" class="btn w-1/2 flex items-center">
85
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
86
-
<span id="spinner" class="group">
87
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
-
</span>
89
-
</button>
90
-
</div>
91
-
<div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div>
92
-
<div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div>
93
-
</form>
94
-
{{ end }}
-62
appview/pages/templates/user/settings/fragments/emailListing.html
-62
appview/pages/templates/user/settings/fragments/emailListing.html
···
1
-
{{ define "user/settings/fragments/emailListing" }}
2
-
{{ $root := index . 0 }}
3
-
{{ $email := index . 1 }}
4
-
<div id="email-{{$email.Address}}" class="flex items-center justify-between p-2">
5
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
6
-
<div class="flex items-center gap-2">
7
-
{{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }}
8
-
<span class="font-bold">
9
-
{{ $email.Address }}
10
-
</span>
11
-
<div class="inline-flex items-center gap-1">
12
-
{{ if $email.Verified }}
13
-
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
14
-
{{ else }}
15
-
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
16
-
{{ end }}
17
-
{{ if $email.Primary }}
18
-
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
19
-
{{ end }}
20
-
</div>
21
-
</div>
22
-
<div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
23
-
<span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span>
24
-
</div>
25
-
</div>
26
-
<div class="flex gap-2 items-center">
27
-
{{ if not $email.Verified }}
28
-
<button
29
-
class="btn flex gap-2 text-sm px-2 py-1"
30
-
hx-post="/settings/emails/verify/resend"
31
-
hx-swap="none"
32
-
hx-vals='{"email": "{{ $email.Address }}"}'>
33
-
{{ i "rotate-cw" "w-4 h-4" }}
34
-
<span class="hidden md:inline">resend</span>
35
-
</button>
36
-
{{ end }}
37
-
{{ if and (not $email.Primary) $email.Verified }}
38
-
<button
39
-
class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
40
-
hx-post="/settings/emails/primary"
41
-
hx-swap="none"
42
-
hx-vals='{"email": "{{ $email.Address }}"}'>
43
-
set as primary
44
-
</button>
45
-
{{ end }}
46
-
{{ if not $email.Primary }}
47
-
<button
48
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
-
title="Delete email"
50
-
hx-delete="/settings/emails"
51
-
hx-swap="none"
52
-
hx-vals='{"email": "{{ $email.Address }}"}'
53
-
hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?"
54
-
>
55
-
{{ i "trash-2" "w-5 h-5" }}
56
-
<span class="hidden md:inline">delete</span>
57
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
-
</button>
59
-
{{ end }}
60
-
</div>
61
-
</div>
62
-
{{ end }}
-31
appview/pages/templates/user/settings/fragments/keyListing.html
-31
appview/pages/templates/user/settings/fragments/keyListing.html
···
1
-
{{ define "user/settings/fragments/keyListing" }}
2
-
{{ $root := index . 0 }}
3
-
{{ $key := index . 1 }}
4
-
<div id="key-{{$key.Name}}" class="flex items-center justify-between p-2">
5
-
<div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]">
6
-
<div class="flex items-center gap-2">
7
-
<span>{{ i "key" "w-4" "h-4" }}</span>
8
-
<span class="font-bold">
9
-
{{ $key.Name }}
10
-
</span>
11
-
</div>
12
-
<span class="font-mono text-sm text-gray-500 dark:text-gray-400">
13
-
{{ sshFingerprint $key.Key }}
14
-
</span>
15
-
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
16
-
<span>added {{ template "repo/fragments/time" $key.Created }}</span>
17
-
</div>
18
-
</div>
19
-
<button
20
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
21
-
title="Delete key"
22
-
hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}"
23
-
hx-swap="none"
24
-
hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?"
25
-
>
26
-
{{ i "trash-2" "w-5 h-5" }}
27
-
<span class="hidden md:inline">delete</span>
28
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
29
-
</button>
30
-
</div>
31
-
{{ end }}
-101
appview/pages/templates/user/settings/keys.html
-101
appview/pages/templates/user/settings/keys.html
···
1
-
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Settings</p>
6
-
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
-
<div class="col-span-1">
10
-
{{ template "user/settings/fragments/sidebar" . }}
11
-
</div>
12
-
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
-
{{ template "sshKeysSettings" . }}
14
-
</div>
15
-
</section>
16
-
</div>
17
-
{{ end }}
18
-
19
-
{{ define "sshKeysSettings" }}
20
-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
-
<div class="col-span-1 md:col-span-2">
22
-
<h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2>
23
-
<p class="text-gray-500 dark:text-gray-400">
24
-
SSH public keys added here will be broadcasted to knots that you are a member of,
25
-
allowing you to push to repositories there.
26
-
</p>
27
-
</div>
28
-
<div class="col-span-1 md:col-span-1 md:justify-self-end">
29
-
{{ template "addKeyButton" . }}
30
-
</div>
31
-
</div>
32
-
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
33
-
{{ range .PubKeys }}
34
-
{{ template "user/settings/fragments/keyListing" (list $ .) }}
35
-
{{ else }}
36
-
<div class="flex items-center justify-center p-2 text-gray-500">
37
-
no keys added yet
38
-
</div>
39
-
{{ end }}
40
-
</div>
41
-
{{ end }}
42
-
43
-
{{ define "addKeyButton" }}
44
-
<button
45
-
class="btn flex items-center gap-2"
46
-
popovertarget="add-key-modal"
47
-
popovertargetaction="toggle">
48
-
{{ i "plus" "size-4" }}
49
-
add key
50
-
</button>
51
-
<div
52
-
id="add-key-modal"
53
-
popover
54
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
55
-
{{ template "addKeyModal" . }}
56
-
</div>
57
-
{{ end}}
58
-
59
-
{{ define "addKeyModal" }}
60
-
<form
61
-
hx-put="/settings/keys"
62
-
hx-indicator="#spinner"
63
-
hx-swap="none"
64
-
class="flex flex-col gap-2"
65
-
>
66
-
<p class="uppercase p-0">ADD SSH KEY</p>
67
-
<p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p>
68
-
<input
69
-
type="text"
70
-
id="key-name"
71
-
name="name"
72
-
required
73
-
placeholder="key name"
74
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
75
-
/>
76
-
<textarea
77
-
type="text"
78
-
id="key-value"
79
-
name="key"
80
-
required
81
-
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
82
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea>
83
-
<div class="flex gap-2 pt-2">
84
-
<button
85
-
type="button"
86
-
popovertarget="add-key-modal"
87
-
popovertargetaction="hide"
88
-
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
89
-
>
90
-
{{ i "x" "size-4" }} cancel
91
-
</button>
92
-
<button type="submit" class="btn w-1/2 flex items-center">
93
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
94
-
<span id="spinner" class="group">
95
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
96
-
</span>
97
-
</button>
98
-
</div>
99
-
<div id="settings-keys" class="text-red-500 dark:text-red-400"></div>
100
-
</form>
101
-
{{ end }}
-64
appview/pages/templates/user/settings/profile.html
-64
appview/pages/templates/user/settings/profile.html
···
1
-
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Settings</p>
6
-
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
-
<div class="col-span-1">
10
-
{{ template "user/settings/fragments/sidebar" . }}
11
-
</div>
12
-
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
-
{{ template "profileInfo" . }}
14
-
</div>
15
-
</section>
16
-
</div>
17
-
{{ end }}
18
-
19
-
{{ define "profileInfo" }}
20
-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
-
<div class="col-span-1 md:col-span-2">
22
-
<h2 class="text-sm pb-2 uppercase font-bold">Profile</h2>
23
-
<p class="text-gray-500 dark:text-gray-400">
24
-
Your account information from your AT Protocol identity.
25
-
</p>
26
-
</div>
27
-
<div class="col-span-1 md:col-span-1 md:justify-self-end">
28
-
</div>
29
-
</div>
30
-
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
31
-
<div class="flex items-center justify-between p-4">
32
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
33
-
{{ if .LoggedInUser.Handle }}
34
-
<span class="font-bold">
35
-
@{{ .LoggedInUser.Handle }}
36
-
</span>
37
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
38
-
<span>Handle</span>
39
-
</div>
40
-
{{ end }}
41
-
</div>
42
-
</div>
43
-
<div class="flex items-center justify-between p-4">
44
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
45
-
<span class="font-mono text-xs">
46
-
{{ .LoggedInUser.Did }}
47
-
</span>
48
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
49
-
<span>Decentralized Identifier (DID)</span>
50
-
</div>
51
-
</div>
52
-
</div>
53
-
<div class="flex items-center justify-between p-4">
54
-
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
55
-
<span class="font-bold">
56
-
{{ .LoggedInUser.Pds }}
57
-
</span>
58
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
59
-
<span>Personal Data Server (PDS)</span>
60
-
</div>
61
-
</div>
62
-
</div>
63
-
</div>
64
-
{{ end }}
-27
appview/repo/repo.go
-27
appview/repo/repo.go
···
125
125
126
126
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
127
127
if err != nil {
128
-
rp.pages.Error503(w)
129
128
log.Println("failed to reach knotserver", err)
130
129
return
131
130
}
132
131
133
132
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
134
133
if err != nil {
135
-
rp.pages.Error503(w)
136
134
log.Println("failed to reach knotserver", err)
137
135
return
138
136
}
···
148
146
149
147
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
150
148
if err != nil {
151
-
rp.pages.Error503(w)
152
149
log.Println("failed to reach knotserver", err)
153
150
return
154
151
}
···
315
312
316
313
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
317
314
if err != nil {
318
-
rp.pages.Error503(w)
319
315
log.Println("failed to reach knotserver", err)
320
316
return
321
317
}
···
379
375
if !rp.config.Core.Dev {
380
376
protocol = "https"
381
377
}
382
-
383
-
// if the tree path has a trailing slash, let's strip it
384
-
// so we don't 404
385
-
treePath = strings.TrimSuffix(treePath, "/")
386
-
387
378
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
388
379
if err != nil {
389
-
rp.pages.Error503(w)
390
380
log.Println("failed to reach knotserver", err)
391
381
return
392
382
}
393
383
394
-
// uhhh so knotserver returns a 500 if the entry isn't found in
395
-
// the requested tree path, so let's stick to not-OK here.
396
-
// we can fix this once we build out the xrpc apis for these operations.
397
-
if resp.StatusCode != http.StatusOK {
398
-
rp.pages.Error404(w)
399
-
return
400
-
}
401
-
402
384
body, err := io.ReadAll(resp.Body)
403
385
if err != nil {
404
386
log.Printf("Error reading response body: %v", err)
···
456
438
457
439
result, err := us.Tags(f.OwnerDid(), f.Name)
458
440
if err != nil {
459
-
rp.pages.Error503(w)
460
441
log.Println("failed to reach knotserver", err)
461
442
return
462
443
}
···
514
495
515
496
result, err := us.Branches(f.OwnerDid(), f.Name)
516
497
if err != nil {
517
-
rp.pages.Error503(w)
518
498
log.Println("failed to reach knotserver", err)
519
499
return
520
500
}
···
544
524
}
545
525
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
546
526
if err != nil {
547
-
rp.pages.Error503(w)
548
527
log.Println("failed to reach knotserver", err)
549
-
return
550
-
}
551
-
552
-
if resp.StatusCode == http.StatusNotFound {
553
-
rp.pages.Error404(w)
554
528
return
555
529
}
556
530
···
1268
1242
1269
1243
result, err := us.Branches(f.OwnerDid(), f.Name)
1270
1244
if err != nil {
1271
-
rp.pages.Error503(w)
1272
1245
log.Println("failed to reach knotserver", err)
1273
1246
return
1274
1247
}
+9
-44
appview/settings/settings.go
+9
-44
appview/settings/settings.go
···
33
33
Config *config.Config
34
34
}
35
35
36
-
type tab = map[string]any
37
-
38
-
var (
39
-
settingsTabs []tab = []tab{
40
-
{"Name": "profile", "Icon": "user"},
41
-
{"Name": "keys", "Icon": "key"},
42
-
{"Name": "emails", "Icon": "mail"},
43
-
}
44
-
)
45
-
46
36
func (s *Settings) Router() http.Handler {
47
37
r := chi.NewRouter()
48
38
49
39
r.Use(middleware.AuthMiddleware(s.OAuth))
50
40
51
-
// settings pages
52
-
r.Get("/", s.profileSettings)
53
-
r.Get("/profile", s.profileSettings)
41
+
r.Get("/", s.settings)
54
42
55
43
r.Route("/keys", func(r chi.Router) {
56
-
r.Get("/", s.keysSettings)
57
44
r.Put("/", s.keys)
58
45
r.Delete("/", s.keys)
59
46
})
60
47
61
48
r.Route("/emails", func(r chi.Router) {
62
-
r.Get("/", s.emailsSettings)
63
49
r.Put("/", s.emails)
64
50
r.Delete("/", s.emails)
65
51
r.Get("/verify", s.emailsVerify)
···
70
56
return r
71
57
}
72
58
73
-
func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) {
74
-
user := s.OAuth.GetUser(r)
75
-
76
-
s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{
77
-
LoggedInUser: user,
78
-
Tabs: settingsTabs,
79
-
Tab: "profile",
80
-
})
81
-
}
82
-
83
-
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
59
+
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
84
60
user := s.OAuth.GetUser(r)
85
61
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
86
62
if err != nil {
87
63
log.Println(err)
88
64
}
89
65
90
-
s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
91
-
LoggedInUser: user,
92
-
PubKeys: pubKeys,
93
-
Tabs: settingsTabs,
94
-
Tab: "keys",
95
-
})
96
-
}
97
-
98
-
func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) {
99
-
user := s.OAuth.GetUser(r)
100
66
emails, err := db.GetAllEmails(s.Db, user.Did)
101
67
if err != nil {
102
68
log.Println(err)
103
69
}
104
70
105
-
s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
71
+
s.Pages.Settings(w, pages.SettingsParams{
106
72
LoggedInUser: user,
73
+
PubKeys: pubKeys,
107
74
Emails: emails,
108
-
Tabs: settingsTabs,
109
-
Tab: "emails",
110
75
})
111
76
}
112
77
···
236
201
return
237
202
}
238
203
239
-
s.Pages.HxLocation(w, "/settings/emails")
204
+
s.Pages.HxLocation(w, "/settings")
240
205
return
241
206
}
242
207
}
···
279
244
return
280
245
}
281
246
282
-
http.Redirect(w, r, "/settings/emails", http.StatusSeeOther)
247
+
http.Redirect(w, r, "/settings", http.StatusSeeOther)
283
248
}
284
249
285
250
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
···
374
339
return
375
340
}
376
341
377
-
s.Pages.HxLocation(w, "/settings/emails")
342
+
s.Pages.HxLocation(w, "/settings")
378
343
}
379
344
380
345
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
···
445
410
return
446
411
}
447
412
448
-
s.Pages.HxLocation(w, "/settings/keys")
413
+
s.Pages.HxLocation(w, "/settings")
449
414
return
450
415
451
416
case http.MethodDelete:
···
490
455
}
491
456
log.Println("deleted successfully")
492
457
493
-
s.Pages.HxLocation(w, "/settings/keys")
458
+
s.Pages.HxLocation(w, "/settings")
494
459
return
495
460
}
496
461
}
+62
-212
appview/state/profile.go
+62
-212
appview/state/profile.go
···
17
17
"github.com/gorilla/feeds"
18
18
"tangled.sh/tangled.sh/core/api/tangled"
19
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/oauth"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
22
21
)
23
22
···
25
24
tabVal := r.URL.Query().Get("tab")
26
25
switch tabVal {
27
26
case "":
28
-
s.profileHomePage(w, r)
27
+
s.profilePage(w, r)
29
28
case "repos":
30
29
s.reposPage(w, r)
31
-
case "followers":
32
-
s.followersPage(w, r)
33
-
case "following":
34
-
s.followingPage(w, r)
35
30
}
36
31
}
37
32
38
-
type ProfilePageParams struct {
39
-
Id identity.Identity
40
-
LoggedInUser *oauth.User
41
-
Card pages.ProfileCard
42
-
}
43
-
44
-
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams {
33
+
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
45
34
didOrHandle := chi.URLParam(r, "user")
46
35
if didOrHandle == "" {
47
-
http.Error(w, "bad request", http.StatusBadRequest)
48
-
return nil
36
+
http.Error(w, "Bad request", http.StatusBadRequest)
37
+
return
49
38
}
50
39
51
40
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
52
41
if !ok {
53
-
log.Printf("malformed middleware")
54
-
w.WriteHeader(http.StatusInternalServerError)
55
-
return nil
42
+
s.pages.Error404(w)
43
+
return
56
44
}
57
-
did := ident.DID.String()
58
45
59
-
profile, err := db.GetProfile(s.db, did)
46
+
profile, err := db.GetProfile(s.db, ident.DID.String())
60
47
if err != nil {
61
-
log.Printf("getting profile data for %s: %s", did, err)
62
-
s.pages.Error500(w)
63
-
return nil
48
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
64
49
}
65
50
66
-
followStats, err := db.GetFollowerFollowingCount(s.db, did)
67
-
if err != nil {
68
-
log.Printf("getting follow stats for %s: %s", did, err)
69
-
}
70
-
71
-
loggedInUser := s.oauth.GetUser(r)
72
-
followStatus := db.IsNotFollowing
73
-
if loggedInUser != nil {
74
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
75
-
}
76
-
77
-
return &ProfilePageParams{
78
-
Id: ident,
79
-
LoggedInUser: loggedInUser,
80
-
Card: pages.ProfileCard{
81
-
UserDid: did,
82
-
UserHandle: ident.Handle.String(),
83
-
Profile: profile,
84
-
FollowStatus: followStatus,
85
-
FollowersCount: followStats.Followers,
86
-
FollowingCount: followStats.Following,
87
-
},
88
-
}
89
-
}
90
-
91
-
func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) {
92
-
pageWithProfile := s.profilePage(w, r)
93
-
if pageWithProfile == nil {
94
-
return
95
-
}
96
-
97
-
id := pageWithProfile.Id
98
51
repos, err := db.GetRepos(
99
52
s.db,
100
53
0,
101
-
db.FilterEq("did", id.DID),
54
+
db.FilterEq("did", ident.DID.String()),
102
55
)
103
56
if err != nil {
104
-
log.Printf("getting repos for %s: %s", id.DID, err)
57
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
105
58
}
106
59
107
-
profile := pageWithProfile.Card.Profile
108
60
// filter out ones that are pinned
109
61
pinnedRepos := []db.Repo{}
110
62
for i, r := range repos {
···
119
71
}
120
72
}
121
73
122
-
collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String())
74
+
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
123
75
if err != nil {
124
-
log.Printf("getting collaborating repos for %s: %s", id.DID, err)
76
+
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
125
77
}
126
78
127
79
pinnedCollaboratingRepos := []db.Repo{}
···
132
84
}
133
85
}
134
86
135
-
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
87
+
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
136
88
if err != nil {
137
-
log.Printf("failed to create profile timeline for %s: %s", id.DID, err)
89
+
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
138
90
}
139
91
140
-
var didsToResolve []string
141
-
for _, r := range collaboratingRepos {
142
-
didsToResolve = append(didsToResolve, r.Did)
92
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
93
+
if err != nil {
94
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
143
95
}
144
-
for _, byMonth := range timeline.ByMonth {
145
-
for _, pe := range byMonth.PullEvents.Items {
146
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
147
-
}
148
-
for _, ie := range byMonth.IssueEvents.Items {
149
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
150
-
}
151
-
for _, re := range byMonth.RepoEvents {
152
-
didsToResolve = append(didsToResolve, re.Repo.Did)
153
-
if re.Source != nil {
154
-
didsToResolve = append(didsToResolve, re.Source.Did)
155
-
}
156
-
}
96
+
97
+
loggedInUser := s.oauth.GetUser(r)
98
+
followStatus := db.IsNotFollowing
99
+
if loggedInUser != nil {
100
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
157
101
}
158
102
159
103
now := time.Now()
160
104
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
161
105
punchcard, err := db.MakePunchcard(
162
106
s.db,
163
-
db.FilterEq("did", id.DID),
107
+
db.FilterEq("did", ident.DID.String()),
164
108
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
165
109
db.FilterLte("date", now.Format(time.DateOnly)),
166
110
)
167
111
if err != nil {
168
-
log.Println("failed to get punchcard for did", "did", id.DID, "err", err)
112
+
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
169
113
}
170
114
171
-
s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{
172
-
LoggedInUser: pageWithProfile.LoggedInUser,
115
+
s.pages.ProfilePage(w, pages.ProfilePageParams{
116
+
LoggedInUser: loggedInUser,
173
117
Repos: pinnedRepos,
174
118
CollaboratingRepos: pinnedCollaboratingRepos,
175
-
Card: pageWithProfile.Card,
176
-
Punchcard: punchcard,
177
-
ProfileTimeline: timeline,
119
+
Card: pages.ProfileCard{
120
+
UserDid: ident.DID.String(),
121
+
UserHandle: ident.Handle.String(),
122
+
Profile: profile,
123
+
FollowStatus: followStatus,
124
+
Followers: followers,
125
+
Following: following,
126
+
},
127
+
Punchcard: punchcard,
128
+
ProfileTimeline: timeline,
178
129
})
179
130
}
180
131
181
132
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
182
-
pageWithProfile := s.profilePage(w, r)
183
-
if pageWithProfile == nil {
133
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
134
+
if !ok {
135
+
s.pages.Error404(w)
184
136
return
185
137
}
186
138
187
-
id := pageWithProfile.Id
139
+
profile, err := db.GetProfile(s.db, ident.DID.String())
140
+
if err != nil {
141
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
142
+
}
143
+
188
144
repos, err := db.GetRepos(
189
145
s.db,
190
146
0,
191
-
db.FilterEq("did", id.DID),
147
+
db.FilterEq("did", ident.DID.String()),
192
148
)
193
149
if err != nil {
194
-
log.Printf("getting repos for %s: %s", id.DID, err)
195
-
}
196
-
197
-
s.pages.ReposPage(w, pages.ReposPageParams{
198
-
LoggedInUser: pageWithProfile.LoggedInUser,
199
-
Repos: repos,
200
-
Card: pageWithProfile.Card,
201
-
})
202
-
}
203
-
204
-
type FollowsPageParams struct {
205
-
LoggedInUser *oauth.User
206
-
Follows []pages.FollowCard
207
-
Card pages.ProfileCard
208
-
}
209
-
210
-
func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) {
211
-
pageWithProfile := s.profilePage(w, r)
212
-
if pageWithProfile == nil {
213
-
return FollowsPageParams{}, nil
150
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
214
151
}
215
152
216
-
id := pageWithProfile.Id
217
-
loggedInUser := pageWithProfile.LoggedInUser
218
-
219
-
follows, err := fetchFollows(s.db, id.DID.String())
220
-
if err != nil {
221
-
log.Printf("getting followers for %s: %s", id.DID, err)
222
-
return FollowsPageParams{}, err
153
+
loggedInUser := s.oauth.GetUser(r)
154
+
followStatus := db.IsNotFollowing
155
+
if loggedInUser != nil {
156
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
223
157
}
224
158
225
-
if len(follows) == 0 {
226
-
return FollowsPageParams{
227
-
LoggedInUser: loggedInUser,
228
-
Follows: []pages.FollowCard{},
229
-
Card: pageWithProfile.Card,
230
-
}, nil
231
-
}
232
-
233
-
followDids := make([]string, 0, len(follows))
234
-
for _, follow := range follows {
235
-
followDids = append(followDids, extractDid(follow))
236
-
}
237
-
238
-
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
159
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
239
160
if err != nil {
240
-
log.Printf("getting profile for %s: %s", followDids, err)
241
-
return FollowsPageParams{}, err
242
-
}
243
-
244
-
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
245
-
if err != nil {
246
-
log.Printf("getting follow counts for %s: %s", followDids, err)
247
-
}
248
-
249
-
var loggedInUserFollowing map[string]struct{}
250
-
if loggedInUser != nil {
251
-
following, err := db.GetFollowing(s.db, loggedInUser.Did)
252
-
if err != nil {
253
-
return FollowsPageParams{}, err
254
-
}
255
-
if len(following) > 0 {
256
-
loggedInUserFollowing = make(map[string]struct{}, len(following))
257
-
for _, follow := range following {
258
-
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
259
-
}
260
-
}
161
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
261
162
}
262
163
263
-
followCards := make([]pages.FollowCard, 0, len(follows))
264
-
for _, did := range followDids {
265
-
followStats, exists := followStatsMap[did]
266
-
if !exists {
267
-
followStats = db.FollowStats{}
268
-
}
269
-
followStatus := db.IsNotFollowing
270
-
if loggedInUserFollowing != nil {
271
-
if _, exists := loggedInUserFollowing[did]; exists {
272
-
followStatus = db.IsFollowing
273
-
} else if loggedInUser.Did == did {
274
-
followStatus = db.IsSelf
275
-
}
276
-
}
277
-
var profile *db.Profile
278
-
if p, exists := profiles[did]; exists {
279
-
profile = p
280
-
} else {
281
-
profile = &db.Profile{}
282
-
profile.Did = did
283
-
}
284
-
followCards = append(followCards, pages.FollowCard{
285
-
UserDid: did,
286
-
FollowStatus: followStatus,
287
-
FollowersCount: followStats.Followers,
288
-
FollowingCount: followStats.Following,
289
-
Profile: profile,
290
-
})
291
-
}
292
-
293
-
return FollowsPageParams{
164
+
s.pages.ReposPage(w, pages.ReposPageParams{
294
165
LoggedInUser: loggedInUser,
295
-
Follows: followCards,
296
-
Card: pageWithProfile.Card,
297
-
}, nil
298
-
}
299
-
300
-
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
301
-
followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
302
-
if err != nil {
303
-
s.pages.Notice(w, "all-followers", "Failed to load followers")
304
-
return
305
-
}
306
-
307
-
s.pages.FollowersPage(w, pages.FollowersPageParams{
308
-
LoggedInUser: followPage.LoggedInUser,
309
-
Followers: followPage.Follows,
310
-
Card: followPage.Card,
311
-
})
312
-
}
313
-
314
-
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
315
-
followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
316
-
if err != nil {
317
-
s.pages.Notice(w, "all-following", "Failed to load following")
318
-
return
319
-
}
320
-
321
-
s.pages.FollowingPage(w, pages.FollowingPageParams{
322
-
LoggedInUser: followPage.LoggedInUser,
323
-
Following: followPage.Follows,
324
-
Card: followPage.Card,
166
+
Repos: repos,
167
+
Card: pages.ProfileCard{
168
+
UserDid: ident.DID.String(),
169
+
UserHandle: ident.Handle.String(),
170
+
Profile: profile,
171
+
FollowStatus: followStatus,
172
+
Followers: followers,
173
+
Following: following,
174
+
},
325
175
})
326
176
}
327
177
+2
appview/state/state.go
+2
appview/state/state.go
+7
-7
appview/strings/strings.go
+7
-7
appview/strings/strings.go
···
202
202
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
203
203
}
204
204
205
-
followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
205
+
followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
206
206
if err != nil {
207
207
l.Error("failed to get follow stats", "err", err)
208
208
}
···
210
210
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
211
211
LoggedInUser: s.OAuth.GetUser(r),
212
212
Card: pages.ProfileCard{
213
-
UserDid: id.DID.String(),
214
-
UserHandle: id.Handle.String(),
215
-
Profile: profile,
216
-
FollowStatus: followStatus,
217
-
FollowersCount: followStats.Followers,
218
-
FollowingCount: followStats.Following,
213
+
UserDid: id.DID.String(),
214
+
UserHandle: id.Handle.String(),
215
+
Profile: profile,
216
+
FollowStatus: followStatus,
217
+
Followers: followers,
218
+
Following: following,
219
219
},
220
220
Strings: all,
221
221
})
+1
-8
lexicons/issue/comment.json
+1
-8
lexicons/issue/comment.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"issue",
14
-
"body",
15
-
"createdAt"
16
-
],
12
+
"required": ["issue", "body", "createdAt"],
17
13
"properties": {
18
14
"issue": {
19
15
"type": "string",
···
22
18
"repo": {
23
19
"type": "string",
24
20
"format": "at-uri"
25
-
},
26
-
"commentId": {
27
-
"type": "integer"
28
21
},
29
22
"owner": {
30
23
"type": "string",
+1
-10
lexicons/issue/issue.json
+1
-10
lexicons/issue/issue.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"issueId",
15
-
"owner",
16
-
"title",
17
-
"createdAt"
18
-
],
12
+
"required": ["repo", "owner", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
-
},
24
-
"issueId": {
25
-
"type": "integer"
26
17
},
27
18
"owner": {
28
19
"type": "string",
-3
spindle/engines/nixery/engine.go
-3
spindle/engines/nixery/engine.go
···
201
201
Tty: false,
202
202
Hostname: "spindle",
203
203
WorkingDir: workspaceDir,
204
-
Labels: map[string]string{
205
-
"sh.tangled.pipeline/workflow_id": wid.String(),
206
-
},
207
204
// TODO(winter): investigate whether environment variables passed here
208
205
// get propagated to ContainerExec processes
209
206
}, &container.HostConfig{
+7
-11
spindle/server.go
+7
-11
spindle/server.go
···
25
25
"tangled.sh/tangled.sh/core/spindle/queue"
26
26
"tangled.sh/tangled.sh/core/spindle/secrets"
27
27
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
29
28
)
30
29
31
30
//go:embed motd
···
214
213
func (s *Spindle) XrpcRouter() http.Handler {
215
214
logger := s.l.With("route", "xrpc")
216
215
217
-
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
218
-
219
216
x := xrpc.Xrpc{
220
-
Logger: logger,
221
-
Db: s.db,
222
-
Enforcer: s.e,
223
-
Engines: s.engs,
224
-
Config: s.cfg,
225
-
Resolver: s.res,
226
-
Vault: s.vault,
227
-
ServiceAuth: serviceAuth,
217
+
Logger: logger,
218
+
Db: s.db,
219
+
Enforcer: s.e,
220
+
Engines: s.engs,
221
+
Config: s.cfg,
222
+
Resolver: s.res,
223
+
Vault: s.vault,
228
224
}
229
225
230
226
return x.Router()
+10
-11
spindle/xrpc/add_secret.go
+10
-11
spindle/xrpc/add_secret.go
···
13
13
"tangled.sh/tangled.sh/core/api/tangled"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
15
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
16
)
18
17
19
18
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
20
19
l := x.Logger
21
-
fail := func(e xrpcerr.XrpcError) {
20
+
fail := func(e XrpcError) {
22
21
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
22
writeError(w, e, http.StatusBadRequest)
24
23
}
25
24
26
25
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
26
if !ok {
28
-
fail(xrpcerr.MissingActorDidError)
27
+
fail(MissingActorDidError)
29
28
return
30
29
}
31
30
32
31
var data tangled.RepoAddSecret_Input
33
32
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(xrpcerr.GenericError(err))
33
+
fail(GenericError(err))
35
34
return
36
35
}
37
36
38
37
if err := secrets.ValidateKey(data.Key); err != nil {
39
-
fail(xrpcerr.GenericError(err))
38
+
fail(GenericError(err))
40
39
return
41
40
}
42
41
43
42
// unfortunately we have to resolve repo-at here
44
43
repoAt, err := syntax.ParseATURI(data.Repo)
45
44
if err != nil {
46
-
fail(xrpcerr.InvalidRepoError(data.Repo))
45
+
fail(InvalidRepoError(data.Repo))
47
46
return
48
47
}
49
48
50
49
// resolve this aturi to extract the repo record
51
50
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
52
51
if err != nil || ident.Handle.IsInvalidHandle() {
53
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
52
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
54
53
return
55
54
}
56
55
57
56
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
58
57
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
59
58
if err != nil {
60
-
fail(xrpcerr.GenericError(err))
59
+
fail(GenericError(err))
61
60
return
62
61
}
63
62
64
63
repo := resp.Value.Val.(*tangled.Repo)
65
64
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
66
65
if err != nil {
67
-
fail(xrpcerr.GenericError(err))
66
+
fail(GenericError(err))
68
67
return
69
68
}
70
69
71
70
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
72
71
l.Error("insufficent permissions", "did", actorDid.String())
73
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
72
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
74
73
return
75
74
}
76
75
···
84
83
err = x.Vault.AddSecret(r.Context(), secret)
85
84
if err != nil {
86
85
l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err)
87
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
86
+
writeError(w, GenericError(err), http.StatusInternalServerError)
88
87
return
89
88
}
90
89
+9
-10
spindle/xrpc/list_secrets.go
+9
-10
spindle/xrpc/list_secrets.go
···
13
13
"tangled.sh/tangled.sh/core/api/tangled"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
15
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
16
)
18
17
19
18
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
20
19
l := x.Logger
21
-
fail := func(e xrpcerr.XrpcError) {
20
+
fail := func(e XrpcError) {
22
21
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
22
writeError(w, e, http.StatusBadRequest)
24
23
}
25
24
26
25
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
26
if !ok {
28
-
fail(xrpcerr.MissingActorDidError)
27
+
fail(MissingActorDidError)
29
28
return
30
29
}
31
30
32
31
repoParam := r.URL.Query().Get("repo")
33
32
if repoParam == "" {
34
-
fail(xrpcerr.GenericError(fmt.Errorf("empty params")))
33
+
fail(GenericError(fmt.Errorf("empty params")))
35
34
return
36
35
}
37
36
38
37
// unfortunately we have to resolve repo-at here
39
38
repoAt, err := syntax.ParseATURI(repoParam)
40
39
if err != nil {
41
-
fail(xrpcerr.InvalidRepoError(repoParam))
40
+
fail(InvalidRepoError(repoParam))
42
41
return
43
42
}
44
43
45
44
// resolve this aturi to extract the repo record
46
45
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
46
if err != nil || ident.Handle.IsInvalidHandle() {
48
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
47
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
48
return
50
49
}
51
50
52
51
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
52
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
53
if err != nil {
55
-
fail(xrpcerr.GenericError(err))
54
+
fail(GenericError(err))
56
55
return
57
56
}
58
57
59
58
repo := resp.Value.Val.(*tangled.Repo)
60
59
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
61
60
if err != nil {
62
-
fail(xrpcerr.GenericError(err))
61
+
fail(GenericError(err))
63
62
return
64
63
}
65
64
66
65
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
66
l.Error("insufficent permissions", "did", actorDid.String())
68
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
68
return
70
69
}
71
70
72
71
ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath))
73
72
if err != nil {
74
73
l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err)
75
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
74
+
writeError(w, GenericError(err), http.StatusInternalServerError)
76
75
return
77
76
}
78
77
+9
-10
spindle/xrpc/remove_secret.go
+9
-10
spindle/xrpc/remove_secret.go
···
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
13
"tangled.sh/tangled.sh/core/rbac"
14
14
"tangled.sh/tangled.sh/core/spindle/secrets"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
15
)
17
16
18
17
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
19
18
l := x.Logger
20
-
fail := func(e xrpcerr.XrpcError) {
19
+
fail := func(e XrpcError) {
21
20
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
21
writeError(w, e, http.StatusBadRequest)
23
22
}
24
23
25
24
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
25
if !ok {
27
-
fail(xrpcerr.MissingActorDidError)
26
+
fail(MissingActorDidError)
28
27
return
29
28
}
30
29
31
30
var data tangled.RepoRemoveSecret_Input
32
31
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
33
-
fail(xrpcerr.GenericError(err))
32
+
fail(GenericError(err))
34
33
return
35
34
}
36
35
37
36
// unfortunately we have to resolve repo-at here
38
37
repoAt, err := syntax.ParseATURI(data.Repo)
39
38
if err != nil {
40
-
fail(xrpcerr.InvalidRepoError(data.Repo))
39
+
fail(InvalidRepoError(data.Repo))
41
40
return
42
41
}
43
42
44
43
// resolve this aturi to extract the repo record
45
44
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
46
45
if err != nil || ident.Handle.IsInvalidHandle() {
47
-
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
46
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
47
return
49
48
}
50
49
51
50
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
52
51
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
53
52
if err != nil {
54
-
fail(xrpcerr.GenericError(err))
53
+
fail(GenericError(err))
55
54
return
56
55
}
57
56
58
57
repo := resp.Value.Val.(*tangled.Repo)
59
58
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
59
if err != nil {
61
-
fail(xrpcerr.GenericError(err))
60
+
fail(GenericError(err))
62
61
return
63
62
}
64
63
65
64
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
66
65
l.Error("insufficent permissions", "did", actorDid.String())
67
-
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
66
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
67
return
69
68
}
70
69
···
75
74
err = x.Vault.RemoveSecret(r.Context(), secret)
76
75
if err != nil {
77
76
l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err)
78
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
77
+
writeError(w, GenericError(err), http.StatusInternalServerError)
79
78
return
80
79
}
81
80
+109
-14
spindle/xrpc/xrpc.go
+109
-14
spindle/xrpc/xrpc.go
···
1
1
package xrpc
2
2
3
3
import (
4
+
"context"
4
5
_ "embed"
5
6
"encoding/json"
7
+
"fmt"
6
8
"log/slog"
7
9
"net/http"
10
+
"strings"
8
11
12
+
"github.com/bluesky-social/indigo/atproto/auth"
9
13
"github.com/go-chi/chi/v5"
10
14
11
15
"tangled.sh/tangled.sh/core/api/tangled"
···
15
19
"tangled.sh/tangled.sh/core/spindle/db"
16
20
"tangled.sh/tangled.sh/core/spindle/models"
17
21
"tangled.sh/tangled.sh/core/spindle/secrets"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
22
)
21
23
22
24
const ActorDid string = "ActorDid"
23
25
24
26
type Xrpc struct {
25
-
Logger *slog.Logger
26
-
Db *db.DB
27
-
Enforcer *rbac.Enforcer
28
-
Engines map[string]models.Engine
29
-
Config *config.Config
30
-
Resolver *idresolver.Resolver
31
-
Vault secrets.Manager
32
-
ServiceAuth *serviceauth.ServiceAuth
27
+
Logger *slog.Logger
28
+
Db *db.DB
29
+
Enforcer *rbac.Enforcer
30
+
Engines map[string]models.Engine
31
+
Config *config.Config
32
+
Resolver *idresolver.Resolver
33
+
Vault secrets.Manager
33
34
}
34
35
35
36
func (x *Xrpc) Router() http.Handler {
36
37
r := chi.NewRouter()
37
38
38
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
39
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
40
-
r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
39
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
40
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
41
+
r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
41
42
42
43
return r
43
44
}
44
45
46
+
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
47
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48
+
l := x.Logger.With("url", r.URL)
49
+
50
+
token := r.Header.Get("Authorization")
51
+
token = strings.TrimPrefix(token, "Bearer ")
52
+
53
+
s := auth.ServiceAuthValidator{
54
+
Audience: x.Config.Server.Did().String(),
55
+
Dir: x.Resolver.Directory(),
56
+
}
57
+
58
+
did, err := s.Validate(r.Context(), token, nil)
59
+
if err != nil {
60
+
l.Error("signature verification failed", "err", err)
61
+
writeError(w, AuthError(err), http.StatusForbidden)
62
+
return
63
+
}
64
+
65
+
r = r.WithContext(
66
+
context.WithValue(r.Context(), ActorDid, did),
67
+
)
68
+
69
+
next.ServeHTTP(w, r)
70
+
})
71
+
}
72
+
73
+
type XrpcError struct {
74
+
Tag string `json:"error"`
75
+
Message string `json:"message"`
76
+
}
77
+
78
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
79
+
x := XrpcError{}
80
+
for _, o := range opts {
81
+
o(&x)
82
+
}
83
+
84
+
return x
85
+
}
86
+
87
+
type ErrOpt = func(xerr *XrpcError)
88
+
89
+
func WithTag(tag string) ErrOpt {
90
+
return func(xerr *XrpcError) {
91
+
xerr.Tag = tag
92
+
}
93
+
}
94
+
95
+
func WithMessage[S ~string](s S) ErrOpt {
96
+
return func(xerr *XrpcError) {
97
+
xerr.Message = string(s)
98
+
}
99
+
}
100
+
101
+
func WithError(e error) ErrOpt {
102
+
return func(xerr *XrpcError) {
103
+
xerr.Message = e.Error()
104
+
}
105
+
}
106
+
107
+
var MissingActorDidError = NewXrpcError(
108
+
WithTag("MissingActorDid"),
109
+
WithMessage("actor DID not supplied"),
110
+
)
111
+
112
+
var AuthError = func(err error) XrpcError {
113
+
return NewXrpcError(
114
+
WithTag("Auth"),
115
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
116
+
)
117
+
}
118
+
119
+
var InvalidRepoError = func(r string) XrpcError {
120
+
return NewXrpcError(
121
+
WithTag("InvalidRepo"),
122
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
123
+
)
124
+
}
125
+
126
+
func GenericError(err error) XrpcError {
127
+
return NewXrpcError(
128
+
WithTag("Generic"),
129
+
WithError(err),
130
+
)
131
+
}
132
+
133
+
var AccessControlError = func(d string) XrpcError {
134
+
return NewXrpcError(
135
+
WithTag("AccessControl"),
136
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
137
+
)
138
+
}
139
+
45
140
// this is slightly different from http_util::write_error to follow the spec:
46
141
//
47
142
// the json object returned must include an "error" and a "message"
48
-
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
143
+
func writeError(w http.ResponseWriter, e XrpcError, status int) {
49
144
w.Header().Set("Content-Type", "application/json")
50
145
w.WriteHeader(status)
51
146
json.NewEncoder(w).Encode(e)
-103
xrpc/errors/errors.go
-103
xrpc/errors/errors.go
···
1
-
package errors
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
)
7
-
8
-
type XrpcError struct {
9
-
Tag string `json:"error"`
10
-
Message string `json:"message"`
11
-
}
12
-
13
-
func (x XrpcError) Error() string {
14
-
if x.Message != "" {
15
-
return fmt.Sprintf("%s: %s", x.Tag, x.Message)
16
-
}
17
-
return x.Tag
18
-
}
19
-
20
-
func NewXrpcError(opts ...ErrOpt) XrpcError {
21
-
x := XrpcError{}
22
-
for _, o := range opts {
23
-
o(&x)
24
-
}
25
-
26
-
return x
27
-
}
28
-
29
-
type ErrOpt = func(xerr *XrpcError)
30
-
31
-
func WithTag(tag string) ErrOpt {
32
-
return func(xerr *XrpcError) {
33
-
xerr.Tag = tag
34
-
}
35
-
}
36
-
37
-
func WithMessage[S ~string](s S) ErrOpt {
38
-
return func(xerr *XrpcError) {
39
-
xerr.Message = string(s)
40
-
}
41
-
}
42
-
43
-
func WithError(e error) ErrOpt {
44
-
return func(xerr *XrpcError) {
45
-
xerr.Message = e.Error()
46
-
}
47
-
}
48
-
49
-
var MissingActorDidError = NewXrpcError(
50
-
WithTag("MissingActorDid"),
51
-
WithMessage("actor DID not supplied"),
52
-
)
53
-
54
-
var AuthError = func(err error) XrpcError {
55
-
return NewXrpcError(
56
-
WithTag("Auth"),
57
-
WithError(fmt.Errorf("signature verification failed: %w", err)),
58
-
)
59
-
}
60
-
61
-
var InvalidRepoError = func(r string) XrpcError {
62
-
return NewXrpcError(
63
-
WithTag("InvalidRepo"),
64
-
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
65
-
)
66
-
}
67
-
68
-
var GitError = func(e error) XrpcError {
69
-
return NewXrpcError(
70
-
WithTag("Git"),
71
-
WithError(fmt.Errorf("git error: %w", e)),
72
-
)
73
-
}
74
-
75
-
var AccessControlError = func(d string) XrpcError {
76
-
return NewXrpcError(
77
-
WithTag("AccessControl"),
78
-
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
79
-
)
80
-
}
81
-
82
-
var RepoExistsError = func(r string) XrpcError {
83
-
return NewXrpcError(
84
-
WithTag("RepoExists"),
85
-
WithError(fmt.Errorf("repo already exists: %s", r)),
86
-
)
87
-
}
88
-
89
-
func GenericError(err error) XrpcError {
90
-
return NewXrpcError(
91
-
WithTag("Generic"),
92
-
WithError(err),
93
-
)
94
-
}
95
-
96
-
func Unmarshal(errStr string) (XrpcError, error) {
97
-
var xerr XrpcError
98
-
err := json.Unmarshal([]byte(errStr), &xerr)
99
-
if err != nil {
100
-
return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err)
101
-
}
102
-
return xerr, nil
103
-
}
-65
xrpc/serviceauth/service_auth.go
-65
xrpc/serviceauth/service_auth.go
···
1
-
package serviceauth
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"log/slog"
7
-
"net/http"
8
-
"strings"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/auth"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
-
)
14
-
15
-
const ActorDid string = "ActorDid"
16
-
17
-
type ServiceAuth struct {
18
-
logger *slog.Logger
19
-
resolver *idresolver.Resolver
20
-
audienceDid string
21
-
}
22
-
23
-
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
24
-
return &ServiceAuth{
25
-
logger: logger,
26
-
resolver: resolver,
27
-
audienceDid: audienceDid,
28
-
}
29
-
}
30
-
31
-
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
32
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33
-
l := sa.logger.With("url", r.URL)
34
-
35
-
token := r.Header.Get("Authorization")
36
-
token = strings.TrimPrefix(token, "Bearer ")
37
-
38
-
s := auth.ServiceAuthValidator{
39
-
Audience: sa.audienceDid,
40
-
Dir: sa.resolver.Directory(),
41
-
}
42
-
43
-
did, err := s.Validate(r.Context(), token, nil)
44
-
if err != nil {
45
-
l.Error("signature verification failed", "err", err)
46
-
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
47
-
return
48
-
}
49
-
50
-
r = r.WithContext(
51
-
context.WithValue(r.Context(), ActorDid, did),
52
-
)
53
-
54
-
next.ServeHTTP(w, r)
55
-
})
56
-
}
57
-
58
-
// this is slightly different from http_util::write_error to follow the spec:
59
-
//
60
-
// the json object returned must include an "error" and a "message"
61
-
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
62
-
w.Header().Set("Content-Type", "application/json")
63
-
w.WriteHeader(status)
64
-
json.NewEncoder(w).Encode(e)
65
-
}