+2
.env.example
+2
.env.example
+21
LICENSE
+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
+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/).
-163
blockstore/blockstore.go
-163
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/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) UpdateRepo(ctx context.Context, root cid.Cid, rev string) error {
140
-
if err := bs.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, bs.did).Error; err != nil {
141
-
return err
142
-
}
143
-
144
-
return nil
145
-
}
146
-
147
-
func (bs *SqliteBlockstore) Execute(ctx context.Context) error {
148
-
if !bs.readonly {
149
-
return fmt.Errorf("blockstore was not readonly")
150
-
}
151
-
152
-
bs.readonly = false
153
-
for _, b := range bs.inserts {
154
-
bs.Put(ctx, b)
155
-
}
156
-
bs.readonly = true
157
-
158
-
return nil
159
-
}
160
-
161
-
func (bs *SqliteBlockstore) GetLog() map[cid.Cid]blocks.Block {
162
-
return bs.inserts
163
-
}
-186
cmd/admin/main.go
-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
-
}
+187
-2
cmd/cocoon/main.go
+187
-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"
···
115
127
Name: "s3-secret-key",
116
128
EnvVars: []string{"COCOON_S3_SECRET_KEY"},
117
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
+
},
118
144
},
119
145
Commands: []*cli.Command{
120
-
run,
146
+
runServe,
147
+
runCreateRotationKey,
148
+
runCreatePrivateJwk,
149
+
runCreateInviteCode,
150
+
runResetPassword,
121
151
},
122
152
ErrWriter: os.Stdout,
123
153
Version: Version,
···
128
158
}
129
159
}
130
160
131
-
var run = &cli.Command{
161
+
var runServe = &cli.Command{
132
162
Name: "run",
133
163
Usage: "Start the cocoon PDS",
134
164
Flags: []cli.Flag{},
135
165
Action: func(cmd *cli.Context) error {
166
+
136
167
s, err := server.New(&server.Args{
137
168
Addr: cmd.String("addr"),
138
169
DbName: cmd.String("db-name"),
···
158
189
AccessKey: cmd.String("s3-access-key"),
159
190
SecretKey: cmd.String("s3-secret-key"),
160
191
},
192
+
SessionSecret: cmd.String("session-secret"),
193
+
DefaultAtprotoProxy: cmd.String("default-atproto-proxy"),
194
+
BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
161
195
})
162
196
if err != nil {
163
197
fmt.Printf("error creating cocoon: %v", err)
···
172
206
return nil
173
207
},
174
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
+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
+
}
+20
-14
go.mod
+20
-14
go.mod
···
8
8
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b
9
9
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
10
10
github.com/domodwyer/mailyak/v3 v3.6.2
11
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0
11
12
github.com/go-playground/validator v9.31.0+incompatible
12
13
github.com/golang-jwt/jwt/v4 v4.5.2
13
14
github.com/google/uuid v1.4.0
15
+
github.com/gorilla/sessions v1.4.0
14
16
github.com/gorilla/websocket v1.5.1
17
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
15
18
github.com/hashicorp/golang-lru/v2 v2.0.7
16
19
github.com/ipfs/go-block-format v0.2.0
17
20
github.com/ipfs/go-cid v0.4.1
18
21
github.com/ipfs/go-ipld-cbor v0.1.0
19
22
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4
20
23
github.com/joho/godotenv v1.5.1
24
+
github.com/labstack/echo-contrib v0.17.4
21
25
github.com/labstack/echo/v4 v4.13.3
22
26
github.com/lestrrat-go/jwx/v2 v2.0.12
23
27
github.com/multiformats/go-multihash v0.2.3
···
25
29
github.com/urfave/cli/v2 v2.27.6
26
30
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
27
31
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b
28
-
golang.org/x/crypto v0.36.0
32
+
golang.org/x/crypto v0.38.0
29
33
gorm.io/driver/sqlite v1.5.7
30
34
gorm.io/gorm v1.25.12
31
35
)
···
35
39
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
36
40
github.com/beorn7/perks v1.0.1 // indirect
37
41
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
38
-
github.com/cespare/xxhash/v2 v2.2.0 // indirect
42
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
39
43
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
40
44
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
41
45
github.com/felixge/httpsnoop v1.0.4 // indirect
···
47
51
github.com/gocql/gocql v1.7.0 // indirect
48
52
github.com/gogo/protobuf v1.3.2 // indirect
49
53
github.com/golang/snappy v0.0.4 // indirect
54
+
github.com/gorilla/context v1.1.2 // indirect
55
+
github.com/gorilla/securecookie v1.1.2 // indirect
50
56
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
51
57
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
52
58
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
···
84
90
github.com/lestrrat-go/httprc v1.0.4 // indirect
85
91
github.com/lestrrat-go/iter v1.0.2 // indirect
86
92
github.com/lestrrat-go/option v1.0.1 // indirect
87
-
github.com/mattn/go-colorable v0.1.13 // indirect
93
+
github.com/mattn/go-colorable v0.1.14 // indirect
88
94
github.com/mattn/go-isatty v0.0.20 // indirect
89
95
github.com/mattn/go-sqlite3 v1.14.22 // indirect
90
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
91
96
github.com/minio/sha256-simd v1.0.1 // indirect
92
97
github.com/mr-tron/base58 v1.2.0 // indirect
93
98
github.com/multiformats/go-base32 v0.1.0 // indirect
94
99
github.com/multiformats/go-base36 v0.2.0 // indirect
95
100
github.com/multiformats/go-multibase v0.2.0 // indirect
96
101
github.com/multiformats/go-varint v0.0.7 // indirect
102
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
97
103
github.com/opentracing/opentracing-go v1.2.0 // indirect
98
104
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
99
-
github.com/prometheus/client_golang v1.17.0 // indirect
100
-
github.com/prometheus/client_model v0.5.0 // indirect
101
-
github.com/prometheus/common v0.45.0 // indirect
102
-
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
103
109
github.com/russross/blackfriday/v2 v2.1.0 // indirect
104
110
github.com/samber/lo v1.49.1 // indirect
105
111
github.com/segmentio/asm v1.2.0 // indirect
···
115
121
go.uber.org/atomic v1.11.0 // indirect
116
122
go.uber.org/multierr v1.11.0 // indirect
117
123
go.uber.org/zap v1.26.0 // indirect
118
-
golang.org/x/net v0.33.0 // indirect
119
-
golang.org/x/sync v0.12.0 // indirect
120
-
golang.org/x/sys v0.31.0 // indirect
121
-
golang.org/x/text v0.23.0 // indirect
122
-
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
123
129
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
124
-
google.golang.org/protobuf v1.33.0 // indirect
130
+
google.golang.org/protobuf v1.36.6 // indirect
125
131
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
126
132
gopkg.in/inf.v0 v0.9.1 // indirect
127
133
gorm.io/driver/postgres v1.5.7 // indirect
+44
-32
go.sum
+44
-32
go.sum
···
24
24
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
25
25
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
26
26
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
27
-
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
28
-
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=
29
29
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
30
30
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
31
31
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
···
48
48
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
49
49
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
50
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=
51
53
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
52
54
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
53
55
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
···
68
70
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
69
71
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
70
72
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
71
-
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
72
-
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=
73
77
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
74
78
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
75
79
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
···
77
81
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
78
82
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
79
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=
80
90
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
81
91
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
82
92
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
83
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=
84
96
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
85
97
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
86
98
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
196
208
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
197
209
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
198
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=
199
213
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
200
214
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
201
215
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
···
233
247
github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM=
234
248
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
235
249
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
236
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
237
-
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=
238
252
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
239
-
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
240
253
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
241
254
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
242
255
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
243
256
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
244
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
245
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
246
257
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
247
258
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
248
259
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
···
269
280
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
270
281
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
271
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=
272
285
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
273
286
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
274
287
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
···
278
291
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
279
292
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
280
293
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
281
-
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
282
-
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
283
-
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
284
-
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
285
-
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
286
-
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
287
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
288
-
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=
289
302
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
290
303
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
291
304
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
···
373
386
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
374
387
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
375
388
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
376
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
377
-
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=
378
391
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
379
392
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
380
393
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
396
409
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
397
410
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
398
411
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
399
-
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
400
-
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=
401
414
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
402
415
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
403
416
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
404
417
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
405
418
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
406
419
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
407
-
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
408
-
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=
409
422
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
410
423
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
411
424
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
417
430
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
418
431
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
419
432
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
420
-
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
421
433
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
422
434
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
423
435
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
424
436
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
425
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
426
-
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=
427
439
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
428
440
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
429
441
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
···
435
447
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
436
448
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
437
449
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
438
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
439
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
440
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
441
-
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=
442
454
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
443
455
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
444
456
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
459
471
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
460
472
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
461
473
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
462
-
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
463
-
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=
464
476
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
465
477
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
466
478
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+73
-54
identity/identity.go
+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
+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
}
+60
internal/helpers/helpers.go
+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
+8
oauth/client/client.go
+389
oauth/client/manager.go
+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
+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
+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
+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
+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
+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
+8
oauth/dpop/proof.go
+80
oauth/helpers.go
+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
+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
+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
+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
+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
+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
+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
+
}
+74
server/handle_account.go
+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
+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
+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
+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
+
}
+2
-3
server/handle_import_repo.go
+2
-3
server/handle_import_repo.go
···
9
9
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
"github.com/bluesky-social/indigo/repo"
12
-
"github.com/haileyok/cocoon/blockstore"
13
12
"github.com/haileyok/cocoon/internal/helpers"
14
13
"github.com/haileyok/cocoon/models"
15
14
blocks "github.com/ipfs/go-block-format"
···
27
26
return helpers.ServerError(e, nil)
28
27
}
29
28
30
-
bs := blockstore.New(urepo.Repo.Did, s.db)
29
+
bs := s.getBlockstore(urepo.Repo.Did)
31
30
32
31
cs, err := car.NewCarReader(bytes.NewReader(b))
33
32
if err != nil {
···
107
106
return helpers.ServerError(e, nil)
108
107
}
109
108
110
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
109
+
if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil {
111
110
s.logger.Error("error updating repo after commit", "error", err)
112
111
return helpers.ServerError(e, nil)
113
112
}
+12
server/handle_oauth_jwks.go
+12
server/handle_oauth_jwks.go
+88
server/handle_oauth_par.go
+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
+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
+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()
+38
-9
server/handle_repo_list_records.go
+38
-9
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
server/handle_server_check_account_status.go
+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
+
}
+2
-2
server/handle_server_confirm_email.go
+2
-2
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()
+2
-3
server/handle_server_create_account.go
+2
-3
server/handle_server_create_account.go
···
14
14
"github.com/bluesky-social/indigo/events"
15
15
"github.com/bluesky-social/indigo/repo"
16
16
"github.com/bluesky-social/indigo/util"
17
-
"github.com/haileyok/cocoon/blockstore"
18
17
"github.com/haileyok/cocoon/internal/helpers"
19
18
"github.com/haileyok/cocoon/models"
20
19
"github.com/labstack/echo/v4"
···
177
176
}
178
177
179
178
if customDidHeader == "" {
180
-
bs := blockstore.New(signupDid, s.db)
179
+
bs := s.getBlockstore(signupDid)
181
180
r := repo.NewRepo(context.TODO(), signupDid, bs)
182
181
183
182
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
···
186
185
return helpers.ServerError(e, nil)
187
186
}
188
187
189
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
188
+
if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil {
190
189
s.logger.Error("error updating repo after commit", "error", err)
191
190
return helpers.ServerError(e, nil)
192
191
}
+114
server/handle_server_get_service_auth.go
+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_reset_password.go
+2
-2
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)
+3
-4
server/handle_server_update_email.go
+3
-4
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
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 {
+1
-2
server/handle_sync_get_blocks.go
+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)
+88
server/handle_well_known.go
+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
+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
+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
+
}
+13
-13
server/repo.go
+13
-13
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"
21
19
"github.com/haileyok/cocoon/internal/db"
22
20
"github.com/haileyok/cocoon/models"
21
+
"github.com/haileyok/cocoon/recording_blockstore"
23
22
blocks "github.com/ipfs/go-block-format"
24
23
"github.com/ipfs/go-cid"
25
24
cbor "github.com/ipfs/go-ipld-cbor"
···
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{}
···
274
274
}
275
275
}
276
276
277
-
for _, op := range dbs.GetLog() {
277
+
for _, op := range bs.GetLogMap() {
278
278
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
279
279
return nil, err
280
280
}
···
318
318
Rev: rev,
319
319
Since: &urepo.Rev,
320
320
Commit: lexutil.LexLink(newroot),
321
-
Time: time.Now().Format(util.ISO8601),
321
+
Time: time.Now().Format(time.RFC3339Nano),
322
322
Ops: ops,
323
323
TooBig: false,
324
324
},
325
325
})
326
326
327
-
if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil {
327
+
if err := rm.s.UpdateRepo(context.TODO(), urepo.Did, newroot, rev); err != nil {
328
328
return nil, err
329
329
}
330
330
···
345
345
return cid.Undef, nil, err
346
346
}
347
347
348
-
dbs := blockstore.New(urepo.Did, rm.db)
349
-
bs := util.NewLoggingBstore(dbs)
348
+
dbs := rm.s.getBlockstore(urepo.Did)
349
+
bs := recording_blockstore.New(dbs)
350
350
351
351
r, err := repo.OpenRepo(context.TODO(), bs, c)
352
352
if err != nil {
···
358
358
return cid.Undef, nil, err
359
359
}
360
360
361
-
return c, bs.GetLoggedBlocks(), nil
361
+
return c, bs.GetLogArray(), nil
362
362
}
363
363
364
364
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
···
414
414
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
415
415
}
416
416
417
-
var deepiter func(interface{}) error
418
-
deepiter = func(item interface{}) error {
417
+
var deepiter func(any) error
418
+
deepiter = func(item any) error {
419
419
switch val := item.(type) {
420
-
case map[string]interface{}:
420
+
case map[string]any:
421
421
if val["$type"] == "blob" {
422
422
if ref, ok := val["ref"].(string); ok {
423
423
c, err := cid.Parse(ref)
···
430
430
return deepiter(v)
431
431
}
432
432
}
433
-
case []interface{}:
433
+
case []any:
434
434
for _, v := range val {
435
435
deepiter(v)
436
436
}
+186
-150
server/server.go
+186
-150
server/server.go
···
4
4
"bytes"
5
5
"context"
6
6
"crypto/ecdsa"
7
+
"embed"
7
8
"errors"
8
9
"fmt"
9
10
"io"
···
11
12
"net/http"
12
13
"net/smtp"
13
14
"os"
14
-
"strings"
15
+
"path/filepath"
15
16
"sync"
17
+
"text/template"
16
18
"time"
17
19
18
-
"github.com/Azure/go-autorest/autorest/to"
19
20
"github.com/aws/aws-sdk-go/aws"
20
21
"github.com/aws/aws-sdk-go/aws/credentials"
21
22
"github.com/aws/aws-sdk-go/aws/session"
···
27
28
"github.com/bluesky-social/indigo/xrpc"
28
29
"github.com/domodwyer/mailyak/v3"
29
30
"github.com/go-playground/validator"
30
-
"github.com/golang-jwt/jwt/v4"
31
+
"github.com/gorilla/sessions"
31
32
"github.com/haileyok/cocoon/identity"
32
33
"github.com/haileyok/cocoon/internal/db"
33
34
"github.com/haileyok/cocoon/internal/helpers"
34
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"
35
40
"github.com/haileyok/cocoon/plc"
41
+
"github.com/ipfs/go-cid"
42
+
echo_session "github.com/labstack/echo-contrib/session"
36
43
"github.com/labstack/echo/v4"
37
44
"github.com/labstack/echo/v4/middleware"
38
-
"github.com/lestrrat-go/jwx/v2/jwk"
39
45
slogecho "github.com/samber/slog-echo"
40
46
"gorm.io/driver/sqlite"
41
47
"gorm.io/gorm"
48
+
)
49
+
50
+
const (
51
+
AccountSessionMaxAge = 30 * 24 * time.Hour // one week
42
52
)
43
53
44
54
type S3Config struct {
···
51
61
}
52
62
53
63
type Server struct {
54
-
http *http.Client
55
-
httpd *http.Server
56
-
mail *mailyak.MailYak
57
-
mailLk *sync.Mutex
58
-
echo *echo.Echo
59
-
db *db.DB
60
-
plcClient *plc.Client
61
-
logger *slog.Logger
62
-
config *config
63
-
privateKey *ecdsa.PrivateKey
64
-
repoman *RepoMan
65
-
evtman *events.EventManager
66
-
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
67
78
68
79
dbName string
69
80
s3Config *S3Config
···
90
101
SmtpName string
91
102
92
103
S3Config *S3Config
104
+
105
+
SessionSecret string
106
+
107
+
DefaultAtprotoProxy string
108
+
109
+
BlockstoreVariant BlockstoreVariant
93
110
}
94
111
95
112
type config struct {
96
-
Version string
97
-
Did string
98
-
Hostname string
99
-
ContactEmail string
100
-
EnforcePeering bool
101
-
Relays []string
102
-
AdminPassword string
103
-
SmtpEmail string
104
-
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
105
124
}
106
125
107
126
type CustomValidator struct {
···
132
151
return nil
133
152
}
134
153
135
-
func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
136
-
return func(e echo.Context) error {
137
-
username, password, ok := e.Request().BasicAuth()
138
-
if !ok || username != "admin" || password != s.config.AdminPassword {
139
-
return helpers.InputError(e, to.StringPtr("Unauthorized"))
140
-
}
154
+
//go:embed templates/*
155
+
var templateFS embed.FS
141
156
142
-
if err := next(e); err != nil {
143
-
e.Error(err)
144
-
}
157
+
//go:embed static/*
158
+
var staticFS embed.FS
145
159
146
-
return nil
147
-
}
160
+
type TemplateRenderer struct {
161
+
templates *template.Template
162
+
isDev bool
163
+
templatePath string
148
164
}
149
165
150
-
func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
151
-
return func(e echo.Context) error {
152
-
authheader := e.Request().Header.Get("authorization")
153
-
if authheader == "" {
154
-
return e.JSON(401, map[string]string{"error": "Unauthorized"})
155
-
}
156
-
157
-
pts := strings.Split(authheader, " ")
158
-
if len(pts) != 2 {
159
-
return helpers.ServerError(e, nil)
160
-
}
161
-
162
-
tokenstr := pts[1]
163
-
164
-
token, err := new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
165
-
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
166
-
return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
167
-
}
168
-
169
-
return s.privateKey.Public(), nil
170
-
})
171
-
if err != nil {
172
-
s.logger.Error("error parsing jwt", "error", err)
173
-
// NOTE: https://github.com/bluesky-social/atproto/discussions/3319
174
-
return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"})
175
-
}
176
-
177
-
claims, ok := token.Claims.(jwt.MapClaims)
178
-
if !ok || !token.Valid {
179
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
180
-
}
181
-
182
-
isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession"
183
-
scope := claims["scope"].(string)
184
-
185
-
if isRefresh && scope != "com.atproto.refresh" {
186
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
187
-
} else if !isRefresh && scope != "com.atproto.access" {
188
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
189
-
}
190
-
191
-
table := "tokens"
192
-
if isRefresh {
193
-
table = "refresh_tokens"
194
-
}
195
-
196
-
type Result struct {
197
-
Found bool
198
-
}
199
-
var result Result
200
-
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil {
201
-
if err == gorm.ErrRecordNotFound {
202
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
203
-
}
204
-
205
-
s.logger.Error("error getting token from db", "error", err)
206
-
return helpers.ServerError(e, nil)
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,
207
174
}
208
-
209
-
if !result.Found {
210
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
211
-
}
212
-
213
-
exp, ok := claims["exp"].(float64)
214
-
if !ok {
215
-
s.logger.Error("error getting iat from token")
216
-
return helpers.ServerError(e, nil)
217
-
}
218
-
219
-
if exp < float64(time.Now().UTC().Unix()) {
220
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
175
+
} else {
176
+
tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))
177
+
s.echo.Renderer = &TemplateRenderer{
178
+
templates: tmpl,
179
+
isDev: false,
221
180
}
181
+
}
182
+
}
222
183
223
-
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)
224
187
if err != nil {
225
-
s.logger.Error("error fetching repo", "error", err)
226
-
return helpers.ServerError(e, nil)
227
-
}
228
-
229
-
e.Set("repo", repo)
230
-
e.Set("did", claims["sub"])
231
-
e.Set("token", tokenstr)
232
-
233
-
if err := next(e); err != nil {
234
-
e.Error(err)
188
+
return err
235
189
}
190
+
t.templates = tmpl
191
+
}
236
192
237
-
return nil
193
+
if viewContext, isMap := data.(map[string]any); isMap {
194
+
viewContext["reverse"] = c.Echo().Reverse
238
195
}
196
+
197
+
return t.templates.ExecuteTemplate(w, name, data)
239
198
}
240
199
241
200
func New(args *Args) (*Server, error) {
···
271
230
args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
272
231
}
273
232
233
+
if args.SessionSecret == "" {
234
+
panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ")
235
+
}
236
+
274
237
e := echo.New()
275
238
276
239
e.Pre(middleware.RemoveTrailingSlash())
277
240
e.Pre(slogecho.New(args.Logger))
241
+
e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret))))
278
242
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
279
243
AllowOrigins: []string{"*"},
280
244
AllowHeaders: []string{"*"},
···
348
312
return nil, err
349
313
}
350
314
351
-
key, err := jwk.ParseKey(jwkbytes)
315
+
key, err := helpers.ParseJWKFromBytes(jwkbytes)
352
316
if err != nil {
353
317
return nil, err
354
318
}
···
358
322
return nil, err
359
323
}
360
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
+
361
337
s := &Server{
362
338
http: h,
363
339
httpd: httpd,
···
367
343
plcClient: plcClient,
368
344
privateKey: &pkey,
369
345
config: &config{
370
-
Version: args.Version,
371
-
Did: args.Did,
372
-
Hostname: args.Hostname,
373
-
ContactEmail: args.ContactEmail,
374
-
EnforcePeering: false,
375
-
Relays: args.Relays,
376
-
AdminPassword: args.AdminPassword,
377
-
SmtpName: args.SmtpName,
378
-
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,
379
357
},
380
358
evtman: events.NewEventManager(events.NewMemPersister()),
381
359
passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
382
360
383
361
dbName: args.DbName,
384
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
+
}),
385
382
}
383
+
384
+
s.loadTemplates()
386
385
387
386
s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
388
387
···
402
401
}
403
402
404
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
+
405
411
// random stuff
406
412
s.echo.GET("/", s.handleRoot)
407
413
s.echo.GET("/xrpc/_health", s.handleHealth)
408
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)
409
417
s.echo.GET("/robots.txt", s.handleRobots)
410
418
411
419
// public
···
428
436
s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
429
437
s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
430
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
+
431
455
// authed
432
-
s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware)
433
-
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware)
434
-
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware)
435
-
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware)
436
-
s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware)
437
-
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)
438
462
s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE
439
-
s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleSessionMiddleware)
440
-
s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleSessionMiddleware)
441
-
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)
442
468
443
469
// repo
444
-
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware)
445
-
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware)
446
-
s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleSessionMiddleware)
447
-
s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware)
448
-
s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware)
449
-
s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, 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)
450
476
451
477
// stupid silly endpoints
452
-
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware)
453
-
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware)
454
-
455
-
// are there any routes that we should be allowing without auth? i dont think so but idk
456
-
s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
457
-
s.echo.POST("/xrpc/*", s.handleProxy, 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)
458
480
459
481
// admin routes
460
482
s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware)
461
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)
462
488
}
463
489
464
490
func (s *Server) Serve(ctx context.Context) error {
···
476
502
&models.Record{},
477
503
&models.Blob{},
478
504
&models.BlobPart{},
505
+
&provider.OauthToken{},
506
+
&provider.OauthAuthorizationRequest{},
479
507
)
480
508
481
509
s.logger.Info("starting cocoon")
···
618
646
go s.doBackup()
619
647
}
620
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
+
}
+4
server/static/pico.css
+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
+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
+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
+4
server/templates/alert.html
+34
server/templates/signin.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
+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
+
}