forked from hailey.at/cocoon
An atproto PDS written in Go

Compare changes

Choose any two refs to compare.

Changed files
+4333 -805
blockstore
cmd
admin
cocoon
identity
internal
db
helpers
oauth
recording_blockstore
server
sqlite_blockstore
+2
.env.example
··· 6 6 COCOON_RELAYS=https://bsky.network 7 7 # Generate with `openssl rand -hex 16` 8 8 COCOON_ADMIN_PASSWORD= 9 + # openssl rand -hex 32 10 + COCOON_SESSION_SECRET=
+2
.gitignore
··· 2 2 .env 3 3 /cocoon 4 4 *.key 5 + *.secret 6 + .DS_Store
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 me@haileyok.com 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+68 -59
README.md
··· 5 5 6 6 Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use. 7 7 8 - ### Impmlemented Endpoints 8 + ## Implemented Endpoints 9 9 10 10 > [!NOTE] 11 - Just because something is implemented doesn't mean it is finisehd. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that. 11 + Just because something is implemented doesn't mean it is finished. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that. 12 12 13 - #### Identity 14 - - [ ] com.atproto.identity.getRecommendedDidCredentials 15 - - [ ] com.atproto.identity.requestPlcOperationSignature 16 - - [x] com.atproto.identity.resolveHandle 17 - - [ ] com.atproto.identity.signPlcOperation 18 - - [ ] com.atproto.identity.submitPlcOperatioin 19 - - [x] com.atproto.identity.updateHandle 13 + ### Identity 14 + 15 + - [ ] `com.atproto.identity.getRecommendedDidCredentials` 16 + - [ ] `com.atproto.identity.requestPlcOperationSignature` 17 + - [x] `com.atproto.identity.resolveHandle` 18 + - [ ] `com.atproto.identity.signPlcOperation` 19 + - [ ] `com.atproto.identity.submitPlcOperation` 20 + - [x] `com.atproto.identity.updateHandle` 21 + 22 + ### Repo 23 + 24 + - [x] `com.atproto.repo.applyWrites` 25 + - [x] `com.atproto.repo.createRecord` 26 + - [x] `com.atproto.repo.putRecord` 27 + - [x] `com.atproto.repo.deleteRecord` 28 + - [x] `com.atproto.repo.describeRepo` 29 + - [x] `com.atproto.repo.getRecord` 30 + - [x] `com.atproto.repo.importRepo` (Works "okay". You still have to handle PLC operations on your own when migrating. Use with extreme caution.) 31 + - [x] `com.atproto.repo.listRecords` 32 + - [ ] `com.atproto.repo.listMissingBlobs` 33 + 34 + ### Server 35 + 36 + - [ ] `com.atproto.server.activateAccount` 37 + - [x] `com.atproto.server.checkAccountStatus` 38 + - [x] `com.atproto.server.confirmEmail` 39 + - [x] `com.atproto.server.createAccount` 40 + - [x] `com.atproto.server.createInviteCode` 41 + - [x] `com.atproto.server.createInviteCodes` 42 + - [ ] `com.atproto.server.deactivateAccount` 43 + - [ ] `com.atproto.server.deleteAccount` 44 + - [x] `com.atproto.server.deleteSession` 45 + - [x] `com.atproto.server.describeServer` 46 + - [ ] `com.atproto.server.getAccountInviteCodes` 47 + - [ ] `com.atproto.server.getServiceAuth` 48 + - ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords 49 + - [x] `com.atproto.server.refreshSession` 50 + - [ ] `com.atproto.server.requestAccountDelete` 51 + - [x] `com.atproto.server.requestEmailConfirmation` 52 + - [x] `com.atproto.server.requestEmailUpdate` 53 + - [x] `com.atproto.server.requestPasswordReset` 54 + - [ ] `com.atproto.server.reserveSigningKey` 55 + - [x] `com.atproto.server.resetPassword` 56 + - ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords 57 + - [x] `com.atproto.server.updateEmail` 58 + 59 + ### Sync 60 + 61 + - [x] `com.atproto.sync.getBlob` 62 + - [x] `com.atproto.sync.getBlocks` 63 + - [x] `com.atproto.sync.getLatestCommit` 64 + - [x] `com.atproto.sync.getRecord` 65 + - [x] `com.atproto.sync.getRepoStatus` 66 + - [x] `com.atproto.sync.getRepo` 67 + - [x] `com.atproto.sync.listBlobs` 68 + - [x] `com.atproto.sync.listRepos` 69 + - ~~[ ] `com.atproto.sync.notifyOfUpdate`~~ - BGS doesn't even have this implemented lol 70 + - [x] `com.atproto.sync.requestCrawl` 71 + - [x] `com.atproto.sync.subscribeRepos` 20 72 21 - #### Repo 22 - - [x] com.atproto.repo.applyWrites 23 - - [x] com.atproto.repo.createRecord 24 - - [x] com.atproto.repo.putRecord 25 - - [x] com.atproto.repo.deleteRecord 26 - - [x] com.atproto.repo.describeRepo 27 - - [x] com.atproto.repo.getRecord 28 - - [ ] com.atproto.repo.importRepo 29 - - [x] com.atproto.repo.listRecords 30 - - [ ] com.atproto.repo.listMissingBlobs 73 + ### Other 31 74 32 - #### Server 33 - - [ ] com.atproto.server.activateAccount 34 - - [ ] com.atproto.server.checkAccountStatus 35 - - [x] com.atproto.server.confirmEmail 36 - - [x] com.atproto.server.createAccount 37 - - [x] com.atproto.server.createInviteCode 38 - - [x] com.atproto.server.createInviteCodes 39 - - [ ] com.atproto.server.deactivateAccount 40 - - [ ] com.atproto.server.deleteAccount 41 - - [x] com.atproto.server.deleteSession 42 - - [x] com.atproto.server.describeServer 43 - - [ ] com.atproto.server.getAccountInviteCodes 44 - - [ ] com.atproto.server.getServiceAuth 45 - - ~[ ] com.atproto.server.listAppPasswords~ - not going to add app passwords 46 - - [x] com.atproto.server.refreshSession 47 - - [ ] com.atproto.server.requestAccountDelete 48 - - [x] com.atproto.server.requestEmailConfirmation 49 - - [x] com.atproto.server.requestEmailUpdate 50 - - [x] com.atproto.server.requestPasswordReset 51 - - [ ] com.atproto.server.reserveSigningKey 52 - - [x] com.atproto.server.resetPassword 53 - - ~[ ] com.atproto.server.revokeAppPassword~ - not going to add app passwords 54 - - [x] com.atproto.server.updateEmail 75 + - [ ] `com.atproto.label.queryLabels` 76 + - [x] `com.atproto.moderation.createReport` (Note: this should be handled by proxying, not actually implemented in the PDS) 77 + - [x] `app.bsky.actor.getPreferences` 78 + - [x] `app.bsky.actor.putPreferences` 55 79 56 - #### Sync 57 - - [x] com.atproto.sync.getBlob 58 - - [x] com.atproto.sync.getBlocks 59 - - [x] com.atproto.sync.getLatestCommit 60 - - [x] com.atproto.sync.getRecord 61 - - [x] com.atproto.sync.getRepoStatus 62 - - [x] com.atproto.sync.getRepo 63 - - [x] com.atproto.sync.listBlobs 64 - - [x] com.atproto.sync.listRepos 65 - - ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol 66 - - [x] com.atproto.sync.requestCrawl 67 - - [x] com.atproto.sync.subscribeRepos 80 + ## License 68 81 69 - #### Other 70 - - [ ] com.atproto.label.queryLabels 71 - - [ ] com.atproto.moderation.createReport 72 - - [x] app.bsky.actor.getPreferences 73 - - [x] app.bsky.actor.putPreferences 82 + This project is licensed under MIT license. `server/static/pico.css` is also licensed under MIT license, available at [https://github.com/picocss/pico/](https://github.com/picocss/pico/).
-126
blockstore/blockstore.go
··· 1 - package blockstore 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - "github.com/haileyok/cocoon/models" 9 - blocks "github.com/ipfs/go-block-format" 10 - "github.com/ipfs/go-cid" 11 - "gorm.io/gorm" 12 - "gorm.io/gorm/clause" 13 - ) 14 - 15 - type SqliteBlockstore struct { 16 - db *gorm.DB 17 - did string 18 - readonly bool 19 - inserts []blocks.Block 20 - } 21 - 22 - func New(did string, db *gorm.DB) *SqliteBlockstore { 23 - return &SqliteBlockstore{ 24 - did: did, 25 - db: db, 26 - readonly: false, 27 - inserts: []blocks.Block{}, 28 - } 29 - } 30 - 31 - func NewReadOnly(did string, db *gorm.DB) *SqliteBlockstore { 32 - return &SqliteBlockstore{ 33 - did: did, 34 - db: db, 35 - readonly: true, 36 - inserts: []blocks.Block{}, 37 - } 38 - } 39 - 40 - func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) { 41 - var block models.Block 42 - if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", bs.did, cid.Bytes()).Scan(&block).Error; err != nil { 43 - return nil, err 44 - } 45 - 46 - b, err := blocks.NewBlockWithCid(block.Value, cid) 47 - if err != nil { 48 - return nil, err 49 - } 50 - 51 - return b, nil 52 - } 53 - 54 - func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error { 55 - bs.inserts = append(bs.inserts, block) 56 - 57 - if bs.readonly { 58 - return nil 59 - } 60 - 61 - b := models.Block{ 62 - Did: bs.did, 63 - Cid: block.Cid().Bytes(), 64 - Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 65 - Value: block.RawData(), 66 - } 67 - 68 - if err := bs.db.Clauses(clause.OnConflict{ 69 - Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 70 - UpdateAll: true, 71 - }).Create(&b).Error; err != nil { 72 - return err 73 - } 74 - 75 - return nil 76 - } 77 - 78 - func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error { 79 - panic("not implemented") 80 - } 81 - 82 - func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) { 83 - panic("not implemented") 84 - } 85 - 86 - func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) { 87 - panic("not implemented") 88 - } 89 - 90 - func (bs *SqliteBlockstore) PutMany(context.Context, []blocks.Block) error { 91 - panic("not implemented") 92 - } 93 - 94 - func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 95 - panic("not implemented") 96 - } 97 - 98 - func (bs *SqliteBlockstore) HashOnRead(enabled bool) { 99 - panic("not implemented") 100 - } 101 - 102 - func (bs *SqliteBlockstore) UpdateRepo(ctx context.Context, root cid.Cid, rev string) error { 103 - if err := bs.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", root.Bytes(), rev, bs.did).Error; err != nil { 104 - return err 105 - } 106 - 107 - return nil 108 - } 109 - 110 - func (bs *SqliteBlockstore) Execute(ctx context.Context) error { 111 - if !bs.readonly { 112 - return fmt.Errorf("blockstore was not readonly") 113 - } 114 - 115 - bs.readonly = false 116 - for _, b := range bs.inserts { 117 - bs.Put(ctx, b) 118 - } 119 - bs.readonly = true 120 - 121 - return nil 122 - } 123 - 124 - func (bs *SqliteBlockstore) GetLog() []blocks.Block { 125 - return bs.inserts 126 - }
-186
cmd/admin/main.go
··· 1 - package main 2 - 3 - import ( 4 - "crypto/ecdsa" 5 - "crypto/elliptic" 6 - "crypto/rand" 7 - "encoding/json" 8 - "fmt" 9 - "os" 10 - "time" 11 - 12 - "github.com/bluesky-social/indigo/atproto/crypto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/haileyok/cocoon/internal/helpers" 15 - "github.com/lestrrat-go/jwx/v2/jwk" 16 - "github.com/urfave/cli/v2" 17 - "golang.org/x/crypto/bcrypt" 18 - "gorm.io/driver/sqlite" 19 - "gorm.io/gorm" 20 - ) 21 - 22 - func main() { 23 - app := cli.App{ 24 - Name: "admin", 25 - Commands: cli.Commands{ 26 - runCreateRotationKey, 27 - runCreatePrivateJwk, 28 - runCreateInviteCode, 29 - runResetPassword, 30 - }, 31 - ErrWriter: os.Stdout, 32 - } 33 - 34 - app.Run(os.Args) 35 - } 36 - 37 - var runCreateRotationKey = &cli.Command{ 38 - Name: "create-rotation-key", 39 - Usage: "creates a rotation key for your pds", 40 - Flags: []cli.Flag{ 41 - &cli.StringFlag{ 42 - Name: "out", 43 - Required: true, 44 - Usage: "output file for your rotation key", 45 - }, 46 - }, 47 - Action: func(cmd *cli.Context) error { 48 - key, err := crypto.GeneratePrivateKeyK256() 49 - if err != nil { 50 - return err 51 - } 52 - 53 - bytes := key.Bytes() 54 - 55 - if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil { 56 - return err 57 - } 58 - 59 - return nil 60 - }, 61 - } 62 - 63 - var runCreatePrivateJwk = &cli.Command{ 64 - Name: "create-private-jwk", 65 - Usage: "creates a private jwk for your pds", 66 - Flags: []cli.Flag{ 67 - &cli.StringFlag{ 68 - Name: "out", 69 - Required: true, 70 - Usage: "output file for your jwk", 71 - }, 72 - }, 73 - Action: func(cmd *cli.Context) error { 74 - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 75 - if err != nil { 76 - return err 77 - } 78 - 79 - key, err := jwk.FromRaw(privKey) 80 - if err != nil { 81 - return err 82 - } 83 - 84 - kid := fmt.Sprintf("%d", time.Now().Unix()) 85 - 86 - if err := key.Set(jwk.KeyIDKey, kid); err != nil { 87 - return err 88 - } 89 - 90 - b, err := json.Marshal(key) 91 - if err != nil { 92 - return err 93 - } 94 - 95 - if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil { 96 - return err 97 - } 98 - 99 - return nil 100 - }, 101 - } 102 - 103 - var runCreateInviteCode = &cli.Command{ 104 - Name: "create-invite-code", 105 - Usage: "creates an invite code", 106 - Flags: []cli.Flag{ 107 - &cli.StringFlag{ 108 - Name: "for", 109 - Usage: "optional did to assign the invite code to", 110 - }, 111 - &cli.IntFlag{ 112 - Name: "uses", 113 - Usage: "number of times the invite code can be used", 114 - Value: 1, 115 - }, 116 - }, 117 - Action: func(cmd *cli.Context) error { 118 - db, err := newDb() 119 - if err != nil { 120 - return err 121 - } 122 - 123 - forDid := "did:plc:123" 124 - if cmd.String("for") != "" { 125 - did, err := syntax.ParseDID(cmd.String("for")) 126 - if err != nil { 127 - return err 128 - } 129 - 130 - forDid = did.String() 131 - } 132 - 133 - uses := cmd.Int("uses") 134 - 135 - code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8)) 136 - 137 - if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil { 138 - return err 139 - } 140 - 141 - fmt.Printf("New invite code created with %d uses: %s\n", uses, code) 142 - 143 - return nil 144 - }, 145 - } 146 - 147 - var runResetPassword = &cli.Command{ 148 - Name: "reset-password", 149 - Usage: "resets a password", 150 - Flags: []cli.Flag{ 151 - &cli.StringFlag{ 152 - Name: "did", 153 - Usage: "did of the user who's password you want to reset", 154 - }, 155 - }, 156 - Action: func(cmd *cli.Context) error { 157 - db, err := newDb() 158 - if err != nil { 159 - return err 160 - } 161 - 162 - didStr := cmd.String("did") 163 - did, err := syntax.ParseDID(didStr) 164 - if err != nil { 165 - return err 166 - } 167 - 168 - newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12)) 169 - hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10) 170 - if err != nil { 171 - return err 172 - } 173 - 174 - if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil { 175 - return err 176 - } 177 - 178 - fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass) 179 - 180 - return nil 181 - }, 182 - } 183 - 184 - func newDb() (*gorm.DB, error) { 185 - return gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{}) 186 - }
+219 -2
cmd/cocoon/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/rand" 7 + "encoding/json" 4 8 "fmt" 5 9 "os" 10 + "time" 6 11 12 + "github.com/bluesky-social/indigo/atproto/crypto" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/haileyok/cocoon/internal/helpers" 7 15 "github.com/haileyok/cocoon/server" 8 16 _ "github.com/joho/godotenv/autoload" 17 + "github.com/lestrrat-go/jwx/v2/jwk" 9 18 "github.com/urfave/cli/v2" 19 + "golang.org/x/crypto/bcrypt" 20 + "gorm.io/driver/sqlite" 21 + "gorm.io/gorm" 10 22 ) 11 23 12 24 var Version = "dev" ··· 91 103 Required: false, 92 104 EnvVars: []string{"COCOON_SMTP_NAME"}, 93 105 }, 106 + &cli.BoolFlag{ 107 + Name: "s3-backups-enabled", 108 + EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"}, 109 + }, 110 + &cli.StringFlag{ 111 + Name: "s3-region", 112 + EnvVars: []string{"COCOON_S3_REGION"}, 113 + }, 114 + &cli.StringFlag{ 115 + Name: "s3-bucket", 116 + EnvVars: []string{"COCOON_S3_BUCKET"}, 117 + }, 118 + &cli.StringFlag{ 119 + Name: "s3-endpoint", 120 + EnvVars: []string{"COCOON_S3_ENDPOINT"}, 121 + }, 122 + &cli.StringFlag{ 123 + Name: "s3-access-key", 124 + EnvVars: []string{"COCOON_S3_ACCESS_KEY"}, 125 + }, 126 + &cli.StringFlag{ 127 + Name: "s3-secret-key", 128 + EnvVars: []string{"COCOON_S3_SECRET_KEY"}, 129 + }, 130 + &cli.StringFlag{ 131 + Name: "session-secret", 132 + EnvVars: []string{"COCOON_SESSION_SECRET"}, 133 + }, 134 + &cli.StringFlag{ 135 + Name: "default-atproto-proxy", 136 + EnvVars: []string{"COCOON_DEFAULT_ATPROTO_PROXY"}, 137 + Value: "did:web:api.bsky.app#bsky_appview", 138 + }, 139 + &cli.StringFlag{ 140 + Name: "blockstore-variant", 141 + EnvVars: []string{"COCOON_BLOCKSTORE_VARIANT"}, 142 + Value: "sqlite", 143 + }, 94 144 }, 95 145 Commands: []*cli.Command{ 96 - run, 146 + runServe, 147 + runCreateRotationKey, 148 + runCreatePrivateJwk, 149 + runCreateInviteCode, 150 + runResetPassword, 97 151 }, 98 152 ErrWriter: os.Stdout, 99 153 Version: Version, ··· 104 158 } 105 159 } 106 160 107 - var run = &cli.Command{ 161 + var runServe = &cli.Command{ 108 162 Name: "run", 109 163 Usage: "Start the cocoon PDS", 110 164 Flags: []cli.Flag{}, 111 165 Action: func(cmd *cli.Context) error { 166 + 112 167 s, err := server.New(&server.Args{ 113 168 Addr: cmd.String("addr"), 114 169 DbName: cmd.String("db-name"), ··· 126 181 SmtpPort: cmd.String("smtp-port"), 127 182 SmtpEmail: cmd.String("smtp-email"), 128 183 SmtpName: cmd.String("smtp-name"), 184 + S3Config: &server.S3Config{ 185 + BackupsEnabled: cmd.Bool("s3-backups-enabled"), 186 + Region: cmd.String("s3-region"), 187 + Bucket: cmd.String("s3-bucket"), 188 + Endpoint: cmd.String("s3-endpoint"), 189 + AccessKey: cmd.String("s3-access-key"), 190 + SecretKey: cmd.String("s3-secret-key"), 191 + }, 192 + SessionSecret: cmd.String("session-secret"), 193 + DefaultAtprotoProxy: cmd.String("default-atproto-proxy"), 194 + BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")), 129 195 }) 130 196 if err != nil { 131 197 fmt.Printf("error creating cocoon: %v", err) ··· 140 206 return nil 141 207 }, 142 208 } 209 + 210 + var runCreateRotationKey = &cli.Command{ 211 + Name: "create-rotation-key", 212 + Usage: "creates a rotation key for your pds", 213 + Flags: []cli.Flag{ 214 + &cli.StringFlag{ 215 + Name: "out", 216 + Required: true, 217 + Usage: "output file for your rotation key", 218 + }, 219 + }, 220 + Action: func(cmd *cli.Context) error { 221 + key, err := crypto.GeneratePrivateKeyK256() 222 + if err != nil { 223 + return err 224 + } 225 + 226 + bytes := key.Bytes() 227 + 228 + if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil { 229 + return err 230 + } 231 + 232 + return nil 233 + }, 234 + } 235 + 236 + var runCreatePrivateJwk = &cli.Command{ 237 + Name: "create-private-jwk", 238 + Usage: "creates a private jwk for your pds", 239 + Flags: []cli.Flag{ 240 + &cli.StringFlag{ 241 + Name: "out", 242 + Required: true, 243 + Usage: "output file for your jwk", 244 + }, 245 + }, 246 + Action: func(cmd *cli.Context) error { 247 + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 248 + if err != nil { 249 + return err 250 + } 251 + 252 + key, err := jwk.FromRaw(privKey) 253 + if err != nil { 254 + return err 255 + } 256 + 257 + kid := fmt.Sprintf("%d", time.Now().Unix()) 258 + 259 + if err := key.Set(jwk.KeyIDKey, kid); err != nil { 260 + return err 261 + } 262 + 263 + b, err := json.Marshal(key) 264 + if err != nil { 265 + return err 266 + } 267 + 268 + if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil { 269 + return err 270 + } 271 + 272 + return nil 273 + }, 274 + } 275 + 276 + var runCreateInviteCode = &cli.Command{ 277 + Name: "create-invite-code", 278 + Usage: "creates an invite code", 279 + Flags: []cli.Flag{ 280 + &cli.StringFlag{ 281 + Name: "for", 282 + Usage: "optional did to assign the invite code to", 283 + }, 284 + &cli.IntFlag{ 285 + Name: "uses", 286 + Usage: "number of times the invite code can be used", 287 + Value: 1, 288 + }, 289 + }, 290 + Action: func(cmd *cli.Context) error { 291 + db, err := newDb() 292 + if err != nil { 293 + return err 294 + } 295 + 296 + forDid := "did:plc:123" 297 + if cmd.String("for") != "" { 298 + did, err := syntax.ParseDID(cmd.String("for")) 299 + if err != nil { 300 + return err 301 + } 302 + 303 + forDid = did.String() 304 + } 305 + 306 + uses := cmd.Int("uses") 307 + 308 + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8)) 309 + 310 + if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil { 311 + return err 312 + } 313 + 314 + fmt.Printf("New invite code created with %d uses: %s\n", uses, code) 315 + 316 + return nil 317 + }, 318 + } 319 + 320 + var runResetPassword = &cli.Command{ 321 + Name: "reset-password", 322 + Usage: "resets a password", 323 + Flags: []cli.Flag{ 324 + &cli.StringFlag{ 325 + Name: "did", 326 + Usage: "did of the user who's password you want to reset", 327 + }, 328 + }, 329 + Action: func(cmd *cli.Context) error { 330 + db, err := newDb() 331 + if err != nil { 332 + return err 333 + } 334 + 335 + didStr := cmd.String("did") 336 + did, err := syntax.ParseDID(didStr) 337 + if err != nil { 338 + return err 339 + } 340 + 341 + newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12)) 342 + hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10) 343 + if err != nil { 344 + return err 345 + } 346 + 347 + if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil { 348 + return err 349 + } 350 + 351 + fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass) 352 + 353 + return nil 354 + }, 355 + } 356 + 357 + func newDb() (*gorm.DB, error) { 358 + return gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{}) 359 + }
+45
cspell.json
··· 1 + { 2 + "version": "0.2", 3 + "language": "en", 4 + "words": [ 5 + "atproto", 6 + "bsky", 7 + "Cocoon", 8 + "PDS", 9 + "Plc", 10 + "plc", 11 + "repo", 12 + "InviteCodes", 13 + "InviteCode", 14 + "Invite", 15 + "Signin", 16 + "Signout", 17 + "JWKS", 18 + "dpop", 19 + "BGS", 20 + "pico", 21 + "picocss", 22 + "par", 23 + "blobs", 24 + "blob", 25 + "did", 26 + "DID", 27 + "OAuth", 28 + "oauth", 29 + "par", 30 + "Cocoon", 31 + "memcache", 32 + "db", 33 + "helpers", 34 + "middleware", 35 + "repo", 36 + "static", 37 + "pico", 38 + "picocss", 39 + "MIT", 40 + "Go" 41 + ], 42 + "ignorePaths": [ 43 + "server/static/pico.css" 44 + ] 45 + }
+28 -29
go.mod
··· 4 4 5 5 require ( 6 6 github.com/Azure/go-autorest/autorest/to v0.4.1 7 + github.com/aws/aws-sdk-go v1.55.7 7 8 github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b 8 9 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 10 + github.com/domodwyer/mailyak/v3 v3.6.2 11 + github.com/go-pkgz/expirable-cache/v3 v3.0.0 9 12 github.com/go-playground/validator v9.31.0+incompatible 10 13 github.com/golang-jwt/jwt/v4 v4.5.2 11 14 github.com/google/uuid v1.4.0 15 + github.com/gorilla/sessions v1.4.0 16 + github.com/gorilla/websocket v1.5.1 17 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 18 + github.com/hashicorp/golang-lru/v2 v2.0.7 12 19 github.com/ipfs/go-block-format v0.2.0 13 20 github.com/ipfs/go-cid v0.4.1 14 21 github.com/ipfs/go-ipld-cbor v0.1.0 15 22 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 16 23 github.com/joho/godotenv v1.5.1 24 + github.com/labstack/echo-contrib v0.17.4 17 25 github.com/labstack/echo/v4 v4.13.3 18 26 github.com/lestrrat-go/jwx/v2 v2.0.12 27 + github.com/multiformats/go-multihash v0.2.3 19 28 github.com/samber/slog-echo v1.16.1 20 29 github.com/urfave/cli/v2 v2.27.6 21 - golang.org/x/crypto v0.36.0 30 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 31 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b 32 + golang.org/x/crypto v0.38.0 22 33 gorm.io/driver/sqlite v1.5.7 23 34 gorm.io/gorm v1.25.12 24 35 ) ··· 27 38 github.com/Azure/go-autorest v14.2.0+incompatible // indirect 28 39 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 29 40 github.com/beorn7/perks v1.0.1 // indirect 30 - github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect 31 41 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 32 - github.com/cespare/xxhash/v2 v2.2.0 // indirect 42 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 33 43 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 34 44 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 35 - github.com/domodwyer/mailyak/v3 v3.6.2 // indirect 36 45 github.com/felixge/httpsnoop v1.0.4 // indirect 37 46 github.com/go-logr/logr v1.4.2 // indirect 38 47 github.com/go-logr/stdr v1.2.2 // indirect ··· 41 50 github.com/goccy/go-json v0.10.2 // indirect 42 51 github.com/gocql/gocql v1.7.0 // indirect 43 52 github.com/gogo/protobuf v1.3.2 // indirect 44 - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 45 53 github.com/golang/snappy v0.0.4 // indirect 46 - github.com/gorilla/websocket v1.5.1 // indirect 54 + github.com/gorilla/context v1.1.2 // indirect 55 + github.com/gorilla/securecookie v1.1.2 // indirect 47 56 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 48 57 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 49 58 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 50 59 github.com/hashicorp/golang-lru v1.0.2 // indirect 51 - github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect 52 - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 53 60 github.com/ipfs/bbloom v0.0.4 // indirect 54 61 github.com/ipfs/go-blockservice v0.5.2 // indirect 55 62 github.com/ipfs/go-datastore v0.6.0 // indirect ··· 65 72 github.com/ipfs/go-merkledag v0.11.0 // indirect 66 73 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 67 74 github.com/ipfs/go-verifcid v0.0.3 // indirect 68 - github.com/ipld/go-car/v2 v2.13.1 // indirect 69 75 github.com/ipld/go-codec-dagpb v1.6.0 // indirect 70 76 github.com/ipld/go-ipld-prime v0.21.0 // indirect 71 77 github.com/jackc/pgpassfile v1.0.0 // indirect ··· 75 81 github.com/jbenet/goprocess v0.1.4 // indirect 76 82 github.com/jinzhu/inflection v1.0.0 // indirect 77 83 github.com/jinzhu/now v1.1.5 // indirect 84 + github.com/jmespath/go-jmespath v0.4.0 // indirect 78 85 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 79 86 github.com/labstack/gommon v0.4.2 // indirect 80 87 github.com/leodido/go-urn v1.4.0 // indirect ··· 83 90 github.com/lestrrat-go/httprc v1.0.4 // indirect 84 91 github.com/lestrrat-go/iter v1.0.2 // indirect 85 92 github.com/lestrrat-go/option v1.0.1 // indirect 86 - github.com/mattn/go-colorable v0.1.13 // indirect 93 + github.com/mattn/go-colorable v0.1.14 // indirect 87 94 github.com/mattn/go-isatty v0.0.20 // indirect 88 95 github.com/mattn/go-sqlite3 v1.14.22 // indirect 89 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 90 96 github.com/minio/sha256-simd v1.0.1 // indirect 91 97 github.com/mr-tron/base58 v1.2.0 // indirect 92 98 github.com/multiformats/go-base32 v0.1.0 // indirect 93 99 github.com/multiformats/go-base36 v0.2.0 // indirect 94 100 github.com/multiformats/go-multibase v0.2.0 // indirect 95 - github.com/multiformats/go-multicodec v0.9.0 // indirect 96 - github.com/multiformats/go-multihash v0.2.3 // indirect 97 101 github.com/multiformats/go-varint v0.0.7 // indirect 102 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 98 103 github.com/opentracing/opentracing-go v1.2.0 // indirect 99 - github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 // indirect 100 104 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 101 - github.com/prometheus/client_golang v1.17.0 // indirect 102 - github.com/prometheus/client_model v0.5.0 // indirect 103 - github.com/prometheus/common v0.45.0 // indirect 104 - github.com/prometheus/procfs v0.12.0 // indirect 105 + github.com/prometheus/client_golang v1.22.0 // indirect 106 + github.com/prometheus/client_model v0.6.2 // indirect 107 + github.com/prometheus/common v0.63.0 // indirect 108 + github.com/prometheus/procfs v0.16.1 // indirect 105 109 github.com/russross/blackfriday/v2 v2.1.0 // indirect 106 110 github.com/samber/lo v1.49.1 // indirect 107 111 github.com/segmentio/asm v1.2.0 // indirect 108 112 github.com/spaolacci/murmur3 v1.1.0 // indirect 109 113 github.com/valyala/bytebufferpool v1.0.0 // indirect 110 114 github.com/valyala/fasttemplate v1.2.2 // indirect 111 - github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 // indirect 112 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 113 - github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect 114 115 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 115 - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 116 116 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 117 117 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 118 118 go.opentelemetry.io/otel v1.29.0 // indirect ··· 121 121 go.uber.org/atomic v1.11.0 // indirect 122 122 go.uber.org/multierr v1.11.0 // indirect 123 123 go.uber.org/zap v1.26.0 // indirect 124 - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect 125 - golang.org/x/net v0.33.0 // indirect 126 - golang.org/x/sync v0.12.0 // indirect 127 - golang.org/x/sys v0.31.0 // indirect 128 - golang.org/x/text v0.23.0 // indirect 129 - golang.org/x/time v0.8.0 // indirect 124 + golang.org/x/net v0.40.0 // indirect 125 + golang.org/x/sync v0.14.0 // indirect 126 + golang.org/x/sys v0.33.0 // indirect 127 + golang.org/x/text v0.25.0 // indirect 128 + golang.org/x/time v0.11.0 // indirect 130 129 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 131 - google.golang.org/protobuf v1.33.0 // indirect 130 + google.golang.org/protobuf v1.36.6 // indirect 132 131 gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 133 132 gopkg.in/inf.v0 v0.9.1 // indirect 134 133 gorm.io/driver/postgres v1.5.7 // indirect
+50 -54
go.sum
··· 7 7 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 8 8 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= 9 9 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= 10 + github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= 11 + github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 10 12 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 11 13 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 12 14 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 14 16 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 15 17 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= 16 18 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= 17 - github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a h1:clnSZRgkiifbvfqu9++OHfIh2DWuIoZ8CucxLueQxO0= 18 - github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA= 19 19 github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b h1:elwfbe+W7GkUmPKFX1h7HaeHvC/kC0XJWfiEHC62xPg= 20 20 github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY= 21 21 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= 22 22 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 23 - github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= 24 - github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= 25 23 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= 26 24 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 27 25 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 28 26 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 29 - github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 30 - github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 27 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 28 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 31 29 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 32 30 github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 33 31 github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= ··· 50 48 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 51 49 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 52 50 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 51 + github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw= 52 + github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec= 53 53 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 54 54 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 55 55 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= ··· 65 65 github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= 66 66 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 67 67 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 68 - github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 69 - github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 70 68 github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 71 69 github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 72 70 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 73 71 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 74 72 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 75 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 76 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 73 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 74 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 75 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 76 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 77 77 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 78 78 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 79 79 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= ··· 81 81 github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 82 82 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 83 83 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 84 + github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 85 + github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 86 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 87 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 88 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 89 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 84 90 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 85 91 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 86 92 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= 87 93 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= 94 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= 95 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= 88 96 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 89 97 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 90 98 github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= ··· 93 101 github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 94 102 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 95 103 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 96 - github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg= 97 - github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno= 98 104 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 99 105 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 100 106 github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= 101 107 github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= 102 108 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 103 109 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 104 - github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA= 105 - github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU= 106 110 github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ= 107 111 github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk= 108 112 github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= ··· 123 127 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 124 128 github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ= 125 129 github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk= 126 - github.com/ipfs/go-ipfs-chunker v0.0.5 h1:ojCf7HV/m+uS2vhUGWcogIIxiO5ubl5O57Q7NapWLY8= 127 - github.com/ipfs/go-ipfs-chunker v0.0.5/go.mod h1:jhgdF8vxRHycr00k13FM8Y0E+6BoalYeobXmUyTreP8= 128 130 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ= 129 131 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw= 130 132 github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= ··· 158 160 github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 159 161 github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg= 160 162 github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU= 161 - github.com/ipfs/go-unixfsnode v1.8.0 h1:yCkakzuE365glu+YkgzZt6p38CSVEBPgngL9ZkfnyQU= 162 - github.com/ipfs/go-unixfsnode v1.8.0/go.mod h1:HxRu9HYHOjK6HUqFBAi++7DVoWAHn0o4v/nZ/VA+0g8= 163 163 github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs= 164 164 github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw= 165 165 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI= ··· 170 170 github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= 171 171 github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= 172 172 github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ= 173 - github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd h1:gMlw/MhNr2Wtp5RwGdsW23cs+yCuj9k2ON7i9MiJlRo= 174 - github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd/go.mod h1:wZ8hH8UxeryOs4kJEJaiui/s00hDSbE37OKsL47g+Sw= 175 173 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 176 174 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 177 175 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= ··· 189 187 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 190 188 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 191 189 github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 190 + github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 191 + github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 192 + github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 193 + github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 192 194 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 193 195 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 194 196 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 206 208 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 207 209 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 208 210 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 211 + github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= 212 + github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= 209 213 github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 210 214 github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 211 215 github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= ··· 243 247 github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM= 244 248 github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= 245 249 github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= 246 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 247 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 250 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 251 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 248 252 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 249 - github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 250 253 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 251 254 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 252 255 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 253 256 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 254 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 255 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 256 257 github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= 257 258 github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 258 259 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= ··· 279 280 github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q= 280 281 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 281 282 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 283 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 284 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 282 285 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 283 286 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 284 - github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g= 285 - github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA= 286 287 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= 287 288 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw= 288 289 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ··· 290 291 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 291 292 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 292 293 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 293 - github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 294 - github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 295 - github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 296 - github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 297 - github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 298 - github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 299 - github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 300 - github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 294 + github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 295 + github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 296 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 297 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 298 + github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 299 + github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 300 + github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 301 + github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 301 302 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 302 303 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 303 304 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= ··· 345 346 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 346 347 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 347 348 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 348 - github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f h1:jQa4QT2UP9WYv2nzyawpKMOCl+Z/jW7djv2/J50lj9E= 349 - github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f/go.mod h1:p9UJB6dDgdPgMJZs7UjUOdulKyRr9fqkS+6JKAInPy8= 350 - github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic= 351 - github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s= 352 349 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 353 350 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 354 351 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 389 386 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 390 387 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 391 388 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 392 - golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 393 - golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 389 + golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 390 + golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 394 391 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 395 392 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 396 393 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 412 409 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 413 410 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 414 411 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 415 - golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 416 - golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 412 + golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 413 + golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 417 414 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 418 415 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 419 416 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 420 417 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 421 418 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 422 419 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 423 - golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 424 - golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 420 + golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 421 + golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 425 422 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 426 423 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 427 424 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 433 430 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 434 431 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 435 432 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 436 - golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 437 433 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 438 434 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 439 435 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 440 436 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 441 - golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 442 - golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 437 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 438 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 443 439 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 444 440 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 445 441 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= ··· 451 447 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 452 448 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 453 449 golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 454 - golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 455 - golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 456 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 457 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 450 + golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 451 + golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 452 + golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 453 + golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 458 454 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 459 455 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 460 456 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 475 471 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 476 472 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 477 473 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 478 - google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 479 - google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 474 + google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 475 + google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 480 476 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 481 477 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 482 478 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+73 -54
identity/identity.go
··· 13 13 "github.com/bluesky-social/indigo/util" 14 14 ) 15 15 16 - func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) { 17 - if cli == nil { 18 - cli = util.RobustHTTPClient() 19 - } 20 - 21 - var did string 22 - 23 - _, err := syntax.ParseHandle(handle) 16 + func ResolveHandleFromTXT(ctx context.Context, handle string) (string, error) { 17 + name := fmt.Sprintf("_atproto.%s", handle) 18 + recs, err := net.LookupTXT(name) 24 19 if err != nil { 25 - return "", err 20 + return "", fmt.Errorf("handle could not be resolved via txt: %w", err) 26 21 } 27 22 28 - recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle)) 29 - if err == nil { 30 - for _, rec := range recs { 31 - if strings.HasPrefix(rec, "did=") { 32 - did = strings.Split(rec, "did=")[1] 33 - break 23 + for _, rec := range recs { 24 + if strings.HasPrefix(rec, "did=") { 25 + maybeDid := strings.Split(rec, "did=")[1] 26 + if _, err := syntax.ParseDID(maybeDid); err == nil { 27 + return maybeDid, nil 34 28 } 35 29 } 36 - } else { 37 - fmt.Printf("erorr getting txt records: %v\n", err) 38 30 } 39 31 40 - if did == "" { 41 - req, err := http.NewRequestWithContext( 42 - ctx, 43 - "GET", 44 - fmt.Sprintf("https://%s/.well-known/atproto-did", handle), 45 - nil, 46 - ) 47 - if err != nil { 48 - return "", nil 49 - } 32 + return "", fmt.Errorf("handle could not be resolved via txt: no record found") 33 + } 50 34 51 - resp, err := http.DefaultClient.Do(req) 52 - if err != nil { 53 - return "", nil 54 - } 55 - defer resp.Body.Close() 35 + func ResolveHandleFromWellKnown(ctx context.Context, cli *http.Client, handle string) (string, error) { 36 + ustr := fmt.Sprintf("https://%s/.well=known/atproto-did", handle) 37 + req, err := http.NewRequestWithContext( 38 + ctx, 39 + "GET", 40 + ustr, 41 + nil, 42 + ) 43 + if err != nil { 44 + return "", fmt.Errorf("handle could not be resolved via web: %w", err) 45 + } 56 46 57 - if resp.StatusCode != http.StatusOK { 58 - io.Copy(io.Discard, resp.Body) 59 - return "", fmt.Errorf("unable to resolve handle") 60 - } 47 + resp, err := cli.Do(req) 48 + if err != nil { 49 + return "", fmt.Errorf("handle could not be resolved via web: %w", err) 50 + } 51 + defer resp.Body.Close() 61 52 62 - b, err := io.ReadAll(resp.Body) 63 - if err != nil { 64 - return "", err 65 - } 53 + b, err := io.ReadAll(resp.Body) 54 + if err != nil { 55 + return "", fmt.Errorf("handle could not be resolved via web: %w", err) 56 + } 66 57 67 - maybeDid := string(b) 58 + if resp.StatusCode != http.StatusOK { 59 + return "", fmt.Errorf("handle could not be resolved via web: invalid status code %d", resp.StatusCode) 60 + } 68 61 69 - if _, err := syntax.ParseDID(maybeDid); err != nil { 70 - return "", fmt.Errorf("unable to resolve handle") 71 - } 62 + maybeDid := string(b) 72 63 73 - did = maybeDid 64 + if _, err := syntax.ParseDID(maybeDid); err != nil { 65 + return "", fmt.Errorf("handle could not be resolved via web: invalid did in document") 74 66 } 75 67 76 - return did, nil 68 + return maybeDid, nil 77 69 } 78 70 79 - func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) { 71 + func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) { 80 72 if cli == nil { 81 73 cli = util.RobustHTTPClient() 82 74 } 83 75 84 - var ustr string 76 + _, err := syntax.ParseHandle(handle) 77 + if err != nil { 78 + return "", err 79 + } 80 + 81 + if maybeDidFromTxt, err := ResolveHandleFromTXT(ctx, handle); err == nil { 82 + return maybeDidFromTxt, nil 83 + } 84 + 85 + if maybeDidFromWeb, err := ResolveHandleFromWellKnown(ctx, cli, handle); err == nil { 86 + return maybeDidFromWeb, nil 87 + } 88 + 89 + return "", fmt.Errorf("handle could not be resolved") 90 + } 91 + 92 + func DidToDocUrl(did string) (string, error) { 85 93 if strings.HasPrefix(did, "did:plc:") { 86 - ustr = fmt.Sprintf("https://plc.directory/%s", did) 94 + return fmt.Sprintf("https://plc.directory/%s", did), nil 87 95 } else if strings.HasPrefix(did, "did:web:") { 88 - ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")) 96 + return fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")), nil 89 97 } else { 90 - return nil, fmt.Errorf("did was not a supported did type") 98 + return "", fmt.Errorf("did was not a supported did type") 99 + } 100 + } 101 + 102 + func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) { 103 + if cli == nil { 104 + cli = util.RobustHTTPClient() 105 + } 106 + 107 + ustr, err := DidToDocUrl(did) 108 + if err != nil { 109 + return nil, err 91 110 } 92 111 93 112 req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil) ··· 95 114 return nil, err 96 115 } 97 116 98 - resp, err := http.DefaultClient.Do(req) 117 + resp, err := cli.Do(req) 99 118 if err != nil { 100 119 return nil, err 101 120 } ··· 103 122 104 123 if resp.StatusCode != 200 { 105 124 io.Copy(io.Discard, resp.Body) 106 - return nil, fmt.Errorf("could not find identity in plc registry") 125 + return nil, fmt.Errorf("unable to find did doc at url. did: %s. url: %s", did, ustr) 107 126 } 108 127 109 128 var diddoc DidDoc ··· 127 146 return nil, err 128 147 } 129 148 130 - resp, err := http.DefaultClient.Do(req) 149 + resp, err := cli.Do(req) 131 150 if err != nil { 132 151 return nil, err 133 152 }
+16 -5
identity/passport.go
··· 19 19 type Passport struct { 20 20 h *http.Client 21 21 bc BackingCache 22 - lk sync.Mutex 22 + mu sync.RWMutex 23 23 } 24 24 25 25 func NewPassport(h *http.Client, bc BackingCache) *Passport { ··· 30 30 return &Passport{ 31 31 h: h, 32 32 bc: bc, 33 - lk: sync.Mutex{}, 34 33 } 35 34 } 36 35 ··· 38 37 skipCache, _ := ctx.Value("skip-cache").(bool) 39 38 40 39 if !skipCache { 40 + p.mu.RLock() 41 41 cached, ok := p.bc.GetDoc(did) 42 + p.mu.RUnlock() 43 + 42 44 if ok { 43 45 return cached, nil 44 46 } 45 47 } 46 48 47 - p.lk.Lock() // this is pretty pathetic, and i should rethink this. but for now, fuck it 48 - defer p.lk.Unlock() 49 - 49 + // TODO: should coalesce requests here 50 50 doc, err := FetchDidDoc(ctx, p.h, did) 51 51 if err != nil { 52 52 return nil, err 53 53 } 54 54 55 + p.mu.Lock() 55 56 p.bc.PutDoc(did, doc) 57 + p.mu.Unlock() 56 58 57 59 return doc, nil 58 60 } ··· 61 63 skipCache, _ := ctx.Value("skip-cache").(bool) 62 64 63 65 if !skipCache { 66 + p.mu.RLock() 64 67 cached, ok := p.bc.GetDid(handle) 68 + p.mu.RUnlock() 69 + 65 70 if ok { 66 71 return cached, nil 67 72 } ··· 72 77 return "", err 73 78 } 74 79 80 + p.mu.Lock() 75 81 p.bc.PutDid(handle, did) 82 + p.mu.Unlock() 76 83 77 84 return did, nil 78 85 } 79 86 80 87 func (p *Passport) BustDoc(ctx context.Context, did string) error { 88 + p.mu.Lock() 89 + defer p.mu.Unlock() 81 90 return p.bc.BustDoc(did) 82 91 } 83 92 84 93 func (p *Passport) BustDid(ctx context.Context, handle string) error { 94 + p.mu.Lock() 95 + defer p.mu.Unlock() 85 96 return p.bc.BustDid(handle) 86 97 }
+65
internal/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "sync" 5 + 6 + "gorm.io/gorm" 7 + "gorm.io/gorm/clause" 8 + ) 9 + 10 + type DB struct { 11 + cli *gorm.DB 12 + mu sync.Mutex 13 + } 14 + 15 + func NewDB(cli *gorm.DB) *DB { 16 + return &DB{ 17 + cli: cli, 18 + mu: sync.Mutex{}, 19 + } 20 + } 21 + 22 + func (db *DB) Create(value any, clauses []clause.Expression) *gorm.DB { 23 + db.mu.Lock() 24 + defer db.mu.Unlock() 25 + return db.cli.Clauses(clauses...).Create(value) 26 + } 27 + 28 + func (db *DB) Exec(sql string, clauses []clause.Expression, values ...any) *gorm.DB { 29 + db.mu.Lock() 30 + defer db.mu.Unlock() 31 + return db.cli.Clauses(clauses...).Exec(sql, values...) 32 + } 33 + 34 + func (db *DB) Raw(sql string, clauses []clause.Expression, values ...any) *gorm.DB { 35 + return db.cli.Clauses(clauses...).Raw(sql, values...) 36 + } 37 + 38 + func (db *DB) AutoMigrate(models ...any) error { 39 + return db.cli.AutoMigrate(models...) 40 + } 41 + 42 + func (db *DB) Delete(value any, clauses []clause.Expression) *gorm.DB { 43 + db.mu.Lock() 44 + defer db.mu.Unlock() 45 + return db.cli.Clauses(clauses...).Delete(value) 46 + } 47 + 48 + func (db *DB) First(dest any, conds ...any) *gorm.DB { 49 + return db.cli.First(dest, conds...) 50 + } 51 + 52 + // TODO: this isn't actually good. we can commit even if the db is locked here. this is probably okay for the time being, but need to figure 53 + // out a better solution. right now we only do this whenever we're importing a repo though so i'm mostly not worried, but it's still bad. 54 + // e.g. when we do apply writes we should also be using a transcation but we don't right now 55 + func (db *DB) BeginDangerously() *gorm.DB { 56 + return db.cli.Begin() 57 + } 58 + 59 + func (db *DB) Lock() { 60 + db.mu.Lock() 61 + } 62 + 63 + func (db *DB) Unlock() { 64 + db.mu.Unlock() 65 + }
+60
internal/helpers/helpers.go
··· 1 1 package helpers 2 2 3 3 import ( 4 + crand "crypto/rand" 5 + "encoding/hex" 6 + "errors" 4 7 "math/rand" 8 + "net/url" 5 9 10 + "github.com/Azure/go-autorest/autorest/to" 6 11 "github.com/labstack/echo/v4" 12 + "github.com/lestrrat-go/jwx/v2/jwk" 7 13 ) 8 14 9 15 // This will confirm to the regex in the application if 5 chars are used for each side of the - ··· 26 32 return genericError(e, 400, msg) 27 33 } 28 34 35 + func InvalidTokenError(e echo.Context) error { 36 + return InputError(e, to.StringPtr("InvalidToken")) 37 + } 38 + 39 + func ExpiredTokenError(e echo.Context) error { 40 + // WARN: See https://github.com/bluesky-social/atproto/discussions/3319 41 + return e.JSON(400, map[string]string{ 42 + "error": "ExpiredToken", 43 + "message": "*", 44 + }) 45 + } 46 + 29 47 func genericError(e echo.Context, code int, msg string) error { 30 48 return e.JSON(code, map[string]string{ 31 49 "error": msg, ··· 39 57 } 40 58 return string(b) 41 59 } 60 + 61 + func RandomHex(n int) (string, error) { 62 + bytes := make([]byte, n) 63 + if _, err := crand.Read(bytes); err != nil { 64 + return "", err 65 + } 66 + return hex.EncodeToString(bytes), nil 67 + } 68 + 69 + func RandomBytes(n int) []byte { 70 + bs := make([]byte, n) 71 + crand.Read(bs) 72 + return bs 73 + } 74 + 75 + func ParseJWKFromBytes(b []byte) (jwk.Key, error) { 76 + return jwk.ParseKey(b) 77 + } 78 + 79 + func OauthParseHtu(htu string) (string, error) { 80 + u, err := url.Parse(htu) 81 + if err != nil { 82 + return "", errors.New("`htu` is not a valid URL") 83 + } 84 + 85 + if u.User != nil { 86 + _, containsPass := u.User.Password() 87 + if u.User.Username() != "" || containsPass { 88 + return "", errors.New("`htu` must not contain credentials") 89 + } 90 + } 91 + 92 + if u.Scheme != "http" && u.Scheme != "https" { 93 + return "", errors.New("`htu` must be http or https") 94 + } 95 + 96 + return OauthNormalizeHtu(u), nil 97 + } 98 + 99 + func OauthNormalizeHtu(u *url.URL) string { 100 + return u.Scheme + "://" + u.Host + u.RawPath 101 + }
+8
oauth/client/client.go
··· 1 + package client 2 + 3 + import "github.com/lestrrat-go/jwx/v2/jwk" 4 + 5 + type Client struct { 6 + Metadata *Metadata 7 + JWKS jwk.Key 8 + }
+389
oauth/client/manager.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "slices" 13 + "strings" 14 + "time" 15 + 16 + cache "github.com/go-pkgz/expirable-cache/v3" 17 + "github.com/haileyok/cocoon/internal/helpers" 18 + "github.com/lestrrat-go/jwx/v2/jwk" 19 + ) 20 + 21 + type Manager struct { 22 + cli *http.Client 23 + logger *slog.Logger 24 + jwksCache cache.Cache[string, jwk.Key] 25 + metadataCache cache.Cache[string, Metadata] 26 + } 27 + 28 + type ManagerArgs struct { 29 + Cli *http.Client 30 + Logger *slog.Logger 31 + } 32 + 33 + func NewManager(args ManagerArgs) *Manager { 34 + if args.Logger == nil { 35 + args.Logger = slog.Default() 36 + } 37 + 38 + if args.Cli == nil { 39 + args.Cli = http.DefaultClient 40 + } 41 + 42 + jwksCache := cache.NewCache[string, jwk.Key]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute) 43 + metadataCache := cache.NewCache[string, Metadata]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute) 44 + 45 + return &Manager{ 46 + cli: args.Cli, 47 + logger: args.Logger, 48 + jwksCache: jwksCache, 49 + metadataCache: metadataCache, 50 + } 51 + } 52 + 53 + func (cm *Manager) GetClient(ctx context.Context, clientId string) (*Client, error) { 54 + metadata, err := cm.getClientMetadata(ctx, clientId) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + var jwks jwk.Key 60 + if metadata.JWKS != nil { 61 + // TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to 62 + // make sure we use the right one 63 + k, err := helpers.ParseJWKFromBytes((*metadata.JWKS)[0]) 64 + if err != nil { 65 + return nil, err 66 + } 67 + jwks = k 68 + } else if metadata.JWKSURI != nil { 69 + maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + jwks = maybeJwks 75 + } 76 + 77 + return &Client{ 78 + Metadata: metadata, 79 + JWKS: jwks, 80 + }, nil 81 + } 82 + 83 + func (cm *Manager) getClientMetadata(ctx context.Context, clientId string) (*Metadata, error) { 84 + metadataCached, ok := cm.metadataCache.Get(clientId) 85 + if !ok { 86 + req, err := http.NewRequestWithContext(ctx, "GET", clientId, nil) 87 + if err != nil { 88 + return nil, err 89 + } 90 + 91 + resp, err := cm.cli.Do(req) 92 + if err != nil { 93 + return nil, err 94 + } 95 + defer resp.Body.Close() 96 + 97 + if resp.StatusCode != http.StatusOK { 98 + io.Copy(io.Discard, resp.Body) 99 + return nil, fmt.Errorf("fetching client metadata returned response code %d", resp.StatusCode) 100 + } 101 + 102 + b, err := io.ReadAll(resp.Body) 103 + if err != nil { 104 + return nil, fmt.Errorf("error reading bytes from client response: %w", err) 105 + } 106 + 107 + validated, err := validateAndParseMetadata(clientId, b) 108 + if err != nil { 109 + return nil, err 110 + } 111 + 112 + return validated, nil 113 + } else { 114 + return &metadataCached, nil 115 + } 116 + } 117 + 118 + func (cm *Manager) getClientJwks(ctx context.Context, clientId, jwksUri string) (jwk.Key, error) { 119 + jwks, ok := cm.jwksCache.Get(clientId) 120 + if !ok { 121 + req, err := http.NewRequestWithContext(ctx, "GET", jwksUri, nil) 122 + if err != nil { 123 + return nil, err 124 + } 125 + 126 + resp, err := cm.cli.Do(req) 127 + if err != nil { 128 + return nil, err 129 + } 130 + defer resp.Body.Close() 131 + 132 + if resp.StatusCode != http.StatusOK { 133 + io.Copy(io.Discard, resp.Body) 134 + return nil, fmt.Errorf("fetching client jwks returned response code %d", resp.StatusCode) 135 + } 136 + 137 + type Keys struct { 138 + Keys []map[string]any `json:"keys"` 139 + } 140 + 141 + var keys Keys 142 + if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil { 143 + return nil, fmt.Errorf("error unmarshaling keys response: %w", err) 144 + } 145 + 146 + if len(keys.Keys) == 0 { 147 + return nil, errors.New("no keys in jwks response") 148 + } 149 + 150 + // TODO: this is again bad, we should be figuring out which one we need to use... 151 + b, err := json.Marshal(keys.Keys[0]) 152 + if err != nil { 153 + return nil, fmt.Errorf("could not marshal key: %w", err) 154 + } 155 + 156 + k, err := helpers.ParseJWKFromBytes(b) 157 + if err != nil { 158 + return nil, err 159 + } 160 + 161 + jwks = k 162 + } 163 + 164 + return jwks, nil 165 + } 166 + 167 + func validateAndParseMetadata(clientId string, b []byte) (*Metadata, error) { 168 + var metadataMap map[string]any 169 + if err := json.Unmarshal(b, &metadataMap); err != nil { 170 + return nil, fmt.Errorf("error unmarshaling metadata: %w", err) 171 + } 172 + 173 + _, jwksOk := metadataMap["jwks"].(string) 174 + _, jwksUriOk := metadataMap["jwks_uri"].(string) 175 + if jwksOk && jwksUriOk { 176 + return nil, errors.New("jwks_uri and jwks are mutually exclusive") 177 + } 178 + 179 + for _, k := range []string{ 180 + "default_max_age", 181 + "userinfo_signed_response_alg", 182 + "id_token_signed_response_alg", 183 + "userinfo_encryhpted_response_alg", 184 + "authorization_encrypted_response_enc", 185 + "authorization_encrypted_response_alg", 186 + "tls_client_certificate_bound_access_tokens", 187 + } { 188 + _, kOk := metadataMap[k] 189 + if kOk { 190 + return nil, fmt.Errorf("unsupported `%s` parameter", k) 191 + } 192 + } 193 + 194 + var metadata Metadata 195 + if err := json.Unmarshal(b, &metadata); err != nil { 196 + return nil, fmt.Errorf("error unmarshaling metadata: %w", err) 197 + } 198 + 199 + u, err := url.Parse(metadata.ClientURI) 200 + if err != nil { 201 + return nil, fmt.Errorf("unable to parse client uri: %w", err) 202 + } 203 + 204 + if isLocalHostname(u.Hostname()) { 205 + return nil, errors.New("`client_uri` hostname is invalid") 206 + } 207 + 208 + if metadata.Scope == "" { 209 + return nil, errors.New("missing `scopes` scope") 210 + } 211 + 212 + scopes := strings.Split(metadata.Scope, " ") 213 + if !slices.Contains(scopes, "atproto") { 214 + return nil, errors.New("missing `atproto` scope") 215 + } 216 + 217 + scopesMap := map[string]bool{} 218 + for _, scope := range scopes { 219 + if scopesMap[scope] { 220 + return nil, fmt.Errorf("duplicate scope `%s`", scope) 221 + } 222 + 223 + // TODO: check for unsupported scopes 224 + 225 + scopesMap[scope] = true 226 + } 227 + 228 + grantTypesMap := map[string]bool{} 229 + for _, gt := range metadata.GrantTypes { 230 + if grantTypesMap[gt] { 231 + return nil, fmt.Errorf("duplicate grant type `%s`", gt) 232 + } 233 + 234 + switch gt { 235 + case "implicit": 236 + return nil, errors.New("grantg type `implicit` is not allowed") 237 + case "authorization_code", "refresh_token": 238 + // TODO check if this grant type is supported 239 + default: 240 + return nil, fmt.Errorf("grant tyhpe `%s` is not supported", gt) 241 + } 242 + 243 + grantTypesMap[gt] = true 244 + } 245 + 246 + if metadata.ClientID != clientId { 247 + return nil, errors.New("`client_id` does not match") 248 + } 249 + 250 + subjectType, subjectTypeOk := metadataMap["subject_type"].(string) 251 + if subjectTypeOk && subjectType != "public" { 252 + return nil, errors.New("only public `subject_type` is supported") 253 + } 254 + 255 + switch metadata.TokenEndpointAuthMethod { 256 + case "none": 257 + if metadata.TokenEndpointAuthSigningAlg != "" { 258 + return nil, errors.New("token_endpoint_auth_method `none` must not have token_endpoint_auth_signing_alg") 259 + } 260 + case "private_key_jwt": 261 + if metadata.JWKS == nil && metadata.JWKSURI == nil { 262 + return nil, errors.New("private_key_jwt auth method requires jwks or jwks_uri") 263 + } 264 + 265 + if metadata.JWKS != nil && len(*metadata.JWKS) == 0 { 266 + return nil, errors.New("private_key_jwt auth method requires atleast one key in jwks") 267 + } 268 + 269 + if metadata.TokenEndpointAuthSigningAlg == "" { 270 + return nil, errors.New("missing token_endpoint_auth_signing_alg in client metadata") 271 + } 272 + default: 273 + return nil, fmt.Errorf("unsupported client authentication method `%s`", metadata.TokenEndpointAuthMethod) 274 + } 275 + 276 + if !metadata.DpopBoundAccessTokens { 277 + return nil, errors.New("dpop_bound_access_tokens must be true") 278 + } 279 + 280 + if !slices.Contains(metadata.ResponseTypes, "code") { 281 + return nil, errors.New("response_types must inclue `code`") 282 + } 283 + 284 + if !slices.Contains(metadata.GrantTypes, "authorization_code") { 285 + return nil, errors.New("the `code` response type requires that `grant_types` contains `authorization_code`") 286 + } 287 + 288 + if len(metadata.RedirectURIs) == 0 { 289 + return nil, errors.New("at least one `redirect_uri` is required") 290 + } 291 + 292 + if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod != "none" { 293 + return nil, errors.New("native clients must authenticate using `none` method") 294 + } 295 + 296 + if metadata.ApplicationType == "web" && slices.Contains(metadata.GrantTypes, "implicit") { 297 + for _, ruri := range metadata.RedirectURIs { 298 + u, err := url.Parse(ruri) 299 + if err != nil { 300 + return nil, fmt.Errorf("error parsing redirect uri: %w", err) 301 + } 302 + 303 + if u.Scheme != "https" { 304 + return nil, errors.New("web clients must use https redirect uris") 305 + } 306 + 307 + if u.Hostname() == "localhost" { 308 + return nil, errors.New("web clients must not use localhost as the hostname") 309 + } 310 + } 311 + } 312 + 313 + for _, ruri := range metadata.RedirectURIs { 314 + u, err := url.Parse(ruri) 315 + if err != nil { 316 + return nil, fmt.Errorf("error parsing redirect uri: %w", err) 317 + } 318 + 319 + if u.User != nil { 320 + if u.User.Username() != "" { 321 + return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri) 322 + } 323 + 324 + if _, hasPass := u.User.Password(); hasPass { 325 + return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri) 326 + } 327 + } 328 + 329 + switch true { 330 + case u.Hostname() == "localhost": 331 + return nil, errors.New("loopback redirect uri is not allowed (use explicit ips instead)") 332 + case u.Hostname() == "127.0.0.1", u.Hostname() == "[::1]": 333 + if metadata.ApplicationType != "native" { 334 + return nil, errors.New("loopback redirect uris are only allowed for native apps") 335 + } 336 + 337 + if u.Port() != "" { 338 + // reference impl doesn't do anything with this? 339 + } 340 + 341 + if u.Scheme != "http" { 342 + return nil, fmt.Errorf("loopback redirect uri %s must use http", ruri) 343 + } 344 + 345 + break 346 + case u.Scheme == "http": 347 + return nil, errors.New("only loopbvack redirect uris are allowed to use the `http` scheme") 348 + case u.Scheme == "https": 349 + if isLocalHostname(u.Hostname()) { 350 + return nil, fmt.Errorf("redirect uri %s's domain must not be a local hostname", ruri) 351 + } 352 + break 353 + case strings.Contains(u.Scheme, "."): 354 + if metadata.ApplicationType != "native" { 355 + return nil, errors.New("private-use uri scheme redirect uris are only allowed for native apps") 356 + } 357 + 358 + revdomain := reverseDomain(u.Scheme) 359 + 360 + if isLocalHostname(revdomain) { 361 + return nil, errors.New("private use uri scheme redirect uris must not be local hostnames") 362 + } 363 + 364 + if strings.HasPrefix(u.String(), fmt.Sprintf("%s://", u.Scheme)) || u.Hostname() != "" || u.Port() != "" { 365 + return nil, fmt.Errorf("private use uri scheme must be in the form ") 366 + } 367 + default: 368 + return nil, fmt.Errorf("invalid redirect uri scheme `%s`", u.Scheme) 369 + } 370 + } 371 + 372 + return &metadata, nil 373 + } 374 + 375 + func isLocalHostname(hostname string) bool { 376 + pts := strings.Split(hostname, ".") 377 + if len(pts) < 2 { 378 + return true 379 + } 380 + 381 + tld := strings.ToLower(pts[len(pts)-1]) 382 + return tld == "test" || tld == "local" || tld == "localhost" || tld == "invalid" || tld == "example" 383 + } 384 + 385 + func reverseDomain(domain string) string { 386 + pts := strings.Split(domain, ".") 387 + slices.Reverse(pts) 388 + return strings.Join(pts, ".") 389 + }
+20
oauth/client/metadata.go
··· 1 + package client 2 + 3 + type Metadata struct { 4 + ClientID string `json:"client_id"` 5 + ClientName string `json:"client_name"` 6 + ClientURI string `json:"client_uri"` 7 + LogoURI string `json:"logo_uri"` 8 + TOSURI string `json:"tos_uri"` 9 + PolicyURI string `json:"policy_uri"` 10 + RedirectURIs []string `json:"redirect_uris"` 11 + GrantTypes []string `json:"grant_types"` 12 + ResponseTypes []string `json:"response_types"` 13 + ApplicationType string `json:"application_type"` 14 + DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 15 + JWKSURI *string `json:"jwks_uri,omitempty"` 16 + JWKS *[][]byte `json:"jwks,omitempty"` 17 + Scope string `json:"scope"` 18 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 19 + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 20 + }
+52
oauth/constants/constants.go
··· 1 + package constants 2 + 3 + import "time" 4 + 5 + const ( 6 + MaxDpopAge = 10 * time.Second 7 + DpopCheckTolerance = 5 * time.Second 8 + 9 + NonceSecretByteLength = 32 10 + 11 + NonceMaxRotationInterval = DpopNonceMaxAge / 3 12 + NonceMinRotationInterval = 1 * time.Second 13 + 14 + JTICacheSize = 100_000 15 + JTITtl = 24 * time.Hour 16 + 17 + ClientAssertionTypeJwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 18 + ParExpiresIn = 5 * time.Minute 19 + 20 + ClientAssertionMaxAge = 1 * time.Minute 21 + 22 + DeviceIdPrefix = "dev-" 23 + DeviceIdBytesLength = 16 24 + 25 + SessionIdPrefix = "ses-" 26 + SessionIdBytesLength = 16 27 + 28 + RefreshTokenPrefix = "ref-" 29 + RefreshTokenBytesLength = 32 30 + 31 + RequestIdPrefix = "req-" 32 + RequestIdBytesLength = 16 33 + RequestUriPrefix = "urn:ietf:params:oauth:request_uri:" 34 + 35 + CodePrefix = "cod-" 36 + CodeBytesLength = 32 37 + 38 + TokenIdPrefix = "tok-" 39 + TokenIdBytesLength = 16 40 + 41 + TokenMaxAge = 60 * time.Minute 42 + 43 + AuthorizationInactivityTimeout = 5 * time.Minute 44 + 45 + DpopNonceMaxAge = 3 * time.Minute 46 + 47 + ConfidentialClientSessionLifetime = 2 * 365 * 24 * time.Hour // 2 years 48 + ConfidentialClientRefreshLifetime = 3 * 30 * 24 * time.Hour // 3 months 49 + 50 + PublicClientSessionLifetime = 2 * 7 * 24 * time.Hour // 2 weeks 51 + PublicClientRefreshLifetime = PublicClientSessionLifetime 52 + )
+28
oauth/dpop/jti_cache.go
··· 1 + package dpop 2 + 3 + import ( 4 + "sync" 5 + "time" 6 + 7 + cache "github.com/go-pkgz/expirable-cache/v3" 8 + "github.com/haileyok/cocoon/oauth/constants" 9 + ) 10 + 11 + type jtiCache struct { 12 + mu sync.Mutex 13 + cache cache.Cache[string, bool] 14 + } 15 + 16 + func newJTICache(size int) *jtiCache { 17 + cache := cache.NewCache[string, bool]().WithTTL(24 * time.Hour).WithLRU().WithTTL(constants.JTITtl) 18 + return &jtiCache{ 19 + cache: cache, 20 + mu: sync.Mutex{}, 21 + } 22 + } 23 + 24 + func (c *jtiCache) add(jti string) bool { 25 + c.mu.Lock() 26 + defer c.mu.Unlock() 27 + return c.cache.Add(jti, true) 28 + }
+249
oauth/dpop/manager.go
··· 1 + package dpop 2 + 3 + import ( 4 + "crypto" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "log/slog" 11 + "net/http" 12 + "net/url" 13 + "strings" 14 + "time" 15 + 16 + "github.com/golang-jwt/jwt/v4" 17 + "github.com/haileyok/cocoon/internal/helpers" 18 + "github.com/haileyok/cocoon/oauth/constants" 19 + "github.com/lestrrat-go/jwx/v2/jwa" 20 + "github.com/lestrrat-go/jwx/v2/jwk" 21 + ) 22 + 23 + type Manager struct { 24 + nonce *Nonce 25 + jtiCache *jtiCache 26 + logger *slog.Logger 27 + hostname string 28 + } 29 + 30 + type ManagerArgs struct { 31 + NonceSecret []byte 32 + NonceRotationInterval time.Duration 33 + OnNonceSecretCreated func([]byte) 34 + JTICacheSize int 35 + Logger *slog.Logger 36 + Hostname string 37 + } 38 + 39 + func NewManager(args ManagerArgs) *Manager { 40 + if args.Logger == nil { 41 + args.Logger = slog.Default() 42 + } 43 + 44 + if args.JTICacheSize == 0 { 45 + args.JTICacheSize = 100_000 46 + } 47 + 48 + if args.NonceSecret == nil { 49 + args.Logger.Warn("nonce secret passed to dpop manager was nil. existing sessions may break. consider saving and restoring your nonce.") 50 + } 51 + 52 + return &Manager{ 53 + nonce: NewNonce(NonceArgs{ 54 + RotationInterval: args.NonceRotationInterval, 55 + Secret: args.NonceSecret, 56 + OnSecretCreated: args.OnNonceSecretCreated, 57 + }), 58 + jtiCache: newJTICache(args.JTICacheSize), 59 + logger: args.Logger, 60 + hostname: args.Hostname, 61 + } 62 + } 63 + 64 + func (dm *Manager) CheckProof(reqMethod, reqUrl string, headers http.Header, accessToken *string) (*Proof, error) { 65 + if reqMethod == "" { 66 + return nil, errors.New("HTTP method is required") 67 + } 68 + 69 + if !strings.HasPrefix(reqUrl, "https://") { 70 + reqUrl = "https://" + dm.hostname + reqUrl 71 + } 72 + 73 + proof := extractProof(headers) 74 + 75 + if proof == "" { 76 + return nil, nil 77 + } 78 + 79 + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) 80 + var token *jwt.Token 81 + 82 + token, _, err := parser.ParseUnverified(proof, jwt.MapClaims{}) 83 + if err != nil { 84 + return nil, fmt.Errorf("could not parse dpop proof jwt: %w", err) 85 + } 86 + 87 + typ, _ := token.Header["typ"].(string) 88 + if typ != "dpop+jwt" { 89 + return nil, errors.New(`invalid dpop proof jwt: "typ" must be 'dpop+jwt'`) 90 + } 91 + 92 + dpopJwk, jwkOk := token.Header["jwk"].(map[string]any) 93 + if !jwkOk { 94 + return nil, errors.New(`invalid dpop proof jwt: "jwk" is missing in header`) 95 + } 96 + 97 + jwkb, err := json.Marshal(dpopJwk) 98 + if err != nil { 99 + return nil, fmt.Errorf("failed to marshal jwk: %w", err) 100 + } 101 + 102 + key, err := jwk.ParseKey(jwkb) 103 + if err != nil { 104 + return nil, fmt.Errorf("failed to parse jwk: %w", err) 105 + } 106 + 107 + var pubKey any 108 + if err := key.Raw(&pubKey); err != nil { 109 + return nil, fmt.Errorf("failed to get raw public key: %w", err) 110 + } 111 + 112 + token, err = jwt.Parse(proof, func(t *jwt.Token) (any, error) { 113 + alg := t.Header["alg"].(string) 114 + 115 + switch key.KeyType() { 116 + case jwa.EC: 117 + if !strings.HasPrefix(alg, "ES") { 118 + return nil, fmt.Errorf("algorithm %s doesn't match EC key type", alg) 119 + } 120 + case jwa.RSA: 121 + if !strings.HasPrefix(alg, "RS") && !strings.HasPrefix(alg, "PS") { 122 + return nil, fmt.Errorf("algorithm %s doesn't match RSA key type", alg) 123 + } 124 + case jwa.OKP: 125 + if alg != "EdDSA" { 126 + return nil, fmt.Errorf("algorithm %s doesn't match OKP key type", alg) 127 + } 128 + } 129 + 130 + return pubKey, nil 131 + }, jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "EdDSA"})) 132 + if err != nil { 133 + return nil, fmt.Errorf("could not verify dpop proof jwt: %w", err) 134 + } 135 + 136 + if !token.Valid { 137 + return nil, errors.New("dpop proof jwt is invalid") 138 + } 139 + 140 + claims, ok := token.Claims.(jwt.MapClaims) 141 + if !ok { 142 + return nil, errors.New("no claims in dpop proof jwt") 143 + } 144 + 145 + iat, iatOk := claims["iat"].(float64) 146 + if !iatOk { 147 + return nil, errors.New(`invalid dpop proof jwt: "iat" is missing`) 148 + } 149 + 150 + iatTime := time.Unix(int64(iat), 0) 151 + now := time.Now() 152 + 153 + if now.Sub(iatTime) > constants.DpopNonceMaxAge+constants.DpopCheckTolerance { 154 + return nil, errors.New("dpop proof too old") 155 + } 156 + 157 + if iatTime.Sub(now) > constants.DpopCheckTolerance { 158 + return nil, errors.New("dpop proof iat is in the future") 159 + } 160 + 161 + jti, _ := claims["jti"].(string) 162 + if jti == "" { 163 + return nil, errors.New(`invalid dpop proof jwt: "jti" is missing`) 164 + } 165 + 166 + if dm.jtiCache.add(jti) { 167 + return nil, errors.New("dpop proof replay detected") 168 + } 169 + 170 + htm, _ := claims["htm"].(string) 171 + if htm == "" { 172 + return nil, errors.New(`invalid dpop proof jwt: "htm" is missing`) 173 + } 174 + 175 + if htm != reqMethod { 176 + return nil, errors.New(`invalid dpop proof jwt: "htm" mismatch`) 177 + } 178 + 179 + htu, _ := claims["htu"].(string) 180 + if htu == "" { 181 + return nil, errors.New(`invalid dpop proof jwt: "htu" is missing`) 182 + } 183 + 184 + parsedHtu, err := helpers.OauthParseHtu(htu) 185 + if err != nil { 186 + return nil, errors.New(`invalid dpop proof jwt: "htu" could not be parsed`) 187 + } 188 + 189 + u, _ := url.Parse(reqUrl) 190 + if parsedHtu != helpers.OauthNormalizeHtu(u) { 191 + return nil, fmt.Errorf(`invalid dpop proof jwt: "htu" mismatch. reqUrl: %s, parsed: %s, normalized: %s`, reqUrl, parsedHtu, helpers.OauthNormalizeHtu(u)) 192 + } 193 + 194 + nonce, _ := claims["nonce"].(string) 195 + if nonce == "" { 196 + // WARN: this _must_ be `use_dpop_nonce` for clients know they should make another request 197 + return nil, errors.New("use_dpop_nonce") 198 + } 199 + 200 + if nonce != "" && !dm.nonce.Check(nonce) { 201 + // WARN: this _must_ be `use_dpop_nonce` so that clients will fetch a new nonce 202 + return nil, errors.New("use_dpop_nonce") 203 + } 204 + 205 + ath, _ := claims["ath"].(string) 206 + 207 + if accessToken != nil && *accessToken != "" { 208 + if ath == "" { 209 + return nil, errors.New(`invalid dpop proof jwt: "ath" is required with access token`) 210 + } 211 + 212 + hash := sha256.Sum256([]byte(*accessToken)) 213 + if ath != base64.RawURLEncoding.EncodeToString(hash[:]) { 214 + return nil, errors.New(`invalid dpop proof jwt: "ath" mismatch`) 215 + } 216 + } else if ath != "" { 217 + return nil, errors.New(`invalid dpop proof jwt: "ath" claim not allowed`) 218 + } 219 + 220 + thumbBytes, err := key.Thumbprint(crypto.SHA256) 221 + if err != nil { 222 + return nil, fmt.Errorf("failed to calculate thumbprint: %w", err) 223 + } 224 + 225 + thumb := base64.RawURLEncoding.EncodeToString(thumbBytes) 226 + 227 + return &Proof{ 228 + JTI: jti, 229 + JKT: thumb, 230 + HTM: htm, 231 + HTU: htu, 232 + }, nil 233 + } 234 + 235 + func extractProof(headers http.Header) string { 236 + dpopHeaders := headers["Dpop"] 237 + switch len(dpopHeaders) { 238 + case 0: 239 + return "" 240 + case 1: 241 + return dpopHeaders[0] 242 + default: 243 + return "" 244 + } 245 + } 246 + 247 + func (dm *Manager) NextNonce() string { 248 + return dm.nonce.NextNonce() 249 + }
+108
oauth/dpop/nonce.go
··· 1 + package dpop 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/binary" 8 + "sync" 9 + "time" 10 + 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/haileyok/cocoon/oauth/constants" 13 + ) 14 + 15 + type Nonce struct { 16 + rotationInterval time.Duration 17 + secret []byte 18 + 19 + mu sync.RWMutex 20 + 21 + counter int64 22 + prev string 23 + curr string 24 + next string 25 + } 26 + 27 + type NonceArgs struct { 28 + RotationInterval time.Duration 29 + Secret []byte 30 + OnSecretCreated func([]byte) 31 + } 32 + 33 + func NewNonce(args NonceArgs) *Nonce { 34 + if args.RotationInterval == 0 { 35 + args.RotationInterval = constants.NonceMaxRotationInterval / 3 36 + } 37 + 38 + if args.RotationInterval > constants.NonceMaxRotationInterval { 39 + args.RotationInterval = constants.NonceMaxRotationInterval 40 + } 41 + 42 + if args.Secret == nil { 43 + args.Secret = helpers.RandomBytes(constants.NonceSecretByteLength) 44 + args.OnSecretCreated(args.Secret) 45 + } 46 + 47 + n := &Nonce{ 48 + rotationInterval: args.RotationInterval, 49 + secret: args.Secret, 50 + mu: sync.RWMutex{}, 51 + } 52 + 53 + n.counter = n.currentCounter() 54 + n.prev = n.compute(n.counter - 1) 55 + n.curr = n.compute(n.counter) 56 + n.next = n.compute(n.counter + 1) 57 + 58 + return n 59 + } 60 + 61 + func (n *Nonce) currentCounter() int64 { 62 + return time.Now().UnixNano() / int64(n.rotationInterval) 63 + } 64 + 65 + func (n *Nonce) compute(counter int64) string { 66 + h := hmac.New(sha256.New, n.secret) 67 + counterBytes := make([]byte, 8) 68 + binary.BigEndian.PutUint64(counterBytes, uint64(counter)) 69 + h.Write(counterBytes) 70 + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 71 + } 72 + 73 + func (n *Nonce) rotate() { 74 + counter := n.currentCounter() 75 + diff := counter - n.counter 76 + 77 + switch diff { 78 + case 0: 79 + // counter == n.counter, do nothing 80 + case 1: 81 + n.prev = n.curr 82 + n.curr = n.next 83 + n.next = n.compute(counter + 1) 84 + case 2: 85 + n.prev = n.next 86 + n.curr = n.compute(counter) 87 + n.next = n.compute(counter + 1) 88 + default: 89 + n.prev = n.compute(counter - 1) 90 + n.curr = n.compute(counter) 91 + n.next = n.compute(counter + 1) 92 + } 93 + 94 + n.counter = counter 95 + } 96 + 97 + func (n *Nonce) NextNonce() string { 98 + n.mu.Lock() 99 + defer n.mu.Unlock() 100 + n.rotate() 101 + return n.next 102 + } 103 + 104 + func (n *Nonce) Check(nonce string) bool { 105 + n.mu.RLock() 106 + defer n.mu.RUnlock() 107 + return nonce == n.prev || nonce == n.curr || nonce == n.next 108 + }
+8
oauth/dpop/proof.go
··· 1 + package dpop 2 + 3 + type Proof struct { 4 + JTI string 5 + JKT string 6 + HTM string 7 + HTU string 8 + }
+80
oauth/helpers.go
··· 1 + package oauth 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/url" 7 + "time" 8 + 9 + "github.com/haileyok/cocoon/internal/helpers" 10 + "github.com/haileyok/cocoon/oauth/constants" 11 + "github.com/haileyok/cocoon/oauth/provider" 12 + ) 13 + 14 + func GenerateCode() string { 15 + h, _ := helpers.RandomHex(constants.CodeBytesLength) 16 + return constants.CodePrefix + h 17 + } 18 + 19 + func GenerateTokenId() string { 20 + h, _ := helpers.RandomHex(constants.TokenIdBytesLength) 21 + return constants.TokenIdPrefix + h 22 + } 23 + 24 + func GenerateRefreshToken() string { 25 + h, _ := helpers.RandomHex(constants.RefreshTokenBytesLength) 26 + return constants.RefreshTokenPrefix + h 27 + } 28 + 29 + func GenerateRequestId() string { 30 + h, _ := helpers.RandomHex(constants.RequestIdBytesLength) 31 + return constants.RequestIdPrefix + h 32 + } 33 + 34 + func EncodeRequestUri(reqId string) string { 35 + return constants.RequestUriPrefix + url.QueryEscape(reqId) 36 + } 37 + 38 + func DecodeRequestUri(reqUri string) (string, error) { 39 + if len(reqUri) < len(constants.RequestUriPrefix) { 40 + return "", errors.New("invalid request uri") 41 + } 42 + 43 + reqIdEnc := reqUri[len(constants.RequestUriPrefix):] 44 + reqId, err := url.QueryUnescape(reqIdEnc) 45 + if err != nil { 46 + return "", fmt.Errorf("could not unescape request id: %w", err) 47 + } 48 + 49 + return reqId, nil 50 + } 51 + 52 + type SessionAgeResult struct { 53 + SessionAge time.Duration 54 + RefreshAge time.Duration 55 + SessionExpired bool 56 + RefreshExpired bool 57 + } 58 + 59 + func GetSessionAgeFromToken(t provider.OauthToken) SessionAgeResult { 60 + sessionLifetime := constants.PublicClientSessionLifetime 61 + refreshLifetime := constants.PublicClientRefreshLifetime 62 + if t.ClientAuth.Method != "none" { 63 + sessionLifetime = constants.ConfidentialClientSessionLifetime 64 + refreshLifetime = constants.ConfidentialClientRefreshLifetime 65 + } 66 + 67 + res := SessionAgeResult{} 68 + 69 + res.SessionAge = time.Since(t.CreatedAt) 70 + if res.SessionAge > sessionLifetime { 71 + res.SessionExpired = true 72 + } 73 + 74 + refreshAge := time.Since(t.UpdatedAt) 75 + if refreshAge > refreshLifetime { 76 + res.RefreshExpired = true 77 + } 78 + 79 + return res 80 + }
+152
oauth/provider/client_auth.go
··· 1 + package provider 2 + 3 + import ( 4 + "context" 5 + "crypto" 6 + "encoding/base64" 7 + "errors" 8 + "fmt" 9 + "time" 10 + 11 + "github.com/golang-jwt/jwt/v4" 12 + "github.com/haileyok/cocoon/oauth/client" 13 + "github.com/haileyok/cocoon/oauth/constants" 14 + "github.com/haileyok/cocoon/oauth/dpop" 15 + ) 16 + 17 + type AuthenticateClientOptions struct { 18 + AllowMissingDpopProof bool 19 + } 20 + 21 + type AuthenticateClientRequestBase struct { 22 + ClientID string `form:"client_id" json:"client_id" validate:"required"` 23 + ClientAssertionType *string `form:"client_assertion_type" json:"client_assertion_type,omitempty"` 24 + ClientAssertion *string `form:"client_assertion" json:"client_assertion,omitempty"` 25 + } 26 + 27 + func (p *Provider) AuthenticateClient(ctx context.Context, req AuthenticateClientRequestBase, proof *dpop.Proof, opts *AuthenticateClientOptions) (*client.Client, *ClientAuth, error) { 28 + client, err := p.ClientManager.GetClient(ctx, req.ClientID) 29 + if err != nil { 30 + return nil, nil, fmt.Errorf("failed to get client: %w", err) 31 + } 32 + 33 + if client.Metadata.DpopBoundAccessTokens && proof == nil && (opts == nil || !opts.AllowMissingDpopProof) { 34 + return nil, nil, errors.New("dpop proof required") 35 + } 36 + 37 + if proof != nil && !client.Metadata.DpopBoundAccessTokens { 38 + return nil, nil, errors.New("dpop proof not allowed for this client") 39 + } 40 + 41 + clientAuth, err := p.Authenticate(ctx, req, client) 42 + if err != nil { 43 + return nil, nil, err 44 + } 45 + 46 + return client, clientAuth, nil 47 + } 48 + 49 + func (p *Provider) Authenticate(_ context.Context, req AuthenticateClientRequestBase, client *client.Client) (*ClientAuth, error) { 50 + metadata := client.Metadata 51 + 52 + if metadata.TokenEndpointAuthMethod == "none" { 53 + return &ClientAuth{ 54 + Method: "none", 55 + }, nil 56 + } 57 + 58 + if metadata.TokenEndpointAuthMethod == "private_key_jwt" { 59 + if req.ClientAssertion == nil { 60 + return nil, errors.New(`client authentication method "private_key_jwt" requires a "client_assertion`) 61 + } 62 + 63 + if req.ClientAssertionType == nil || *req.ClientAssertionType != constants.ClientAssertionTypeJwtBearer { 64 + return nil, fmt.Errorf("unsupported client_assertion_type %s", *req.ClientAssertionType) 65 + } 66 + 67 + token, _, err := jwt.NewParser().ParseUnverified(*req.ClientAssertion, jwt.MapClaims{}) 68 + if err != nil { 69 + return nil, fmt.Errorf("error parsing client assertion: %w", err) 70 + } 71 + 72 + kid, ok := token.Header["kid"].(string) 73 + if !ok || kid == "" { 74 + return nil, errors.New(`"kid" required in client_assertion`) 75 + } 76 + 77 + var rawKey any 78 + if err := client.JWKS.Raw(&rawKey); err != nil { 79 + return nil, fmt.Errorf("failed to extract raw key: %w", err) 80 + } 81 + 82 + token, err = jwt.Parse(*req.ClientAssertion, func(token *jwt.Token) (any, error) { 83 + if token.Method.Alg() != jwt.SigningMethodES256.Alg() { 84 + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 85 + } 86 + 87 + return rawKey, nil 88 + }) 89 + if err != nil { 90 + return nil, fmt.Errorf(`unable to verify "client_assertion" jwt: %w`, err) 91 + } 92 + 93 + if !token.Valid { 94 + return nil, errors.New("client_assertion jwt is invalid") 95 + } 96 + 97 + claims, ok := token.Claims.(jwt.MapClaims) 98 + if !ok { 99 + return nil, errors.New("no claims in client_assertion jwt") 100 + } 101 + 102 + sub, _ := claims["sub"].(string) 103 + if sub != metadata.ClientID { 104 + return nil, errors.New("subject must be client_id") 105 + } 106 + 107 + aud, _ := claims["aud"].(string) 108 + if aud != "" && aud != "https://"+p.hostname { 109 + return nil, fmt.Errorf("audience must be %s, got %s", "https://"+p.hostname, aud) 110 + } 111 + 112 + iat, iatOk := claims["iat"].(float64) 113 + if !iatOk { 114 + return nil, errors.New(`invalid client_assertion jwt: "iat" is missing`) 115 + } 116 + 117 + iatTime := time.Unix(int64(iat), 0) 118 + if time.Since(iatTime) > constants.ClientAssertionMaxAge { 119 + return nil, errors.New("client_assertion jwt too old") 120 + } 121 + 122 + jti, _ := claims["jti"].(string) 123 + if jti == "" { 124 + return nil, errors.New(`invalid client_assertion jwt: "jti" is missing`) 125 + } 126 + 127 + var exp *float64 128 + if maybeExp, ok := claims["exp"].(float64); ok { 129 + exp = &maybeExp 130 + } 131 + 132 + alg := token.Header["alg"].(string) 133 + 134 + thumbBytes, err := client.JWKS.Thumbprint(crypto.SHA256) 135 + if err != nil { 136 + return nil, fmt.Errorf("failed to calculate thumbprint: %w", err) 137 + } 138 + 139 + thumb := base64.RawURLEncoding.EncodeToString(thumbBytes) 140 + 141 + return &ClientAuth{ 142 + Method: "private_key_jwt", 143 + Jti: jti, 144 + Exp: exp, 145 + Jkt: thumb, 146 + Alg: alg, 147 + Kid: kid, 148 + }, nil 149 + } 150 + 151 + return nil, fmt.Errorf("auth method %s is not implemented in this pds", metadata.TokenEndpointAuthMethod) 152 + }
+20
oauth/provider/middleware.go
··· 1 + package provider 2 + 3 + import ( 4 + "github.com/labstack/echo/v4" 5 + ) 6 + 7 + func (p *Provider) BaseMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 8 + return func(e echo.Context) error { 9 + e.Response().Header().Set("cache-control", "no-store") 10 + e.Response().Header().Set("pragma", "no-cache") 11 + 12 + nonce := p.NextNonce() 13 + if nonce != "" { 14 + e.Response().Header().Set("DPoP-Nonce", nonce) 15 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 16 + } 17 + 18 + return next(e) 19 + } 20 + }
+83
oauth/provider/models.go
··· 1 + package provider 2 + 3 + import ( 4 + "database/sql/driver" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "gorm.io/gorm" 10 + ) 11 + 12 + type ClientAuth struct { 13 + Method string 14 + Alg string 15 + Kid string 16 + Jkt string 17 + Jti string 18 + Exp *float64 19 + } 20 + 21 + func (ca *ClientAuth) Scan(value any) error { 22 + b, ok := value.([]byte) 23 + if !ok { 24 + return fmt.Errorf("failed to unmarshal OauthParRequest value") 25 + } 26 + return json.Unmarshal(b, ca) 27 + } 28 + 29 + func (ca ClientAuth) Value() (driver.Value, error) { 30 + return json.Marshal(ca) 31 + } 32 + 33 + type ParRequest struct { 34 + AuthenticateClientRequestBase 35 + ResponseType string `form:"response_type" json:"response_type" validate:"required"` 36 + CodeChallenge *string `form:"code_challenge" json:"code_challenge" validate:"required"` 37 + CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" validate:"required"` 38 + State string `form:"state" json:"state" validate:"required"` 39 + RedirectURI string `form:"redirect_uri" json:"redirect_uri" validate:"required"` 40 + Scope string `form:"scope" json:"scope" validate:"required"` 41 + LoginHint *string `form:"login_hint" json:"login_hint,omitempty"` 42 + DpopJkt *string `form:"dpop_jkt" json:"dpop_jkt,omitempty"` 43 + } 44 + 45 + func (opr *ParRequest) Scan(value any) error { 46 + b, ok := value.([]byte) 47 + if !ok { 48 + return fmt.Errorf("failed to unmarshal OauthParRequest value") 49 + } 50 + return json.Unmarshal(b, opr) 51 + } 52 + 53 + func (opr ParRequest) Value() (driver.Value, error) { 54 + return json.Marshal(opr) 55 + } 56 + 57 + type OauthToken struct { 58 + gorm.Model 59 + ClientId string `gorm:"index"` 60 + ClientAuth ClientAuth `gorm:"type:json"` 61 + Parameters ParRequest `gorm:"type:json"` 62 + ExpiresAt time.Time `gorm:"index"` 63 + DeviceId string 64 + Sub string `gorm:"index"` 65 + Code string `gorm:"index"` 66 + Token string `gorm:"uniqueIndex"` 67 + RefreshToken string `gorm:"uniqueIndex"` 68 + Ip string 69 + } 70 + 71 + type OauthAuthorizationRequest struct { 72 + gorm.Model 73 + RequestId string `gorm:"primaryKey"` 74 + ClientId string `gorm:"index"` 75 + ClientAuth ClientAuth `gorm:"type:json"` 76 + Parameters ParRequest `gorm:"type:json"` 77 + ExpiresAt time.Time `gorm:"index"` 78 + DeviceId *string 79 + Sub *string 80 + Code *string 81 + Accepted *bool 82 + Ip string 83 + }
+31
oauth/provider/provider.go
··· 1 + package provider 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/oauth/client" 5 + "github.com/haileyok/cocoon/oauth/dpop" 6 + ) 7 + 8 + type Provider struct { 9 + ClientManager *client.Manager 10 + DpopManager *dpop.Manager 11 + 12 + hostname string 13 + } 14 + 15 + type Args struct { 16 + Hostname string 17 + ClientManagerArgs client.ManagerArgs 18 + DpopManagerArgs dpop.ManagerArgs 19 + } 20 + 21 + func NewProvider(args Args) *Provider { 22 + return &Provider{ 23 + ClientManager: client.NewManager(args.ClientManagerArgs), 24 + DpopManager: dpop.NewManager(args.DpopManagerArgs), 25 + hostname: args.Hostname, 26 + } 27 + } 28 + 29 + func (p *Provider) NextNonce() string { 30 + return p.DpopManager.NextNonce() 31 + }
+77
recording_blockstore/recording_blockstore.go
··· 1 + package recording_blockstore 2 + 3 + import ( 4 + "context" 5 + 6 + blockformat "github.com/ipfs/go-block-format" 7 + "github.com/ipfs/go-cid" 8 + blockstore "github.com/ipfs/go-ipfs-blockstore" 9 + ) 10 + 11 + type RecordingBlockstore struct { 12 + base blockstore.Blockstore 13 + 14 + inserts map[cid.Cid]blockformat.Block 15 + } 16 + 17 + func New(base blockstore.Blockstore) *RecordingBlockstore { 18 + return &RecordingBlockstore{ 19 + base: base, 20 + inserts: make(map[cid.Cid]blockformat.Block), 21 + } 22 + } 23 + 24 + func (bs *RecordingBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) { 25 + return bs.base.Has(ctx, c) 26 + } 27 + 28 + func (bs *RecordingBlockstore) Get(ctx context.Context, c cid.Cid) (blockformat.Block, error) { 29 + return bs.base.Get(ctx, c) 30 + } 31 + 32 + func (bs *RecordingBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) { 33 + return bs.base.GetSize(ctx, c) 34 + } 35 + 36 + func (bs *RecordingBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error { 37 + return bs.base.DeleteBlock(ctx, c) 38 + } 39 + 40 + func (bs *RecordingBlockstore) Put(ctx context.Context, block blockformat.Block) error { 41 + if err := bs.base.Put(ctx, block); err != nil { 42 + return err 43 + } 44 + bs.inserts[block.Cid()] = block 45 + return nil 46 + } 47 + 48 + func (bs *RecordingBlockstore) PutMany(ctx context.Context, blocks []blockformat.Block) error { 49 + if err := bs.base.PutMany(ctx, blocks); err != nil { 50 + return err 51 + } 52 + 53 + for _, b := range blocks { 54 + bs.inserts[b.Cid()] = b 55 + } 56 + 57 + return nil 58 + } 59 + 60 + func (bs *RecordingBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 61 + return bs.AllKeysChan(ctx) 62 + } 63 + 64 + func (bs *RecordingBlockstore) HashOnRead(enabled bool) { 65 + } 66 + 67 + func (bs *RecordingBlockstore) GetLogMap() map[cid.Cid]blockformat.Block { 68 + return bs.inserts 69 + } 70 + 71 + func (bs *RecordingBlockstore) GetLogArray() []blockformat.Block { 72 + var blocks []blockformat.Block 73 + for _, b := range bs.inserts { 74 + blocks = append(blocks, b) 75 + } 76 + return blocks 77 + }
+30
server/blockstore_variant.go
··· 1 + package server 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/sqlite_blockstore" 5 + blockstore "github.com/ipfs/go-ipfs-blockstore" 6 + ) 7 + 8 + type BlockstoreVariant int 9 + 10 + const ( 11 + BlockstoreVariantSqlite = iota 12 + ) 13 + 14 + func MustReturnBlockstoreVariant(maybeBsv string) BlockstoreVariant { 15 + switch maybeBsv { 16 + case "sqlite": 17 + return BlockstoreVariantSqlite 18 + default: 19 + panic("invalid blockstore variant provided") 20 + } 21 + } 22 + 23 + func (s *Server) getBlockstore(did string) blockstore.Blockstore { 24 + switch s.config.BlockstoreVariant { 25 + case BlockstoreVariantSqlite: 26 + return sqlite_blockstore.New(did, s.db) 27 + default: 28 + return sqlite_blockstore.New(did, s.db) 29 + } 30 + }
+2 -2
server/common.go
··· 22 22 23 23 func (s *Server) getRepoActorByEmail(email string) (*models.RepoActor, error) { 24 24 var repo models.RepoActor 25 - if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email= ?", email).Scan(&repo).Error; err != nil { 25 + if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email= ?", nil, email).Scan(&repo).Error; err != nil { 26 26 return nil, err 27 27 } 28 28 return &repo, nil ··· 30 30 31 31 func (s *Server) getRepoActorByDid(did string) (*models.RepoActor, error) { 32 32 var repo models.RepoActor 33 - if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", did).Scan(&repo).Error; err != nil { 33 + if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, did).Scan(&repo).Error; err != nil { 34 34 return nil, err 35 35 } 36 36 return &repo, nil
+74
server/handle_account.go
··· 1 + package server 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/haileyok/cocoon/oauth" 7 + "github.com/haileyok/cocoon/oauth/constants" 8 + "github.com/haileyok/cocoon/oauth/provider" 9 + "github.com/hako/durafmt" 10 + "github.com/labstack/echo/v4" 11 + ) 12 + 13 + func (s *Server) handleAccount(e echo.Context) error { 14 + ctx := e.Request().Context() 15 + repo, sess, err := s.getSessionRepoOrErr(e) 16 + if err != nil { 17 + return e.Redirect(303, "/account/signin") 18 + } 19 + 20 + oldestPossibleSession := time.Now().Add(constants.ConfidentialClientSessionLifetime) 21 + 22 + var tokens []provider.OauthToken 23 + if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE sub = ? AND created_at < ? ORDER BY created_at ASC", nil, repo.Repo.Did, oldestPossibleSession).Scan(&tokens).Error; err != nil { 24 + s.logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err) 25 + sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error") 26 + sess.Save(e.Request(), e.Response()) 27 + return e.Render(200, "account.html", map[string]any{ 28 + "flashes": getFlashesFromSession(e, sess), 29 + }) 30 + } 31 + 32 + var filtered []provider.OauthToken 33 + for _, t := range tokens { 34 + ageRes := oauth.GetSessionAgeFromToken(t) 35 + if ageRes.SessionExpired { 36 + continue 37 + } 38 + filtered = append(filtered, t) 39 + } 40 + 41 + now := time.Now() 42 + 43 + tokenInfo := []map[string]string{} 44 + for _, t := range tokens { 45 + ageRes := oauth.GetSessionAgeFromToken(t) 46 + maxTime := constants.PublicClientSessionLifetime 47 + if t.ClientAuth.Method != "none" { 48 + maxTime = constants.ConfidentialClientSessionLifetime 49 + } 50 + 51 + var clientName string 52 + metadata, err := s.oauthProvider.ClientManager.GetClient(ctx, t.ClientId) 53 + if err != nil { 54 + clientName = t.ClientId 55 + } else { 56 + clientName = metadata.Metadata.ClientName 57 + } 58 + 59 + tokenInfo = append(tokenInfo, map[string]string{ 60 + "ClientName": clientName, 61 + "Age": durafmt.Parse(ageRes.SessionAge).LimitFirstN(2).String(), 62 + "LastUpdated": durafmt.Parse(now.Sub(t.UpdatedAt)).LimitFirstN(2).String(), 63 + "ExpiresIn": durafmt.Parse(now.Add(maxTime).Sub(now)).LimitFirstN(2).String(), 64 + "Token": t.Token, 65 + "Ip": t.Ip, 66 + }) 67 + } 68 + 69 + return e.Render(200, "account.html", map[string]any{ 70 + "Repo": repo, 71 + "Tokens": tokenInfo, 72 + "flashes": getFlashesFromSession(e, sess), 73 + }) 74 + }
+34
server/handle_account_revoke.go
··· 1 + package server 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/internal/helpers" 5 + "github.com/labstack/echo/v4" 6 + ) 7 + 8 + type AccountRevokeRequest struct { 9 + Token string `form:"token"` 10 + } 11 + 12 + func (s *Server) handleAccountRevoke(e echo.Context) error { 13 + var req AccountRevokeRequest 14 + if err := e.Bind(&req); err != nil { 15 + s.logger.Error("could not bind account revoke request", "error", err) 16 + return helpers.ServerError(e, nil) 17 + } 18 + 19 + repo, sess, err := s.getSessionRepoOrErr(e) 20 + if err != nil { 21 + return e.Redirect(303, "/account/signin") 22 + } 23 + 24 + if err := s.db.Exec("DELETE FROM oauth_tokens WHERE sub = ? AND token = ?", nil, repo.Repo.Did, req.Token).Error; err != nil { 25 + s.logger.Error("couldnt delete oauth session for account", "did", repo.Repo.Did, "token", req.Token, "error", err) 26 + sess.AddFlash("Unable to revoke session. See server logs for more details.", "error") 27 + sess.Save(e.Request(), e.Response()) 28 + return e.Redirect(303, "/account") 29 + } 30 + 31 + sess.AddFlash("Session successfully revoked!", "success") 32 + sess.Save(e.Request(), e.Response()) 33 + return e.Redirect(303, "/account") 34 + }
+130
server/handle_account_signin.go
··· 1 + package server 2 + 3 + import ( 4 + "errors" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/gorilla/sessions" 9 + "github.com/haileyok/cocoon/internal/helpers" 10 + "github.com/haileyok/cocoon/models" 11 + "github.com/labstack/echo-contrib/session" 12 + "github.com/labstack/echo/v4" 13 + "golang.org/x/crypto/bcrypt" 14 + "gorm.io/gorm" 15 + ) 16 + 17 + type OauthSigninRequest struct { 18 + Username string `form:"username"` 19 + Password string `form:"password"` 20 + QueryParams string `form:"query_params"` 21 + } 22 + 23 + func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) { 24 + sess, err := session.Get("session", e) 25 + if err != nil { 26 + return nil, nil, err 27 + } 28 + 29 + did, ok := sess.Values["did"].(string) 30 + if !ok { 31 + return nil, sess, errors.New("did was not set in session") 32 + } 33 + 34 + repo, err := s.getRepoActorByDid(did) 35 + if err != nil { 36 + return nil, sess, err 37 + } 38 + 39 + return repo, sess, nil 40 + } 41 + 42 + func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any { 43 + defer sess.Save(e.Request(), e.Response()) 44 + return map[string]any{ 45 + "errors": sess.Flashes("error"), 46 + "successes": sess.Flashes("success"), 47 + } 48 + } 49 + 50 + func (s *Server) handleAccountSigninGet(e echo.Context) error { 51 + _, sess, err := s.getSessionRepoOrErr(e) 52 + if err == nil { 53 + return e.Redirect(303, "/account") 54 + } 55 + 56 + return e.Render(200, "signin.html", map[string]any{ 57 + "flashes": getFlashesFromSession(e, sess), 58 + "QueryParams": e.QueryParams().Encode(), 59 + }) 60 + } 61 + 62 + func (s *Server) handleAccountSigninPost(e echo.Context) error { 63 + var req OauthSigninRequest 64 + if err := e.Bind(&req); err != nil { 65 + s.logger.Error("error binding sign in req", "error", err) 66 + return helpers.ServerError(e, nil) 67 + } 68 + 69 + sess, _ := session.Get("session", e) 70 + 71 + req.Username = strings.ToLower(req.Username) 72 + var idtype string 73 + if _, err := syntax.ParseDID(req.Username); err == nil { 74 + idtype = "did" 75 + } else if _, err := syntax.ParseHandle(req.Username); err == nil { 76 + idtype = "handle" 77 + } else { 78 + idtype = "email" 79 + } 80 + 81 + // TODO: we should make this a helper since we do it for the base create_session as well 82 + var repo models.RepoActor 83 + var err error 84 + switch idtype { 85 + case "did": 86 + err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Username).Scan(&repo).Error 87 + case "handle": 88 + err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Username).Scan(&repo).Error 89 + case "email": 90 + err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Username).Scan(&repo).Error 91 + } 92 + if err != nil { 93 + if err == gorm.ErrRecordNotFound { 94 + sess.AddFlash("Handle or password is incorrect", "error") 95 + } else { 96 + sess.AddFlash("Something went wrong!", "error") 97 + } 98 + sess.Save(e.Request(), e.Response()) 99 + return e.Redirect(303, "/account/signin") 100 + } 101 + 102 + if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { 103 + if err != bcrypt.ErrMismatchedHashAndPassword { 104 + sess.AddFlash("Handle or password is incorrect", "error") 105 + } else { 106 + sess.AddFlash("Something went wrong!", "error") 107 + } 108 + sess.Save(e.Request(), e.Response()) 109 + return e.Redirect(303, "/account/signin") 110 + } 111 + 112 + sess.Options = &sessions.Options{ 113 + Path: "/", 114 + MaxAge: int(AccountSessionMaxAge.Seconds()), 115 + HttpOnly: true, 116 + } 117 + 118 + sess.Values = map[any]any{} 119 + sess.Values["did"] = repo.Repo.Did 120 + 121 + if err := sess.Save(e.Request(), e.Response()); err != nil { 122 + return err 123 + } 124 + 125 + if req.QueryParams != "" { 126 + return e.Redirect(303, "/oauth/authorize?"+req.QueryParams) 127 + } else { 128 + return e.Redirect(303, "/account") 129 + } 130 + }
+35
server/handle_account_signout.go
··· 1 + package server 2 + 3 + import ( 4 + "github.com/gorilla/sessions" 5 + "github.com/labstack/echo-contrib/session" 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + func (s *Server) handleAccountSignout(e echo.Context) error { 10 + sess, err := session.Get("session", e) 11 + if err != nil { 12 + return err 13 + } 14 + 15 + sess.Options = &sessions.Options{ 16 + Path: "/", 17 + MaxAge: -1, 18 + HttpOnly: true, 19 + } 20 + 21 + sess.Values = map[any]any{} 22 + 23 + if err := sess.Save(e.Request(), e.Response()); err != nil { 24 + return err 25 + } 26 + 27 + reqUri := e.QueryParam("request_uri") 28 + 29 + redirect := "/account/signin" 30 + if reqUri != "" { 31 + redirect += "?" + e.QueryParams().Encode() 32 + } 33 + 34 + return e.Redirect(303, redirect) 35 + }
+1 -1
server/handle_actor_get_preferences.go
··· 14 14 15 15 var prefs map[string]any 16 16 err := json.Unmarshal(repo.Preferences, &prefs) 17 - if err != nil || prefs == nil { 17 + if err != nil || prefs["preferences"] == nil { 18 18 prefs = map[string]any{ 19 19 "preferences": map[string]any{}, 20 20 }
+1 -1
server/handle_actor_put_preferences.go
··· 22 22 return err 23 23 } 24 24 25 - if err := s.db.Exec("UPDATE repos SET preferences = ? WHERE did = ?", b, repo.Repo.Did).Error; err != nil { 25 + if err := s.db.Exec("UPDATE repos SET preferences = ? WHERE did = ?", nil, b, repo.Repo.Did).Error; err != nil { 26 26 return err 27 27 } 28 28
+5 -1
server/handle_identity_update_handle.go
··· 81 81 } 82 82 } 83 83 84 + if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil { 85 + s.logger.Warn("error busting did doc", "error", err) 86 + } 87 + 84 88 s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 85 89 RepoHandle: &atproto.SyncSubscribeRepos_Handle{ 86 90 Did: repo.Repo.Did, ··· 99 103 }, 100 104 }) 101 105 102 - if err := s.db.Exec("UPDATE actors SET handle = ? WHERE did = ?", req.Handle, repo.Repo.Did).Error; err != nil { 106 + if err := s.db.Exec("UPDATE actors SET handle = ? WHERE did = ?", nil, req.Handle, repo.Repo.Did).Error; err != nil { 103 107 s.logger.Error("error updating handle in db", "error", err) 104 108 return helpers.ServerError(e, nil) 105 109 }
+115
server/handle_import_repo.go
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "slices" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/repo" 12 + "github.com/haileyok/cocoon/internal/helpers" 13 + "github.com/haileyok/cocoon/models" 14 + blocks "github.com/ipfs/go-block-format" 15 + "github.com/ipfs/go-cid" 16 + "github.com/ipld/go-car" 17 + "github.com/labstack/echo/v4" 18 + ) 19 + 20 + func (s *Server) handleRepoImportRepo(e echo.Context) error { 21 + urepo := e.Get("repo").(*models.RepoActor) 22 + 23 + b, err := io.ReadAll(e.Request().Body) 24 + if err != nil { 25 + s.logger.Error("could not read bytes in import request", "error", err) 26 + return helpers.ServerError(e, nil) 27 + } 28 + 29 + bs := s.getBlockstore(urepo.Repo.Did) 30 + 31 + cs, err := car.NewCarReader(bytes.NewReader(b)) 32 + if err != nil { 33 + s.logger.Error("could not read car in import request", "error", err) 34 + return helpers.ServerError(e, nil) 35 + } 36 + 37 + orderedBlocks := []blocks.Block{} 38 + currBlock, err := cs.Next() 39 + if err != nil { 40 + s.logger.Error("could not get first block from car", "error", err) 41 + return helpers.ServerError(e, nil) 42 + } 43 + currBlockCt := 1 44 + 45 + for currBlock != nil { 46 + s.logger.Info("someone is importing their repo", "block", currBlockCt) 47 + orderedBlocks = append(orderedBlocks, currBlock) 48 + next, _ := cs.Next() 49 + currBlock = next 50 + currBlockCt++ 51 + } 52 + 53 + slices.Reverse(orderedBlocks) 54 + 55 + if err := bs.PutMany(context.TODO(), orderedBlocks); err != nil { 56 + s.logger.Error("could not insert blocks", "error", err) 57 + return helpers.ServerError(e, nil) 58 + } 59 + 60 + r, err := repo.OpenRepo(context.TODO(), bs, cs.Header.Roots[0]) 61 + if err != nil { 62 + s.logger.Error("could not open repo", "error", err) 63 + return helpers.ServerError(e, nil) 64 + } 65 + 66 + tx := s.db.BeginDangerously() 67 + 68 + clock := syntax.NewTIDClock(0) 69 + 70 + if err := r.ForEach(context.TODO(), "", func(key string, cid cid.Cid) error { 71 + pts := strings.Split(key, "/") 72 + nsid := pts[0] 73 + rkey := pts[1] 74 + cidStr := cid.String() 75 + b, err := bs.Get(context.TODO(), cid) 76 + if err != nil { 77 + s.logger.Error("record bytes don't exist in blockstore", "error", err) 78 + return helpers.ServerError(e, nil) 79 + } 80 + 81 + rec := models.Record{ 82 + Did: urepo.Repo.Did, 83 + CreatedAt: clock.Next().String(), 84 + Nsid: nsid, 85 + Rkey: rkey, 86 + Cid: cidStr, 87 + Value: b.RawData(), 88 + } 89 + 90 + if err := tx.Create(rec).Error; err != nil { 91 + return err 92 + } 93 + 94 + return nil 95 + }); err != nil { 96 + tx.Rollback() 97 + s.logger.Error("record bytes don't exist in blockstore", "error", err) 98 + return helpers.ServerError(e, nil) 99 + } 100 + 101 + tx.Commit() 102 + 103 + root, rev, err := r.Commit(context.TODO(), urepo.SignFor) 104 + if err != nil { 105 + s.logger.Error("error committing", "error", err) 106 + return helpers.ServerError(e, nil) 107 + } 108 + 109 + if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil { 110 + s.logger.Error("error updating repo after commit", "error", err) 111 + return helpers.ServerError(e, nil) 112 + } 113 + 114 + return nil 115 + }
+132
server/handle_oauth_authorize.go
··· 1 + package server 2 + 3 + import ( 4 + "net/url" 5 + "strings" 6 + "time" 7 + 8 + "github.com/Azure/go-autorest/autorest/to" 9 + "github.com/haileyok/cocoon/internal/helpers" 10 + "github.com/haileyok/cocoon/oauth" 11 + "github.com/haileyok/cocoon/oauth/provider" 12 + "github.com/labstack/echo/v4" 13 + ) 14 + 15 + func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { 16 + reqUri := e.QueryParam("request_uri") 17 + if reqUri == "" { 18 + // render page for logged out dev 19 + if s.config.Version == "dev" { 20 + return e.Render(200, "authorize.html", map[string]any{ 21 + "Scopes": []string{"atproto", "transition:generic"}, 22 + "AppName": "DEV MODE AUTHORIZATION PAGE", 23 + "Handle": "paula.cocoon.social", 24 + "RequestUri": "", 25 + }) 26 + } 27 + return helpers.InputError(e, to.StringPtr("no request uri")) 28 + } 29 + 30 + repo, _, err := s.getSessionRepoOrErr(e) 31 + if err != nil { 32 + return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode()) 33 + } 34 + 35 + reqId, err := oauth.DecodeRequestUri(reqUri) 36 + if err != nil { 37 + return helpers.InputError(e, to.StringPtr(err.Error())) 38 + } 39 + 40 + var req provider.OauthAuthorizationRequest 41 + if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&req).Error; err != nil { 42 + return helpers.ServerError(e, to.StringPtr(err.Error())) 43 + } 44 + 45 + clientId := e.QueryParam("client_id") 46 + if clientId != req.ClientId { 47 + return helpers.InputError(e, to.StringPtr("client id does not match the client id for the supplied request")) 48 + } 49 + 50 + client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), req.ClientId) 51 + if err != nil { 52 + return helpers.ServerError(e, to.StringPtr(err.Error())) 53 + } 54 + 55 + scopes := strings.Split(req.Parameters.Scope, " ") 56 + appName := client.Metadata.ClientName 57 + 58 + data := map[string]any{ 59 + "Scopes": scopes, 60 + "AppName": appName, 61 + "RequestUri": reqUri, 62 + "QueryParams": e.QueryParams().Encode(), 63 + "Handle": repo.Actor.Handle, 64 + } 65 + 66 + return e.Render(200, "authorize.html", data) 67 + } 68 + 69 + type OauthAuthorizePostRequest struct { 70 + RequestUri string `form:"request_uri"` 71 + AcceptOrRejct string `form:"accept_or_reject"` 72 + } 73 + 74 + func (s *Server) handleOauthAuthorizePost(e echo.Context) error { 75 + repo, _, err := s.getSessionRepoOrErr(e) 76 + if err != nil { 77 + return e.Redirect(303, "/account/signin") 78 + } 79 + 80 + var req OauthAuthorizePostRequest 81 + if err := e.Bind(&req); err != nil { 82 + s.logger.Error("error binding authorize post request", "error", err) 83 + return helpers.InputError(e, nil) 84 + } 85 + 86 + reqId, err := oauth.DecodeRequestUri(req.RequestUri) 87 + if err != nil { 88 + return helpers.InputError(e, to.StringPtr(err.Error())) 89 + } 90 + 91 + var authReq provider.OauthAuthorizationRequest 92 + if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&authReq).Error; err != nil { 93 + return helpers.ServerError(e, to.StringPtr(err.Error())) 94 + } 95 + 96 + client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), authReq.ClientId) 97 + if err != nil { 98 + return helpers.ServerError(e, to.StringPtr(err.Error())) 99 + } 100 + 101 + // TODO: figure out how im supposed to actually redirect 102 + if req.AcceptOrRejct == "reject" { 103 + return e.Redirect(303, client.Metadata.ClientURI) 104 + } 105 + 106 + if time.Now().After(authReq.ExpiresAt) { 107 + return helpers.InputError(e, to.StringPtr("the request has expired")) 108 + } 109 + 110 + if authReq.Sub != nil || authReq.Code != nil { 111 + return helpers.InputError(e, to.StringPtr("this request was already authorized")) 112 + } 113 + 114 + code := oauth.GenerateCode() 115 + 116 + if err := s.db.Exec("UPDATE oauth_authorization_requests SET sub = ?, code = ?, accepted = ?, ip = ? WHERE request_id = ?", nil, repo.Repo.Did, code, true, e.RealIP(), reqId).Error; err != nil { 117 + s.logger.Error("error updating authorization request", "error", err) 118 + return helpers.ServerError(e, nil) 119 + } 120 + 121 + q := url.Values{} 122 + q.Set("state", authReq.Parameters.State) 123 + q.Set("iss", "https://"+s.config.Hostname) 124 + q.Set("code", code) 125 + 126 + hashOrQuestion := "?" 127 + if authReq.ClientAuth.Method != "private_key_jwt" { 128 + hashOrQuestion = "#" 129 + } 130 + 131 + return e.Redirect(303, authReq.Parameters.RedirectURI+hashOrQuestion+q.Encode()) 132 + }
+12
server/handle_oauth_jwks.go
··· 1 + package server 2 + 3 + import "github.com/labstack/echo/v4" 4 + 5 + type OauthJwksResponse struct { 6 + Keys []any `json:"keys"` 7 + } 8 + 9 + // TODO: ? 10 + func (s *Server) handleOauthJwks(e echo.Context) error { 11 + return e.JSON(200, OauthJwksResponse{Keys: []any{}}) 12 + }
+88
server/handle_oauth_par.go
··· 1 + package server 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/Azure/go-autorest/autorest/to" 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/oauth" 9 + "github.com/haileyok/cocoon/oauth/constants" 10 + "github.com/haileyok/cocoon/oauth/provider" 11 + "github.com/labstack/echo/v4" 12 + ) 13 + 14 + type OauthParResponse struct { 15 + ExpiresIn int64 `json:"expires_in"` 16 + RequestURI string `json:"request_uri"` 17 + } 18 + 19 + func (s *Server) handleOauthPar(e echo.Context) error { 20 + var parRequest provider.ParRequest 21 + if err := e.Bind(&parRequest); err != nil { 22 + s.logger.Error("error binding for par request", "error", err) 23 + return helpers.ServerError(e, nil) 24 + } 25 + 26 + if err := e.Validate(parRequest); err != nil { 27 + s.logger.Error("missing parameters for par request", "error", err) 28 + return helpers.InputError(e, nil) 29 + } 30 + 31 + // TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now 32 + dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil) 33 + if err != nil { 34 + s.logger.Error("error getting dpop proof", "error", err) 35 + return helpers.InputError(e, to.StringPtr(err.Error())) 36 + } 37 + 38 + client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{ 39 + // rfc9449 40 + // https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts#L473 41 + AllowMissingDpopProof: true, 42 + }) 43 + if err != nil { 44 + s.logger.Error("error authenticating client", "error", err) 45 + return helpers.InputError(e, to.StringPtr(err.Error())) 46 + } 47 + 48 + if parRequest.DpopJkt == nil { 49 + if client.Metadata.DpopBoundAccessTokens { 50 + parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) 51 + } 52 + } else { 53 + if !client.Metadata.DpopBoundAccessTokens { 54 + msg := "dpop bound access tokens are not enabled for this client" 55 + s.logger.Error(msg) 56 + return helpers.InputError(e, &msg) 57 + } 58 + 59 + if dpopProof.JKT != *parRequest.DpopJkt { 60 + msg := "supplied dpop jkt does not match header dpop jkt" 61 + s.logger.Error(msg) 62 + return helpers.InputError(e, &msg) 63 + } 64 + } 65 + 66 + eat := time.Now().Add(constants.ParExpiresIn) 67 + id := oauth.GenerateRequestId() 68 + 69 + authRequest := &provider.OauthAuthorizationRequest{ 70 + RequestId: id, 71 + ClientId: client.Metadata.ClientID, 72 + ClientAuth: *clientAuth, 73 + Parameters: parRequest, 74 + ExpiresAt: eat, 75 + } 76 + 77 + if err := s.db.Create(authRequest, nil).Error; err != nil { 78 + s.logger.Error("error creating auth request in db", "error", err) 79 + return helpers.ServerError(e, nil) 80 + } 81 + 82 + uri := oauth.EncodeRequestUri(id) 83 + 84 + return e.JSON(201, OauthParResponse{ 85 + ExpiresIn: int64(constants.ParExpiresIn.Seconds()), 86 + RequestURI: uri, 87 + }) 88 + }
+270
server/handle_oauth_token.go
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "fmt" 8 + "slices" 9 + "time" 10 + 11 + "github.com/Azure/go-autorest/autorest/to" 12 + "github.com/golang-jwt/jwt/v4" 13 + "github.com/haileyok/cocoon/internal/helpers" 14 + "github.com/haileyok/cocoon/oauth" 15 + "github.com/haileyok/cocoon/oauth/constants" 16 + "github.com/haileyok/cocoon/oauth/provider" 17 + "github.com/labstack/echo/v4" 18 + ) 19 + 20 + type OauthTokenRequest struct { 21 + provider.AuthenticateClientRequestBase 22 + GrantType string `form:"grant_type" json:"grant_type"` 23 + Code *string `form:"code" json:"code,omitempty"` 24 + CodeVerifier *string `form:"code_verifier" json:"code_verifier,omitempty"` 25 + RedirectURI *string `form:"redirect_uri" json:"redirect_uri,omitempty"` 26 + RefreshToken *string `form:"refresh_token" json:"refresh_token,omitempty"` 27 + } 28 + 29 + type OauthTokenResponse struct { 30 + AccessToken string `json:"access_token"` 31 + TokenType string `json:"token_type"` 32 + RefreshToken string `json:"refresh_token"` 33 + Scope string `json:"scope"` 34 + ExpiresIn int64 `json:"expires_in"` 35 + Sub string `json:"sub"` 36 + } 37 + 38 + func (s *Server) handleOauthToken(e echo.Context) error { 39 + var req OauthTokenRequest 40 + if err := e.Bind(&req); err != nil { 41 + s.logger.Error("error binding token request", "error", err) 42 + return helpers.ServerError(e, nil) 43 + } 44 + 45 + proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil) 46 + if err != nil { 47 + s.logger.Error("error getting dpop proof", "error", err) 48 + return helpers.InputError(e, to.StringPtr(err.Error())) 49 + } 50 + 51 + client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), req.AuthenticateClientRequestBase, proof, &provider.AuthenticateClientOptions{ 52 + AllowMissingDpopProof: true, 53 + }) 54 + if err != nil { 55 + s.logger.Error("error authenticating client", "error", err) 56 + return helpers.InputError(e, to.StringPtr(err.Error())) 57 + } 58 + 59 + // TODO: this should come from an oauth provier config 60 + if !slices.Contains([]string{"authorization_code", "refresh_token"}, req.GrantType) { 61 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the server`, req.GrantType))) 62 + } 63 + 64 + if !slices.Contains(client.Metadata.GrantTypes, req.GrantType) { 65 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the client`, req.GrantType))) 66 + } 67 + 68 + if req.GrantType == "authorization_code" { 69 + if req.Code == nil { 70 + return helpers.InputError(e, to.StringPtr(`"code" is required"`)) 71 + } 72 + 73 + var authReq provider.OauthAuthorizationRequest 74 + // get the lil guy and delete him 75 + if err := s.db.Raw("DELETE FROM oauth_authorization_requests WHERE code = ? RETURNING *", nil, *req.Code).Scan(&authReq).Error; err != nil { 76 + s.logger.Error("error finding authorization request", "error", err) 77 + return helpers.ServerError(e, nil) 78 + } 79 + 80 + if req.RedirectURI == nil || *req.RedirectURI != authReq.Parameters.RedirectURI { 81 + return helpers.InputError(e, to.StringPtr(`"redirect_uri" mismatch`)) 82 + } 83 + 84 + if authReq.Parameters.CodeChallenge != nil { 85 + if req.CodeVerifier == nil { 86 + return helpers.InputError(e, to.StringPtr(`"code_verifier" is required`)) 87 + } 88 + 89 + if len(*req.CodeVerifier) < 43 { 90 + return helpers.InputError(e, to.StringPtr(`"code_verifier" is too short`)) 91 + } 92 + 93 + switch *&authReq.Parameters.CodeChallengeMethod { 94 + case "", "plain": 95 + if authReq.Parameters.CodeChallenge != req.CodeVerifier { 96 + return helpers.InputError(e, to.StringPtr("invalid code_verifier")) 97 + } 98 + case "S256": 99 + inputChal, err := base64.RawURLEncoding.DecodeString(*authReq.Parameters.CodeChallenge) 100 + if err != nil { 101 + s.logger.Error("error decoding code challenge", "error", err) 102 + return helpers.ServerError(e, nil) 103 + } 104 + 105 + h := sha256.New() 106 + h.Write([]byte(*req.CodeVerifier)) 107 + compdChal := h.Sum(nil) 108 + 109 + if !bytes.Equal(inputChal, compdChal) { 110 + return helpers.InputError(e, to.StringPtr("invalid code_verifier")) 111 + } 112 + default: 113 + return helpers.InputError(e, to.StringPtr("unsupported code_challenge_method "+*&authReq.Parameters.CodeChallengeMethod)) 114 + } 115 + } else if req.CodeVerifier != nil { 116 + return helpers.InputError(e, to.StringPtr("code_challenge parameter wasn't provided")) 117 + } 118 + 119 + repo, err := s.getRepoActorByDid(*authReq.Sub) 120 + if err != nil { 121 + helpers.InputError(e, to.StringPtr("unable to find actor")) 122 + } 123 + 124 + now := time.Now() 125 + eat := now.Add(constants.TokenMaxAge) 126 + id := oauth.GenerateTokenId() 127 + 128 + refreshToken := oauth.GenerateRefreshToken() 129 + 130 + accessClaims := jwt.MapClaims{ 131 + "scope": authReq.Parameters.Scope, 132 + "aud": s.config.Did, 133 + "sub": repo.Repo.Did, 134 + "iat": now.Unix(), 135 + "exp": eat.Unix(), 136 + "jti": id, 137 + "client_id": authReq.ClientId, 138 + } 139 + 140 + if authReq.Parameters.DpopJkt != nil { 141 + accessClaims["cnf"] = *authReq.Parameters.DpopJkt 142 + } 143 + 144 + accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 145 + accessString, err := accessToken.SignedString(s.privateKey) 146 + if err != nil { 147 + return err 148 + } 149 + 150 + if err := s.db.Create(&provider.OauthToken{ 151 + ClientId: authReq.ClientId, 152 + ClientAuth: *clientAuth, 153 + Parameters: authReq.Parameters, 154 + ExpiresAt: eat, 155 + DeviceId: "", 156 + Sub: repo.Repo.Did, 157 + Code: *authReq.Code, 158 + Token: accessString, 159 + RefreshToken: refreshToken, 160 + Ip: authReq.Ip, 161 + }, nil).Error; err != nil { 162 + s.logger.Error("error creating token in db", "error", err) 163 + return helpers.ServerError(e, nil) 164 + } 165 + 166 + // prob not needed 167 + tokenType := "Bearer" 168 + if authReq.Parameters.DpopJkt != nil { 169 + tokenType = "DPoP" 170 + } 171 + 172 + e.Response().Header().Set("content-type", "application/json") 173 + 174 + return e.JSON(200, OauthTokenResponse{ 175 + AccessToken: accessString, 176 + RefreshToken: refreshToken, 177 + TokenType: tokenType, 178 + Scope: authReq.Parameters.Scope, 179 + ExpiresIn: int64(eat.Sub(time.Now()).Seconds()), 180 + Sub: repo.Repo.Did, 181 + }) 182 + } 183 + 184 + if req.GrantType == "refresh_token" { 185 + if req.RefreshToken == nil { 186 + return helpers.InputError(e, to.StringPtr(`"refresh_token" is required`)) 187 + } 188 + 189 + var oauthToken provider.OauthToken 190 + if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE refresh_token = ?", nil, req.RefreshToken).Scan(&oauthToken).Error; err != nil { 191 + s.logger.Error("error finding oauth token by refresh token", "error", err, "refresh_token", req.RefreshToken) 192 + return helpers.ServerError(e, nil) 193 + } 194 + 195 + if client.Metadata.ClientID != oauthToken.ClientId { 196 + return helpers.InputError(e, to.StringPtr(`"client_id" mismatch`)) 197 + } 198 + 199 + if clientAuth.Method != oauthToken.ClientAuth.Method { 200 + return helpers.InputError(e, to.StringPtr(`"client authentication method mismatch`)) 201 + } 202 + 203 + if *oauthToken.Parameters.DpopJkt != proof.JKT { 204 + return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt")) 205 + } 206 + 207 + ageRes := oauth.GetSessionAgeFromToken(oauthToken) 208 + 209 + if ageRes.SessionExpired { 210 + return helpers.InputError(e, to.StringPtr("Session expired")) 211 + } 212 + 213 + if ageRes.RefreshExpired { 214 + return helpers.InputError(e, to.StringPtr("Refresh token expired")) 215 + } 216 + 217 + if client.Metadata.DpopBoundAccessTokens && oauthToken.Parameters.DpopJkt == nil { 218 + // why? ref impl 219 + return helpers.InputError(e, to.StringPtr("dpop jkt is required for dpop bound access tokens")) 220 + } 221 + 222 + nextTokenId := oauth.GenerateTokenId() 223 + nextRefreshToken := oauth.GenerateRefreshToken() 224 + 225 + now := time.Now() 226 + eat := now.Add(constants.TokenMaxAge) 227 + 228 + accessClaims := jwt.MapClaims{ 229 + "scope": oauthToken.Parameters.Scope, 230 + "aud": s.config.Did, 231 + "sub": oauthToken.Sub, 232 + "iat": now.Unix(), 233 + "exp": eat.Unix(), 234 + "jti": nextTokenId, 235 + "client_id": oauthToken.ClientId, 236 + } 237 + 238 + if oauthToken.Parameters.DpopJkt != nil { 239 + accessClaims["cnf"] = *&oauthToken.Parameters.DpopJkt 240 + } 241 + 242 + accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 243 + accessString, err := accessToken.SignedString(s.privateKey) 244 + if err != nil { 245 + return err 246 + } 247 + 248 + if err := s.db.Exec("UPDATE oauth_tokens SET token = ?, refresh_token = ?, expires_at = ?, updated_at = ? WHERE refresh_token = ?", nil, accessString, nextRefreshToken, eat, now, *req.RefreshToken).Error; err != nil { 249 + s.logger.Error("error updating token", "error", err) 250 + return helpers.ServerError(e, nil) 251 + } 252 + 253 + // prob not needed 254 + tokenType := "Bearer" 255 + if oauthToken.Parameters.DpopJkt != nil { 256 + tokenType = "DPoP" 257 + } 258 + 259 + return e.JSON(200, OauthTokenResponse{ 260 + AccessToken: accessString, 261 + RefreshToken: nextRefreshToken, 262 + TokenType: tokenType, 263 + Scope: oauthToken.Parameters.Scope, 264 + ExpiresIn: int64(eat.Sub(time.Now()).Seconds()), 265 + Sub: oauthToken.Sub, 266 + }) 267 + } 268 + 269 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`grant type "%s" is not supported`, req.GrantType))) 270 + }
+27 -15
server/handle_proxy.go
··· 17 17 secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 18 ) 19 19 20 - func (s *Server) handleProxy(e echo.Context) error { 21 - repo, isAuthed := e.Get("repo").(*models.RepoActor) 22 - 23 - pts := strings.Split(e.Request().URL.Path, "/") 24 - if len(pts) != 3 { 25 - return fmt.Errorf("incorrect number of parts") 26 - } 27 - 20 + func (s *Server) getAtprotoProxyEndpointFromRequest(e echo.Context) (string, string, error) { 28 21 svc := e.Request().Header.Get("atproto-proxy") 29 22 if svc == "" { 30 - svc = "did:web:api.bsky.app#bsky_appview" // TODO: should be a config var probably 23 + svc = s.config.DefaultAtprotoProxy 31 24 } 32 25 33 26 svcPts := strings.Split(svc, "#") 34 27 if len(svcPts) != 2 { 35 - return fmt.Errorf("invalid service header") 28 + return "", "", fmt.Errorf("invalid service header") 36 29 } 37 30 38 31 svcDid := svcPts[0] ··· 40 33 41 34 doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid) 42 35 if err != nil { 43 - return err 36 + return "", "", err 44 37 } 45 38 46 39 var endpoint string ··· 50 43 } 51 44 } 52 45 46 + return endpoint, svcDid, nil 47 + } 48 + 49 + func (s *Server) handleProxy(e echo.Context) error { 50 + lgr := s.logger.With("handler", "handleProxy") 51 + 52 + repo, isAuthed := e.Get("repo").(*models.RepoActor) 53 + 54 + pts := strings.Split(e.Request().URL.Path, "/") 55 + if len(pts) != 3 { 56 + return fmt.Errorf("incorrect number of parts") 57 + } 58 + 59 + endpoint, svcDid, err := s.getAtprotoProxyEndpointFromRequest(e) 60 + if err != nil { 61 + lgr.Error("could not get atproto proxy", "error", err) 62 + return helpers.ServerError(e, nil) 63 + } 64 + 53 65 requrl := e.Request().URL 54 66 requrl.Host = strings.TrimPrefix(endpoint, "https://") 55 67 requrl.Scheme = "https" ··· 78 90 } 79 91 hj, err := json.Marshal(header) 80 92 if err != nil { 81 - s.logger.Error("error marshaling header", "error", err) 93 + lgr.Error("error marshaling header", "error", err) 82 94 return helpers.ServerError(e, nil) 83 95 } 84 96 ··· 93 105 } 94 106 pj, err := json.Marshal(payload) 95 107 if err != nil { 96 - s.logger.Error("error marashaling payload", "error", err) 108 + lgr.Error("error marashaling payload", "error", err) 97 109 return helpers.ServerError(e, nil) 98 110 } 99 111 ··· 104 116 105 117 sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 106 118 if err != nil { 107 - s.logger.Error("can't load private key", "error", err) 119 + lgr.Error("can't load private key", "error", err) 108 120 return err 109 121 } 110 122 111 123 R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 112 124 if err != nil { 113 - s.logger.Error("error signing", "error", err) 125 + lgr.Error("error signing", "error", err) 114 126 } 115 127 116 128 rBytes := R.Bytes()
+2 -2
server/handle_repo_describe_repo.go
··· 64 64 } 65 65 66 66 var records []models.Record 67 - if err := s.db.Raw("SELECT DISTINCT(nsid) FROM records WHERE did = ?", repo.Repo.Did).Scan(&records).Error; err != nil { 67 + if err := s.db.Raw("SELECT DISTINCT(nsid) FROM records WHERE did = ?", nil, repo.Repo.Did).Scan(&records).Error; err != nil { 68 68 s.logger.Error("error getting collections", "error", err) 69 69 return helpers.ServerError(e, nil) 70 70 } 71 71 72 - var collections []string = make([]string, 0) 72 + var collections []string = make([]string, 0, len(records)) 73 73 for _, r := range records { 74 74 collections = append(collections, r.Nsid) 75 75 }
+1 -1
server/handle_repo_get_record.go
··· 32 32 } 33 33 34 34 var record models.Record 35 - if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? AND rkey = ?"+cidquery, params...).Scan(&record).Error; err != nil { 35 + if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? AND rkey = ?"+cidquery, nil, params...).Scan(&record).Error; err != nil { 36 36 // TODO: handle error nicely 37 37 return err 38 38 }
+39 -10
server/handle_repo_list_records.go
··· 2 2 3 3 import ( 4 4 "strconv" 5 - "strings" 6 5 7 6 "github.com/Azure/go-autorest/autorest/to" 8 7 "github.com/bluesky-social/indigo/atproto/data" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 9 "github.com/haileyok/cocoon/internal/helpers" 10 10 "github.com/haileyok/cocoon/models" 11 11 "github.com/labstack/echo/v4" 12 12 ) 13 + 14 + type ComAtprotoRepoListRecordsRequest struct { 15 + Repo string `query:"repo" validate:"required"` 16 + Collection string `query:"collection" validate:"required,atproto-nsid"` 17 + Limit int64 `query:"limit"` 18 + Cursor string `query:"cursor"` 19 + Reverse bool `query:"reverse"` 20 + } 13 21 14 22 type ComAtprotoRepoListRecordsResponse struct { 15 23 Cursor *string `json:"cursor,omitempty"` ··· 38 46 } 39 47 40 48 func (s *Server) handleListRecords(e echo.Context) error { 41 - did := e.QueryParam("repo") 42 - collection := e.QueryParam("collection") 43 - cursor := e.QueryParam("cursor") 44 - reverse := e.QueryParam("reverse") 49 + var req ComAtprotoRepoListRecordsRequest 50 + if err := e.Bind(&req); err != nil { 51 + s.logger.Error("could not bind list records request", "error", err) 52 + return helpers.ServerError(e, nil) 53 + } 54 + 55 + if err := e.Validate(req); err != nil { 56 + return helpers.InputError(e, nil) 57 + } 58 + 59 + if req.Limit <= 0 { 60 + req.Limit = 50 61 + } else if req.Limit > 100 { 62 + req.Limit = 100 63 + } 64 + 45 65 limit, err := getLimitFromContext(e, 50) 46 66 if err != nil { 47 67 return helpers.InputError(e, nil) ··· 51 71 dir := "<" 52 72 cursorquery := "" 53 73 54 - if strings.ToLower(reverse) == "true" { 74 + if req.Reverse { 55 75 sort = "ASC" 56 76 dir = ">" 57 77 } 58 78 59 - params := []any{did, collection} 60 - if cursor != "" { 61 - params = append(params, cursor) 79 + did := req.Repo 80 + if _, err := syntax.ParseDID(did); err != nil { 81 + actor, err := s.getActorByHandle(req.Repo) 82 + if err != nil { 83 + return helpers.InputError(e, to.StringPtr("RepoNotFound")) 84 + } 85 + did = actor.Did 86 + } 87 + 88 + params := []any{did, req.Collection} 89 + if req.Cursor != "" { 90 + params = append(params, req.Cursor) 62 91 cursorquery = "AND created_at " + dir + " ?" 63 92 } 64 93 params = append(params, limit) 65 94 66 95 var records []models.Record 67 - if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? "+cursorquery+" ORDER BY created_at "+sort+" limit ?", params...).Scan(&records).Error; err != nil { 96 + if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? "+cursorquery+" ORDER BY created_at "+sort+" limit ?", nil, params...).Scan(&records).Error; err != nil { 68 97 s.logger.Error("error getting records", "error", err) 69 98 return helpers.ServerError(e, nil) 70 99 }
+1 -1
server/handle_repo_list_repos.go
··· 22 22 // TODO: paginate this bitch 23 23 func (s *Server) handleListRepos(e echo.Context) error { 24 24 var repos []models.Repo 25 - if err := s.db.Raw("SELECT * FROM repos ORDER BY created_at DESC LIMIT 500").Scan(&repos).Error; err != nil { 25 + if err := s.db.Raw("SELECT * FROM repos ORDER BY created_at DESC LIMIT 500", nil).Scan(&repos).Error; err != nil { 26 26 return err 27 27 } 28 28
+3 -3
server/handle_repo_upload_blob.go
··· 40 40 CreatedAt: s.repoman.clock.Next().String(), 41 41 } 42 42 43 - if err := s.db.Create(&blob).Error; err != nil { 43 + if err := s.db.Create(&blob, nil).Error; err != nil { 44 44 s.logger.Error("error creating new blob in db", "error", err) 45 45 return helpers.ServerError(e, nil) 46 46 } ··· 72 72 Data: data, 73 73 } 74 74 75 - if err := s.db.Create(&blobPart).Error; err != nil { 75 + if err := s.db.Create(&blobPart, nil).Error; err != nil { 76 76 s.logger.Error("error adding blob part to db", "error", err) 77 77 return helpers.ServerError(e, nil) 78 78 } ··· 89 89 return helpers.ServerError(e, nil) 90 90 } 91 91 92 - if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", c.Bytes(), blob.ID).Error; err != nil { 92 + if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", nil, c.Bytes(), blob.ID).Error; err != nil { 93 93 // there should probably be somme handling here if this fails... 94 94 s.logger.Error("error updating blob", "error", err) 95 95 return helpers.ServerError(e, nil)
+65
server/handle_server_check_account_status.go
··· 1 + package server 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/internal/helpers" 5 + "github.com/haileyok/cocoon/models" 6 + "github.com/ipfs/go-cid" 7 + "github.com/labstack/echo/v4" 8 + ) 9 + 10 + type ComAtprotoServerCheckAccountStatusResponse struct { 11 + Activated bool `json:"activated"` 12 + ValidDid bool `json:"validDid"` 13 + RepoCommit string `json:"repoCommit"` 14 + RepoRev string `json:"repoRev"` 15 + RepoBlocks int64 `json:"repoBlocks"` 16 + IndexedRecords int64 `json:"indexedRecords"` 17 + PrivateStateValues int64 `json:"privateStateValues"` 18 + ExpectedBlobs int64 `json:"expectedBlobs"` 19 + ImportedBlobs int64 `json:"importedBlobs"` 20 + } 21 + 22 + func (s *Server) handleServerCheckAccountStatus(e echo.Context) error { 23 + urepo := e.Get("repo").(*models.RepoActor) 24 + 25 + resp := ComAtprotoServerCheckAccountStatusResponse{ 26 + Activated: true, // TODO: should allow for deactivation etc. 27 + ValidDid: true, // TODO: should probably verify? 28 + RepoRev: urepo.Rev, 29 + ImportedBlobs: 0, // TODO: ??? 30 + } 31 + 32 + rootcid, err := cid.Cast(urepo.Root) 33 + if err != nil { 34 + s.logger.Error("error casting cid", "error", err) 35 + return helpers.ServerError(e, nil) 36 + } 37 + resp.RepoCommit = rootcid.String() 38 + 39 + type CountResp struct { 40 + Ct int64 41 + } 42 + 43 + var blockCtResp CountResp 44 + if err := s.db.Raw("SELECT COUNT(*) AS ct FROM blocks WHERE did = ?", nil, urepo.Repo.Did).Scan(&blockCtResp).Error; err != nil { 45 + s.logger.Error("error getting block count", "error", err) 46 + return helpers.ServerError(e, nil) 47 + } 48 + resp.RepoBlocks = blockCtResp.Ct 49 + 50 + var recCtResp CountResp 51 + if err := s.db.Raw("SELECT COUNT(*) AS ct FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&recCtResp).Error; err != nil { 52 + s.logger.Error("error getting record count", "error", err) 53 + return helpers.ServerError(e, nil) 54 + } 55 + resp.IndexedRecords = recCtResp.Ct 56 + 57 + var blobCtResp CountResp 58 + if err := s.db.Raw("SELECT COUNT(*) AS ct FROM blobs WHERE did = ?", nil, urepo.Repo.Did).Scan(&blobCtResp).Error; err != nil { 59 + s.logger.Error("error getting record count", "error", err) 60 + return helpers.ServerError(e, nil) 61 + } 62 + resp.ExpectedBlobs = blobCtResp.Ct 63 + 64 + return e.JSON(200, resp) 65 + }
+3 -3
server/handle_server_confirm_email.go
··· 28 28 } 29 29 30 30 if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil { 31 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 31 + return helpers.ExpiredTokenError(e) 32 32 } 33 33 34 34 if *urepo.EmailVerificationCode != req.Token { ··· 36 36 } 37 37 38 38 if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) { 39 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 39 + return helpers.ExpiredTokenError(e) 40 40 } 41 41 42 42 now := time.Now().UTC() 43 43 44 - if err := s.db.Exec("UPDATE repos SET email_verification_code = NULL, email_verification_code_expires_at = NULL, email_confirmed_at = ? WHERE did = ?", now, urepo.Repo.Did).Error; err != nil { 44 + if err := s.db.Exec("UPDATE repos SET email_verification_code = NULL, email_verification_code_expires_at = NULL, email_confirmed_at = ? WHERE did = ?", nil, now, urepo.Repo.Did).Error; err != nil { 45 45 s.logger.Error("error updating user", "error", err) 46 46 return helpers.ServerError(e, nil) 47 47 }
+65 -46
server/handle_server_create_account.go
··· 10 10 "github.com/Azure/go-autorest/autorest/to" 11 11 "github.com/bluesky-social/indigo/api/atproto" 12 12 "github.com/bluesky-social/indigo/atproto/crypto" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 13 14 "github.com/bluesky-social/indigo/events" 14 15 "github.com/bluesky-social/indigo/repo" 15 16 "github.com/bluesky-social/indigo/util" 16 - "github.com/haileyok/cocoon/blockstore" 17 17 "github.com/haileyok/cocoon/internal/helpers" 18 18 "github.com/haileyok/cocoon/models" 19 19 "github.com/labstack/echo/v4" ··· 39 39 func (s *Server) handleCreateAccount(e echo.Context) error { 40 40 var request ComAtprotoServerCreateAccountRequest 41 41 42 + var signupDid string 43 + customDidHeader := e.Request().Header.Get("authorization") 44 + if customDidHeader != "" { 45 + pts := strings.Split(customDidHeader, " ") 46 + if len(pts) != 2 { 47 + return helpers.InputError(e, to.StringPtr("InvalidDid")) 48 + } 49 + 50 + _, err := syntax.ParseDID(pts[1]) 51 + if err != nil { 52 + return helpers.InputError(e, to.StringPtr("InvalidDid")) 53 + } 54 + 55 + signupDid = pts[1] 56 + } 57 + 42 58 if err := e.Bind(&request); err != nil { 43 59 s.logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err) 44 60 return helpers.ServerError(e, nil) ··· 85 101 } 86 102 87 103 var ic models.InviteCode 88 - if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil { 104 + if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil { 89 105 if err == gorm.ErrRecordNotFound { 90 106 return helpers.InputError(e, to.StringPtr("InvalidInviteCode")) 91 107 } ··· 109 125 110 126 // TODO: unsupported domains 111 127 112 - // TODO: did stuff 113 - 114 128 k, err := crypto.GeneratePrivateKeyK256() 115 129 if err != nil { 116 130 s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 117 131 return helpers.ServerError(e, nil) 118 132 } 119 133 120 - did, op, err := s.plcClient.CreateDID(k, "", request.Handle) 121 - if err != nil { 122 - s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err) 123 - return helpers.ServerError(e, nil) 124 - } 134 + if signupDid == "" { 135 + did, op, err := s.plcClient.CreateDID(k, "", request.Handle) 136 + if err != nil { 137 + s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err) 138 + return helpers.ServerError(e, nil) 139 + } 125 140 126 - if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil { 127 - s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err) 128 - return helpers.ServerError(e, nil) 141 + if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil { 142 + s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err) 143 + return helpers.ServerError(e, nil) 144 + } 145 + signupDid = did 129 146 } 130 147 131 148 hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10) ··· 135 152 } 136 153 137 154 urepo := models.Repo{ 138 - Did: did, 155 + Did: signupDid, 139 156 CreatedAt: time.Now(), 140 157 Email: request.Email, 141 158 EmailVerificationCode: to.StringPtr(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))), ··· 144 161 } 145 162 146 163 actor := models.Actor{ 147 - Did: did, 164 + Did: signupDid, 148 165 Handle: request.Handle, 149 166 } 150 167 151 - if err := s.db.Create(&urepo).Error; err != nil { 168 + if err := s.db.Create(&urepo, nil).Error; err != nil { 152 169 s.logger.Error("error inserting new repo", "error", err) 153 170 return helpers.ServerError(e, nil) 154 171 } 155 172 156 - bs := blockstore.New(did, s.db) 157 - r := repo.NewRepo(context.TODO(), did, bs) 158 - 159 - root, rev, err := r.Commit(context.TODO(), urepo.SignFor) 160 - if err != nil { 161 - s.logger.Error("error committing", "error", err) 173 + if err := s.db.Create(&actor, nil).Error; err != nil { 174 + s.logger.Error("error inserting new actor", "error", err) 162 175 return helpers.ServerError(e, nil) 163 176 } 164 177 165 - if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil { 166 - s.logger.Error("error updating repo after commit", "error", err) 167 - return helpers.ServerError(e, nil) 168 - } 178 + if customDidHeader == "" { 179 + bs := s.getBlockstore(signupDid) 180 + r := repo.NewRepo(context.TODO(), signupDid, bs) 169 181 170 - s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 171 - RepoHandle: &atproto.SyncSubscribeRepos_Handle{ 172 - Did: urepo.Did, 173 - Handle: request.Handle, 174 - Seq: time.Now().UnixMicro(), // TODO: no 175 - Time: time.Now().Format(util.ISO8601), 176 - }, 177 - }) 182 + root, rev, err := r.Commit(context.TODO(), urepo.SignFor) 183 + if err != nil { 184 + s.logger.Error("error committing", "error", err) 185 + return helpers.ServerError(e, nil) 186 + } 178 187 179 - s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 180 - RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 181 - Did: urepo.Did, 182 - Handle: to.StringPtr(request.Handle), 183 - Seq: time.Now().UnixMicro(), // TODO: no 184 - Time: time.Now().Format(util.ISO8601), 185 - }, 186 - }) 188 + if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil { 189 + s.logger.Error("error updating repo after commit", "error", err) 190 + return helpers.ServerError(e, nil) 191 + } 192 + 193 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 194 + RepoHandle: &atproto.SyncSubscribeRepos_Handle{ 195 + Did: urepo.Did, 196 + Handle: request.Handle, 197 + Seq: time.Now().UnixMicro(), // TODO: no 198 + Time: time.Now().Format(util.ISO8601), 199 + }, 200 + }) 187 201 188 - if err := s.db.Create(&actor).Error; err != nil { 189 - s.logger.Error("error inserting new actor", "error", err) 190 - return helpers.ServerError(e, nil) 202 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 203 + RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 204 + Did: urepo.Did, 205 + Handle: to.StringPtr(request.Handle), 206 + Seq: time.Now().UnixMicro(), // TODO: no 207 + Time: time.Now().Format(util.ISO8601), 208 + }, 209 + }) 191 210 } 192 211 193 - if err := s.db.Raw("UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil { 212 + if err := s.db.Raw("UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil { 194 213 s.logger.Error("error decrementing use count", "error", err) 195 214 return helpers.ServerError(e, nil) 196 215 } ··· 214 233 AccessJwt: sess.AccessToken, 215 234 RefreshJwt: sess.RefreshToken, 216 235 Handle: request.Handle, 217 - Did: did, 236 + Did: signupDid, 218 237 }) 219 238 }
+1 -1
server/handle_server_create_invite_code.go
··· 41 41 Code: ic, 42 42 Did: acc, 43 43 RemainingUseCount: req.UseCount, 44 - }).Error; err != nil { 44 + }, nil).Error; err != nil { 45 45 s.logger.Error("error creating invite code", "error", err) 46 46 return helpers.ServerError(e, nil) 47 47 }
+1 -1
server/handle_server_create_invite_codes.go
··· 54 54 Code: ic, 55 55 Did: did, 56 56 RemainingUseCount: req.UseCount, 57 - }).Error; err != nil { 57 + }, nil).Error; err != nil { 58 58 s.logger.Error("error creating invite code", "error", err) 59 59 return helpers.ServerError(e, nil) 60 60 }
+3 -3
server/handle_server_create_session.go
··· 65 65 var err error 66 66 switch idtype { 67 67 case "did": 68 - err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", req.Identifier).Scan(&repo).Error 68 + err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Identifier).Scan(&repo).Error 69 69 case "handle": 70 - err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", req.Identifier).Scan(&repo).Error 70 + err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Identifier).Scan(&repo).Error 71 71 case "email": 72 - err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE a.email = ?", req.Identifier).Scan(&repo).Error 72 + err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Identifier).Scan(&repo).Error 73 73 } 74 74 75 75 if err != nil {
+2 -2
server/handle_server_delete_session.go
··· 10 10 token := e.Get("token").(string) 11 11 12 12 var acctok models.Token 13 - if err := s.db.Raw("DELETE FROM tokens WHERE token = ? RETURNING *", token).Scan(&acctok).Error; err != nil { 13 + if err := s.db.Raw("DELETE FROM tokens WHERE token = ? RETURNING *", nil, token).Scan(&acctok).Error; err != nil { 14 14 s.logger.Error("error deleting access token from db", "error", err) 15 15 return helpers.ServerError(e, nil) 16 16 } 17 17 18 - if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", acctok.RefreshToken).Error; err != nil { 18 + if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", nil, acctok.RefreshToken).Error; err != nil { 19 19 s.logger.Error("error deleting refresh token from db", "error", err) 20 20 return helpers.ServerError(e, nil) 21 21 }
+114
server/handle_server_get_service_auth.go
··· 1 + package server 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "strings" 10 + "time" 11 + 12 + "github.com/Azure/go-autorest/autorest/to" 13 + "github.com/google/uuid" 14 + "github.com/haileyok/cocoon/internal/helpers" 15 + "github.com/haileyok/cocoon/models" 16 + "github.com/labstack/echo/v4" 17 + secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 + ) 19 + 20 + type ServerGetServiceAuthRequest struct { 21 + Aud string `query:"aud" validate:"required,atproto-did"` 22 + // exp should be a float, as some clients will send a non-integer expiration 23 + Exp float64 `query:"exp"` 24 + Lxm string `query:"lxm" validate:"required,atproto-nsid"` 25 + } 26 + 27 + func (s *Server) handleServerGetServiceAuth(e echo.Context) error { 28 + var req ServerGetServiceAuthRequest 29 + if err := e.Bind(&req); err != nil { 30 + s.logger.Error("could not bind service auth request", "error", err) 31 + return helpers.ServerError(e, nil) 32 + } 33 + 34 + if err := e.Validate(req); err != nil { 35 + return helpers.InputError(e, nil) 36 + } 37 + 38 + exp := int64(req.Exp) 39 + now := time.Now().Unix() 40 + if exp == 0 { 41 + exp = now + 60 // default 42 + } 43 + 44 + if req.Lxm == "com.atproto.server.getServiceAuth" { 45 + return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively")) 46 + } 47 + 48 + maxExp := now + (60 * 30) 49 + if exp > maxExp { 50 + return helpers.InputError(e, to.StringPtr("expiration too big. smoller please")) 51 + } 52 + 53 + repo := e.Get("repo").(*models.RepoActor) 54 + 55 + header := map[string]string{ 56 + "alg": "ES256K", 57 + "crv": "secp256k1", 58 + "typ": "JWT", 59 + } 60 + hj, err := json.Marshal(header) 61 + if err != nil { 62 + s.logger.Error("error marshaling header", "error", err) 63 + return helpers.ServerError(e, nil) 64 + } 65 + 66 + encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 67 + 68 + payload := map[string]any{ 69 + "iss": repo.Repo.Did, 70 + "aud": req.Aud, 71 + "lxm": req.Lxm, 72 + "jti": uuid.NewString(), 73 + "exp": exp, 74 + "iat": now, 75 + } 76 + pj, err := json.Marshal(payload) 77 + if err != nil { 78 + s.logger.Error("error marashaling payload", "error", err) 79 + return helpers.ServerError(e, nil) 80 + } 81 + 82 + encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 83 + 84 + input := fmt.Sprintf("%s.%s", encheader, encpayload) 85 + hash := sha256.Sum256([]byte(input)) 86 + 87 + sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 88 + if err != nil { 89 + s.logger.Error("can't load private key", "error", err) 90 + return err 91 + } 92 + 93 + R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 94 + if err != nil { 95 + s.logger.Error("error signing", "error", err) 96 + return helpers.ServerError(e, nil) 97 + } 98 + 99 + rBytes := R.Bytes() 100 + sBytes := S.Bytes() 101 + 102 + rPadded := make([]byte, 32) 103 + sPadded := make([]byte, 32) 104 + copy(rPadded[32-len(rBytes):], rBytes) 105 + copy(sPadded[32-len(sBytes):], sBytes) 106 + 107 + rawsig := append(rPadded, sPadded...) 108 + encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(rawsig), "=") 109 + token := fmt.Sprintf("%s.%s", input, encsig) 110 + 111 + return e.JSON(200, map[string]string{ 112 + "token": token, 113 + }) 114 + }
+2 -2
server/handle_server_refresh_session.go
··· 19 19 token := e.Get("token").(string) 20 20 repo := e.Get("repo").(*models.RepoActor) 21 21 22 - if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", token).Error; err != nil { 22 + if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", nil, token).Error; err != nil { 23 23 s.logger.Error("error getting refresh token from db", "error", err) 24 24 return helpers.ServerError(e, nil) 25 25 } 26 26 27 - if err := s.db.Exec("DELETE FROM tokens WHERE refresh_token = ?", token).Error; err != nil { 27 + if err := s.db.Exec("DELETE FROM tokens WHERE refresh_token = ?", nil, token).Error; err != nil { 28 28 s.logger.Error("error deleting access token from db", "error", err) 29 29 return helpers.ServerError(e, nil) 30 30 }
+1 -1
server/handle_server_request_email_confirmation.go
··· 20 20 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 21 21 eat := time.Now().Add(10 * time.Minute).UTC() 22 22 23 - if err := s.db.Exec("UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { 23 + if err := s.db.Exec("UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { 24 24 s.logger.Error("error updating user", "error", err) 25 25 return helpers.ServerError(e, nil) 26 26 }
+1 -1
server/handle_server_request_email_update.go
··· 20 20 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 21 21 eat := time.Now().Add(10 * time.Minute).UTC() 22 22 23 - if err := s.db.Exec("UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { 23 + if err := s.db.Exec("UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { 24 24 s.logger.Error("error updating repo", "error", err) 25 25 return helpers.ServerError(e, nil) 26 26 }
+1 -1
server/handle_server_request_password_reset.go
··· 36 36 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 37 37 eat := time.Now().Add(10 * time.Minute).UTC() 38 38 39 - if err := s.db.Exec("UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { 39 + if err := s.db.Exec("UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { 40 40 s.logger.Error("error updating repo", "error", err) 41 41 return helpers.ServerError(e, nil) 42 42 }
+3 -3
server/handle_server_reset_password.go
··· 33 33 } 34 34 35 35 if *urepo.PasswordResetCode != req.Token { 36 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 36 + return helpers.InvalidTokenError(e) 37 37 } 38 38 39 39 if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) { 40 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 40 + return helpers.ExpiredTokenError(e) 41 41 } 42 42 43 43 hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) ··· 46 46 return helpers.ServerError(e, nil) 47 47 } 48 48 49 - if err := s.db.Exec("UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", hash, urepo.Repo.Did).Error; err != nil { 49 + if err := s.db.Exec("UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", nil, hash, urepo.Repo.Did).Error; err != nil { 50 50 s.logger.Error("error updating repo", "error", err) 51 51 return helpers.ServerError(e, nil) 52 52 }
+4 -5
server/handle_server_update_email.go
··· 3 3 import ( 4 4 "time" 5 5 6 - "github.com/Azure/go-autorest/autorest/to" 7 6 "github.com/haileyok/cocoon/internal/helpers" 8 7 "github.com/haileyok/cocoon/models" 9 8 "github.com/labstack/echo/v4" ··· 29 28 } 30 29 31 30 if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil { 32 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 31 + return helpers.InvalidTokenError(e) 33 32 } 34 33 35 34 if *urepo.EmailUpdateCode != req.Token { 36 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 35 + return helpers.InvalidTokenError(e) 37 36 } 38 37 39 38 if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) { 40 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 39 + return helpers.ExpiredTokenError(e) 41 40 } 42 41 43 - if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", req.Email, urepo.Repo.Did).Error; err != nil { 42 + if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", nil, req.Email, urepo.Repo.Did).Error; err != nil { 44 43 s.logger.Error("error updating repo", "error", err) 45 44 return helpers.ServerError(e, nil) 46 45 }
+4 -2
server/handle_sync_get_blob.go
··· 26 26 } 27 27 28 28 var blob models.Blob 29 - if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", did, c.Bytes()).Scan(&blob).Error; err != nil { 29 + if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&blob).Error; err != nil { 30 30 s.logger.Error("error looking up blob", "error", err) 31 31 return helpers.ServerError(e, nil) 32 32 } ··· 34 34 buf := new(bytes.Buffer) 35 35 36 36 var parts []models.BlobPart 37 - if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", blob.ID).Scan(&parts).Error; err != nil { 37 + if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", nil, blob.ID).Scan(&parts).Error; err != nil { 38 38 s.logger.Error("error getting blob parts", "error", err) 39 39 return helpers.ServerError(e, nil) 40 40 } ··· 43 43 for _, p := range parts { 44 44 buf.Write(p.Data) 45 45 } 46 + 47 + e.Response().Header().Set(echo.HeaderContentDisposition, "attachment; filename="+c.String()) 46 48 47 49 return e.Stream(200, "application/octet-stream", buf) 48 50 }
+1 -2
server/handle_sync_get_blocks.go
··· 6 6 "strings" 7 7 8 8 "github.com/bluesky-social/indigo/carstore" 9 - "github.com/haileyok/cocoon/blockstore" 10 9 "github.com/haileyok/cocoon/internal/helpers" 11 10 "github.com/ipfs/go-cid" 12 11 cbor "github.com/ipfs/go-ipld-cbor" ··· 54 53 return helpers.ServerError(e, nil) 55 54 } 56 55 57 - bs := blockstore.New(urepo.Repo.Did, s.db) 56 + bs := s.getBlockstore(urepo.Repo.Did) 58 57 59 58 for _, c := range cids { 60 59 b, err := bs.Get(context.TODO(), c)
+1 -1
server/handle_sync_get_record.go
··· 18 18 rkey := e.QueryParam("rkey") 19 19 20 20 var urepo models.Repo 21 - if err := s.db.Raw("SELECT * FROM repos WHERE did = ?", did).Scan(&urepo).Error; err != nil { 21 + if err := s.db.Raw("SELECT * FROM repos WHERE did = ?", nil, did).Scan(&urepo).Error; err != nil { 22 22 s.logger.Error("error getting repo", "error", err) 23 23 return helpers.ServerError(e, nil) 24 24 }
+1 -1
server/handle_sync_get_repo.go
··· 41 41 } 42 42 43 43 var blocks []models.Block 44 - if err := s.db.Raw("SELECT * FROM blocks WHERE did = ? ORDER BY rev ASC", urepo.Repo.Did).Scan(&blocks).Error; err != nil { 44 + if err := s.db.Raw("SELECT * FROM blocks WHERE did = ? ORDER BY rev ASC", nil, urepo.Repo.Did).Scan(&blocks).Error; err != nil { 45 45 return err 46 46 } 47 47
+1 -1
server/handle_sync_list_blobs.go
··· 35 35 params = append(params, limit) 36 36 37 37 var blobs []models.Blob 38 - if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", params...).Scan(&blobs).Error; err != nil { 38 + if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil { 39 39 s.logger.Error("error getting records", "error", err) 40 40 return helpers.ServerError(e, nil) 41 41 }
+88
server/handle_well_known.go
··· 1 1 package server 2 2 3 3 import ( 4 + "fmt" 5 + 6 + "github.com/Azure/go-autorest/autorest/to" 4 7 "github.com/labstack/echo/v4" 5 8 ) 6 9 10 + var ( 11 + CocoonSupportedScopes = []string{ 12 + "atproto", 13 + "transition:email", 14 + "transition:generic", 15 + "transition:chat.bsky", 16 + } 17 + ) 18 + 19 + type OauthAuthorizationMetadata struct { 20 + Issuer string `json:"issuer"` 21 + RequestParameterSupported bool `json:"request_parameter_supported"` 22 + RequestUriParameterSupported bool `json:"request_uri_parameter_supported"` 23 + RequireRequestUriRegistration *bool `json:"require_request_uri_registration,omitempty"` 24 + ScopesSupported []string `json:"scopes_supported"` 25 + SubjectTypesSupported []string `json:"subject_types_supported"` 26 + ResponseTypesSupported []string `json:"response_types_supported"` 27 + ResponseModesSupported []string `json:"response_modes_supported"` 28 + GrantTypesSupported []string `json:"grant_types_supported"` 29 + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` 30 + UILocalesSupported []string `json:"ui_locales_supported"` 31 + DisplayValuesSupported []string `json:"display_values_supported"` 32 + RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"` 33 + AuthorizationResponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"` 34 + RequestObjectEncryptionAlgValuesSupported []string `json:"request_object_encryption_alg_values_supported"` 35 + RequestObjectEncryptionEncValuesSupported []string `json:"request_object_encryption_enc_values_supported"` 36 + JwksUri string `json:"jwks_uri"` 37 + AuthorizationEndpoint string `json:"authorization_endpoint"` 38 + TokenEndpoint string `json:"token_endpoint"` 39 + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` 40 + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"` 41 + RevocationEndpoint string `json:"revocation_endpoint"` 42 + IntrospectionEndpoint string `json:"introspection_endpoint"` 43 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 44 + RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"` 45 + DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"` 46 + ProtectedResources []string `json:"protected_resources"` 47 + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` 48 + } 49 + 7 50 func (s *Server) handleWellKnown(e echo.Context) error { 8 51 return e.JSON(200, map[string]any{ 9 52 "@context": []string{ ··· 19 62 }, 20 63 }) 21 64 } 65 + 66 + func (s *Server) handleOauthProtectedResource(e echo.Context) error { 67 + return e.JSON(200, map[string]any{ 68 + "resource": "https://" + s.config.Hostname, 69 + "authorization_servers": []string{ 70 + "https://" + s.config.Hostname, 71 + }, 72 + "scopes_supported": []string{}, 73 + "bearer_methods_supported": []string{"header"}, 74 + "resource_documentation": "https://atproto.com", 75 + }) 76 + } 77 + 78 + func (s *Server) handleOauthAuthorizationServer(e echo.Context) error { 79 + return e.JSON(200, OauthAuthorizationMetadata{ 80 + Issuer: "https://" + s.config.Hostname, 81 + RequestParameterSupported: true, 82 + RequestUriParameterSupported: true, 83 + RequireRequestUriRegistration: to.BoolPtr(true), 84 + ScopesSupported: CocoonSupportedScopes, 85 + SubjectTypesSupported: []string{"public"}, 86 + ResponseTypesSupported: []string{"code"}, 87 + ResponseModesSupported: []string{"query", "fragment", "form_post"}, 88 + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, 89 + CodeChallengeMethodsSupported: []string{"S256"}, 90 + UILocalesSupported: []string{"en-US"}, 91 + DisplayValuesSupported: []string{"page", "popup", "touch"}, 92 + RequestObjectSigningAlgValuesSupported: []string{"ES256"}, // only es256 for now... 93 + AuthorizationResponseISSParameterSupported: true, 94 + RequestObjectEncryptionAlgValuesSupported: []string{}, 95 + RequestObjectEncryptionEncValuesSupported: []string{}, 96 + JwksUri: fmt.Sprintf("https://%s/oauth/jwks", s.config.Hostname), 97 + AuthorizationEndpoint: fmt.Sprintf("https://%s/oauth/authorize", s.config.Hostname), 98 + TokenEndpoint: fmt.Sprintf("https://%s/oauth/token", s.config.Hostname), 99 + TokenEndpointAuthMethodsSupported: []string{"none", "private_key_jwt"}, 100 + TokenEndpointAuthSigningAlgValuesSupported: []string{"ES256"}, // Same as above, just es256 101 + RevocationEndpoint: fmt.Sprintf("https://%s/oauth/revoke", s.config.Hostname), 102 + IntrospectionEndpoint: fmt.Sprintf("https://%s/oauth/introspect", s.config.Hostname), 103 + PushedAuthorizationRequestEndpoint: fmt.Sprintf("https://%s/oauth/par", s.config.Hostname), 104 + RequirePushedAuthorizationRequests: true, 105 + DpopSigningAlgValuesSupported: []string{"ES256"}, // again same as above 106 + ProtectedResources: []string{"https://" + s.config.Hostname}, 107 + ClientIDMetadataDocumentSupported: true, 108 + }) 109 + }
+16
server/mail.go
··· 3 3 import "fmt" 4 4 5 5 func (s *Server) sendWelcomeMail(email, handle string) error { 6 + if s.mail == nil { 7 + return nil 8 + } 9 + 6 10 s.mailLk.Lock() 7 11 defer s.mailLk.Unlock() 8 12 ··· 18 22 } 19 23 20 24 func (s *Server) sendPasswordReset(email, handle, code string) error { 25 + if s.mail == nil { 26 + return nil 27 + } 28 + 21 29 s.mailLk.Lock() 22 30 defer s.mailLk.Unlock() 23 31 ··· 33 41 } 34 42 35 43 func (s *Server) sendEmailUpdate(email, handle, code string) error { 44 + if s.mail == nil { 45 + return nil 46 + } 47 + 36 48 s.mailLk.Lock() 37 49 defer s.mailLk.Unlock() 38 50 ··· 48 60 } 49 61 50 62 func (s *Server) sendEmailVerification(email, handle, code string) error { 63 + if s.mail == nil { 64 + return nil 65 + } 66 + 51 67 s.mailLk.Lock() 52 68 defer s.mailLk.Unlock() 53 69
+268
server/middleware.go
··· 1 + package server 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "github.com/Azure/go-autorest/autorest/to" 11 + "github.com/golang-jwt/jwt/v4" 12 + "github.com/haileyok/cocoon/internal/helpers" 13 + "github.com/haileyok/cocoon/models" 14 + "github.com/haileyok/cocoon/oauth/provider" 15 + "github.com/labstack/echo/v4" 16 + "gitlab.com/yawning/secp256k1-voi" 17 + secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 + "gorm.io/gorm" 19 + ) 20 + 21 + func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 22 + return func(e echo.Context) error { 23 + username, password, ok := e.Request().BasicAuth() 24 + if !ok || username != "admin" || password != s.config.AdminPassword { 25 + return helpers.InputError(e, to.StringPtr("Unauthorized")) 26 + } 27 + 28 + if err := next(e); err != nil { 29 + e.Error(err) 30 + } 31 + 32 + return nil 33 + } 34 + } 35 + 36 + func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 37 + return func(e echo.Context) error { 38 + authheader := e.Request().Header.Get("authorization") 39 + if authheader == "" { 40 + return e.JSON(401, map[string]string{"error": "Unauthorized"}) 41 + } 42 + 43 + pts := strings.Split(authheader, " ") 44 + if len(pts) != 2 { 45 + return helpers.ServerError(e, nil) 46 + } 47 + 48 + // move on to oauth session middleware if this is a dpop token 49 + if pts[0] == "DPoP" { 50 + return next(e) 51 + } 52 + 53 + tokenstr := pts[1] 54 + token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{}) 55 + claims, ok := token.Claims.(jwt.MapClaims) 56 + if !ok { 57 + return helpers.InvalidTokenError(e) 58 + } 59 + 60 + var did string 61 + var repo *models.RepoActor 62 + 63 + // service auth tokens 64 + lxm, hasLxm := claims["lxm"] 65 + if hasLxm { 66 + pts := strings.Split(e.Request().URL.String(), "/") 67 + if lxm != pts[len(pts)-1] { 68 + s.logger.Error("service auth lxm incorrect", "lxm", lxm, "expected", pts[len(pts)-1], "error", err) 69 + return helpers.InputError(e, nil) 70 + } 71 + 72 + maybeDid, ok := claims["iss"].(string) 73 + if !ok { 74 + s.logger.Error("no iss in service auth token", "error", err) 75 + return helpers.InputError(e, nil) 76 + } 77 + did = maybeDid 78 + 79 + maybeRepo, err := s.getRepoActorByDid(did) 80 + if err != nil { 81 + s.logger.Error("error fetching repo", "error", err) 82 + return helpers.ServerError(e, nil) 83 + } 84 + repo = maybeRepo 85 + } 86 + 87 + if token.Header["alg"] != "ES256K" { 88 + token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 89 + if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 90 + return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 91 + } 92 + return s.privateKey.Public(), nil 93 + }) 94 + if err != nil { 95 + s.logger.Error("error parsing jwt", "error", err) 96 + return helpers.ExpiredTokenError(e) 97 + } 98 + 99 + if !token.Valid { 100 + return helpers.InvalidTokenError(e) 101 + } 102 + } else { 103 + kpts := strings.Split(tokenstr, ".") 104 + signingInput := kpts[0] + "." + kpts[1] 105 + hash := sha256.Sum256([]byte(signingInput)) 106 + sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2]) 107 + if err != nil { 108 + s.logger.Error("error decoding signature bytes", "error", err) 109 + return helpers.ServerError(e, nil) 110 + } 111 + 112 + if len(sigBytes) != 64 { 113 + s.logger.Error("incorrect sigbytes length", "length", len(sigBytes)) 114 + return helpers.ServerError(e, nil) 115 + } 116 + 117 + rBytes := sigBytes[:32] 118 + sBytes := sigBytes[32:] 119 + rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes)) 120 + ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes)) 121 + 122 + sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 123 + if err != nil { 124 + s.logger.Error("can't load private key", "error", err) 125 + return err 126 + } 127 + 128 + pubKey, ok := sk.Public().(*secp256k1secec.PublicKey) 129 + if !ok { 130 + s.logger.Error("error getting public key from sk") 131 + return helpers.ServerError(e, nil) 132 + } 133 + 134 + verified := pubKey.VerifyRaw(hash[:], rr, ss) 135 + if !verified { 136 + s.logger.Error("error verifying", "error", err) 137 + return helpers.ServerError(e, nil) 138 + } 139 + } 140 + 141 + isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession" 142 + scope, _ := claims["scope"].(string) 143 + 144 + if isRefresh && scope != "com.atproto.refresh" { 145 + return helpers.InvalidTokenError(e) 146 + } else if !hasLxm && !isRefresh && scope != "com.atproto.access" { 147 + return helpers.InvalidTokenError(e) 148 + } 149 + 150 + table := "tokens" 151 + if isRefresh { 152 + table = "refresh_tokens" 153 + } 154 + 155 + if isRefresh { 156 + type Result struct { 157 + Found bool 158 + } 159 + var result Result 160 + if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil { 161 + if err == gorm.ErrRecordNotFound { 162 + return helpers.InvalidTokenError(e) 163 + } 164 + 165 + s.logger.Error("error getting token from db", "error", err) 166 + return helpers.ServerError(e, nil) 167 + } 168 + 169 + if !result.Found { 170 + return helpers.InvalidTokenError(e) 171 + } 172 + } 173 + 174 + exp, ok := claims["exp"].(float64) 175 + if !ok { 176 + s.logger.Error("error getting iat from token") 177 + return helpers.ServerError(e, nil) 178 + } 179 + 180 + if exp < float64(time.Now().UTC().Unix()) { 181 + return helpers.ExpiredTokenError(e) 182 + } 183 + 184 + if repo == nil { 185 + maybeRepo, err := s.getRepoActorByDid(claims["sub"].(string)) 186 + if err != nil { 187 + s.logger.Error("error fetching repo", "error", err) 188 + return helpers.ServerError(e, nil) 189 + } 190 + repo = maybeRepo 191 + did = repo.Repo.Did 192 + } 193 + 194 + e.Set("repo", repo) 195 + e.Set("did", did) 196 + e.Set("token", tokenstr) 197 + 198 + if err := next(e); err != nil { 199 + return helpers.InvalidTokenError(e) 200 + } 201 + 202 + return nil 203 + } 204 + } 205 + 206 + func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 207 + return func(e echo.Context) error { 208 + authheader := e.Request().Header.Get("authorization") 209 + if authheader == "" { 210 + return e.JSON(401, map[string]string{"error": "Unauthorized"}) 211 + } 212 + 213 + pts := strings.Split(authheader, " ") 214 + if len(pts) != 2 { 215 + return helpers.ServerError(e, nil) 216 + } 217 + 218 + if pts[0] != "DPoP" { 219 + return next(e) 220 + } 221 + 222 + accessToken := pts[1] 223 + 224 + nonce := s.oauthProvider.NextNonce() 225 + if nonce != "" { 226 + e.Response().Header().Set("DPoP-Nonce", nonce) 227 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 228 + } 229 + 230 + proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 231 + if err != nil { 232 + s.logger.Error("invalid dpop proof", "error", err) 233 + return helpers.InputError(e, to.StringPtr(err.Error())) 234 + } 235 + 236 + var oauthToken provider.OauthToken 237 + if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil { 238 + s.logger.Error("error finding access token in db", "error", err) 239 + return helpers.InputError(e, nil) 240 + } 241 + 242 + if oauthToken.Token == "" { 243 + return helpers.InvalidTokenError(e) 244 + } 245 + 246 + if *oauthToken.Parameters.DpopJkt != proof.JKT { 247 + s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT) 248 + return helpers.InputError(e, to.StringPtr("dpop jkt mismatch")) 249 + } 250 + 251 + if time.Now().After(oauthToken.ExpiresAt) { 252 + return helpers.ExpiredTokenError(e) 253 + } 254 + 255 + repo, err := s.getRepoActorByDid(oauthToken.Sub) 256 + if err != nil { 257 + s.logger.Error("could not find actor in db", "error", err) 258 + return helpers.ServerError(e, nil) 259 + } 260 + 261 + e.Set("repo", repo) 262 + e.Set("did", repo.Repo.Did) 263 + e.Set("token", accessToken) 264 + e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " ")) 265 + 266 + return next(e) 267 + } 268 + }
+28 -23
server/repo.go
··· 16 16 "github.com/bluesky-social/indigo/events" 17 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 18 "github.com/bluesky-social/indigo/repo" 19 - "github.com/bluesky-social/indigo/util" 20 - "github.com/haileyok/cocoon/blockstore" 19 + "github.com/haileyok/cocoon/internal/db" 21 20 "github.com/haileyok/cocoon/models" 21 + "github.com/haileyok/cocoon/recording_blockstore" 22 22 blocks "github.com/ipfs/go-block-format" 23 23 "github.com/ipfs/go-cid" 24 24 cbor "github.com/ipfs/go-ipld-cbor" 25 25 "github.com/ipld/go-car" 26 - "gorm.io/gorm" 27 26 "gorm.io/gorm/clause" 28 27 ) 29 28 30 29 type RepoMan struct { 31 - db *gorm.DB 30 + db *db.DB 32 31 s *Server 33 32 clock *syntax.TIDClock 34 33 } ··· 103 102 return nil, err 104 103 } 105 104 106 - dbs := blockstore.New(urepo.Did, rm.db) 105 + dbs := rm.s.getBlockstore(urepo.Did) 106 + bs := recording_blockstore.New(dbs) 107 107 r, err := repo.OpenRepo(context.TODO(), dbs, rootcid) 108 108 109 109 entries := []models.Record{} ··· 112 112 for i, op := range writes { 113 113 if op.Type != OpTypeCreate && op.Rkey == nil { 114 114 return nil, fmt.Errorf("invalid rkey") 115 + } else if op.Type == OpTypeCreate && op.Rkey != nil { 116 + _, _, err := r.GetRecord(context.TODO(), op.Collection+"/"+*op.Rkey) 117 + if err == nil { 118 + op.Type = OpTypeUpdate 119 + } 115 120 } else if op.Rkey == nil { 116 121 op.Rkey = to.StringPtr(rm.clock.Next().String()) 117 122 writes[i].Rkey = op.Rkey ··· 157 162 }) 158 163 case OpTypeDelete: 159 164 var old models.Record 160 - if err := rm.db.Raw("SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil { 165 + if err := rm.db.Raw("SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", nil, urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil { 161 166 return nil, err 162 167 } 163 168 entries = append(entries, models.Record{ ··· 269 274 } 270 275 } 271 276 272 - for _, op := range dbs.GetLog() { 277 + for _, op := range bs.GetLogMap() { 273 278 if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil { 274 279 return nil, err 275 280 } ··· 279 284 for _, entry := range entries { 280 285 var cids []cid.Cid 281 286 if entry.Cid != "" { 282 - if err := rm.s.db.Clauses(clause.OnConflict{ 287 + if err := rm.s.db.Create(&entry, []clause.Expression{clause.OnConflict{ 283 288 Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}}, 284 289 UpdateAll: true, 285 - }).Create(&entry).Error; err != nil { 290 + }}).Error; err != nil { 286 291 return nil, err 287 292 } 288 293 ··· 291 296 return nil, err 292 297 } 293 298 } else { 294 - if err := rm.s.db.Delete(&entry).Error; err != nil { 299 + if err := rm.s.db.Delete(&entry, nil).Error; err != nil { 295 300 return nil, err 296 301 } 297 302 cids, err = rm.decrementBlobRefs(urepo, entry.Value) ··· 313 318 Rev: rev, 314 319 Since: &urepo.Rev, 315 320 Commit: lexutil.LexLink(newroot), 316 - Time: time.Now().Format(util.ISO8601), 321 + Time: time.Now().Format(time.RFC3339Nano), 317 322 Ops: ops, 318 323 TooBig: false, 319 324 }, 320 325 }) 321 326 322 - if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil { 327 + if err := rm.s.UpdateRepo(context.TODO(), urepo.Did, newroot, rev); err != nil { 323 328 return nil, err 324 329 } 325 330 ··· 340 345 return cid.Undef, nil, err 341 346 } 342 347 343 - dbs := blockstore.New(urepo.Did, rm.db) 344 - bs := util.NewLoggingBstore(dbs) 348 + dbs := rm.s.getBlockstore(urepo.Did) 349 + bs := recording_blockstore.New(dbs) 345 350 346 351 r, err := repo.OpenRepo(context.TODO(), bs, c) 347 352 if err != nil { ··· 353 358 return cid.Undef, nil, err 354 359 } 355 360 356 - return c, bs.GetLoggedBlocks(), nil 361 + return c, bs.GetLogArray(), nil 357 362 } 358 363 359 364 func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) { ··· 363 368 } 364 369 365 370 for _, c := range cids { 366 - if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", urepo.Did, c.Bytes()).Error; err != nil { 371 + if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", nil, urepo.Did, c.Bytes()).Error; err != nil { 367 372 return nil, err 368 373 } 369 374 } ··· 382 387 ID uint 383 388 Count int 384 389 } 385 - if err := rm.db.Raw("UPDATE blobs SET ref_count = ref_count - 1 WHERE did = ? AND cid = ? RETURNING id, ref_count", urepo.Did, c.Bytes()).Scan(&res).Error; err != nil { 390 + if err := rm.db.Raw("UPDATE blobs SET ref_count = ref_count - 1 WHERE did = ? AND cid = ? RETURNING id, ref_count", nil, urepo.Did, c.Bytes()).Scan(&res).Error; err != nil { 386 391 return nil, err 387 392 } 388 393 389 394 if res.Count == 0 { 390 - if err := rm.db.Exec("DELETE FROM blobs WHERE id = ?", res.ID).Error; err != nil { 395 + if err := rm.db.Exec("DELETE FROM blobs WHERE id = ?", nil, res.ID).Error; err != nil { 391 396 return nil, err 392 397 } 393 - if err := rm.db.Exec("DELETE FROM blob_parts WHERE blob_id = ?", res.ID).Error; err != nil { 398 + if err := rm.db.Exec("DELETE FROM blob_parts WHERE blob_id = ?", nil, res.ID).Error; err != nil { 394 399 return nil, err 395 400 } 396 401 } ··· 409 414 return nil, fmt.Errorf("error unmarshaling cbor: %w", err) 410 415 } 411 416 412 - var deepiter func(interface{}) error 413 - deepiter = func(item interface{}) error { 417 + var deepiter func(any) error 418 + deepiter = func(item any) error { 414 419 switch val := item.(type) { 415 - case map[string]interface{}: 420 + case map[string]any: 416 421 if val["$type"] == "blob" { 417 422 if ref, ok := val["ref"].(string); ok { 418 423 c, err := cid.Parse(ref) ··· 425 430 return deepiter(v) 426 431 } 427 432 } 428 - case []interface{}: 433 + case []any: 429 434 for _, v := range val { 430 435 deepiter(v) 431 436 }
+337 -152
server/server.go
··· 1 1 package server 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "crypto/ecdsa" 7 + "embed" 6 8 "errors" 7 9 "fmt" 10 + "io" 8 11 "log/slog" 9 12 "net/http" 10 13 "net/smtp" 11 14 "os" 12 - "strings" 15 + "path/filepath" 13 16 "sync" 17 + "text/template" 14 18 "time" 15 19 16 - "github.com/Azure/go-autorest/autorest/to" 20 + "github.com/aws/aws-sdk-go/aws" 21 + "github.com/aws/aws-sdk-go/aws/credentials" 22 + "github.com/aws/aws-sdk-go/aws/session" 23 + "github.com/aws/aws-sdk-go/service/s3" 17 24 "github.com/bluesky-social/indigo/api/atproto" 18 25 "github.com/bluesky-social/indigo/atproto/syntax" 19 26 "github.com/bluesky-social/indigo/events" ··· 21 28 "github.com/bluesky-social/indigo/xrpc" 22 29 "github.com/domodwyer/mailyak/v3" 23 30 "github.com/go-playground/validator" 24 - "github.com/golang-jwt/jwt/v4" 31 + "github.com/gorilla/sessions" 25 32 "github.com/haileyok/cocoon/identity" 33 + "github.com/haileyok/cocoon/internal/db" 26 34 "github.com/haileyok/cocoon/internal/helpers" 27 35 "github.com/haileyok/cocoon/models" 36 + "github.com/haileyok/cocoon/oauth/client" 37 + "github.com/haileyok/cocoon/oauth/constants" 38 + "github.com/haileyok/cocoon/oauth/dpop" 39 + "github.com/haileyok/cocoon/oauth/provider" 28 40 "github.com/haileyok/cocoon/plc" 41 + "github.com/ipfs/go-cid" 42 + echo_session "github.com/labstack/echo-contrib/session" 29 43 "github.com/labstack/echo/v4" 30 44 "github.com/labstack/echo/v4/middleware" 31 - "github.com/lestrrat-go/jwx/v2/jwk" 32 45 slogecho "github.com/samber/slog-echo" 33 46 "gorm.io/driver/sqlite" 34 47 "gorm.io/gorm" 35 48 ) 36 49 50 + const ( 51 + AccountSessionMaxAge = 30 * 24 * time.Hour // one week 52 + ) 53 + 54 + type S3Config struct { 55 + BackupsEnabled bool 56 + Endpoint string 57 + Region string 58 + Bucket string 59 + AccessKey string 60 + SecretKey string 61 + } 62 + 37 63 type Server struct { 38 - http *http.Client 39 - httpd *http.Server 40 - mail *mailyak.MailYak 41 - mailLk *sync.Mutex 42 - echo *echo.Echo 43 - db *gorm.DB 44 - plcClient *plc.Client 45 - logger *slog.Logger 46 - config *config 47 - privateKey *ecdsa.PrivateKey 48 - repoman *RepoMan 49 - evtman *events.EventManager 50 - passport *identity.Passport 64 + http *http.Client 65 + httpd *http.Server 66 + mail *mailyak.MailYak 67 + mailLk *sync.Mutex 68 + echo *echo.Echo 69 + db *db.DB 70 + plcClient *plc.Client 71 + logger *slog.Logger 72 + config *config 73 + privateKey *ecdsa.PrivateKey 74 + repoman *RepoMan 75 + oauthProvider *provider.Provider 76 + evtman *events.EventManager 77 + passport *identity.Passport 78 + 79 + dbName string 80 + s3Config *S3Config 51 81 } 52 82 53 83 type Args struct { ··· 69 99 SmtpPort string 70 100 SmtpEmail string 71 101 SmtpName string 102 + 103 + S3Config *S3Config 104 + 105 + SessionSecret string 106 + 107 + DefaultAtprotoProxy string 108 + 109 + BlockstoreVariant BlockstoreVariant 72 110 } 73 111 74 112 type config struct { 75 - Version string 76 - Did string 77 - Hostname string 78 - ContactEmail string 79 - EnforcePeering bool 80 - Relays []string 81 - AdminPassword string 82 - SmtpEmail string 83 - SmtpName string 113 + Version string 114 + Did string 115 + Hostname string 116 + ContactEmail string 117 + EnforcePeering bool 118 + Relays []string 119 + AdminPassword string 120 + SmtpEmail string 121 + SmtpName string 122 + DefaultAtprotoProxy string 123 + BlockstoreVariant BlockstoreVariant 84 124 } 85 125 86 126 type CustomValidator struct { ··· 111 151 return nil 112 152 } 113 153 114 - func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 115 - return func(e echo.Context) error { 116 - username, password, ok := e.Request().BasicAuth() 117 - if !ok || username != "admin" || password != s.config.AdminPassword { 118 - return helpers.InputError(e, to.StringPtr("Unauthorized")) 119 - } 154 + //go:embed templates/* 155 + var templateFS embed.FS 120 156 121 - if err := next(e); err != nil { 122 - e.Error(err) 123 - } 124 - 125 - return nil 126 - } 157 + //go:embed static/* 158 + var staticFS embed.FS 159 + 160 + type TemplateRenderer struct { 161 + templates *template.Template 162 + isDev bool 163 + templatePath string 127 164 } 128 165 129 - func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 130 - return func(e echo.Context) error { 131 - authheader := e.Request().Header.Get("authorization") 132 - if authheader == "" { 133 - return e.JSON(401, map[string]string{"error": "Unauthorized"}) 166 + func (s *Server) loadTemplates() { 167 + absPath, _ := filepath.Abs("server/templates/*.html") 168 + if s.config.Version == "dev" { 169 + tmpl := template.Must(template.ParseGlob(absPath)) 170 + s.echo.Renderer = &TemplateRenderer{ 171 + templates: tmpl, 172 + isDev: true, 173 + templatePath: absPath, 134 174 } 135 - 136 - pts := strings.Split(authheader, " ") 137 - if len(pts) != 2 { 138 - return helpers.ServerError(e, nil) 139 - } 140 - 141 - tokenstr := pts[1] 142 - 143 - token, err := new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 144 - if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 145 - return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 146 - } 147 - 148 - return s.privateKey.Public(), nil 149 - }) 150 - if err != nil { 151 - s.logger.Error("error parsing jwt", "error", err) 152 - // NOTE: https://github.com/bluesky-social/atproto/discussions/3319 153 - return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"}) 154 - } 155 - 156 - claims, ok := token.Claims.(jwt.MapClaims) 157 - if !ok || !token.Valid { 158 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 159 - } 160 - 161 - isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession" 162 - scope := claims["scope"].(string) 163 - 164 - if isRefresh && scope != "com.atproto.refresh" { 165 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 166 - } else if !isRefresh && scope != "com.atproto.access" { 167 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 168 - } 169 - 170 - table := "tokens" 171 - if isRefresh { 172 - table = "refresh_tokens" 173 - } 174 - 175 - type Result struct { 176 - Found bool 177 - } 178 - var result Result 179 - if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", tokenstr).Scan(&result).Error; err != nil { 180 - if err == gorm.ErrRecordNotFound { 181 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 182 - } 183 - 184 - s.logger.Error("error getting token from db", "error", err) 185 - return helpers.ServerError(e, nil) 186 - } 187 - 188 - if !result.Found { 189 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 190 - } 191 - 192 - exp, ok := claims["exp"].(float64) 193 - if !ok { 194 - s.logger.Error("error getting iat from token") 195 - return helpers.ServerError(e, nil) 175 + } else { 176 + tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html")) 177 + s.echo.Renderer = &TemplateRenderer{ 178 + templates: tmpl, 179 + isDev: false, 196 180 } 181 + } 182 + } 197 183 198 - if exp < float64(time.Now().UTC().Unix()) { 199 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 200 - } 201 - 202 - repo, err := s.getRepoActorByDid(claims["sub"].(string)) 184 + func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error { 185 + if t.isDev { 186 + tmpl, err := template.ParseGlob(t.templatePath) 203 187 if err != nil { 204 - s.logger.Error("error fetching repo", "error", err) 205 - return helpers.ServerError(e, nil) 188 + return err 206 189 } 190 + t.templates = tmpl 191 + } 207 192 208 - e.Set("repo", repo) 209 - e.Set("did", claims["sub"]) 210 - e.Set("token", tokenstr) 193 + if viewContext, isMap := data.(map[string]any); isMap { 194 + viewContext["reverse"] = c.Echo().Reverse 195 + } 211 196 212 - if err := next(e); err != nil { 213 - e.Error(err) 214 - } 215 - 216 - return nil 217 - } 197 + return t.templates.ExecuteTemplate(w, name, data) 218 198 } 219 199 220 200 func New(args *Args) (*Server, error) { ··· 250 230 args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})) 251 231 } 252 232 233 + if args.SessionSecret == "" { 234 + panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ") 235 + } 236 + 253 237 e := echo.New() 254 238 255 239 e.Pre(middleware.RemoveTrailingSlash()) 256 240 e.Pre(slogecho.New(args.Logger)) 241 + e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret)))) 257 242 e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 258 243 AllowOrigins: []string{"*"}, 259 244 AllowHeaders: []string{"*"}, ··· 293 278 httpd := &http.Server{ 294 279 Addr: args.Addr, 295 280 Handler: e, 281 + // shitty defaults but okay for now, needed for import repo 282 + ReadTimeout: 5 * time.Minute, 283 + WriteTimeout: 5 * time.Minute, 284 + IdleTimeout: 5 * time.Minute, 296 285 } 297 286 298 - db, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{}) 287 + gdb, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{}) 299 288 if err != nil { 300 289 return nil, err 301 290 } 291 + dbw := db.NewDB(gdb) 302 292 303 293 rkbytes, err := os.ReadFile(args.RotationKeyPath) 304 294 if err != nil { ··· 322 312 return nil, err 323 313 } 324 314 325 - key, err := jwk.ParseKey(jwkbytes) 315 + key, err := helpers.ParseJWKFromBytes(jwkbytes) 326 316 if err != nil { 327 317 return nil, err 328 318 } ··· 332 322 return nil, err 333 323 } 334 324 325 + oauthCli := &http.Client{ 326 + Timeout: 10 * time.Second, 327 + } 328 + 329 + var nonceSecret []byte 330 + maybeSecret, err := os.ReadFile("nonce.secret") 331 + if err != nil && !os.IsNotExist(err) { 332 + args.Logger.Error("error attempting to read nonce secret", "error", err) 333 + } else { 334 + nonceSecret = maybeSecret 335 + } 336 + 335 337 s := &Server{ 336 338 http: h, 337 339 httpd: httpd, 338 340 echo: e, 339 341 logger: args.Logger, 340 - db: db, 342 + db: dbw, 341 343 plcClient: plcClient, 342 344 privateKey: &pkey, 343 345 config: &config{ 344 - Version: args.Version, 345 - Did: args.Did, 346 - Hostname: args.Hostname, 347 - ContactEmail: args.ContactEmail, 348 - EnforcePeering: false, 349 - Relays: args.Relays, 350 - AdminPassword: args.AdminPassword, 351 - SmtpName: args.SmtpName, 352 - SmtpEmail: args.SmtpEmail, 346 + Version: args.Version, 347 + Did: args.Did, 348 + Hostname: args.Hostname, 349 + ContactEmail: args.ContactEmail, 350 + EnforcePeering: false, 351 + Relays: args.Relays, 352 + AdminPassword: args.AdminPassword, 353 + SmtpName: args.SmtpName, 354 + SmtpEmail: args.SmtpEmail, 355 + DefaultAtprotoProxy: args.DefaultAtprotoProxy, 356 + BlockstoreVariant: args.BlockstoreVariant, 353 357 }, 354 358 evtman: events.NewEventManager(events.NewMemPersister()), 355 359 passport: identity.NewPassport(h, identity.NewMemCache(10_000)), 360 + 361 + dbName: args.DbName, 362 + s3Config: args.S3Config, 363 + 364 + oauthProvider: provider.NewProvider(provider.Args{ 365 + Hostname: args.Hostname, 366 + ClientManagerArgs: client.ManagerArgs{ 367 + Cli: oauthCli, 368 + Logger: args.Logger, 369 + }, 370 + DpopManagerArgs: dpop.ManagerArgs{ 371 + NonceSecret: nonceSecret, 372 + NonceRotationInterval: constants.NonceMaxRotationInterval / 3, 373 + OnNonceSecretCreated: func(newNonce []byte) { 374 + if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil { 375 + args.Logger.Error("error writing new nonce secret", "error", err) 376 + } 377 + }, 378 + Logger: args.Logger, 379 + Hostname: args.Hostname, 380 + }, 381 + }), 356 382 } 357 383 384 + s.loadTemplates() 385 + 358 386 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it 359 387 360 388 // TODO: should validate these args ··· 373 401 } 374 402 375 403 func (s *Server) addRoutes() { 404 + // static 405 + if s.config.Version == "dev" { 406 + s.echo.Static("/static", "server/static") 407 + } else { 408 + s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS)))) 409 + } 410 + 376 411 // random stuff 377 412 s.echo.GET("/", s.handleRoot) 378 413 s.echo.GET("/xrpc/_health", s.handleHealth) 379 414 s.echo.GET("/.well-known/did.json", s.handleWellKnown) 415 + s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource) 416 + s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer) 380 417 s.echo.GET("/robots.txt", s.handleRobots) 381 418 382 419 // public ··· 399 436 s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs) 400 437 s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob) 401 438 439 + // account 440 + s.echo.GET("/account", s.handleAccount) 441 + s.echo.POST("/account/revoke", s.handleAccountRevoke) 442 + s.echo.GET("/account/signin", s.handleAccountSigninGet) 443 + s.echo.POST("/account/signin", s.handleAccountSigninPost) 444 + s.echo.GET("/account/signout", s.handleAccountSignout) 445 + 446 + // oauth account 447 + s.echo.GET("/oauth/jwks", s.handleOauthJwks) 448 + s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet) 449 + s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost) 450 + 451 + // oauth authorization 452 + s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware) 453 + s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware) 454 + 402 455 // authed 403 - s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware) 404 - s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware) 405 - s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware) 406 - s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware) 407 - s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware) 408 - s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware) 456 + s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 457 + s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 458 + s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 459 + s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 460 + s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 461 + s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 409 462 s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE 410 - s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleSessionMiddleware) 411 - s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleSessionMiddleware) 412 - s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleSessionMiddleware) 463 + s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 464 + s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 465 + s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 466 + s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 467 + s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 413 468 414 469 // repo 415 - s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware) 416 - s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware) 417 - s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleSessionMiddleware) 418 - s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware) 419 - s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware) 470 + s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 471 + s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 472 + s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 473 + s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 474 + s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 475 + s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 420 476 421 477 // stupid silly endpoints 422 - s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware) 423 - s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware) 478 + s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 479 + s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 424 480 425 - // are there any routes that we should be allowing without auth? i dont think so but idk 426 - s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 427 - s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 428 - 429 481 // admin routes 430 482 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware) 431 483 s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware) 484 + 485 + // are there any routes that we should be allowing without auth? i dont think so but idk 486 + s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 487 + s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 432 488 } 433 489 434 490 func (s *Server) Serve(ctx context.Context) error { ··· 446 502 &models.Record{}, 447 503 &models.Blob{}, 448 504 &models.BlobPart{}, 505 + &provider.OauthToken{}, 506 + &provider.OauthAuthorizationRequest{}, 449 507 ) 450 508 451 509 s.logger.Info("starting cocoon") ··· 455 513 panic(err) 456 514 } 457 515 }() 516 + 517 + go s.backupRoutine() 458 518 459 519 for _, relay := range s.config.Relays { 460 520 cli := xrpc.Client{Host: relay} ··· 469 529 470 530 return nil 471 531 } 532 + 533 + func (s *Server) doBackup() { 534 + start := time.Now() 535 + 536 + s.logger.Info("beginning backup to s3...") 537 + 538 + var buf bytes.Buffer 539 + if err := func() error { 540 + s.logger.Info("reading database bytes...") 541 + s.db.Lock() 542 + defer s.db.Unlock() 543 + 544 + sf, err := os.Open(s.dbName) 545 + if err != nil { 546 + return fmt.Errorf("error opening database for backup: %w", err) 547 + } 548 + defer sf.Close() 549 + 550 + if _, err := io.Copy(&buf, sf); err != nil { 551 + return fmt.Errorf("error reading bytes of backup db: %w", err) 552 + } 553 + 554 + return nil 555 + }(); err != nil { 556 + s.logger.Error("error backing up database", "error", err) 557 + return 558 + } 559 + 560 + if err := func() error { 561 + s.logger.Info("sending to s3...") 562 + 563 + currTime := time.Now().Format("2006-01-02_15-04-05") 564 + key := "cocoon-backup-" + currTime + ".db" 565 + 566 + config := &aws.Config{ 567 + Region: aws.String(s.s3Config.Region), 568 + Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""), 569 + } 570 + 571 + if s.s3Config.Endpoint != "" { 572 + config.Endpoint = aws.String(s.s3Config.Endpoint) 573 + config.S3ForcePathStyle = aws.Bool(true) 574 + } 575 + 576 + sess, err := session.NewSession(config) 577 + if err != nil { 578 + return err 579 + } 580 + 581 + svc := s3.New(sess) 582 + 583 + if _, err := svc.PutObject(&s3.PutObjectInput{ 584 + Bucket: aws.String(s.s3Config.Bucket), 585 + Key: aws.String(key), 586 + Body: bytes.NewReader(buf.Bytes()), 587 + }); err != nil { 588 + return fmt.Errorf("error uploading file to s3: %w", err) 589 + } 590 + 591 + s.logger.Info("finished uploading backup to s3", "key", key, "duration", time.Now().Sub(start).Seconds()) 592 + 593 + return nil 594 + }(); err != nil { 595 + s.logger.Error("error uploading database backup", "error", err) 596 + return 597 + } 598 + 599 + os.WriteFile("last-backup.txt", []byte(time.Now().String()), 0644) 600 + } 601 + 602 + func (s *Server) backupRoutine() { 603 + if s.s3Config == nil || !s.s3Config.BackupsEnabled { 604 + return 605 + } 606 + 607 + if s.s3Config.Region == "" { 608 + s.logger.Warn("no s3 region configured but backups are enabled. backups will not run.") 609 + return 610 + } 611 + 612 + if s.s3Config.Bucket == "" { 613 + s.logger.Warn("no s3 bucket configured but backups are enabled. backups will not run.") 614 + return 615 + } 616 + 617 + if s.s3Config.AccessKey == "" { 618 + s.logger.Warn("no s3 access key configured but backups are enabled. backups will not run.") 619 + return 620 + } 621 + 622 + if s.s3Config.SecretKey == "" { 623 + s.logger.Warn("no s3 secret key configured but backups are enabled. backups will not run.") 624 + return 625 + } 626 + 627 + shouldBackupNow := false 628 + lastBackupStr, err := os.ReadFile("last-backup.txt") 629 + if err != nil { 630 + shouldBackupNow = true 631 + } else { 632 + lastBackup, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(lastBackupStr)) 633 + if err != nil { 634 + shouldBackupNow = true 635 + } else if time.Now().Sub(lastBackup).Seconds() > 3600 { 636 + shouldBackupNow = true 637 + } 638 + } 639 + 640 + if shouldBackupNow { 641 + go s.doBackup() 642 + } 643 + 644 + ticker := time.NewTicker(time.Hour) 645 + for range ticker.C { 646 + go s.doBackup() 647 + } 648 + } 649 + 650 + func (s *Server) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error { 651 + if err := s.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil { 652 + return err 653 + } 654 + 655 + return nil 656 + }
+2 -2
server/session.go
··· 55 55 RefreshToken: refreshString, 56 56 CreatedAt: now, 57 57 ExpiresAt: accexp, 58 - }).Error; err != nil { 58 + }, nil).Error; err != nil { 59 59 return nil, err 60 60 } 61 61 ··· 64 64 Did: repo.Did, 65 65 CreatedAt: now, 66 66 ExpiresAt: refexp, 67 - }).Error; err != nil { 67 + }, nil).Error; err != nil { 68 68 return nil, err 69 69 } 70 70
+4
server/static/pico.css
··· 1 + @charset "UTF-8";/*! 2 + * Pico CSS โœจ v2.1.1 (https://picocss.com) 3 + * Copyright 2019-2025 - Licensed under MIT 4 + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(116, 139, 248, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#2060df;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(32, 96, 223, 0.5);--pico-primary-hover:#184eb8;--pico-primary-hover-background:#1d59d0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(116, 139, 248, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"โ€‹"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
+83
server/static/style.css
··· 1 + :root { 2 + --zinc-700: rgb(66, 71, 81); 3 + --success: rgb(0, 166, 110); 4 + --danger: rgb(155, 35, 24); 5 + } 6 + 7 + body { 8 + display: flex; 9 + flex-direction: column; 10 + } 11 + 12 + main { 13 + } 14 + 15 + .margin-top-sm { 16 + margin-top: 2em; 17 + } 18 + 19 + .margin-top-md { 20 + margin-top: 2.5em; 21 + } 22 + 23 + .margin-bottom-xs { 24 + margin-bottom: 1.5em; 25 + } 26 + 27 + .centered-body { 28 + min-height: 100vh; 29 + justify-content: center; 30 + } 31 + 32 + .base-container { 33 + border: 1px solid var(--zinc-700); 34 + border-radius: 10px; 35 + padding: 1.75em 1.2em; 36 + } 37 + 38 + .box-shadow-container { 39 + box-shadow: 1px 1px 52px 2px rgba(0, 0, 0, 0.42); 40 + } 41 + 42 + .login-container { 43 + max-width: 50ch; 44 + form :last-child { 45 + margin-bottom: 0; 46 + } 47 + form button { 48 + float: right; 49 + } 50 + } 51 + 52 + .authorize-container { 53 + max-width: 100ch; 54 + } 55 + 56 + button { 57 + width: unset; 58 + min-width: 16ch; 59 + } 60 + 61 + .button-row { 62 + display: flex; 63 + gap: 1ch; 64 + justify-content: end; 65 + } 66 + 67 + .alert { 68 + border: 1px solid var(--zinc-700); 69 + border-radius: 10px; 70 + padding: 1em 1em; 71 + p { 72 + color: white; 73 + margin-bottom: unset; 74 + } 75 + } 76 + 77 + .alert-success { 78 + background-color: var(--success); 79 + } 80 + 81 + .alert-danger { 82 + background-color: var(--danger); 83 + }
+40
server/templates/account.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Your Account</title> 10 + </head> 11 + <body class="margin-top-md"> 12 + <main class="container base-container authorize-container margin-top-xl"> 13 + <h2>Welcome, {{ .Repo.Handle }}</h2> 14 + <ul> 15 + <li><a href="/account/signout">Sign Out</a></li> 16 + </ul> 17 + {{ if .flashes.successes }} 18 + <div class="alert alert-success margin-bottom-xs"> 19 + <p>{{ index .flashes.successes 0 }}</p> 20 + </div> 21 + {{ end }} {{ if eq (len .Tokens) 0 }} 22 + <div class="alert alert-success" role="alert"> 23 + <p class="alert-message">You do not have any active OAuth sessions!</p> 24 + </div> 25 + {{ else }} {{ range .Tokens }} 26 + <div class="base-container"> 27 + <h4>{{ .ClientName }}</h4> 28 + <p>Session Age: {{ .Age}}</p> 29 + <p>Last Updated: {{ .LastUpdated }} ago</p> 30 + <p>Expires In: {{ .ExpiresIn }}</p> 31 + <p>IP Address: {{ .Ip }}</p> 32 + <form action="/account/revoke" method="post"> 33 + <input type="hidden" name="token" value="{{ .Token }}" /> 34 + <button type="submit" value="">Revoke</button> 35 + </form> 36 + </div> 37 + {{ end }} {{ end }} 38 + </main> 39 + </body> 40 + </html>
+4
server/templates/alert.html
··· 1 + <!doctype html> 2 + <div class="alert alert-success" role="alert"> 3 + <p class="alert-message"></p> 4 + </div>
+44
server/templates/authorize.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Application Authorization</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main 13 + class="container base-container box-shadow-container authorizer-container" 14 + > 15 + <h2>Authorizing with {{ .AppName }}</h2> 16 + <p> 17 + You are signed in as <b>{{ .Handle }}</b>. 18 + <a href="/account/signout?{{ .QueryParams }}">Switch Account</a> 19 + </p> 20 + <p><b>{{ .AppName }}</b> is asking for you to grant it these scopes:</p> 21 + <ul> 22 + {{ range .Scopes }} 23 + <li><b>{{.}}</b></li> 24 + {{ end }} 25 + </ul> 26 + <p> 27 + If you press Accept, the application will be granted permissions for 28 + these scopes with your account <b>{{ .Handle }}</b>. If you reject, you 29 + will be sent back to the application. 30 + </p> 31 + <form action="/oauth/authorize" method="post"> 32 + <div class="button-row"> 33 + <input type="hidden" name="request_uri" value="{{ .RequestUri }}" /> 34 + <button class="secondary" name="accept_or_reject" value="reject"> 35 + Reject 36 + </button> 37 + <button class="primary" name="accept_or_reject" value="accept"> 38 + Accept 39 + </button> 40 + </div> 41 + </form> 42 + </main> 43 + </body> 44 + </html>
+34
server/templates/signin.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>PDS Authentication</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main class="container base-container box-shadow-container login-container"> 13 + <h2>Sign into your account</h2> 14 + <p>Enter your handle and password below.</p> 15 + {{ if .flashes.errors }} 16 + <div class="alert alert-danger margin-bottom-xs"> 17 + <p>{{ index .flashes.errors 0 }}</p> 18 + </div> 19 + {{ end }} 20 + <form action="/account/signin" method="post"> 21 + <input name="username" id="username" placeholder="Handle" /> 22 + <br /> 23 + <input 24 + name="password" 25 + id="password" 26 + type="password" 27 + placeholder="Password" 28 + /> 29 + <input name="query_params" type="hidden" value="{{ .QueryParams }}" /> 30 + <button class="primary" type="submit" value="Login">Login</button> 31 + </form> 32 + </main> 33 + </body> 34 + </html>
+155
sqlite_blockstore/sqlite_blockstore.go
··· 1 + package sqlite_blockstore 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/haileyok/cocoon/internal/db" 9 + "github.com/haileyok/cocoon/models" 10 + blocks "github.com/ipfs/go-block-format" 11 + "github.com/ipfs/go-cid" 12 + "gorm.io/gorm/clause" 13 + ) 14 + 15 + type SqliteBlockstore struct { 16 + db *db.DB 17 + did string 18 + readonly bool 19 + inserts map[cid.Cid]blocks.Block 20 + } 21 + 22 + func New(did string, db *db.DB) *SqliteBlockstore { 23 + return &SqliteBlockstore{ 24 + did: did, 25 + db: db, 26 + readonly: false, 27 + inserts: map[cid.Cid]blocks.Block{}, 28 + } 29 + } 30 + 31 + func NewReadOnly(did string, db *db.DB) *SqliteBlockstore { 32 + return &SqliteBlockstore{ 33 + did: did, 34 + db: db, 35 + readonly: true, 36 + inserts: map[cid.Cid]blocks.Block{}, 37 + } 38 + } 39 + 40 + func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) { 41 + var block models.Block 42 + 43 + maybeBlock, ok := bs.inserts[cid] 44 + if ok { 45 + return maybeBlock, nil 46 + } 47 + 48 + if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", nil, bs.did, cid.Bytes()).Scan(&block).Error; err != nil { 49 + return nil, err 50 + } 51 + 52 + b, err := blocks.NewBlockWithCid(block.Value, cid) 53 + if err != nil { 54 + return nil, err 55 + } 56 + 57 + return b, nil 58 + } 59 + 60 + func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error { 61 + bs.inserts[block.Cid()] = block 62 + 63 + if bs.readonly { 64 + return nil 65 + } 66 + 67 + b := models.Block{ 68 + Did: bs.did, 69 + Cid: block.Cid().Bytes(), 70 + Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 71 + Value: block.RawData(), 72 + } 73 + 74 + if err := bs.db.Create(&b, []clause.Expression{clause.OnConflict{ 75 + Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 76 + UpdateAll: true, 77 + }}).Error; err != nil { 78 + return err 79 + } 80 + 81 + return nil 82 + } 83 + 84 + func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error { 85 + panic("not implemented") 86 + } 87 + 88 + func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) { 89 + panic("not implemented") 90 + } 91 + 92 + func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) { 93 + panic("not implemented") 94 + } 95 + 96 + func (bs *SqliteBlockstore) PutMany(ctx context.Context, blocks []blocks.Block) error { 97 + tx := bs.db.BeginDangerously() 98 + 99 + for _, block := range blocks { 100 + bs.inserts[block.Cid()] = block 101 + 102 + if bs.readonly { 103 + continue 104 + } 105 + 106 + b := models.Block{ 107 + Did: bs.did, 108 + Cid: block.Cid().Bytes(), 109 + Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 110 + Value: block.RawData(), 111 + } 112 + 113 + if err := tx.Clauses(clause.OnConflict{ 114 + Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 115 + UpdateAll: true, 116 + }).Create(&b).Error; err != nil { 117 + tx.Rollback() 118 + return err 119 + } 120 + } 121 + 122 + if bs.readonly { 123 + return nil 124 + } 125 + 126 + tx.Commit() 127 + 128 + return nil 129 + } 130 + 131 + func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 132 + panic("not implemented") 133 + } 134 + 135 + func (bs *SqliteBlockstore) HashOnRead(enabled bool) { 136 + panic("not implemented") 137 + } 138 + 139 + func (bs *SqliteBlockstore) Execute(ctx context.Context) error { 140 + if !bs.readonly { 141 + return fmt.Errorf("blockstore was not readonly") 142 + } 143 + 144 + bs.readonly = false 145 + for _, b := range bs.inserts { 146 + bs.Put(ctx, b) 147 + } 148 + bs.readonly = true 149 + 150 + return nil 151 + } 152 + 153 + func (bs *SqliteBlockstore) GetLog() map[cid.Cid]blocks.Block { 154 + return bs.inserts 155 + }