+24
api/yoten/actorprofile.go
+24
api/yoten/actorprofile.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package yoten
4
+
5
+
// schema: app.yoten.actor.profile
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
func init() {
12
+
util.RegisterType("app.yoten.actor.profile", &ActorProfile{})
13
+
} //
14
+
// RECORDTYPE: ActorProfile
15
+
type ActorProfile struct {
16
+
LexiconTypeID string `json:"$type,const=app.yoten.actor.profile" cborgen:"$type,const=app.yoten.actor.profile"`
17
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
18
+
// description: Free-form profile description text.
19
+
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
20
+
DisplayName string `json:"displayName" cborgen:"displayName"`
21
+
Languages []string `json:"languages,omitempty" cborgen:"languages,omitempty"`
22
+
// location: Free-form location text.
23
+
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
24
+
}
+379
api/yoten/cbor_gen.go
+379
api/yoten/cbor_gen.go
···
1
+
// Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT.
2
+
3
+
package yoten
4
+
5
+
import (
6
+
"fmt"
7
+
"io"
8
+
"math"
9
+
"sort"
10
+
11
+
cid "github.com/ipfs/go-cid"
12
+
cbg "github.com/whyrusleeping/cbor-gen"
13
+
xerrors "golang.org/x/xerrors"
14
+
)
15
+
16
+
var _ = xerrors.Errorf
17
+
var _ = cid.Undef
18
+
var _ = math.E
19
+
var _ = sort.Sort
20
+
21
+
func (t *ActorProfile) MarshalCBOR(w io.Writer) error {
22
+
if t == nil {
23
+
_, err := w.Write(cbg.CborNull)
24
+
return err
25
+
}
26
+
27
+
cw := cbg.NewCborWriter(w)
28
+
fieldCount := 6
29
+
30
+
if t.Description == nil {
31
+
fieldCount--
32
+
}
33
+
34
+
if t.Languages == nil {
35
+
fieldCount--
36
+
}
37
+
38
+
if t.Location == nil {
39
+
fieldCount--
40
+
}
41
+
42
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
43
+
return err
44
+
}
45
+
46
+
// t.LexiconTypeID (string) (string)
47
+
if len("$type") > 1000000 {
48
+
return xerrors.Errorf("Value in field \"$type\" was too long")
49
+
}
50
+
51
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
52
+
return err
53
+
}
54
+
if _, err := cw.WriteString(string("$type")); err != nil {
55
+
return err
56
+
}
57
+
58
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("app.yoten.actor.profile"))); err != nil {
59
+
return err
60
+
}
61
+
if _, err := cw.WriteString(string("app.yoten.actor.profile")); err != nil {
62
+
return err
63
+
}
64
+
65
+
// t.Location (string) (string)
66
+
if t.Location != nil {
67
+
68
+
if len("location") > 1000000 {
69
+
return xerrors.Errorf("Value in field \"location\" was too long")
70
+
}
71
+
72
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil {
73
+
return err
74
+
}
75
+
if _, err := cw.WriteString(string("location")); err != nil {
76
+
return err
77
+
}
78
+
79
+
if t.Location == nil {
80
+
if _, err := cw.Write(cbg.CborNull); err != nil {
81
+
return err
82
+
}
83
+
} else {
84
+
if len(*t.Location) > 1000000 {
85
+
return xerrors.Errorf("Value in field t.Location was too long")
86
+
}
87
+
88
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Location))); err != nil {
89
+
return err
90
+
}
91
+
if _, err := cw.WriteString(string(*t.Location)); err != nil {
92
+
return err
93
+
}
94
+
}
95
+
}
96
+
97
+
// t.CreatedAt (string) (string)
98
+
if len("createdAt") > 1000000 {
99
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
100
+
}
101
+
102
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
103
+
return err
104
+
}
105
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
106
+
return err
107
+
}
108
+
109
+
if len(t.CreatedAt) > 1000000 {
110
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
111
+
}
112
+
113
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
114
+
return err
115
+
}
116
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
117
+
return err
118
+
}
119
+
120
+
// t.Languages ([]string) (slice)
121
+
if t.Languages != nil {
122
+
123
+
if len("languages") > 1000000 {
124
+
return xerrors.Errorf("Value in field \"languages\" was too long")
125
+
}
126
+
127
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("languages"))); err != nil {
128
+
return err
129
+
}
130
+
if _, err := cw.WriteString(string("languages")); err != nil {
131
+
return err
132
+
}
133
+
134
+
if len(t.Languages) > 8192 {
135
+
return xerrors.Errorf("Slice value in field t.Languages was too long")
136
+
}
137
+
138
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Languages))); err != nil {
139
+
return err
140
+
}
141
+
for _, v := range t.Languages {
142
+
if len(v) > 1000000 {
143
+
return xerrors.Errorf("Value in field v was too long")
144
+
}
145
+
146
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
147
+
return err
148
+
}
149
+
if _, err := cw.WriteString(string(v)); err != nil {
150
+
return err
151
+
}
152
+
153
+
}
154
+
}
155
+
156
+
// t.Description (string) (string)
157
+
if t.Description != nil {
158
+
159
+
if len("description") > 1000000 {
160
+
return xerrors.Errorf("Value in field \"description\" was too long")
161
+
}
162
+
163
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
164
+
return err
165
+
}
166
+
if _, err := cw.WriteString(string("description")); err != nil {
167
+
return err
168
+
}
169
+
170
+
if t.Description == nil {
171
+
if _, err := cw.Write(cbg.CborNull); err != nil {
172
+
return err
173
+
}
174
+
} else {
175
+
if len(*t.Description) > 1000000 {
176
+
return xerrors.Errorf("Value in field t.Description was too long")
177
+
}
178
+
179
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil {
180
+
return err
181
+
}
182
+
if _, err := cw.WriteString(string(*t.Description)); err != nil {
183
+
return err
184
+
}
185
+
}
186
+
}
187
+
188
+
// t.DisplayName (string) (string)
189
+
if len("displayName") > 1000000 {
190
+
return xerrors.Errorf("Value in field \"displayName\" was too long")
191
+
}
192
+
193
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("displayName"))); err != nil {
194
+
return err
195
+
}
196
+
if _, err := cw.WriteString(string("displayName")); err != nil {
197
+
return err
198
+
}
199
+
200
+
if len(t.DisplayName) > 1000000 {
201
+
return xerrors.Errorf("Value in field t.DisplayName was too long")
202
+
}
203
+
204
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.DisplayName))); err != nil {
205
+
return err
206
+
}
207
+
if _, err := cw.WriteString(string(t.DisplayName)); err != nil {
208
+
return err
209
+
}
210
+
return nil
211
+
}
212
+
213
+
func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) {
214
+
*t = ActorProfile{}
215
+
216
+
cr := cbg.NewCborReader(r)
217
+
218
+
maj, extra, err := cr.ReadHeader()
219
+
if err != nil {
220
+
return err
221
+
}
222
+
defer func() {
223
+
if err == io.EOF {
224
+
err = io.ErrUnexpectedEOF
225
+
}
226
+
}()
227
+
228
+
if maj != cbg.MajMap {
229
+
return fmt.Errorf("cbor input should be of type map")
230
+
}
231
+
232
+
if extra > cbg.MaxLength {
233
+
return fmt.Errorf("ActorProfile: map struct too large (%d)", extra)
234
+
}
235
+
236
+
n := extra
237
+
238
+
nameBuf := make([]byte, 11)
239
+
for i := uint64(0); i < n; i++ {
240
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
241
+
if err != nil {
242
+
return err
243
+
}
244
+
245
+
if !ok {
246
+
// Field doesn't exist on this type, so ignore it
247
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
248
+
return err
249
+
}
250
+
continue
251
+
}
252
+
253
+
switch string(nameBuf[:nameLen]) {
254
+
// t.LexiconTypeID (string) (string)
255
+
case "$type":
256
+
257
+
{
258
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
259
+
if err != nil {
260
+
return err
261
+
}
262
+
263
+
t.LexiconTypeID = string(sval)
264
+
}
265
+
// t.Location (string) (string)
266
+
case "location":
267
+
268
+
{
269
+
b, err := cr.ReadByte()
270
+
if err != nil {
271
+
return err
272
+
}
273
+
if b != cbg.CborNull[0] {
274
+
if err := cr.UnreadByte(); err != nil {
275
+
return err
276
+
}
277
+
278
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
279
+
if err != nil {
280
+
return err
281
+
}
282
+
283
+
t.Location = (*string)(&sval)
284
+
}
285
+
}
286
+
// t.CreatedAt (string) (string)
287
+
case "createdAt":
288
+
289
+
{
290
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
291
+
if err != nil {
292
+
return err
293
+
}
294
+
295
+
t.CreatedAt = string(sval)
296
+
}
297
+
// t.Languages ([]string) (slice)
298
+
case "languages":
299
+
300
+
maj, extra, err = cr.ReadHeader()
301
+
if err != nil {
302
+
return err
303
+
}
304
+
305
+
if extra > 8192 {
306
+
return fmt.Errorf("t.Languages: array too large (%d)", extra)
307
+
}
308
+
309
+
if maj != cbg.MajArray {
310
+
return fmt.Errorf("expected cbor array")
311
+
}
312
+
313
+
if extra > 0 {
314
+
t.Languages = make([]string, extra)
315
+
}
316
+
317
+
for i := 0; i < int(extra); i++ {
318
+
{
319
+
var maj byte
320
+
var extra uint64
321
+
var err error
322
+
_ = maj
323
+
_ = extra
324
+
_ = err
325
+
326
+
{
327
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
328
+
if err != nil {
329
+
return err
330
+
}
331
+
332
+
t.Languages[i] = string(sval)
333
+
}
334
+
335
+
}
336
+
}
337
+
// t.Description (string) (string)
338
+
case "description":
339
+
340
+
{
341
+
b, err := cr.ReadByte()
342
+
if err != nil {
343
+
return err
344
+
}
345
+
if b != cbg.CborNull[0] {
346
+
if err := cr.UnreadByte(); err != nil {
347
+
return err
348
+
}
349
+
350
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
351
+
if err != nil {
352
+
return err
353
+
}
354
+
355
+
t.Description = (*string)(&sval)
356
+
}
357
+
}
358
+
// t.DisplayName (string) (string)
359
+
case "displayName":
360
+
361
+
{
362
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
363
+
if err != nil {
364
+
return err
365
+
}
366
+
367
+
t.DisplayName = string(sval)
368
+
}
369
+
370
+
default:
371
+
// Field doesn't exist on this type, so ignore it
372
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
373
+
return err
374
+
}
375
+
}
376
+
}
377
+
378
+
return nil
379
+
}
+47
cmd/gen.go
+47
cmd/gen.go
···
1
+
package main
2
+
3
+
import (
4
+
"reflect"
5
+
"strings"
6
+
7
+
"github.com/bluesky-social/indigo/mst"
8
+
cbg "github.com/whyrusleeping/cbor-gen"
9
+
"yoten.app/api/yoten"
10
+
)
11
+
12
+
func main() {
13
+
var typVals []any
14
+
for _, typ := range mst.CBORTypes() {
15
+
typVals = append(typVals, reflect.New(typ).Elem().Interface())
16
+
}
17
+
18
+
genCfg := cbg.Gen{
19
+
MaxStringLength: 1_000_000,
20
+
}
21
+
22
+
yotenTypes := []any{
23
+
yoten.ActorProfile{},
24
+
}
25
+
26
+
for name, rt := range AllLexTypes() {
27
+
if strings.HasPrefix(name, "app.yoten.") {
28
+
yotenTypes = append(yotenTypes, reflect.New(rt).Interface())
29
+
}
30
+
}
31
+
yotenGenCfg := genCfg
32
+
yotenGenCfg.SortTypeNames = true
33
+
34
+
if err := yotenGenCfg.WriteMapEncodersToFile("api/yoten/cbor_gen.go", "yoten", yotenTypes...); err != nil {
35
+
panic(err)
36
+
}
37
+
}
38
+
39
+
var lexTypesMap map[string]reflect.Type
40
+
41
+
func AllLexTypes() map[string]reflect.Type {
42
+
out := make(map[string]reflect.Type, len(lexTypesMap))
43
+
for k, v := range lexTypesMap {
44
+
out[k] = v
45
+
}
46
+
return out
47
+
}
+12
-10
go.mod
+12
-10
go.mod
···
7
7
require (
8
8
github.com/a-h/templ v0.3.898
9
9
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e
10
+
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e
10
11
github.com/carlmjohnson/versioninfo v0.22.5
11
12
github.com/go-chi/chi/v5 v5.2.1
12
13
github.com/gorilla/sessions v1.4.0
14
+
github.com/ipfs/go-cid v0.4.1
13
15
github.com/mattn/go-sqlite3 v1.14.22
14
16
github.com/sethvargo/go-envconfig v1.3.0
17
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
18
+
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
15
19
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421
16
20
)
17
21
···
20
24
github.com/andybalholm/brotli v1.1.0 // indirect
21
25
github.com/beorn7/perks v1.0.1 // indirect
22
26
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
23
-
github.com/cespare/xxhash/v2 v2.2.0 // indirect
27
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
24
28
github.com/cli/browser v1.3.0 // indirect
25
29
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
26
30
github.com/fatih/color v1.16.0 // indirect
···
33
37
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
34
38
github.com/google/uuid v1.6.0 // indirect
35
39
github.com/gorilla/securecookie v1.1.2 // indirect
40
+
github.com/gorilla/websocket v1.5.1 // indirect
36
41
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
37
42
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
38
43
github.com/hashicorp/golang-lru v1.0.2 // indirect
39
44
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
40
45
github.com/ipfs/bbloom v0.0.4 // indirect
41
46
github.com/ipfs/go-block-format v0.2.0 // indirect
42
-
github.com/ipfs/go-cid v0.4.1 // indirect
43
47
github.com/ipfs/go-datastore v0.6.0 // indirect
44
48
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
45
49
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
···
50
54
github.com/ipfs/go-log/v2 v2.5.1 // indirect
51
55
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
52
56
github.com/jbenet/goprocess v0.1.4 // indirect
57
+
github.com/klauspost/compress v1.17.9 // indirect
53
58
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
54
59
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
55
60
github.com/lestrrat-go/httpcc v1.0.1 // indirect
···
59
64
github.com/lestrrat-go/option v1.0.1 // indirect
60
65
github.com/mattn/go-colorable v0.1.13 // indirect
61
66
github.com/mattn/go-isatty v0.0.20 // indirect
62
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
63
67
github.com/minio/sha256-simd v1.0.1 // indirect
64
68
github.com/mr-tron/base58 v1.2.0 // indirect
65
69
github.com/multiformats/go-base32 v0.1.0 // indirect
···
70
74
github.com/natefinch/atomic v1.0.1 // indirect
71
75
github.com/opentracing/opentracing-go v1.2.0 // indirect
72
76
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
73
-
github.com/prometheus/client_golang v1.17.0 // indirect
74
-
github.com/prometheus/client_model v0.5.0 // indirect
75
-
github.com/prometheus/common v0.45.0 // indirect
76
-
github.com/prometheus/procfs v0.12.0 // indirect
77
+
github.com/prometheus/client_golang v1.19.1 // indirect
78
+
github.com/prometheus/client_model v0.6.1 // indirect
79
+
github.com/prometheus/common v0.54.0 // indirect
80
+
github.com/prometheus/procfs v0.15.1 // indirect
77
81
github.com/segmentio/asm v1.2.0 // indirect
78
82
github.com/spaolacci/murmur3 v1.1.0 // indirect
79
-
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
80
83
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
81
84
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
82
85
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
···
93
96
golang.org/x/sys v0.32.0 // indirect
94
97
golang.org/x/time v0.8.0 // indirect
95
98
golang.org/x/tools v0.32.0 // indirect
96
-
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
97
-
google.golang.org/protobuf v1.33.0 // indirect
99
+
google.golang.org/protobuf v1.34.2 // indirect
98
100
lukechampine.com/blake3 v1.2.1 // indirect
99
101
)
100
102
+31
-14
go.sum
+31
-14
go.sum
···
10
10
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
11
11
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4=
12
12
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng=
13
+
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e h1:P/O6TDHs53gwgV845uDHI+Nri889ixksRrh4bCkCdxo=
14
+
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
13
15
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
14
16
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
15
17
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
16
18
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
17
-
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
18
-
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
19
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
20
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
19
21
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
20
22
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
21
23
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
···
59
61
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
60
62
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
61
63
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
64
+
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
65
+
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
62
66
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
63
67
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
64
68
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
96
100
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
97
101
github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg=
98
102
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
103
+
github.com/ipld/go-car v0.6.2 h1:Hlnl3Awgnq8icK+ze3iRghk805lu8YNq3wlREDTF2qc=
104
+
github.com/ipld/go-car/v2 v2.13.1 h1:KnlrKvEPEzr5IZHKTXLAEub+tPrzeAFQVRlSQvuxBO4=
105
+
github.com/ipld/go-car/v2 v2.13.1/go.mod h1:QkdjjFNGit2GIkpQ953KBwowuoukoM75nP/JI1iDJdo=
106
+
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
107
+
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
99
108
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
100
109
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
101
110
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
···
105
114
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
106
115
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
107
116
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
117
+
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
118
+
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
108
119
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
109
120
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
110
121
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
···
136
147
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
137
148
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
138
149
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
139
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
140
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
141
150
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
142
151
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
143
152
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
···
148
157
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
149
158
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
150
159
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
160
+
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
161
+
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
151
162
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
152
163
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
153
164
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
156
167
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
157
168
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
158
169
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
170
+
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
171
+
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
159
172
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
160
173
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
161
174
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
162
175
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
163
176
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
164
177
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
165
-
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
166
-
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
167
-
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
168
-
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
169
-
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
170
-
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
171
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
172
-
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
178
+
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
179
+
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
180
+
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
181
+
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
182
+
github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8=
183
+
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
184
+
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
185
+
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
173
186
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
174
187
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
175
188
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
···
201
214
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
202
215
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
203
216
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
217
+
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 h1:5HZfQkwe0mIfyDmc1Em5GqlNRzcdtlv4HTNmdpt7XH0=
218
+
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ=
204
219
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
205
220
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
206
221
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
243
258
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
244
259
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
245
260
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
261
+
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
262
+
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
246
263
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
247
264
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
248
265
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
323
340
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
324
341
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
325
342
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
326
-
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
327
-
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
343
+
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
344
+
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
328
345
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
329
346
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
330
347
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+7
-2
internal/web/config/config.go
+7
-2
internal/web/config/config.go
···
15
15
Jwks string `env:"JWKS"`
16
16
}
17
17
18
+
type JetstreamConfig struct {
19
+
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
20
+
}
21
+
18
22
type Config struct {
19
-
Core CoreConfig `env:",prefix=YOTEN"`
20
-
OAuth OAuthConfig `env:",prefix=YOTEN_OAUTH_"`
23
+
Core CoreConfig `env:",prefix=YOTEN"`
24
+
Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"`
25
+
OAuth OAuthConfig `env:",prefix=YOTEN_OAUTH_"`
21
26
}
22
27
23
28
func LoadConfig(ctx context.Context) (*Config, error) {
+51
internal/web/db/artifact.go
+51
internal/web/db/artifact.go
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
)
7
+
8
+
type filter struct {
9
+
key string
10
+
arg any
11
+
cmp string
12
+
}
13
+
14
+
func FilterEq(key string, arg any) filter {
15
+
return filter{
16
+
key: key,
17
+
arg: arg,
18
+
cmp: "=",
19
+
}
20
+
}
21
+
22
+
func FilterNotEq(key string, arg any) filter {
23
+
return filter{
24
+
key: key,
25
+
arg: arg,
26
+
cmp: "<>",
27
+
}
28
+
}
29
+
30
+
func (f filter) Condition() string {
31
+
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
32
+
}
33
+
34
+
func DeleteArtifact(e Execer, filters ...filter) error {
35
+
var conditions []string
36
+
var args []any
37
+
for _, filter := range filters {
38
+
conditions = append(conditions, filter.Condition())
39
+
args = append(args, filter.arg)
40
+
}
41
+
42
+
whereClause := ""
43
+
if conditions != nil {
44
+
whereClause = " where " + strings.Join(conditions, " and ")
45
+
}
46
+
47
+
query := fmt.Sprintf(`delete from artifacts %s`, whereClause)
48
+
49
+
_, err := e.Exec(query, args...)
50
+
return err
51
+
}
+63
-30
internal/web/db/db.go
+63
-30
internal/web/db/db.go
···
26
26
func Make(dbPath string) (*DB, error) {
27
27
db, err := sql.Open("sqlite3", dbPath)
28
28
if err != nil {
29
-
return nil, fmt.Errorf("failed to open db: %v", err)
29
+
return nil, fmt.Errorf("failed to open db: %w", err)
30
30
}
31
31
_, err = db.Exec(`
32
32
pragma journal_mode = WAL;
···
38
38
pragma auto_vacuum = incremental;
39
39
pragma busy_timeout = 5000;
40
40
41
-
create table if not exists oauth_requests (
42
-
id integer primary key autoincrement,
43
-
auth_server_iss text not null,
44
-
state text not null,
45
-
did text not null,
46
-
handle text not null,
47
-
pds_url text not null,
48
-
pkce_verifier text not null,
49
-
dpop_auth_server_nonce text not null,
50
-
dpop_private_jwk text not null
51
-
);
41
+
create table if not exists oauth_requests (
42
+
id integer primary key autoincrement,
43
+
auth_server_iss text not null,
44
+
state text not null,
45
+
did text not null,
46
+
handle text not null,
47
+
pds_url text not null,
48
+
pkce_verifier text not null,
49
+
dpop_auth_server_nonce text not null,
50
+
dpop_private_jwk text not null
51
+
);
52
+
53
+
create table if not exists oauth_sessions (
54
+
id integer primary key autoincrement,
55
+
did text not null,
56
+
handle text not null,
57
+
pds_url text not null,
58
+
auth_server_iss text not null,
59
+
access_jwt text not null,
60
+
refresh_jwt text not null,
61
+
dpop_pds_nonce text,
62
+
dpop_auth_server_nonce text not null,
63
+
dpop_private_jwk text not null,
64
+
expiry text not null
65
+
);
66
+
67
+
create table if not exists profile (
68
+
-- id
69
+
id integer primary key autoincrement,
70
+
did text not null,
71
+
72
+
-- data
73
+
display_name text not null,
74
+
description text,
75
+
location text,
76
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
77
+
78
+
-- constraints
79
+
unique(did)
80
+
);
81
+
82
+
create table if not exists profile_languages (
83
+
-- id
84
+
did text not null,
85
+
86
+
-- data
87
+
language_code text not null,
52
88
53
-
create table if not exists oauth_sessions (
54
-
id integer primary key autoincrement,
55
-
did text not null,
56
-
handle text not null,
57
-
pds_url text not null,
58
-
auth_server_iss text not null,
59
-
access_jwt text not null,
60
-
refresh_jwt text not null,
61
-
dpop_pds_nonce text,
62
-
dpop_auth_server_nonce text not null,
63
-
dpop_private_jwk text not null,
64
-
expiry text not null
65
-
);
89
+
-- constraints
90
+
primary key (did, language_code),
91
+
check (length(language_code) = 2),
92
+
foreign key (did) references profile(did) on delete cascade
93
+
);
94
+
95
+
create table if not exists _jetstream (
96
+
id integer primary key autoincrement,
97
+
last_time_us integer not null
98
+
);
66
99
67
-
create table if not exists migrations (
68
-
id integer primary key autoincrement,
69
-
name text unique
70
-
)
100
+
create table if not exists migrations (
101
+
id integer primary key autoincrement,
102
+
name text unique
103
+
);
71
104
`)
72
105
if err != nil {
73
-
return nil, fmt.Errorf("failed to execute db create statement: %v", err)
106
+
return nil, fmt.Errorf("failed to execute db create statement: %w", err)
74
107
}
75
108
76
109
return &DB{db}, nil
+21
internal/web/db/jetstream.go
+21
internal/web/db/jetstream.go
···
1
+
package db
2
+
3
+
type DbWrapper struct {
4
+
Execer
5
+
}
6
+
7
+
func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error {
8
+
_, err := db.Exec(`
9
+
insert into _jetstream (id, last_time_us)
10
+
values (1, ?)
11
+
on conflict(id) do update set last_time_us = excluded.last_time_us
12
+
`, lastTimeUs)
13
+
return err
14
+
}
15
+
16
+
func (db DbWrapper) GetLastTimeUs() (int64, error) {
17
+
var lastTimeUs int64
18
+
row := db.QueryRow(`select last_time_us from _jetstream where id = 1;`)
19
+
err := row.Scan(&lastTimeUs)
20
+
return lastTimeUs, err
21
+
}
+11
internal/web/db/oauth.go
+11
internal/web/db/oauth.go
···
160
160
where did = ?`, did)
161
161
return err
162
162
}
163
+
164
+
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
165
+
_, err := e.Exec(`
166
+
update oauth_sessions
167
+
set dpop_pds_nonce = ?
168
+
where did = ?`,
169
+
dpopPdsNonce,
170
+
did,
171
+
)
172
+
return err
173
+
}
+142
internal/web/db/profile.go
+142
internal/web/db/profile.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"log"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
// An ISO 639-1 two-letter language code (e.g., 'en', 'es', 'ko')
13
+
type LanguageCode string
14
+
15
+
type Profile struct {
16
+
ID int
17
+
Did string
18
+
DisplayName string
19
+
Description string
20
+
Location string
21
+
// Can only hold 10 languages at once.
22
+
Languages []Language
23
+
CreatedAt time.Time
24
+
}
25
+
26
+
type Language struct {
27
+
Code LanguageCode
28
+
// The common name, typically in English.
29
+
Name string
30
+
// The name in its own language (e.g., "日本語").
31
+
NativeName *string
32
+
}
33
+
34
+
var Languages = map[LanguageCode]Language{
35
+
"en": {Code: "en", Name: "English"},
36
+
"ko": {Code: "ko", Name: "Korean", NativeName: ToPtr("한국어")},
37
+
"jp": {Code: "jp", Name: "Japanese", NativeName: ToPtr("日本語")},
38
+
}
39
+
40
+
func (p *Profile) ProfileAt() syntax.ATURI {
41
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, "app.yoten.actor.profile", "self"))
42
+
}
43
+
44
+
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
45
+
defer tx.Rollback()
46
+
47
+
_, err := tx.Exec(`delete from profile_languages where did = ?`, profile.Did)
48
+
if err != nil {
49
+
return err
50
+
}
51
+
52
+
_, err = tx.Exec(
53
+
`insert or replace into profile (
54
+
did,
55
+
display_name,
56
+
description,
57
+
location
58
+
)
59
+
values (?, ?, ?, ?)`,
60
+
profile.Did,
61
+
profile.DisplayName,
62
+
profile.Description,
63
+
profile.Location,
64
+
)
65
+
if err != nil {
66
+
log.Println("profile", "err", err)
67
+
return err
68
+
}
69
+
70
+
for _, language := range profile.Languages {
71
+
_, err := tx.Exec(
72
+
`insert into profile_languages (did, language_code) values (?, ?)`,
73
+
profile.Did,
74
+
language.Code,
75
+
)
76
+
if err != nil {
77
+
log.Println("profile_languages", "err", err)
78
+
return err
79
+
}
80
+
}
81
+
82
+
return tx.Commit()
83
+
}
84
+
85
+
func GetProfile(e Execer, did string) (*Profile, error) {
86
+
var profile Profile
87
+
profile.Did = did
88
+
89
+
err := e.QueryRow(
90
+
`select display_name, description, location from profile where did = ?`,
91
+
did,
92
+
).Scan(&profile.DisplayName, &profile.Description, &profile.Location)
93
+
if err != nil {
94
+
if err == sql.ErrNoRows {
95
+
return nil, fmt.Errorf("user does not exist")
96
+
}
97
+
return nil, err
98
+
}
99
+
100
+
rows, err := e.Query(`select language_code from profile_languages where did = ?`, did)
101
+
if err != nil {
102
+
return nil, err
103
+
}
104
+
defer rows.Close()
105
+
i := 0
106
+
for rows.Next() {
107
+
var lang string
108
+
if err := rows.Scan(&lang); err != nil {
109
+
return nil, err
110
+
}
111
+
profile.Languages = append(profile.Languages, Languages[LanguageCode(lang)])
112
+
i++
113
+
}
114
+
115
+
return &profile, nil
116
+
}
117
+
118
+
func ValidateProfile(e Execer, profile *Profile) error {
119
+
if len(profile.Description) > 256 {
120
+
return fmt.Errorf("Entered bio is too long.")
121
+
}
122
+
123
+
if len(profile.Location) > 40 {
124
+
return fmt.Errorf("Entered location is too long.")
125
+
}
126
+
127
+
err := validateLanguageCodes(profile)
128
+
if err != nil {
129
+
return err
130
+
}
131
+
132
+
return nil
133
+
}
134
+
135
+
func validateLanguageCodes(profile *Profile) error {
136
+
for _, l := range profile.Languages {
137
+
if len(l.Code) != 2 {
138
+
return fmt.Errorf("Invalid language code '%s'.", l.Code)
139
+
}
140
+
}
141
+
return nil
142
+
}
+5
internal/web/db/utils.go
+5
internal/web/db/utils.go
+2
-1
internal/web/htmx/htmx.go
+2
-1
internal/web/htmx/htmx.go
···
5
5
"net/http"
6
6
)
7
7
8
-
func Notice(w http.ResponseWriter, id, msg string) {
8
+
func HxOobUpdate(w http.ResponseWriter, id, msg string) {
9
9
html := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, msg)
10
10
11
11
w.Header().Set("Content-Type", "text/html")
12
+
w.Header().Set("HX-Reswap", "none")
12
13
w.WriteHeader(http.StatusOK)
13
14
w.Write([]byte(html))
14
15
}
+110
internal/web/ingester/ingester.go
+110
internal/web/ingester/ingester.go
···
1
+
package ingester
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"log"
8
+
9
+
"github.com/bluesky-social/jetstream/pkg/models"
10
+
"yoten.app/api/yoten"
11
+
"yoten.app/internal/web/db"
12
+
)
13
+
14
+
type Ingester func(ctx context.Context, e *models.Event) error
15
+
16
+
func Ingest(d db.DbWrapper) Ingester {
17
+
return func(ctx context.Context, e *models.Event) error {
18
+
var err error
19
+
defer func() {
20
+
eventTime := e.TimeUS
21
+
lastTimeUs := eventTime + 1
22
+
if err := d.SaveLastTimeUs(lastTimeUs); err != nil {
23
+
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
24
+
}
25
+
}()
26
+
27
+
if e.Kind != models.EventKindCommit {
28
+
return nil
29
+
}
30
+
31
+
switch e.Commit.Collection {
32
+
case "app.yoten.actor.profile":
33
+
ingestProfile(&d, e)
34
+
}
35
+
36
+
return err
37
+
}
38
+
}
39
+
40
+
func ingestProfile(d *db.DbWrapper, e *models.Event) error {
41
+
did := e.Did
42
+
var err error
43
+
44
+
if e.Commit.RKey != "self" {
45
+
return fmt.Errorf("ingestProfile only ingests `self` record")
46
+
}
47
+
48
+
switch e.Commit.Operation {
49
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
50
+
raw := json.RawMessage(e.Commit.Record)
51
+
record := yoten.ActorProfile{}
52
+
err = json.Unmarshal(raw, &record)
53
+
if err != nil {
54
+
log.Printf("invalid record: %s", err)
55
+
return err
56
+
}
57
+
58
+
displayName := *&record.DisplayName
59
+
60
+
description := ""
61
+
if record.Description != nil {
62
+
description = *record.Description
63
+
}
64
+
65
+
location := ""
66
+
if record.Location != nil {
67
+
location = *record.Location
68
+
}
69
+
70
+
var languages []db.Language
71
+
for i, l := range record.Languages {
72
+
if i < 10 {
73
+
languages = append(languages, db.Languages[db.LanguageCode(l)])
74
+
}
75
+
}
76
+
77
+
profile := db.Profile{
78
+
Did: did,
79
+
DisplayName: displayName,
80
+
Description: description,
81
+
Location: location,
82
+
Languages: languages,
83
+
}
84
+
85
+
ddb, ok := d.Execer.(*db.DB)
86
+
if !ok {
87
+
return fmt.Errorf("failed to index profile record, invalid db cast")
88
+
}
89
+
90
+
tx, err := ddb.Begin()
91
+
if err != nil {
92
+
return fmt.Errorf("failed to start transaction")
93
+
}
94
+
95
+
err = db.ValidateProfile(tx, &profile)
96
+
if err != nil {
97
+
return fmt.Errorf("invalid profile record")
98
+
}
99
+
100
+
log.Println("Upserting profile:", profile.Did)
101
+
err = db.UpsertProfile(tx, &profile)
102
+
case models.CommitOperationDelete:
103
+
err = db.DeleteArtifact(d, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
104
+
}
105
+
if err != nil {
106
+
return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err)
107
+
}
108
+
109
+
return nil
110
+
}
+214
internal/web/jetstream/jetstream.go
+214
internal/web/jetstream/jetstream.go
···
1
+
package jetstream
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
"os"
8
+
"os/signal"
9
+
"sync"
10
+
"syscall"
11
+
"time"
12
+
13
+
"github.com/bluesky-social/jetstream/pkg/client"
14
+
"github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential"
15
+
"github.com/bluesky-social/jetstream/pkg/models"
16
+
"yoten.app/internal/web/log"
17
+
)
18
+
19
+
type DB interface {
20
+
GetLastTimeUs() (int64, error)
21
+
SaveLastTimeUs(int64) error
22
+
}
23
+
24
+
type Set[T comparable] map[T]struct{}
25
+
26
+
type JetstreamClient struct {
27
+
cfg *client.ClientConfig
28
+
client *client.Client
29
+
ident string
30
+
l *slog.Logger
31
+
32
+
wantedDids Set[string]
33
+
db DB
34
+
waitForDid bool
35
+
mu sync.RWMutex
36
+
37
+
cancel context.CancelFunc
38
+
cancelMu sync.Mutex
39
+
}
40
+
41
+
func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) {
42
+
if cfg == nil {
43
+
cfg = client.DefaultClientConfig()
44
+
cfg.WebsocketURL = endpoint
45
+
cfg.WantedCollections = collections
46
+
}
47
+
48
+
return &JetstreamClient{
49
+
cfg: cfg,
50
+
ident: ident,
51
+
db: db,
52
+
l: logger,
53
+
wantedDids: make(map[string]struct{}),
54
+
55
+
// This will make the goroutine in StartJetstream wait until
56
+
// j.wantedDids has been populated, typically using addDids.
57
+
waitForDid: waitForDid,
58
+
}, nil
59
+
}
60
+
61
+
// Starts the jetstream client and processes events using the provided
62
+
// processFunc. The caller is responsible for saving the last time_us to the
63
+
// database (just use your db.UpdateLastTimeUs).
64
+
func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error {
65
+
logger := j.l
66
+
67
+
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
68
+
69
+
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
70
+
if err != nil {
71
+
return fmt.Errorf("failed to create jetstream client: %w", err)
72
+
}
73
+
j.client = client
74
+
75
+
go func() {
76
+
if j.waitForDid {
77
+
for len(j.wantedDids) == 0 {
78
+
time.Sleep(time.Second)
79
+
}
80
+
}
81
+
logger.Info("done waiting for did")
82
+
83
+
go j.periodicLastTimeSave(ctx)
84
+
j.saveIfKilled(ctx)
85
+
86
+
j.connectAndRead(ctx)
87
+
}()
88
+
89
+
return nil
90
+
}
91
+
92
+
type processor func(context.Context, *models.Event) error
93
+
94
+
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
95
+
// Empty filter => all dids allowed.
96
+
if len(j.wantedDids) == 0 {
97
+
return processFunc
98
+
}
99
+
// Since this closure references j.WantedDids; it should auto-update
100
+
// existing instances of the closure when j.WantedDids is mutated.
101
+
return func(ctx context.Context, evt *models.Event) error {
102
+
if _, ok := j.wantedDids[evt.Did]; ok {
103
+
return processFunc(ctx, evt)
104
+
} else {
105
+
return nil
106
+
}
107
+
}
108
+
}
109
+
110
+
// save cursor periodically
111
+
func (j *JetstreamClient) periodicLastTimeSave(ctx context.Context) {
112
+
ticker := time.NewTicker(time.Minute)
113
+
defer ticker.Stop()
114
+
115
+
for {
116
+
select {
117
+
case <-ctx.Done():
118
+
return
119
+
case <-ticker.C:
120
+
j.db.SaveLastTimeUs(time.Now().UnixMicro())
121
+
}
122
+
}
123
+
}
124
+
125
+
func (j *JetstreamClient) saveIfKilled(ctx context.Context) context.Context {
126
+
ctxWithCancel, cancel := context.WithCancel(ctx)
127
+
128
+
sigChan := make(chan os.Signal, 1)
129
+
130
+
signal.Notify(sigChan,
131
+
syscall.SIGINT,
132
+
syscall.SIGTERM,
133
+
syscall.SIGQUIT,
134
+
syscall.SIGHUP,
135
+
syscall.SIGKILL,
136
+
syscall.SIGSTOP,
137
+
)
138
+
139
+
go func() {
140
+
sig := <-sigChan
141
+
j.l.Info("Received signal, initiating graceful shutdown", "signal", sig)
142
+
143
+
lastTimeUs := time.Now().UnixMicro()
144
+
if err := j.db.SaveLastTimeUs(lastTimeUs); err != nil {
145
+
j.l.Error("failed to save last time during shutdown", "error", err)
146
+
}
147
+
j.l.Info("Saved lastTimeUs before shutdown", "lastTimeUs", lastTimeUs)
148
+
149
+
j.cancelMu.Lock()
150
+
if j.cancel != nil {
151
+
j.cancel()
152
+
}
153
+
j.cancelMu.Unlock()
154
+
155
+
cancel()
156
+
157
+
os.Exit(0)
158
+
}()
159
+
160
+
return ctxWithCancel
161
+
}
162
+
163
+
func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 {
164
+
l := log.FromContext(ctx)
165
+
lastTimeUs, err := j.db.GetLastTimeUs()
166
+
if err != nil {
167
+
l.Warn("couldn't get last time us, starting from now", "error", err)
168
+
lastTimeUs = time.Now().UnixMicro()
169
+
err = j.db.SaveLastTimeUs(lastTimeUs)
170
+
if err != nil {
171
+
l.Error("failed to save last time us", "error", err)
172
+
}
173
+
}
174
+
175
+
// If last time is older than 2 days, start from now
176
+
if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 {
177
+
lastTimeUs = time.Now().UnixMicro()
178
+
l.Warn("last time us is older than 2 days; discarding that and starting from now")
179
+
err = j.db.SaveLastTimeUs(lastTimeUs)
180
+
if err != nil {
181
+
l.Error("failed to save last time us", "error", err)
182
+
}
183
+
}
184
+
185
+
l.Info("found last time_us", "time_us", lastTimeUs)
186
+
return &lastTimeUs
187
+
}
188
+
189
+
func (j *JetstreamClient) connectAndRead(ctx context.Context) {
190
+
l := log.FromContext(ctx)
191
+
for {
192
+
cursor := j.getLastTimeUs(ctx)
193
+
194
+
connCtx, cancel := context.WithCancel(ctx)
195
+
j.cancelMu.Lock()
196
+
j.cancel = cancel
197
+
j.cancelMu.Unlock()
198
+
199
+
if err := j.client.ConnectAndRead(connCtx, cursor); err != nil {
200
+
l.Error("error reading jetstream", "error", err)
201
+
cancel()
202
+
continue
203
+
}
204
+
205
+
select {
206
+
case <-ctx.Done():
207
+
l.Info("context done, stopping jetstream")
208
+
return
209
+
case <-connCtx.Done():
210
+
l.Info("connection context done, reconnecting")
211
+
continue
212
+
}
213
+
}
214
+
}
+47
internal/web/log/log.go
+47
internal/web/log/log.go
···
1
+
package log
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"os"
7
+
)
8
+
9
+
// NewHandler sets up a new slog.Handler with the service name as an attribute
10
+
func NewHandler(name string) slog.Handler {
11
+
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
12
+
13
+
var attrs []slog.Attr
14
+
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
15
+
handler.WithAttrs(attrs)
16
+
return handler
17
+
}
18
+
19
+
func New(name string) *slog.Logger {
20
+
return slog.New(NewHandler(name))
21
+
}
22
+
23
+
func NewContext(ctx context.Context, name string) context.Context {
24
+
return IntoContext(ctx, New(name))
25
+
}
26
+
27
+
type ctxKey struct{}
28
+
29
+
// IntoContext adds a logger to a context. Use FromContext to pull the logger
30
+
// out.
31
+
func IntoContext(ctx context.Context, l *slog.Logger) context.Context {
32
+
return context.WithValue(ctx, ctxKey{}, l)
33
+
}
34
+
35
+
// FromContext returns a logger from a context.Context; if the passed context
36
+
// is nil, return the default slog logger.
37
+
func FromContext(ctx context.Context) *slog.Logger {
38
+
if ctx != nil {
39
+
v := ctx.Value(ctxKey{})
40
+
if v == nil {
41
+
return slog.Default()
42
+
}
43
+
return v.(*slog.Logger)
44
+
}
45
+
46
+
return slog.Default()
47
+
}
+47
-5
internal/web/middleware/middleware.go
+47
-5
internal/web/middleware/middleware.go
···
1
1
package middleware
2
2
3
3
import (
4
+
"context"
4
5
"log"
5
6
"net/http"
7
+
"slices"
8
+
"strings"
6
9
10
+
"github.com/go-chi/chi/v5"
7
11
"yoten.app/internal/web/db"
8
12
"yoten.app/internal/web/oauth"
13
+
"yoten.app/internal/web/resolver"
9
14
)
10
15
11
16
type Middleware struct {
12
-
oauth *oauth.OAuth
13
-
db *db.DB
17
+
oauth *oauth.OAuth
18
+
db *db.DB
19
+
idResolver *resolver.Resolver
14
20
}
15
21
16
-
func New(oauth *oauth.OAuth, db *db.DB) Middleware {
22
+
func New(oauth *oauth.OAuth, db *db.DB, idResolver *resolver.Resolver) Middleware {
17
23
return Middleware{
18
-
oauth: oauth,
19
-
db: db,
24
+
oauth: oauth,
25
+
db: db,
26
+
idResolver: idResolver,
20
27
}
21
28
}
22
29
···
53
60
})
54
61
}
55
62
}
63
+
64
+
func (mw Middleware) ResolveIdent() middlewareFunc {
65
+
excluded := []string{"favicon.ico"}
66
+
67
+
return func(next http.Handler) http.Handler {
68
+
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
69
+
didOrHandle := chi.URLParam(req, "user")
70
+
if slices.Contains(excluded, didOrHandle) {
71
+
next.ServeHTTP(w, req)
72
+
return
73
+
}
74
+
75
+
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
76
+
if err != nil {
77
+
log.Println("failed to resolve did/handle:", err)
78
+
w.WriteHeader(http.StatusNotFound)
79
+
return
80
+
}
81
+
82
+
ctx := context.WithValue(req.Context(), "resolvedId", *id)
83
+
84
+
next.ServeHTTP(w, req.WithContext(ctx))
85
+
})
86
+
}
87
+
}
88
+
89
+
func StripLeadingAt(next http.Handler) http.Handler {
90
+
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
91
+
path := req.URL.EscapedPath()
92
+
if strings.HasPrefix(path, "/@") {
93
+
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
94
+
}
95
+
next.ServeHTTP(w, req)
96
+
})
97
+
}
+61
-20
internal/web/oauth/handler/handler.go
+61
-20
internal/web/oauth/handler/handler.go
···
7
7
"net/http"
8
8
"net/url"
9
9
"strings"
10
+
"time"
10
11
12
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
+
lexutil "github.com/bluesky-social/indigo/lex/util"
11
14
"github.com/go-chi/chi/v5"
15
+
"github.com/gorilla/sessions"
12
16
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
13
17
14
-
"github.com/gorilla/sessions"
18
+
"yoten.app/api/yoten"
15
19
"yoten.app/internal/web/config"
16
20
"yoten.app/internal/web/db"
17
21
"yoten.app/internal/web/htmx"
···
79
83
resolved, err := idResolver.ResolveIdent(r.Context(), handle)
80
84
if err != nil {
81
85
log.Println("failed to resolve handle:", err)
82
-
htmx.Notice(w, "login-msg", fmt.Sprintf("'%s' is an invalid handle.", handle))
86
+
htmx.HxOobUpdate(w, "login-msg", fmt.Sprintf("'%s' is an invalid handle.", handle))
83
87
return
84
88
}
85
89
···
91
95
)
92
96
if err != nil {
93
97
log.Println("failed to create oauth client:", err)
94
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
98
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
95
99
return
96
100
}
97
101
98
102
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
99
103
if err != nil {
100
104
log.Println("failed to resolve auth server:", err)
101
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
105
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
102
106
return
103
107
}
104
108
105
109
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
106
110
if err != nil {
107
111
log.Println("failed to fetch auth server metadata:", err)
108
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
112
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
109
113
return
110
114
}
111
115
112
116
dpopKey, err := helpers.GenerateKey(nil)
113
117
if err != nil {
114
118
log.Println("failed to generate dpop key:", err)
115
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
119
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
116
120
return
117
121
}
118
122
119
123
dpopKeyJson, err := json.Marshal(dpopKey)
120
124
if err != nil {
121
125
log.Println("failed to marshal dpop key:", err)
122
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
126
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
123
127
return
124
128
}
125
129
126
130
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
127
131
if err != nil {
128
132
log.Println("failed to send par auth request:", err)
129
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
133
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
130
134
return
131
135
}
132
136
···
142
146
})
143
147
if err != nil {
144
148
log.Println("failed to save oauth request:", err)
145
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
149
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
146
150
return
147
151
}
148
152
···
198
202
oauthRequest, err := db.GetOAuthRequestByState(o.db, state)
199
203
if err != nil {
200
204
log.Println("failed to get oauth request:", err)
201
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
205
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
202
206
return
203
207
}
204
208
···
213
217
errorDescription := r.FormValue("error_description")
214
218
if error != "" || errorDescription != "" {
215
219
log.Printf("oauth callback error: %s, %s", error, errorDescription)
216
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
220
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
217
221
return
218
222
}
219
223
220
224
iss := r.FormValue("iss")
221
225
if iss == "" {
222
226
log.Println("missing iss for state: ", state)
223
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
227
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
224
228
return
225
229
}
226
230
227
231
code := r.FormValue("code")
228
232
if code == "" {
229
233
log.Println("missing code for state: ", state)
230
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
234
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
231
235
return
232
236
}
233
237
···
239
243
)
240
244
if err != nil {
241
245
log.Println("failed to create oauth client:", err)
242
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
246
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
243
247
return
244
248
}
245
249
246
250
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
247
251
if err != nil {
248
252
log.Println("failed to parse jwk:", err)
249
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
253
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
250
254
return
251
255
}
252
256
···
260
264
)
261
265
if err != nil {
262
266
log.Println("failed to get token:", err)
263
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
267
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
264
268
return
265
269
}
266
270
267
271
if tokenResp.Scope != oauthScope {
268
272
log.Println("oauth scope doesn't match:", tokenResp.Scope)
269
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
273
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
270
274
return
271
275
}
272
276
273
-
err = o.oauth.SaveSession(w, r, oauthRequest, tokenResp)
277
+
userSession, err := o.oauth.SaveSession(w, r, oauthRequest, tokenResp)
274
278
if err != nil {
275
279
log.Println("failed to save session:", err)
276
-
htmx.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
280
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
277
281
return
278
282
}
283
+
log.Println("successfully saved session")
279
284
280
-
log.Println("successfully saved session")
285
+
xrpcClient, err := o.oauth.AuthorizedClientFromSession(*userSession, r)
286
+
if err != nil {
287
+
log.Println("failed to retrieve authorized client:", err)
288
+
htmx.HxOobUpdate(w, "login-msg", "Failed to authenticate. Try again later.")
289
+
return
290
+
}
291
+
292
+
ex, _ := xrpcClient.RepoGetRecord(r.Context(), "", "app.yoten.actor.profile", oauthRequest.Did, "self")
293
+
var cid *string
294
+
if ex != nil {
295
+
cid = ex.Cid
296
+
}
297
+
298
+
// This should only occur once per account
299
+
if ex == nil {
300
+
createdAt := time.Now().Format(time.RFC3339)
301
+
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
302
+
Collection: "app.yoten.actor.profile",
303
+
Repo: oauthRequest.Did,
304
+
Rkey: "self",
305
+
Record: &lexutil.LexiconTypeDecoder{
306
+
Val: &yoten.ActorProfile{
307
+
DisplayName: oauthRequest.Handle,
308
+
Description: db.ToPtr(""),
309
+
Languages: make([]string, 0),
310
+
Location: db.ToPtr(""),
311
+
CreatedAt: createdAt,
312
+
}},
313
+
SwapRecord: cid,
314
+
})
315
+
if err != nil {
316
+
log.Println("failed to create record:", err)
317
+
htmx.HxOobUpdate(w, "login-msg", "Failed to announce profile creation.")
318
+
return
319
+
}
320
+
log.Println("created profile record: ", atresp.Uri)
321
+
}
281
322
282
323
http.Redirect(w, r, "/", http.StatusFound)
283
324
}
+112
-17
internal/web/oauth/oauth.go
+112
-17
internal/web/oauth/oauth.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"log"
5
6
"net/http"
6
7
"net/url"
7
8
"time"
···
12
13
"yoten.app/internal/web/config"
13
14
"yoten.app/internal/web/db"
14
15
"yoten.app/internal/web/oauth/client"
16
+
xrpc "yoten.app/internal/web/xrpcclient"
15
17
)
16
18
17
19
type OAuth struct {
···
28
30
}
29
31
}
30
32
31
-
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq db.OAuthRequest, oresp *oauth.TokenResponse) error {
33
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq db.OAuthRequest, oresp *oauth.TokenResponse) (*sessions.Session, error) {
32
34
// Save did in user session.
33
35
userSession, err := o.Store.Get(r, SessionName)
34
36
if err != nil {
35
-
return err
37
+
return nil, err
36
38
}
37
39
38
40
userSession.Values[SessionDid] = oreq.Did
···
41
43
userSession.Values[SessionAuthenticated] = true
42
44
err = userSession.Save(r, w)
43
45
if err != nil {
44
-
return fmt.Errorf("error saving user session: %v", err)
46
+
return nil, fmt.Errorf("failed to save user session: %w", err)
45
47
}
46
48
47
49
// Save the whole thing in the db.
···
57
59
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
58
60
}
59
61
60
-
return db.SaveOAuthSession(o.Db, session)
62
+
return userSession, db.SaveOAuthSession(o.Db, session)
61
63
}
62
64
63
65
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
64
66
userSession, err := o.Store.Get(r, SessionName)
65
-
if err != nil || userSession.IsNew {
66
-
return fmt.Errorf("error getting user session (or new session?): %w", err)
67
+
if err != nil {
68
+
return fmt.Errorf("failed to get user session: %w", err)
69
+
}
70
+
if userSession.IsNew {
71
+
return fmt.Errorf("user session is new")
67
72
}
68
73
69
74
did := userSession.Values[SessionDid].(string)
70
75
71
76
err = db.DeleteOAuthSessionByDid(o.Db, did)
72
77
if err != nil {
73
-
return fmt.Errorf("error deleting oauth session: %w", err)
78
+
return fmt.Errorf("failed to delete oauth session: %w", err)
74
79
}
75
80
76
81
userSession.Options.MaxAge = -1
···
78
83
return userSession.Save(r, w)
79
84
}
80
85
81
-
func (o *OAuth) GetSession(r *http.Request) (*db.OAuthSession, bool, error) {
82
-
userSession, err := o.Store.Get(r, SessionName)
83
-
if err != nil || userSession.IsNew {
84
-
return nil, false, fmt.Errorf("error getting user session: %v", err)
85
-
}
86
-
86
+
func (o *OAuth) CheckSessionAuth(userSession sessions.Session, r *http.Request) (*db.OAuthSession, bool, error) {
87
87
did := userSession.Values[SessionDid].(string)
88
88
auth := userSession.Values[SessionAuthenticated].(bool)
89
89
90
90
session, err := db.GetOAuthSessionByDid(o.Db, did)
91
91
if err != nil {
92
-
return nil, false, fmt.Errorf("error getting oauth session: %v", err)
92
+
return nil, false, fmt.Errorf("failed to get oauth session: %w", err)
93
93
}
94
94
95
95
expiry, err := time.Parse(time.RFC3339, session.Expiry)
96
96
if err != nil {
97
-
return nil, false, fmt.Errorf("error parsing expiry time: %v", err)
97
+
return nil, false, fmt.Errorf("failed to parse expiry time: %w", err)
98
98
}
99
99
100
100
if expiry.Sub(time.Now()) <= 5*time.Minute {
···
123
123
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
124
124
err = db.RefreshOAuthSession(o.Db, did, resp.AccessToken, resp.RefreshToken, newExpiry)
125
125
if err != nil {
126
-
return nil, false, fmt.Errorf("error refreshing oauth session: %w", err)
126
+
return nil, false, fmt.Errorf("failed to refresh oauth session: %w", err)
127
127
}
128
128
129
129
// Update the current session.
···
136
136
return session, auth, nil
137
137
}
138
138
139
+
func (o *OAuth) GetSession(r *http.Request) (*db.OAuthSession, bool, error) {
140
+
userSession, err := o.Store.Get(r, SessionName)
141
+
if err != nil {
142
+
return nil, false, fmt.Errorf("failed to get user session: %w", err)
143
+
}
144
+
if userSession.IsNew {
145
+
return nil, false, fmt.Errorf("user session is new")
146
+
}
147
+
148
+
session, auth, err := o.CheckSessionAuth(*userSession, r)
149
+
if err != nil {
150
+
return nil, false, fmt.Errorf("failed to check user session auth: %w", err)
151
+
}
152
+
153
+
return session, auth, nil
154
+
}
155
+
139
156
type User struct {
140
157
Handle string
141
158
Did string
···
144
161
145
162
func (a *OAuth) GetUser(r *http.Request) *User {
146
163
clientSession, err := a.Store.Get(r, SessionName)
147
-
148
164
if err != nil || clientSession.IsNew {
149
165
return nil
150
166
}
···
154
170
Did: clientSession.Values[SessionDid].(string),
155
171
Pds: clientSession.Values[SessionPds].(string),
156
172
}
173
+
}
174
+
175
+
func (a *OAuth) GetDid(r *http.Request) string {
176
+
clientSession, err := a.Store.Get(r, SessionName)
177
+
if err != nil || clientSession.IsNew {
178
+
return ""
179
+
}
180
+
181
+
return clientSession.Values[SessionDid].(string)
182
+
}
183
+
184
+
func (o *OAuth) AuthorizedClientFromSession(userSession sessions.Session, r *http.Request) (*xrpc.Client, error) {
185
+
session, auth, err := o.CheckSessionAuth(userSession, r)
186
+
if err != nil {
187
+
return nil, fmt.Errorf("failed to get session: %w", err)
188
+
}
189
+
if !auth {
190
+
return nil, fmt.Errorf("not authorized")
191
+
}
192
+
193
+
client := &oauth.XrpcClient{
194
+
OnDpopPdsNonceChanged: func(did, newNonce string) {
195
+
err := db.UpdateDpopPdsNonce(o.Db, did, newNonce)
196
+
if err != nil {
197
+
log.Printf("failed to update dpop pds nonce: %v", err)
198
+
}
199
+
},
200
+
}
201
+
202
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
203
+
if err != nil {
204
+
return nil, fmt.Errorf("failed to parse private jwk: %w", err)
205
+
}
206
+
207
+
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
208
+
Did: session.Did,
209
+
PdsUrl: session.PdsUrl,
210
+
DpopPdsNonce: session.PdsUrl,
211
+
AccessToken: session.AccessJwt,
212
+
Issuer: session.AuthServerIss,
213
+
DpopPrivateJwk: privateJwk,
214
+
})
215
+
216
+
return xrpcClient, nil
217
+
}
218
+
219
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
220
+
session, auth, err := o.GetSession(r)
221
+
if err != nil {
222
+
return nil, fmt.Errorf("failed to get session: %w", err)
223
+
}
224
+
if !auth {
225
+
return nil, fmt.Errorf("not authorized")
226
+
}
227
+
228
+
client := &oauth.XrpcClient{
229
+
OnDpopPdsNonceChanged: func(did, newNonce string) {
230
+
err := db.UpdateDpopPdsNonce(o.Db, did, newNonce)
231
+
if err != nil {
232
+
log.Printf("failed to update dpop pds nonce: %v", err)
233
+
}
234
+
},
235
+
}
236
+
237
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
238
+
if err != nil {
239
+
return nil, fmt.Errorf("failed to parse private jwk: %w", err)
240
+
}
241
+
242
+
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
243
+
Did: session.Did,
244
+
PdsUrl: session.PdsUrl,
245
+
DpopPdsNonce: session.PdsUrl,
246
+
AccessToken: session.AccessJwt,
247
+
Issuer: session.AuthServerIss,
248
+
DpopPrivateJwk: privateJwk,
249
+
})
250
+
251
+
return xrpcClient, nil
157
252
}
158
253
159
254
type ClientMetadata struct {
+11
internal/web/pages/404.templ
+11
internal/web/pages/404.templ
+16
-16
internal/web/pages/index.templ
+16
-16
internal/web/pages/index.templ
···
3
3
import "yoten.app/internal/web/pages/templates"
4
4
5
5
templ Index(params IndexParams) {
6
-
{{ user := params.User }}
7
-
@templates.BaseLayout("home") {
8
-
<span>
9
-
Hello,
10
-
if user != nil {
11
-
{ user.Handle }
12
-
} else {
13
-
world
14
-
}
15
-
</span>
16
-
if user != nil {
17
-
<button type="button" hx-post="/logout" hx-swap="none">logout</button>
18
-
} else {
19
-
<a href="/login">login</a>
20
-
}
21
-
}
6
+
{{ user := params.User }}
7
+
@templates.BaseLayout("home") {
8
+
<span>
9
+
Hello,
10
+
if user != nil {
11
+
{ user.Handle }
12
+
} else {
13
+
world
14
+
}
15
+
</span>
16
+
if user != nil {
17
+
<button type="button" hx-post="/logout" hx-swap="none">logout</button>
18
+
} else {
19
+
<a href="/login">login</a>
20
+
}
21
+
}
22
22
}
+17
-17
internal/web/pages/login.templ
+17
-17
internal/web/pages/login.templ
···
3
3
import "yoten.app/internal/web/pages/templates"
4
4
5
5
templ Login(params LoginParams) {
6
-
@templates.BaseLayout("login") {
7
-
<h2>Login Page</h2>
8
-
<p>Please enter your credentials.</p>
9
-
<form hx-post="/login" hx-swap="none" hx-disabled-elt="#login-button">
10
-
<div>
11
-
<label for="handle">Handle</label>
12
-
<input type="text" id="handle" name="handle" />
13
-
<p>
14
-
Use your
15
-
<a href="https://bsky.app/">Bluesky</a>
16
-
handle to log in. You will then be redirected to your PDS to complete authentication.
17
-
</p>
18
-
</div>
19
-
<button type="submit" id="login-button">Log In</button>
20
-
</form>
21
-
<p id="login-msg"></p>
22
-
}
6
+
@templates.BaseLayout("login") {
7
+
<h2>Login Page</h2>
8
+
<p>Please enter your credentials.</p>
9
+
<form hx-post="/login" hx-swap="none" hx-disabled-elt="#login-button">
10
+
<div>
11
+
<label for="handle">Handle</label>
12
+
<input type="text" id="handle" name="handle"/>
13
+
<p>
14
+
Use your
15
+
<a href="https://bsky.app/">Bluesky</a>
16
+
handle to log in. You will then be redirected to your PDS to complete authentication.
17
+
</p>
18
+
</div>
19
+
<button type="submit" id="login-button">Log In</button>
20
+
</form>
21
+
<p id="login-msg"></p>
22
+
}
23
23
}
+18
internal/web/pages/pages.go
+18
internal/web/pages/pages.go
···
2
2
3
3
import (
4
4
"github.com/a-h/templ"
5
+
6
+
"yoten.app/internal/web/db"
5
7
"yoten.app/internal/web/oauth"
6
8
)
7
9
8
10
type IndexParams struct {
11
+
// The current logged in user.
9
12
User *oauth.User
10
13
}
11
14
···
19
22
func LoginComponent(params LoginParams) templ.Component {
20
23
return Login(params)
21
24
}
25
+
26
+
type ProfileParams struct {
27
+
// The current logged in user.
28
+
User *oauth.User
29
+
Profile *db.Profile
30
+
AvatarUri string
31
+
}
32
+
33
+
func ProfileComponent(params ProfileParams) templ.Component {
34
+
return Profile(params)
35
+
}
36
+
37
+
func NotFoundComponent() templ.Component {
38
+
return NotFound()
39
+
}
+33
internal/web/pages/partials/edit-bio.templ
+33
internal/web/pages/partials/edit-bio.templ
···
1
+
package partials
2
+
3
+
import "fmt"
4
+
5
+
templ EditBio(params EditBioParams) {
6
+
<form hx-post="/profile/edit-bio" hx-swap="none" hx-disabled-elt="#save-button,#cancel-button">
7
+
<div>
8
+
<label for="display-name">Display name</label>
9
+
<input type="text" name="display-name" value={ params.Profile.DisplayName }/>
10
+
</div>
11
+
<div>
12
+
<label for="description">Description</label>
13
+
<textarea type="text" name="description" rows="3" placeholder="write a bio">
14
+
{ params.Profile.Description }
15
+
</textarea>
16
+
</div>
17
+
<div>
18
+
<label for="location">location</label>
19
+
<input type="text" name="location" value={ params.Profile.Location }/>
20
+
</div>
21
+
<div>
22
+
<button id="save-button" type="submit">
23
+
save
24
+
</button>
25
+
<a href={ templ.URL(fmt.Sprintf("/%s", params.Profile.Did)) }>
26
+
<button id="cancel-button" type="button">
27
+
cancel
28
+
</button>
29
+
</a>
30
+
</div>
31
+
@SelectedLanguages(SelectedLanguagesParams{Languages: params.Profile.Languages})
32
+
</form>
33
+
}
+26
internal/web/pages/partials/partials.go
+26
internal/web/pages/partials/partials.go
···
1
+
package partials
2
+
3
+
import (
4
+
"github.com/a-h/templ"
5
+
6
+
"yoten.app/internal/web/db"
7
+
"yoten.app/internal/web/oauth"
8
+
)
9
+
10
+
type EditBioParams struct {
11
+
// The current logged in user.
12
+
User *oauth.User
13
+
Profile *db.Profile
14
+
}
15
+
16
+
func EditBioPartial(params EditBioParams) templ.Component {
17
+
return EditBio(params)
18
+
}
19
+
20
+
type SelectedLanguagesParams struct {
21
+
Languages []db.Language
22
+
}
23
+
24
+
func SelectedLanguagesPartial(params SelectedLanguagesParams) templ.Component {
25
+
return SelectedLanguages(params)
26
+
}
+60
internal/web/pages/partials/selected-languages.templ
+60
internal/web/pages/partials/selected-languages.templ
···
1
+
package partials
2
+
3
+
import (
4
+
"fmt"
5
+
"slices"
6
+
7
+
"yoten.app/internal/web/db"
8
+
)
9
+
10
+
templ SelectedLanguages(params SelectedLanguagesParams) {
11
+
<div id="language-list">
12
+
<select
13
+
name="language_code"
14
+
hx-post="/profile/add-language"
15
+
hx-trigger="change"
16
+
hx-target="#language-list"
17
+
hx-swap="innerHTML"
18
+
>
19
+
<option disabled selected>Add a language...</option>
20
+
for c, l := range db.Languages {
21
+
{{
22
+
if slices.ContainsFunc(params.Languages, func(userLang db.Language) bool {
23
+
return userLang.Code == l.Code
24
+
}) {
25
+
continue
26
+
}
27
+
}}
28
+
<option value={ string(c) }>
29
+
{ l.Name }
30
+
if l.NativeName != nil {
31
+
({ *l.NativeName })
32
+
}
33
+
</option>
34
+
}
35
+
</select>
36
+
<p>Selected Languages:</p>
37
+
<ul>
38
+
for _, l := range params.Languages {
39
+
<li>
40
+
<small>
41
+
{ l.Name }
42
+
if l.NativeName != nil {
43
+
({ *l.NativeName })
44
+
}
45
+
</small>
46
+
<button
47
+
id="remove-language-button"
48
+
hx-disabled-elt="#remove-language-button,#save-button,#cancel-button"
49
+
hx-post="/profile/remove-language"
50
+
hx-target="#language-list"
51
+
hx-swap="innerHTML"
52
+
hx-vals={ fmt.Sprintf("{\"language_code\": \"%s\"}", string(l.Code)) }
53
+
>
54
+
x
55
+
</button>
56
+
</li>
57
+
}
58
+
</ul>
59
+
</div>
60
+
}
+34
internal/web/pages/profile.templ
+34
internal/web/pages/profile.templ
···
1
+
package pages
2
+
3
+
import "yoten.app/internal/web/pages/templates"
4
+
5
+
templ Profile(params ProfileParams) {
6
+
@templates.BaseLayout("profile") {
7
+
<img src={ params.AvatarUri } width="200"/>
8
+
<p><b>{ params.Profile.DisplayName }</b></p>
9
+
<div id="profile-bio">
10
+
<p>{ params.Profile.Description }</p>
11
+
<small>Location: { params.Profile.Location }</small>
12
+
<br/>
13
+
<small>Languages:</small>
14
+
<ul>
15
+
for _, l := range params.Profile.Languages {
16
+
<li>
17
+
<small>
18
+
{ l.Name }
19
+
if l.NativeName != nil {
20
+
({ *l.NativeName })
21
+
}
22
+
</small>
23
+
</li>
24
+
}
25
+
</ul>
26
+
if params.User != nil && params.User.Did == params.Profile.Did {
27
+
<button hx-get="/profile/edit-bio" hx-swap="innerHTML" hx-target="#profile-bio">
28
+
edit
29
+
</button>
30
+
}
31
+
</div>
32
+
<p id="update-profile-msg"></p>
33
+
}
34
+
}
+14
-17
internal/web/pages/templates/base.templ
+14
-17
internal/web/pages/templates/base.templ
···
1
1
package templates
2
2
3
3
templ BaseLayout(title string) {
4
-
<!DOCTYPE html>
5
-
<html lang="en">
6
-
7
-
<head>
8
-
<meta charset="UTF-8" />
9
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
-
<title>{ title }</title>
11
-
</head>
12
-
13
-
<body>
14
-
<main>
15
-
{ children... }
16
-
</main>
17
-
</body>
18
-
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
19
-
20
-
</html>
4
+
<!DOCTYPE html>
5
+
<html lang="en">
6
+
<head>
7
+
<meta charset="UTF-8"/>
8
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
9
+
<title>{ title }</title>
10
+
</head>
11
+
<body>
12
+
<main>
13
+
{ children... }
14
+
</main>
15
+
</body>
16
+
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
17
+
</html>
21
18
}
+272
internal/web/state/profile.go
+272
internal/web/state/profile.go
···
1
+
package state
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"log"
7
+
"net/http"
8
+
"slices"
9
+
"time"
10
+
11
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/atproto/identity"
13
+
lexutil "github.com/bluesky-social/indigo/lex/util"
14
+
"github.com/go-chi/chi/v5"
15
+
16
+
"yoten.app/api/yoten"
17
+
"yoten.app/internal/web/db"
18
+
"yoten.app/internal/web/htmx"
19
+
"yoten.app/internal/web/pages"
20
+
"yoten.app/internal/web/pages/partials"
21
+
)
22
+
23
+
type ProfileResponse struct {
24
+
Avatar string `json:"avatar"`
25
+
}
26
+
27
+
func (s *State) HandleProfile(w http.ResponseWriter, r *http.Request) {
28
+
didOrHandle := chi.URLParam(r, "user")
29
+
if didOrHandle == "" {
30
+
http.Error(w, "Bad request", http.StatusBadRequest)
31
+
return
32
+
}
33
+
34
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
35
+
if !ok {
36
+
pages.NotFoundComponent().Render(r.Context(), w)
37
+
return
38
+
}
39
+
40
+
profileAvatarUri, err := s.GetAvatarUri(ident.Handle.String())
41
+
if err != nil {
42
+
log.Printf("failed to get profile avatar for %s: %s", ident.DID.String(), err)
43
+
}
44
+
45
+
profile, err := db.GetProfile(s.db, ident.DID.String())
46
+
if err != nil || profile == nil {
47
+
log.Printf("failed to find %s in db: %s", ident.DID.String(), err)
48
+
pages.NotFound().Render(r.Context(), w)
49
+
return
50
+
}
51
+
52
+
user := s.oauth.GetUser(r)
53
+
54
+
pages.ProfileComponent(pages.ProfileParams{
55
+
Profile: profile,
56
+
AvatarUri: profileAvatarUri,
57
+
User: user,
58
+
}).Render(r.Context(), w)
59
+
}
60
+
61
+
func (s *State) HandleAddLanguage(w http.ResponseWriter, r *http.Request) {
62
+
if err := r.ParseForm(); err != nil {
63
+
htmx.HxOobUpdate(w, "update-profile-msg", "Failed to parse form.")
64
+
return
65
+
}
66
+
67
+
langCodeStr := r.PostFormValue("language_code")
68
+
if langCodeStr == "" {
69
+
htmx.HxOobUpdate(w, "update-profile-msg", "Language code is missing.")
70
+
return
71
+
}
72
+
73
+
newLangCode := db.LanguageCode(langCodeStr)
74
+
75
+
user := s.oauth.GetUser(r)
76
+
profile, err := db.GetProfile(s.db, user.Did)
77
+
if err != nil || profile == nil {
78
+
log.Printf("failed to find %s in db: %s", user.Did, err)
79
+
pages.NotFound().Render(r.Context(), w)
80
+
return
81
+
}
82
+
83
+
contains := slices.ContainsFunc(profile.Languages, func(l db.Language) bool {
84
+
return l.Code == newLangCode
85
+
})
86
+
if contains {
87
+
htmx.HxOobUpdate(w, "update-profile-msg", "Language already selected.")
88
+
return
89
+
}
90
+
profile.Languages = append(profile.Languages, db.Languages[newLangCode])
91
+
92
+
if err := db.ValidateProfile(s.db, profile); err != nil {
93
+
log.Println("invalid profile", err)
94
+
htmx.HxOobUpdate(w, "update-profile-msg", err.Error())
95
+
return
96
+
}
97
+
98
+
s.updateProfile(profile, w, r)
99
+
100
+
partials.SelectedLanguagesPartial(partials.SelectedLanguagesParams{
101
+
Languages: profile.Languages,
102
+
}).Render(r.Context(), w)
103
+
}
104
+
105
+
func (s *State) HandleRemoveLanguage(w http.ResponseWriter, r *http.Request) {
106
+
if err := r.ParseForm(); err != nil {
107
+
htmx.HxOobUpdate(w, "update-profile-msg", "Failed to parse form.")
108
+
return
109
+
}
110
+
111
+
langCodeStr := r.PostFormValue("language_code")
112
+
if langCodeStr == "" {
113
+
htmx.HxOobUpdate(w, "update-profile-msg", "Language code is missing.")
114
+
return
115
+
}
116
+
117
+
newLangCode := db.LanguageCode(langCodeStr)
118
+
119
+
user := s.oauth.GetUser(r)
120
+
profile, err := db.GetProfile(s.db, user.Did)
121
+
if err != nil || profile == nil {
122
+
log.Printf("failed to find %s in db: %s", user.Did, err)
123
+
pages.NotFound().Render(r.Context(), w)
124
+
return
125
+
}
126
+
127
+
for i, l := range profile.Languages {
128
+
if l.Code == newLangCode {
129
+
profile.Languages = append(profile.Languages[:i], profile.Languages[i+1:]...)
130
+
}
131
+
}
132
+
133
+
if err := db.ValidateProfile(s.db, profile); err != nil {
134
+
log.Println("invalid profile", err)
135
+
htmx.HxOobUpdate(w, "update-profile-msg", err.Error())
136
+
return
137
+
}
138
+
139
+
s.updateProfile(profile, w, r)
140
+
141
+
partials.SelectedLanguagesPartial(partials.SelectedLanguagesParams{
142
+
Languages: profile.Languages,
143
+
}).Render(r.Context(), w)
144
+
}
145
+
146
+
func (s *State) HandleEditBio(w http.ResponseWriter, r *http.Request) {
147
+
user := s.oauth.GetUser(r)
148
+
profile, err := db.GetProfile(s.db, user.Did)
149
+
if err != nil || profile == nil {
150
+
log.Printf("failed to find %s in db: %s", user.Did, err)
151
+
pages.NotFound().Render(r.Context(), w)
152
+
return
153
+
}
154
+
155
+
switch r.Method {
156
+
case http.MethodGet:
157
+
partials.EditBioPartial(partials.EditBioParams{
158
+
Profile: profile,
159
+
User: user,
160
+
}).Render(r.Context(), w)
161
+
case http.MethodPost:
162
+
err := r.ParseForm()
163
+
if err != nil {
164
+
log.Println("invalid profile update form", err)
165
+
htmx.HxOobUpdate(w, "update-profile-msg", "Invalid form.")
166
+
return
167
+
}
168
+
169
+
profile.DisplayName = r.FormValue("display-name")
170
+
profile.Description = r.FormValue("description")
171
+
profile.Location = r.FormValue("location")
172
+
173
+
var languages []db.Language
174
+
for i := range 10 {
175
+
l := r.FormValue(fmt.Sprintf("language%d", i))
176
+
if l == "" {
177
+
break
178
+
}
179
+
languages = append(languages, db.Languages[db.LanguageCode(l)])
180
+
}
181
+
profile.Languages = languages
182
+
183
+
if err := db.ValidateProfile(s.db, profile); err != nil {
184
+
log.Println("invalid profile", err)
185
+
htmx.HxOobUpdate(w, "update-profile-msg", err.Error())
186
+
return
187
+
}
188
+
189
+
s.updateProfile(profile, w, r)
190
+
htmx.HxRedirect(w, "/"+user.Did)
191
+
}
192
+
}
193
+
194
+
func (s *State) GetAvatarUri(actor string) (string, error) {
195
+
profileURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", actor)
196
+
197
+
profileResp, err := http.Get(profileURL)
198
+
if err != nil {
199
+
return "", fmt.Errorf("failed to fetch profile: %w", err)
200
+
}
201
+
defer profileResp.Body.Close()
202
+
203
+
if profileResp.StatusCode != http.StatusOK {
204
+
return "", fmt.Errorf("failed to get profile, status: %s", profileResp.Status)
205
+
}
206
+
207
+
var profile ProfileResponse
208
+
if err := json.NewDecoder(profileResp.Body).Decode(&profile); err != nil {
209
+
return "", fmt.Errorf("failed to decode profile JSON: %w", err)
210
+
}
211
+
212
+
if profile.Avatar == "" {
213
+
return "", fmt.Errorf("failed to find avatar for actor %s.", actor)
214
+
}
215
+
216
+
return profile.Avatar, nil
217
+
}
218
+
219
+
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
220
+
user := s.oauth.GetUser(r)
221
+
tx, err := s.db.BeginTx(r.Context(), nil)
222
+
if err != nil {
223
+
log.Println("failed to start transaction", err)
224
+
htmx.HxOobUpdate(w, "update-profile-msg", "Failed to update profile, try again later.")
225
+
return
226
+
}
227
+
228
+
client, err := s.oauth.AuthorizedClient(r)
229
+
if err != nil {
230
+
log.Println("failed to get authorized client", err)
231
+
htmx.HxOobUpdate(w, "update-profile-msg", "Failed to update profile, try again later.")
232
+
return
233
+
}
234
+
235
+
ex, _ := client.RepoGetRecord(r.Context(), "", "app.yoten.actor.profile", user.Did, "self")
236
+
var cid *string
237
+
if ex != nil {
238
+
cid = ex.Cid
239
+
}
240
+
241
+
languages := make([]string, 0, len(profile.Languages))
242
+
for _, lc := range profile.Languages {
243
+
languages = append(languages, string(lc.Code))
244
+
}
245
+
246
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
247
+
Collection: "app.yoten.actor.profile",
248
+
Repo: user.Did,
249
+
Rkey: "self",
250
+
Record: &lexutil.LexiconTypeDecoder{
251
+
Val: &yoten.ActorProfile{
252
+
DisplayName: profile.DisplayName,
253
+
Description: &profile.Description,
254
+
Location: &profile.Location,
255
+
Languages: languages,
256
+
CreatedAt: profile.CreatedAt.Format(time.RFC3339),
257
+
}},
258
+
SwapRecord: cid,
259
+
})
260
+
if err != nil {
261
+
log.Println("failed to put profile record", err)
262
+
htmx.HxOobUpdate(w, "update-profile-msg", "Failed to update PDS, try again later.")
263
+
return
264
+
}
265
+
266
+
err = db.UpsertProfile(tx, profile)
267
+
if err != nil {
268
+
log.Println("failed to upsert profile", err)
269
+
htmx.HxOobUpdate(w, "update-profile-msg", "Failed to update profile, try again later.")
270
+
return
271
+
}
272
+
}
+34
-2
internal/web/state/router.go
+34
-2
internal/web/state/router.go
···
2
2
3
3
import (
4
4
"net/http"
5
+
"strings"
5
6
6
7
"github.com/go-chi/chi/v5"
7
8
"github.com/gorilla/sessions"
8
9
"yoten.app/internal/web/middleware"
9
10
oauthhandler "yoten.app/internal/web/oauth/handler"
11
+
"yoten.app/internal/web/pages"
10
12
)
11
13
12
14
func (s *State) Router() http.Handler {
···
14
16
middleware := middleware.New(
15
17
s.oauth,
16
18
s.db,
19
+
s.idResolver,
17
20
)
18
21
19
22
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
20
-
s.StandardRouter(&middleware).ServeHTTP(w, r)
23
+
pat := chi.URLParam(r, "*")
24
+
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
25
+
s.UserRouter(&middleware).ServeHTTP(w, r)
26
+
} else {
27
+
s.StandardRouter(&middleware).ServeHTTP(w, r)
28
+
}
21
29
})
22
30
23
31
return router
···
27
35
r := chi.NewRouter()
28
36
29
37
r.Get("/", s.HandleIndex)
30
-
31
38
r.Mount("/", s.OAuthRouter())
39
+
40
+
r.Route("/profile", func(r chi.Router) {
41
+
r.Use(middleware.AuthMiddleware(s.oauth))
42
+
r.Get("/edit-bio", s.HandleEditBio)
43
+
r.Post("/edit-bio", s.HandleEditBio)
44
+
r.Post("/add-language", s.HandleAddLanguage)
45
+
r.Post("/remove-language", s.HandleRemoveLanguage)
46
+
})
47
+
48
+
return r
49
+
}
50
+
51
+
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
52
+
r := chi.NewRouter()
53
+
54
+
// Strip @ from user.
55
+
r.Use(middleware.StripLeadingAt)
56
+
57
+
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
58
+
r.Get("/", s.HandleProfile)
59
+
})
60
+
61
+
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
62
+
pages.NotFoundComponent().Render(r.Context(), w)
63
+
})
32
64
33
65
return r
34
66
}
+36
-6
internal/web/state/state.go
+36
-6
internal/web/state/state.go
···
1
1
package state
2
2
3
3
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
4
7
"net/http"
5
8
6
9
"yoten.app/internal/web/config"
7
10
"yoten.app/internal/web/db"
11
+
"yoten.app/internal/web/ingester"
12
+
"yoten.app/internal/web/jetstream"
8
13
"yoten.app/internal/web/oauth"
9
14
"yoten.app/internal/web/pages"
15
+
"yoten.app/internal/web/resolver"
10
16
)
11
17
12
18
type State struct {
13
-
db *db.DB
14
-
oauth *oauth.OAuth
15
-
config *config.Config
19
+
db *db.DB
20
+
oauth *oauth.OAuth
21
+
config *config.Config
22
+
idResolver *resolver.Resolver
16
23
}
17
24
18
25
func Make(config *config.Config) (*State, error) {
···
23
30
24
31
oauth := oauth.NewOAuth(d, config)
25
32
33
+
idResolver := resolver.DefaultResolver()
34
+
35
+
wrapper := db.DbWrapper{d}
36
+
jc, err := jetstream.NewJetstreamClient(
37
+
config.Jetstream.Endpoint,
38
+
"appview",
39
+
[]string{
40
+
"app.yoten.actor.profile",
41
+
},
42
+
nil,
43
+
slog.Default(),
44
+
wrapper,
45
+
false,
46
+
)
47
+
if err != nil {
48
+
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
49
+
}
50
+
err = jc.StartJetstream(context.Background(), ingester.Ingest(wrapper))
51
+
if err != nil {
52
+
return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
53
+
}
54
+
26
55
state := &State{
27
-
db: d,
28
-
oauth: oauth,
29
-
config: config,
56
+
db: d,
57
+
oauth: oauth,
58
+
config: config,
59
+
idResolver: idResolver,
30
60
}
31
61
32
62
return state, nil
+46
internal/web/xrpcclient/xrpc.go
+46
internal/web/xrpcclient/xrpc.go
···
1
+
package xrpcclient
2
+
3
+
import (
4
+
"context"
5
+
6
+
"github.com/bluesky-social/indigo/api/atproto"
7
+
"github.com/bluesky-social/indigo/xrpc"
8
+
oauth "tangled.sh/icyphox.sh/atproto-oauth"
9
+
)
10
+
11
+
type Client struct {
12
+
*oauth.XrpcClient
13
+
authArgs *oauth.XrpcAuthedRequestArgs
14
+
}
15
+
16
+
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
17
+
return &Client{
18
+
XrpcClient: client,
19
+
authArgs: authArgs,
20
+
}
21
+
}
22
+
23
+
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
24
+
var out atproto.RepoPutRecord_Output
25
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
return &out, nil
30
+
}
31
+
32
+
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
33
+
var out atproto.RepoGetRecord_Output
34
+
35
+
params := map[string]interface{}{
36
+
"cid": cid,
37
+
"collection": collection,
38
+
"repo": repo,
39
+
"rkey": rkey,
40
+
}
41
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
42
+
return nil, err
43
+
}
44
+
45
+
return &out, nil
46
+
}
+10
lexicon-build-config.json
+10
lexicon-build-config.json
+50
lexicons/actor/profile.json
+50
lexicons/actor/profile.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "app.yoten.actor.profile",
4
+
"defs": {
5
+
"main": {
6
+
"type": "record",
7
+
"description": "A declaration of a Yōten account profile.",
8
+
"key": "literal:self",
9
+
"record": {
10
+
"type": "object",
11
+
"required": ["displayName", "createdAt"],
12
+
"properties": {
13
+
"displayName": {
14
+
"type": "string",
15
+
"maxGraphemes": 64,
16
+
"maxLength": 640
17
+
},
18
+
"description": {
19
+
"type": "string",
20
+
"description": "Free-form profile description text.",
21
+
"maxGraphemes": 256,
22
+
"maxLength": 2560
23
+
},
24
+
"location": {
25
+
"type": "string",
26
+
"description": "Free-form location text.",
27
+
"maxGraphemes": 40,
28
+
"maxLength": 400
29
+
},
30
+
"languages": {
31
+
"type": "array",
32
+
"minLength": 0,
33
+
"maxLength": 10,
34
+
"items": {
35
+
"type": "string",
36
+
"description": "An ISO 639-1 two-letter language code (e.g., 'en', 'es', 'ko').",
37
+
"minLength": 2,
38
+
"maxLength": 2
39
+
}
40
+
},
41
+
"createdAt": {
42
+
"type": "string",
43
+
"format": "datetime"
44
+
}
45
+
}
46
+
}
47
+
}
48
+
}
49
+
}
50
+