Yōten: A social tracker for your language learning journey built on the atproto.

feat(actor.profile): add app.yoten.actor.profile lexicon and logic to parse and save from db and jetstream

brookjeynes.dev 8b101531 837d482d

verified
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + package db 2 + 3 + func ToPtr[T any](v T) *T { 4 + return &v 5 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + package pages 2 + 3 + import "yoten.app/internal/web/pages/templates" 4 + 5 + templ NotFound() { 6 + @templates.BaseLayout("404") { 7 + <span> 8 + 404 9 + </span> 10 + } 11 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + [ 2 + { 3 + "package": "yoten", 4 + "prefix": "app.yoten", 5 + "outdir": "api/yoten", 6 + "import": "yoten.app/api/yoten", 7 + "gen-server": true 8 + } 9 + ] 10 +
+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 +