+10
-2
.env.example
+10
-2
.env.example
···
1
+
COCOON_DID="did:web:cocoon.example.com"
2
+
COCOON_HOSTNAME="cocoon.example.com"
3
+
COCOON_ROTATION_KEY_PATH="./rotation.key"
4
+
COCOON_JWK_PATH="./jwk.key"
5
+
COCOON_CONTACT_EMAIL="me@example.com"
6
+
COCOON_RELAYS=https://bsky.network
7
+
# Generate with `openssl rand -hex 16`
8
+
COCOON_ADMIN_PASSWORD=
9
+
# openssl rand -hex 32
10
+
COCOON_SESSION_SECRET=
+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.
+67
-50
README.md
+67
-50
README.md
···
5
6
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
7
8
-
### Impmlemented Endpoints
9
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.
12
13
-
- [ ] com.atproto.identity.getRecommendedDidCredentials
14
-
- [ ] com.atproto.identity.requestPlcOperationSignature
15
-
- [x] com.atproto.identity.resolveHandle
16
-
- [ ] com.atproto.identity.signPlcOperation
17
-
- [ ] com.atproto.identity.submitPlcOperatioin
18
-
- [x] com.atproto.identity.updateHandle
19
-
- [ ] com.atproto.label.queryLabels
20
-
- [ ] com.atproto.moderation.createReport
21
22
-
- [x] com.atproto.repo.applyWrites
23
-
- [x] com.atproto.repo.createRecord
24
-
- [x] com.atproto.repo.putRecord
25
-
- [ ] 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
31
32
33
-
- [ ] com.atproto.server.activateAccount
34
-
- [ ] com.atproto.server.checkAccountStatus
35
-
- [ ] com.atproto.server.confirmEmail
36
-
- [x] com.atproto.server.createAccount
37
-
- [ ] com.atproto.server.deactivateAccount
38
-
- [ ] com.atproto.server.deleteAccount
39
-
- [x] com.atproto.server.deleteSession
40
-
- [x] com.atproto.server.describeServer
41
-
- [ ] com.atproto.server.getAccountInviteCodes
42
-
- [ ] com.atproto.server.getServiceAuth
43
-
- [ ] com.atproto.server.listAppPasswords
44
-
- [x] com.atproto.server.refreshSession
45
-
- [ ] com.atproto.server.requestAccountDelete
46
-
- [ ] com.atproto.server.requestEmailConfirmation
47
-
- [ ] com.atproto.server.requestEmailUpdate
48
-
- [ ] com.atproto.server.requestPasswordReset
49
-
- [ ] com.atproto.server.reserveSigningKey
50
-
- [ ] com.atproto.server.resetPassword
51
-
- [ ] com.atproto.server.revokeAppPassword
52
-
- [ ] com.atproto.server.updateEmail
53
54
-
- [x] com.atproto.sync.getBlob
55
-
- [x] com.atproto.sync.getBlocks
56
-
- [x] com.atproto.sync.getLatestCommit
57
-
- [x] com.atproto.sync.getRecord
58
-
- [x] com.atproto.sync.getRepoStatus
59
-
- [x] com.atproto.sync.getRepo
60
-
- [x] com.atproto.sync.listBlobs
61
-
- [x] com.atproto.sync.listRepos
62
-
- ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol
63
-
- [x] com.atproto.sync.requestCrawl
64
-
- [x] com.atproto.sync.subscribeRepos
65
···
5
6
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
7
8
+
## Implemented Endpoints
9
10
> [!NOTE]
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
+
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`
72
73
+
### Other
74
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`
79
80
+
## License
81
82
+
This project is licensed under MIT license. `server/static/pico.css` is also licensed under MIT license, available at [https://github.com/picocss/pico/](https://github.com/picocss/pico/).
-126
blockstore/blockstore.go
-126
blockstore/blockstore.go
···
1
-
package blockstore
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
7
-
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
"github.com/haileyok/cocoon/models"
9
-
blocks "github.com/ipfs/go-block-format"
10
-
"github.com/ipfs/go-cid"
11
-
"gorm.io/gorm"
12
-
"gorm.io/gorm/clause"
13
-
)
14
-
15
-
type SqliteBlockstore struct {
16
-
db *gorm.DB
17
-
did string
18
-
readonly bool
19
-
inserts []blocks.Block
20
-
}
21
-
22
-
func New(did string, db *gorm.DB) *SqliteBlockstore {
23
-
return &SqliteBlockstore{
24
-
did: did,
25
-
db: db,
26
-
readonly: false,
27
-
inserts: []blocks.Block{},
28
-
}
29
-
}
30
-
31
-
func NewReadOnly(did string, db *gorm.DB) *SqliteBlockstore {
32
-
return &SqliteBlockstore{
33
-
did: did,
34
-
db: db,
35
-
readonly: true,
36
-
inserts: []blocks.Block{},
37
-
}
38
-
}
39
-
40
-
func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) {
41
-
var block models.Block
42
-
if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", bs.did, cid.Bytes()).Scan(&block).Error; err != nil {
43
-
return nil, err
44
-
}
45
-
46
-
b, err := blocks.NewBlockWithCid(block.Value, cid)
47
-
if err != nil {
48
-
return nil, err
49
-
}
50
-
51
-
return b, nil
52
-
}
53
-
54
-
func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error {
55
-
bs.inserts = append(bs.inserts, block)
56
-
57
-
if bs.readonly {
58
-
return nil
59
-
}
60
-
61
-
b := models.Block{
62
-
Did: bs.did,
63
-
Cid: block.Cid().Bytes(),
64
-
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
65
-
Value: block.RawData(),
66
-
}
67
-
68
-
if err := bs.db.Clauses(clause.OnConflict{
69
-
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
70
-
UpdateAll: true,
71
-
}).Create(&b).Error; err != nil {
72
-
return err
73
-
}
74
-
75
-
return nil
76
-
}
77
-
78
-
func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error {
79
-
panic("not implemented")
80
-
}
81
-
82
-
func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) {
83
-
panic("not implemented")
84
-
}
85
-
86
-
func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) {
87
-
panic("not implemented")
88
-
}
89
-
90
-
func (bs *SqliteBlockstore) PutMany(context.Context, []blocks.Block) error {
91
-
panic("not implemented")
92
-
}
93
-
94
-
func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
95
-
panic("not implemented")
96
-
}
97
-
98
-
func (bs *SqliteBlockstore) HashOnRead(enabled bool) {
99
-
panic("not implemented")
100
-
}
101
-
102
-
func (bs *SqliteBlockstore) UpdateRepo(ctx context.Context, root cid.Cid, rev string) error {
103
-
if err := bs.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", root.Bytes(), rev, bs.did).Error; err != nil {
104
-
return err
105
-
}
106
-
107
-
return nil
108
-
}
109
-
110
-
func (bs *SqliteBlockstore) Execute(ctx context.Context) error {
111
-
if !bs.readonly {
112
-
return fmt.Errorf("blockstore was not readonly")
113
-
}
114
-
115
-
bs.readonly = false
116
-
for _, b := range bs.inserts {
117
-
bs.Put(ctx, b)
118
-
}
119
-
bs.readonly = true
120
-
121
-
return nil
122
-
}
123
-
124
-
func (bs *SqliteBlockstore) GetLog() []blocks.Block {
125
-
return bs.inserts
126
-
}
···
-186
cmd/admin/main.go
-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
-
}
···
+264
-3
cmd/cocoon/main.go
+264
-3
cmd/cocoon/main.go
···
1
package main
2
3
import (
4
"fmt"
5
"os"
6
7
"github.com/haileyok/cocoon/server"
8
_ "github.com/joho/godotenv/autoload"
9
"github.com/urfave/cli/v2"
10
)
11
12
var Version = "dev"
···
56
Required: true,
57
EnvVars: []string{"COCOON_RELAYS"},
58
},
59
},
60
Commands: []*cli.Command{
61
-
run,
62
},
63
ErrWriter: os.Stdout,
64
Version: Version,
65
}
66
67
-
app.Run(os.Args)
68
}
69
70
-
var run = &cli.Command{
71
Name: "run",
72
Usage: "Start the cocoon PDS",
73
Flags: []cli.Flag{},
74
Action: func(cmd *cli.Context) error {
75
s, err := server.New(&server.Args{
76
Addr: cmd.String("addr"),
77
DbName: cmd.String("db-name"),
···
82
ContactEmail: cmd.String("contact-email"),
83
Version: Version,
84
Relays: cmd.StringSlice("relays"),
85
})
86
if err != nil {
87
fmt.Printf("error creating cocoon: %v", err)
···
96
return nil
97
},
98
}
···
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/haileyok/cocoon/server"
16
_ "github.com/joho/godotenv/autoload"
17
+
"github.com/lestrrat-go/jwx/v2/jwk"
18
"github.com/urfave/cli/v2"
19
+
"golang.org/x/crypto/bcrypt"
20
+
"gorm.io/driver/sqlite"
21
+
"gorm.io/gorm"
22
)
23
24
var Version = "dev"
···
68
Required: true,
69
EnvVars: []string{"COCOON_RELAYS"},
70
},
71
+
&cli.StringFlag{
72
+
Name: "admin-password",
73
+
Required: true,
74
+
EnvVars: []string{"COCOON_ADMIN_PASSWORD"},
75
+
},
76
+
&cli.StringFlag{
77
+
Name: "smtp-user",
78
+
Required: false,
79
+
EnvVars: []string{"COCOON_SMTP_USER"},
80
+
},
81
+
&cli.StringFlag{
82
+
Name: "smtp-pass",
83
+
Required: false,
84
+
EnvVars: []string{"COCOON_SMTP_PASS"},
85
+
},
86
+
&cli.StringFlag{
87
+
Name: "smtp-host",
88
+
Required: false,
89
+
EnvVars: []string{"COCOON_SMTP_HOST"},
90
+
},
91
+
&cli.StringFlag{
92
+
Name: "smtp-port",
93
+
Required: false,
94
+
EnvVars: []string{"COCOON_SMTP_PORT"},
95
+
},
96
+
&cli.StringFlag{
97
+
Name: "smtp-email",
98
+
Required: false,
99
+
EnvVars: []string{"COCOON_SMTP_EMAIL"},
100
+
},
101
+
&cli.StringFlag{
102
+
Name: "smtp-name",
103
+
Required: false,
104
+
EnvVars: []string{"COCOON_SMTP_NAME"},
105
+
},
106
+
&cli.BoolFlag{
107
+
Name: "s3-backups-enabled",
108
+
EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"},
109
+
},
110
+
&cli.StringFlag{
111
+
Name: "s3-region",
112
+
EnvVars: []string{"COCOON_S3_REGION"},
113
+
},
114
+
&cli.StringFlag{
115
+
Name: "s3-bucket",
116
+
EnvVars: []string{"COCOON_S3_BUCKET"},
117
+
},
118
+
&cli.StringFlag{
119
+
Name: "s3-endpoint",
120
+
EnvVars: []string{"COCOON_S3_ENDPOINT"},
121
+
},
122
+
&cli.StringFlag{
123
+
Name: "s3-access-key",
124
+
EnvVars: []string{"COCOON_S3_ACCESS_KEY"},
125
+
},
126
+
&cli.StringFlag{
127
+
Name: "s3-secret-key",
128
+
EnvVars: []string{"COCOON_S3_SECRET_KEY"},
129
+
},
130
+
&cli.StringFlag{
131
+
Name: "session-secret",
132
+
EnvVars: []string{"COCOON_SESSION_SECRET"},
133
+
},
134
+
&cli.StringFlag{
135
+
Name: "default-atproto-proxy",
136
+
EnvVars: []string{"COCOON_DEFAULT_ATPROTO_PROXY"},
137
+
Value: "did:web:api.bsky.app#bsky_appview",
138
+
},
139
+
&cli.StringFlag{
140
+
Name: "blockstore-variant",
141
+
EnvVars: []string{"COCOON_BLOCKSTORE_VARIANT"},
142
+
Value: "sqlite",
143
+
},
144
},
145
Commands: []*cli.Command{
146
+
runServe,
147
+
runCreateRotationKey,
148
+
runCreatePrivateJwk,
149
+
runCreateInviteCode,
150
+
runResetPassword,
151
},
152
ErrWriter: os.Stdout,
153
Version: Version,
154
}
155
156
+
if err := app.Run(os.Args); err != nil {
157
+
fmt.Printf("Error: %v\n", err)
158
+
}
159
}
160
161
+
var runServe = &cli.Command{
162
Name: "run",
163
Usage: "Start the cocoon PDS",
164
Flags: []cli.Flag{},
165
Action: func(cmd *cli.Context) error {
166
+
167
s, err := server.New(&server.Args{
168
Addr: cmd.String("addr"),
169
DbName: cmd.String("db-name"),
···
174
ContactEmail: cmd.String("contact-email"),
175
Version: Version,
176
Relays: cmd.StringSlice("relays"),
177
+
AdminPassword: cmd.String("admin-password"),
178
+
SmtpUser: cmd.String("smtp-user"),
179
+
SmtpPass: cmd.String("smtp-pass"),
180
+
SmtpHost: cmd.String("smtp-host"),
181
+
SmtpPort: cmd.String("smtp-port"),
182
+
SmtpEmail: cmd.String("smtp-email"),
183
+
SmtpName: cmd.String("smtp-name"),
184
+
S3Config: &server.S3Config{
185
+
BackupsEnabled: cmd.Bool("s3-backups-enabled"),
186
+
Region: cmd.String("s3-region"),
187
+
Bucket: cmd.String("s3-bucket"),
188
+
Endpoint: cmd.String("s3-endpoint"),
189
+
AccessKey: cmd.String("s3-access-key"),
190
+
SecretKey: cmd.String("s3-secret-key"),
191
+
},
192
+
SessionSecret: cmd.String("session-secret"),
193
+
DefaultAtprotoProxy: cmd.String("default-atproto-proxy"),
194
+
BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
195
})
196
if err != nil {
197
fmt.Printf("error creating cocoon: %v", err)
···
206
return nil
207
},
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
+
}
+27
contrib/flake.lock
+27
contrib/flake.lock
···
···
1
+
{
2
+
"nodes": {
3
+
"nixpkgs": {
4
+
"locked": {
5
+
"lastModified": 1745742390,
6
+
"narHash": "sha256-1rqa/XPSJqJg21BKWjzJZC7yU0l/YTVtjRi0RJmipus=",
7
+
"owner": "NixOS",
8
+
"repo": "nixpkgs",
9
+
"rev": "26245db0cb552047418cfcef9a25da91b222d6c7",
10
+
"type": "github"
11
+
},
12
+
"original": {
13
+
"owner": "NixOS",
14
+
"ref": "nixos-24.11",
15
+
"repo": "nixpkgs",
16
+
"type": "github"
17
+
}
18
+
},
19
+
"root": {
20
+
"inputs": {
21
+
"nixpkgs": "nixpkgs"
22
+
}
23
+
}
24
+
},
25
+
"root": "root",
26
+
"version": 7
27
+
}
+41
contrib/flake.nix
+41
contrib/flake.nix
···
···
1
+
{
2
+
inputs = {
3
+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
4
+
};
5
+
outputs = { self, nixpkgs }:
6
+
let
7
+
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
8
+
forAllSystems = f: nixpkgs.lib.genAttrs systems f;
9
+
outputsBySystem = forAllSystems (system:
10
+
let
11
+
pkgs = nixpkgs.legacyPackages.${system};
12
+
in
13
+
{
14
+
packages = {
15
+
default = pkgs.buildGo124Module {
16
+
pname = "cocoon";
17
+
version = "0.1.0";
18
+
src = ../.;
19
+
vendorHash = "sha256-kFwd2FnOueEOg/YRTQ8c7/iAO3PoO3yzWyVDFu43QOs=";
20
+
meta.mainProgram = "cocoon";
21
+
};
22
+
};
23
+
devShells = {
24
+
default = pkgs.mkShell {
25
+
buildInputs = [
26
+
pkgs.go_1_24
27
+
pkgs.gopls
28
+
pkgs.gotools
29
+
pkgs.go-tools
30
+
];
31
+
};
32
+
};
33
+
});
34
+
mergeOutputs = outputType:
35
+
nixpkgs.lib.mapAttrs (system: systemOutputs: systemOutputs.${outputType} or {}) outputsBySystem;
36
+
in
37
+
{
38
+
packages = mergeOutputs "packages";
39
+
devShells = mergeOutputs "devShells";
40
+
};
41
+
}
+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
+
}
+29
-29
go.mod
+29
-29
go.mod
···
4
5
require (
6
github.com/Azure/go-autorest/autorest/to v0.4.1
7
-
github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a
8
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
9
github.com/go-playground/validator v9.31.0+incompatible
10
github.com/golang-jwt/jwt/v4 v4.5.2
11
github.com/google/uuid v1.4.0
12
github.com/ipfs/go-block-format v0.2.0
13
github.com/ipfs/go-cid v0.4.1
14
github.com/ipfs/go-ipld-cbor v0.1.0
15
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4
16
github.com/joho/godotenv v1.5.1
17
github.com/labstack/echo/v4 v4.13.3
18
github.com/lestrrat-go/jwx/v2 v2.0.12
19
github.com/samber/slog-echo v1.16.1
20
github.com/urfave/cli/v2 v2.27.6
21
-
golang.org/x/crypto v0.36.0
22
gorm.io/driver/sqlite v1.5.7
23
gorm.io/gorm v1.25.12
24
)
···
27
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
28
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
29
github.com/beorn7/perks v1.0.1 // indirect
30
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
31
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
32
-
github.com/cespare/xxhash/v2 v2.2.0 // indirect
33
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
34
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
35
github.com/felixge/httpsnoop v1.0.4 // indirect
···
40
github.com/goccy/go-json v0.10.2 // indirect
41
github.com/gocql/gocql v1.7.0 // indirect
42
github.com/gogo/protobuf v1.3.2 // indirect
43
-
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
44
github.com/golang/snappy v0.0.4 // indirect
45
-
github.com/gorilla/websocket v1.5.1 // indirect
46
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
47
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
48
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
49
github.com/hashicorp/golang-lru v1.0.2 // indirect
50
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
51
-
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
52
github.com/ipfs/bbloom v0.0.4 // indirect
53
github.com/ipfs/go-blockservice v0.5.2 // indirect
54
github.com/ipfs/go-datastore v0.6.0 // indirect
···
64
github.com/ipfs/go-merkledag v0.11.0 // indirect
65
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
66
github.com/ipfs/go-verifcid v0.0.3 // indirect
67
-
github.com/ipld/go-car/v2 v2.13.1 // indirect
68
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
69
github.com/ipld/go-ipld-prime v0.21.0 // indirect
70
github.com/jackc/pgpassfile v1.0.0 // indirect
···
74
github.com/jbenet/goprocess v0.1.4 // indirect
75
github.com/jinzhu/inflection v1.0.0 // indirect
76
github.com/jinzhu/now v1.1.5 // indirect
77
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
78
github.com/labstack/gommon v0.4.2 // indirect
79
github.com/leodido/go-urn v1.4.0 // indirect
···
82
github.com/lestrrat-go/httprc v1.0.4 // indirect
83
github.com/lestrrat-go/iter v1.0.2 // indirect
84
github.com/lestrrat-go/option v1.0.1 // indirect
85
-
github.com/mattn/go-colorable v0.1.13 // indirect
86
github.com/mattn/go-isatty v0.0.20 // indirect
87
github.com/mattn/go-sqlite3 v1.14.22 // indirect
88
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
89
github.com/minio/sha256-simd v1.0.1 // indirect
90
github.com/mr-tron/base58 v1.2.0 // indirect
91
github.com/multiformats/go-base32 v0.1.0 // indirect
92
github.com/multiformats/go-base36 v0.2.0 // indirect
93
github.com/multiformats/go-multibase v0.2.0 // indirect
94
-
github.com/multiformats/go-multicodec v0.9.0 // indirect
95
-
github.com/multiformats/go-multihash v0.2.3 // indirect
96
github.com/multiformats/go-varint v0.0.7 // indirect
97
github.com/opentracing/opentracing-go v1.2.0 // indirect
98
-
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 // indirect
99
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
100
-
github.com/prometheus/client_golang v1.17.0 // indirect
101
-
github.com/prometheus/client_model v0.5.0 // indirect
102
-
github.com/prometheus/common v0.45.0 // indirect
103
-
github.com/prometheus/procfs v0.12.0 // indirect
104
github.com/russross/blackfriday/v2 v2.1.0 // indirect
105
github.com/samber/lo v1.49.1 // indirect
106
github.com/segmentio/asm v1.2.0 // indirect
107
github.com/spaolacci/murmur3 v1.1.0 // indirect
108
github.com/valyala/bytebufferpool v1.0.0 // indirect
109
github.com/valyala/fasttemplate v1.2.2 // indirect
110
-
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 // indirect
111
-
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
112
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
113
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
114
-
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
115
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
116
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
117
go.opentelemetry.io/otel v1.29.0 // indirect
···
120
go.uber.org/atomic v1.11.0 // indirect
121
go.uber.org/multierr v1.11.0 // indirect
122
go.uber.org/zap v1.26.0 // indirect
123
-
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
124
-
golang.org/x/net v0.33.0 // indirect
125
-
golang.org/x/sync v0.12.0 // indirect
126
-
golang.org/x/sys v0.31.0 // indirect
127
-
golang.org/x/text v0.23.0 // indirect
128
-
golang.org/x/time v0.8.0 // indirect
129
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
130
-
google.golang.org/protobuf v1.33.0 // indirect
131
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
132
gopkg.in/inf.v0 v0.9.1 // indirect
133
gorm.io/driver/postgres v1.5.7 // indirect
···
4
5
require (
6
github.com/Azure/go-autorest/autorest/to v0.4.1
7
+
github.com/aws/aws-sdk-go v1.55.7
8
+
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b
9
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
10
+
github.com/domodwyer/mailyak/v3 v3.6.2
11
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0
12
github.com/go-playground/validator v9.31.0+incompatible
13
github.com/golang-jwt/jwt/v4 v4.5.2
14
github.com/google/uuid v1.4.0
15
+
github.com/gorilla/sessions v1.4.0
16
+
github.com/gorilla/websocket v1.5.1
17
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
18
+
github.com/hashicorp/golang-lru/v2 v2.0.7
19
github.com/ipfs/go-block-format v0.2.0
20
github.com/ipfs/go-cid v0.4.1
21
github.com/ipfs/go-ipld-cbor v0.1.0
22
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4
23
github.com/joho/godotenv v1.5.1
24
+
github.com/labstack/echo-contrib v0.17.4
25
github.com/labstack/echo/v4 v4.13.3
26
github.com/lestrrat-go/jwx/v2 v2.0.12
27
+
github.com/multiformats/go-multihash v0.2.3
28
github.com/samber/slog-echo v1.16.1
29
github.com/urfave/cli/v2 v2.27.6
30
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
31
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b
32
+
golang.org/x/crypto v0.38.0
33
gorm.io/driver/sqlite v1.5.7
34
gorm.io/gorm v1.25.12
35
)
···
38
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
39
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
40
github.com/beorn7/perks v1.0.1 // indirect
41
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
42
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
43
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
44
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
45
github.com/felixge/httpsnoop v1.0.4 // indirect
···
50
github.com/goccy/go-json v0.10.2 // indirect
51
github.com/gocql/gocql v1.7.0 // indirect
52
github.com/gogo/protobuf v1.3.2 // indirect
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
56
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
57
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
58
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
59
github.com/hashicorp/golang-lru v1.0.2 // indirect
60
github.com/ipfs/bbloom v0.0.4 // indirect
61
github.com/ipfs/go-blockservice v0.5.2 // indirect
62
github.com/ipfs/go-datastore v0.6.0 // indirect
···
72
github.com/ipfs/go-merkledag v0.11.0 // indirect
73
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
74
github.com/ipfs/go-verifcid v0.0.3 // indirect
75
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
76
github.com/ipld/go-ipld-prime v0.21.0 // indirect
77
github.com/jackc/pgpassfile v1.0.0 // indirect
···
81
github.com/jbenet/goprocess v0.1.4 // indirect
82
github.com/jinzhu/inflection v1.0.0 // indirect
83
github.com/jinzhu/now v1.1.5 // indirect
84
+
github.com/jmespath/go-jmespath v0.4.0 // indirect
85
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
86
github.com/labstack/gommon v0.4.2 // indirect
87
github.com/leodido/go-urn v1.4.0 // indirect
···
90
github.com/lestrrat-go/httprc v1.0.4 // indirect
91
github.com/lestrrat-go/iter v1.0.2 // indirect
92
github.com/lestrrat-go/option v1.0.1 // indirect
93
+
github.com/mattn/go-colorable v0.1.14 // indirect
94
github.com/mattn/go-isatty v0.0.20 // indirect
95
github.com/mattn/go-sqlite3 v1.14.22 // indirect
96
github.com/minio/sha256-simd v1.0.1 // indirect
97
github.com/mr-tron/base58 v1.2.0 // indirect
98
github.com/multiformats/go-base32 v0.1.0 // indirect
99
github.com/multiformats/go-base36 v0.2.0 // indirect
100
github.com/multiformats/go-multibase v0.2.0 // indirect
101
github.com/multiformats/go-varint v0.0.7 // indirect
102
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
103
github.com/opentracing/opentracing-go v1.2.0 // indirect
104
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // 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
109
github.com/russross/blackfriday/v2 v2.1.0 // indirect
110
github.com/samber/lo v1.49.1 // indirect
111
github.com/segmentio/asm v1.2.0 // indirect
112
github.com/spaolacci/murmur3 v1.1.0 // indirect
113
github.com/valyala/bytebufferpool v1.0.0 // indirect
114
github.com/valyala/fasttemplate v1.2.2 // indirect
115
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
116
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
117
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
118
go.opentelemetry.io/otel v1.29.0 // indirect
···
121
go.uber.org/atomic v1.11.0 // indirect
122
go.uber.org/multierr v1.11.0 // indirect
123
go.uber.org/zap v1.26.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
129
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
130
+
google.golang.org/protobuf v1.36.6 // indirect
131
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
132
gopkg.in/inf.v0 v0.9.1 // indirect
133
gorm.io/driver/postgres v1.5.7 // indirect
+54
-54
go.sum
+54
-54
go.sum
···
7
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4=
8
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM=
9
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA=
10
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
11
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
12
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
···
14
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
15
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
16
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
17
-
github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a h1:clnSZRgkiifbvfqu9++OHfIh2DWuIoZ8CucxLueQxO0=
18
-
github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA=
19
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
20
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
21
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
22
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
23
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
24
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
25
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
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=
29
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
30
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
31
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
···
37
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
38
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
39
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
40
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
41
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
42
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
···
46
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
47
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
48
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
49
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
50
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
51
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
···
61
github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
62
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
63
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
64
-
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
65
-
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
66
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
67
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
68
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
69
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
70
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/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
74
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
75
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
···
77
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
78
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
79
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
80
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
81
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
82
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
83
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
84
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
85
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
86
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
89
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
90
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
91
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
92
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg=
93
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
94
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
95
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
96
github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
97
github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y=
98
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
99
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
100
-
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
101
-
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
102
github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ=
103
github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk=
104
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
···
119
github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
120
github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ=
121
github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk=
122
-
github.com/ipfs/go-ipfs-chunker v0.0.5 h1:ojCf7HV/m+uS2vhUGWcogIIxiO5ubl5O57Q7NapWLY8=
123
-
github.com/ipfs/go-ipfs-chunker v0.0.5/go.mod h1:jhgdF8vxRHycr00k13FM8Y0E+6BoalYeobXmUyTreP8=
124
github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
125
github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
126
github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
···
154
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
155
github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg=
156
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
157
-
github.com/ipfs/go-unixfsnode v1.8.0 h1:yCkakzuE365glu+YkgzZt6p38CSVEBPgngL9ZkfnyQU=
158
-
github.com/ipfs/go-unixfsnode v1.8.0/go.mod h1:HxRu9HYHOjK6HUqFBAi++7DVoWAHn0o4v/nZ/VA+0g8=
159
github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs=
160
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
161
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI=
···
166
github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s=
167
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
168
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
169
-
github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd h1:gMlw/MhNr2Wtp5RwGdsW23cs+yCuj9k2ON7i9MiJlRo=
170
-
github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd/go.mod h1:wZ8hH8UxeryOs4kJEJaiui/s00hDSbE37OKsL47g+Sw=
171
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
172
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
173
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
···
185
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
186
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
187
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
188
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
189
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
190
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
202
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
203
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
204
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
205
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
206
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
207
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
···
239
github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM=
240
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
241
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
242
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
243
-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
244
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
245
-
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
246
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
247
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
248
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
249
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
250
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
251
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
252
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
253
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
254
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
···
275
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
276
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
277
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
278
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
279
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
280
-
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
281
-
github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
282
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
283
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
284
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
···
286
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
287
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
288
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
289
-
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
290
-
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
291
-
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
292
-
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
293
-
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
294
-
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
295
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
296
-
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
297
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
298
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
299
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
···
341
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ=
342
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
343
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
344
-
github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f h1:jQa4QT2UP9WYv2nzyawpKMOCl+Z/jW7djv2/J50lj9E=
345
-
github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f/go.mod h1:p9UJB6dDgdPgMJZs7UjUOdulKyRr9fqkS+6JKAInPy8=
346
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
347
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
348
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
349
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
350
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
385
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
386
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
387
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
388
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
389
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
390
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
391
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
392
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
408
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
409
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
410
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
411
-
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
412
-
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
413
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
414
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
415
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
416
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
417
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
418
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
419
-
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
420
-
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
421
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
422
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
423
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
429
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
430
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
431
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
432
-
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
433
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
434
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
435
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
436
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
437
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
438
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
439
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
440
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
441
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
···
447
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
448
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
449
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
450
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
451
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
452
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
453
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
454
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
455
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
456
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
471
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
472
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
473
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
474
-
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
475
-
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
476
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
477
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
478
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
···
7
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4=
8
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM=
9
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA=
10
+
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
11
+
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
12
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
13
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
14
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
···
16
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
17
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
18
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
19
+
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b h1:elwfbe+W7GkUmPKFX1h7HaeHvC/kC0XJWfiEHC62xPg=
20
+
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY=
21
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
22
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
23
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
24
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
25
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
26
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
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
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
30
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
31
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
···
37
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
38
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
39
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
40
+
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
41
+
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
42
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
43
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
44
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
···
48
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
49
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
50
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
51
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw=
52
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec=
53
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
54
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
55
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
···
65
github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
66
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
67
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
68
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
69
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
70
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
71
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
72
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
73
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
74
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
75
+
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
76
+
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
77
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
78
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
79
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
···
81
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
82
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
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=
90
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
91
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
92
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
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=
96
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
97
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
98
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
101
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
102
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
103
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
104
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
105
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
106
github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
107
github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y=
108
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
109
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
110
github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ=
111
github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk=
112
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
···
127
github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
128
github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ=
129
github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk=
130
github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
131
github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
132
github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
···
160
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
161
github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg=
162
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
163
github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs=
164
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
165
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI=
···
170
github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s=
171
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
172
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
173
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
174
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
175
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
···
187
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
188
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
189
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
190
+
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
191
+
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
192
+
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
193
+
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
194
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
195
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
196
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
208
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
209
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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=
213
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
214
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
215
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
···
247
github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM=
248
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
249
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
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=
252
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
253
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
254
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
255
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
256
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
257
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
258
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
259
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
···
280
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
281
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
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=
285
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
286
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
287
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
288
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
289
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
···
291
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
292
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
293
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
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=
302
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
303
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
304
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
···
346
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ=
347
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
348
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
349
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
350
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
351
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
386
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
387
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
388
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
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=
391
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
392
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
393
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
409
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
410
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
411
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
412
+
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
413
+
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
414
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
415
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
416
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
417
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
418
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
419
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
420
+
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
421
+
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
422
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
423
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
424
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
430
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
431
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
432
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
433
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
434
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
435
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
436
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
437
+
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
438
+
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
439
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
440
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
441
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
···
447
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
448
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
449
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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=
454
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
455
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
456
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
471
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
472
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
473
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
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=
476
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
477
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
478
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+88
-104
identity/identity.go
+88
-104
identity/identity.go
···
10
"strings"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
)
14
15
-
func ResolveHandle(ctx context.Context, handle string) (string, error) {
16
-
var did string
17
-
18
-
_, err := syntax.ParseHandle(handle)
19
if err != nil {
20
-
return "", err
21
}
22
23
-
recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle))
24
-
if err == nil {
25
-
for _, rec := range recs {
26
-
if strings.HasPrefix(rec, "did=") {
27
-
did = strings.Split(rec, "did=")[1]
28
-
break
29
}
30
}
31
-
} else {
32
-
fmt.Printf("erorr getting txt records: %v\n", err)
33
}
34
35
-
if did == "" {
36
-
req, err := http.NewRequestWithContext(
37
-
ctx,
38
-
"GET",
39
-
fmt.Sprintf("https://%s/.well-known/atproto-did", handle),
40
-
nil,
41
-
)
42
-
if err != nil {
43
-
return "", nil
44
-
}
45
-
46
-
resp, err := http.DefaultClient.Do(req)
47
-
if err != nil {
48
-
return "", nil
49
-
}
50
-
defer resp.Body.Close()
51
-
52
-
if resp.StatusCode != http.StatusOK {
53
-
io.Copy(io.Discard, resp.Body)
54
-
return "", fmt.Errorf("unable to resolve handle")
55
-
}
56
-
57
-
b, err := io.ReadAll(resp.Body)
58
-
if err != nil {
59
-
return "", err
60
-
}
61
62
-
maybeDid := string(b)
63
64
-
if _, err := syntax.ParseDID(maybeDid); err != nil {
65
-
return "", fmt.Errorf("unable to resolve handle")
66
-
}
67
68
-
did = maybeDid
69
}
70
71
-
return did, nil
72
-
}
73
74
-
type DidDoc struct {
75
-
Context []string `json:"@context"`
76
-
Id string `json:"id"`
77
-
AlsoKnownAs []string `json:"alsoKnownAs"`
78
-
VerificationMethods []DidDocVerificationMethod `json:"verificationMethods"`
79
-
Service []DidDocService `json:"service"`
80
-
}
81
82
-
type DidDocVerificationMethod struct {
83
-
Id string `json:"id"`
84
-
Type string `json:"type"`
85
-
Controller string `json:"controller"`
86
-
PublicKeyMultibase string `json:"publicKeyMultibase"`
87
-
}
88
89
-
type DidDocService struct {
90
-
Id string `json:"id"`
91
-
Type string `json:"type"`
92
-
ServiceEndpoint string `json:"serviceEndpoint"`
93
}
94
95
-
type DidData struct {
96
-
Did string `json:"did"`
97
-
VerificationMethods map[string]string `json:"verificationMethods"`
98
-
RotationKeys []string `json:"rotationKeys"`
99
-
AlsoKnownAs []string `json:"alsoKnownAs"`
100
-
Services map[string]OperationService `json:"services"`
101
-
}
102
103
-
type OperationService struct {
104
-
Type string `json:"type"`
105
-
Endpoint string `json:"endpoint"`
106
-
}
107
108
-
type DidLog []DidLogEntry
109
110
-
type DidLogEntry struct {
111
-
Sig string `json:"sig"`
112
-
Prev *string `json:"prev"`
113
-
Type string `json:"string"`
114
-
Services map[string]OperationService `json:"services"`
115
-
AlsoKnownAs []string `json:"alsoKnownAs"`
116
-
RotationKeys []string `json:"rotationKeys"`
117
-
VerificationMethods map[string]string `json:"verificationMethods"`
118
-
}
119
120
-
type DidAuditEntry struct {
121
-
Did string `json:"did"`
122
-
Operation DidLogEntry `json:"operation"`
123
-
Cid string `json:"cid"`
124
-
Nullified bool `json:"nullified"`
125
-
CreatedAt string `json:"createdAt"`
126
}
127
128
-
type DidAuditLog []DidAuditEntry
129
-
130
-
func FetchDidDoc(ctx context.Context, did string) (*DidDoc, error) {
131
-
var ustr string
132
if strings.HasPrefix(did, "did:plc:") {
133
-
ustr = fmt.Sprintf("https://plc.directory/%s", did)
134
} else if strings.HasPrefix(did, "did:web:") {
135
-
ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:"))
136
} else {
137
-
return nil, fmt.Errorf("did was not a supported did type")
138
}
139
140
req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
···
142
return nil, err
143
}
144
145
-
resp, err := http.DefaultClient.Do(req)
146
if err != nil {
147
return nil, err
148
}
···
150
151
if resp.StatusCode != 200 {
152
io.Copy(io.Discard, resp.Body)
153
-
return nil, fmt.Errorf("could not find identity in plc registry")
154
}
155
156
var diddoc DidDoc
···
161
return &diddoc, nil
162
}
163
164
-
func FetchDidData(ctx context.Context, did string) (*DidData, error) {
165
var ustr string
166
ustr = fmt.Sprintf("https://plc.directory/%s/data", did)
167
···
170
return nil, err
171
}
172
173
-
resp, err := http.DefaultClient.Do(req)
174
if err != nil {
175
return nil, err
176
}
···
189
return &diddata, nil
190
}
191
192
-
func FetchDidAuditLog(ctx context.Context, did string) (DidAuditLog, error) {
193
var ustr string
194
ustr = fmt.Sprintf("https://plc.directory/%s/log/audit", did)
195
···
217
return didlog, nil
218
}
219
220
-
func ResolveService(ctx context.Context, did string) (string, error) {
221
-
diddoc, err := FetchDidDoc(ctx, did)
222
if err != nil {
223
return "", err
224
}
···
10
"strings"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/bluesky-social/indigo/util"
14
)
15
16
+
func ResolveHandleFromTXT(ctx context.Context, handle string) (string, error) {
17
+
name := fmt.Sprintf("_atproto.%s", handle)
18
+
recs, err := net.LookupTXT(name)
19
if err != nil {
20
+
return "", fmt.Errorf("handle could not be resolved via txt: %w", err)
21
}
22
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
28
}
29
}
30
}
31
32
+
return "", fmt.Errorf("handle could not be resolved via txt: no record found")
33
+
}
34
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
+
}
46
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()
52
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
}
57
58
+
if resp.StatusCode != http.StatusOK {
59
+
return "", fmt.Errorf("handle could not be resolved via web: invalid status code %d", resp.StatusCode)
60
+
}
61
62
+
maybeDid := string(b)
63
64
+
if _, err := syntax.ParseDID(maybeDid); err != nil {
65
+
return "", fmt.Errorf("handle could not be resolved via web: invalid did in document")
66
+
}
67
68
+
return maybeDid, nil
69
}
70
71
+
func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) {
72
+
if cli == nil {
73
+
cli = util.RobustHTTPClient()
74
+
}
75
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) {
93
if strings.HasPrefix(did, "did:plc:") {
94
+
return fmt.Sprintf("https://plc.directory/%s", did), nil
95
} else if strings.HasPrefix(did, "did:web:") {
96
+
return fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")), nil
97
} else {
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
110
}
111
112
req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
···
114
return nil, err
115
}
116
117
+
resp, err := cli.Do(req)
118
if err != nil {
119
return nil, err
120
}
···
122
123
if resp.StatusCode != 200 {
124
io.Copy(io.Discard, resp.Body)
125
+
return nil, fmt.Errorf("unable to find did doc at url. did: %s. url: %s", did, ustr)
126
}
127
128
var diddoc DidDoc
···
133
return &diddoc, nil
134
}
135
136
+
func FetchDidData(ctx context.Context, cli *http.Client, did string) (*DidData, error) {
137
+
if cli == nil {
138
+
cli = util.RobustHTTPClient()
139
+
}
140
+
141
var ustr string
142
ustr = fmt.Sprintf("https://plc.directory/%s/data", did)
143
···
146
return nil, err
147
}
148
149
+
resp, err := cli.Do(req)
150
if err != nil {
151
return nil, err
152
}
···
165
return &diddata, nil
166
}
167
168
+
func FetchDidAuditLog(ctx context.Context, cli *http.Client, did string) (DidAuditLog, error) {
169
+
if cli == nil {
170
+
cli = util.RobustHTTPClient()
171
+
}
172
+
173
var ustr string
174
ustr = fmt.Sprintf("https://plc.directory/%s/log/audit", did)
175
···
197
return didlog, nil
198
}
199
200
+
func ResolveService(ctx context.Context, cli *http.Client, did string) (string, error) {
201
+
if cli == nil {
202
+
cli = util.RobustHTTPClient()
203
+
}
204
+
205
+
diddoc, err := FetchDidDoc(ctx, cli, did)
206
if err != nil {
207
return "", err
208
}
+26
-8
identity/passport.go
+26
-8
identity/passport.go
···
2
3
import (
4
"context"
5
"sync"
6
)
7
···
16
}
17
18
type Passport struct {
19
bc BackingCache
20
-
lk sync.Mutex
21
}
22
23
-
func NewPassport(bc BackingCache) *Passport {
24
return &Passport{
25
bc: bc,
26
-
lk: sync.Mutex{},
27
}
28
}
29
···
31
skipCache, _ := ctx.Value("skip-cache").(bool)
32
33
if !skipCache {
34
cached, ok := p.bc.GetDoc(did)
35
if ok {
36
return cached, nil
37
}
38
}
39
40
-
p.lk.Lock() // this is pretty pathetic, and i should rethink this. but for now, fuck it
41
-
defer p.lk.Unlock()
42
-
43
-
doc, err := FetchDidDoc(ctx, did)
44
if err != nil {
45
return nil, err
46
}
47
48
p.bc.PutDoc(did, doc)
49
50
return doc, nil
51
}
···
54
skipCache, _ := ctx.Value("skip-cache").(bool)
55
56
if !skipCache {
57
cached, ok := p.bc.GetDid(handle)
58
if ok {
59
return cached, nil
60
}
61
}
62
63
-
did, err := ResolveHandle(ctx, handle)
64
if err != nil {
65
return "", err
66
}
67
68
p.bc.PutDid(handle, did)
69
70
return did, nil
71
}
72
73
func (p *Passport) BustDoc(ctx context.Context, did string) error {
74
return p.bc.BustDoc(did)
75
}
76
77
func (p *Passport) BustDid(ctx context.Context, handle string) error {
78
return p.bc.BustDid(handle)
79
}
···
2
3
import (
4
"context"
5
+
"net/http"
6
"sync"
7
)
8
···
17
}
18
19
type Passport struct {
20
+
h *http.Client
21
bc BackingCache
22
+
mu sync.RWMutex
23
}
24
25
+
func NewPassport(h *http.Client, bc BackingCache) *Passport {
26
+
if h == nil {
27
+
h = http.DefaultClient
28
+
}
29
+
30
return &Passport{
31
+
h: h,
32
bc: bc,
33
}
34
}
35
···
37
skipCache, _ := ctx.Value("skip-cache").(bool)
38
39
if !skipCache {
40
+
p.mu.RLock()
41
cached, ok := p.bc.GetDoc(did)
42
+
p.mu.RUnlock()
43
+
44
if ok {
45
return cached, nil
46
}
47
}
48
49
+
// TODO: should coalesce requests here
50
+
doc, err := FetchDidDoc(ctx, p.h, did)
51
if err != nil {
52
return nil, err
53
}
54
55
+
p.mu.Lock()
56
p.bc.PutDoc(did, doc)
57
+
p.mu.Unlock()
58
59
return doc, nil
60
}
···
63
skipCache, _ := ctx.Value("skip-cache").(bool)
64
65
if !skipCache {
66
+
p.mu.RLock()
67
cached, ok := p.bc.GetDid(handle)
68
+
p.mu.RUnlock()
69
+
70
if ok {
71
return cached, nil
72
}
73
}
74
75
+
did, err := ResolveHandle(ctx, p.h, handle)
76
if err != nil {
77
return "", err
78
}
79
80
+
p.mu.Lock()
81
p.bc.PutDid(handle, did)
82
+
p.mu.Unlock()
83
84
return did, nil
85
}
86
87
func (p *Passport) BustDoc(ctx context.Context, did string) error {
88
+
p.mu.Lock()
89
+
defer p.mu.Unlock()
90
return p.bc.BustDoc(did)
91
}
92
93
func (p *Passport) BustDid(ctx context.Context, handle string) error {
94
+
p.mu.Lock()
95
+
defer p.mu.Unlock()
96
return p.bc.BustDid(handle)
97
}
+57
identity/types.go
+57
identity/types.go
···
···
1
+
package identity
2
+
3
+
type DidDoc struct {
4
+
Context []string `json:"@context"`
5
+
Id string `json:"id"`
6
+
AlsoKnownAs []string `json:"alsoKnownAs"`
7
+
VerificationMethods []DidDocVerificationMethod `json:"verificationMethods"`
8
+
Service []DidDocService `json:"service"`
9
+
}
10
+
11
+
type DidDocVerificationMethod struct {
12
+
Id string `json:"id"`
13
+
Type string `json:"type"`
14
+
Controller string `json:"controller"`
15
+
PublicKeyMultibase string `json:"publicKeyMultibase"`
16
+
}
17
+
18
+
type DidDocService struct {
19
+
Id string `json:"id"`
20
+
Type string `json:"type"`
21
+
ServiceEndpoint string `json:"serviceEndpoint"`
22
+
}
23
+
24
+
type DidData struct {
25
+
Did string `json:"did"`
26
+
VerificationMethods map[string]string `json:"verificationMethods"`
27
+
RotationKeys []string `json:"rotationKeys"`
28
+
AlsoKnownAs []string `json:"alsoKnownAs"`
29
+
Services map[string]OperationService `json:"services"`
30
+
}
31
+
32
+
type OperationService struct {
33
+
Type string `json:"type"`
34
+
Endpoint string `json:"endpoint"`
35
+
}
36
+
37
+
type DidLog []DidLogEntry
38
+
39
+
type DidLogEntry struct {
40
+
Sig string `json:"sig"`
41
+
Prev *string `json:"prev"`
42
+
Type string `json:"string"`
43
+
Services map[string]OperationService `json:"services"`
44
+
AlsoKnownAs []string `json:"alsoKnownAs"`
45
+
RotationKeys []string `json:"rotationKeys"`
46
+
VerificationMethods map[string]string `json:"verificationMethods"`
47
+
}
48
+
49
+
type DidAuditEntry struct {
50
+
Did string `json:"did"`
51
+
Operation DidLogEntry `json:"operation"`
52
+
Cid string `json:"cid"`
53
+
Nullified bool `json:"nullified"`
54
+
CreatedAt string `json:"createdAt"`
55
+
}
56
+
57
+
type DidAuditLog []DidAuditEntry
+65
internal/db/db.go
+65
internal/db/db.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"sync"
5
+
6
+
"gorm.io/gorm"
7
+
"gorm.io/gorm/clause"
8
+
)
9
+
10
+
type DB struct {
11
+
cli *gorm.DB
12
+
mu sync.Mutex
13
+
}
14
+
15
+
func NewDB(cli *gorm.DB) *DB {
16
+
return &DB{
17
+
cli: cli,
18
+
mu: sync.Mutex{},
19
+
}
20
+
}
21
+
22
+
func (db *DB) Create(value any, clauses []clause.Expression) *gorm.DB {
23
+
db.mu.Lock()
24
+
defer db.mu.Unlock()
25
+
return db.cli.Clauses(clauses...).Create(value)
26
+
}
27
+
28
+
func (db *DB) Exec(sql string, clauses []clause.Expression, values ...any) *gorm.DB {
29
+
db.mu.Lock()
30
+
defer db.mu.Unlock()
31
+
return db.cli.Clauses(clauses...).Exec(sql, values...)
32
+
}
33
+
34
+
func (db *DB) Raw(sql string, clauses []clause.Expression, values ...any) *gorm.DB {
35
+
return db.cli.Clauses(clauses...).Raw(sql, values...)
36
+
}
37
+
38
+
func (db *DB) AutoMigrate(models ...any) error {
39
+
return db.cli.AutoMigrate(models...)
40
+
}
41
+
42
+
func (db *DB) Delete(value any, clauses []clause.Expression) *gorm.DB {
43
+
db.mu.Lock()
44
+
defer db.mu.Unlock()
45
+
return db.cli.Clauses(clauses...).Delete(value)
46
+
}
47
+
48
+
func (db *DB) First(dest any, conds ...any) *gorm.DB {
49
+
return db.cli.First(dest, conds...)
50
+
}
51
+
52
+
// TODO: this isn't actually good. we can commit even if the db is locked here. this is probably okay for the time being, but need to figure
53
+
// out a better solution. right now we only do this whenever we're importing a repo though so i'm mostly not worried, but it's still bad.
54
+
// e.g. when we do apply writes we should also be using a transcation but we don't right now
55
+
func (db *DB) BeginDangerously() *gorm.DB {
56
+
return db.cli.Begin()
57
+
}
58
+
59
+
func (db *DB) Lock() {
60
+
db.mu.Lock()
61
+
}
62
+
63
+
func (db *DB) Unlock() {
64
+
db.mu.Unlock()
65
+
}
+63
-1
internal/helpers/helpers.go
+63
-1
internal/helpers/helpers.go
···
1
package helpers
2
3
import (
4
"math/rand"
5
6
"github.com/labstack/echo/v4"
7
)
8
9
-
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
10
11
func InputError(e echo.Context, custom *string) error {
12
msg := "InvalidRequest"
···
24
return genericError(e, 400, msg)
25
}
26
27
func genericError(e echo.Context, code int, msg string) error {
28
return e.JSON(code, map[string]string{
29
"error": msg,
···
37
}
38
return string(b)
39
}
···
1
package helpers
2
3
import (
4
+
crand "crypto/rand"
5
+
"encoding/hex"
6
+
"errors"
7
"math/rand"
8
+
"net/url"
9
10
+
"github.com/Azure/go-autorest/autorest/to"
11
"github.com/labstack/echo/v4"
12
+
"github.com/lestrrat-go/jwx/v2/jwk"
13
)
14
15
+
// This will confirm to the regex in the application if 5 chars are used for each side of the -
16
+
// /^[A-Z2-7]{5}-[A-Z2-7]{5}$/
17
+
var letters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
18
19
func InputError(e echo.Context, custom *string) error {
20
msg := "InvalidRequest"
···
32
return genericError(e, 400, msg)
33
}
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
+
47
func genericError(e echo.Context, code int, msg string) error {
48
return e.JSON(code, map[string]string{
49
"error": msg,
···
57
}
58
return string(b)
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
+
}
+15
-9
models/models.go
+15
-9
models/models.go
···
8
)
9
10
type Repo struct {
11
-
Did string `gorm:"primaryKey"`
12
-
CreatedAt time.Time
13
-
Email string `gorm:"uniqueIndex"`
14
-
EmailConfirmedAt *time.Time
15
-
Password string
16
-
SigningKey []byte
17
-
Rev string
18
-
Root []byte
19
-
Preferences []byte
20
}
21
22
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
···
8
)
9
10
type Repo struct {
11
+
Did string `gorm:"primaryKey"`
12
+
CreatedAt time.Time
13
+
Email string `gorm:"uniqueIndex"`
14
+
EmailConfirmedAt *time.Time
15
+
EmailVerificationCode *string
16
+
EmailVerificationCodeExpiresAt *time.Time
17
+
EmailUpdateCode *string
18
+
EmailUpdateCodeExpiresAt *time.Time
19
+
PasswordResetCode *string
20
+
PasswordResetCodeExpiresAt *time.Time
21
+
Password string
22
+
SigningKey []byte
23
+
Rev string
24
+
Root []byte
25
+
Preferences []byte
26
}
27
28
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+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
+
}
+8
-2
plc/client.go
+8
-2
plc/client.go
···
15
16
"github.com/bluesky-social/indigo/atproto/crypto"
17
"github.com/bluesky-social/indigo/util"
18
)
19
20
type Client struct {
···
25
}
26
27
type ClientArgs struct {
28
Service string
29
RotationKey []byte
30
PdsHostname string
···
35
args.Service = "https://plc.directory"
36
}
37
38
rk, err := crypto.ParsePrivateBytesK256([]byte(args.RotationKey))
39
if err != nil {
40
return nil, err
41
}
42
43
return &Client{
44
-
h: util.RobustHTTPClient(),
45
service: args.Service,
46
rotationKey: rk,
47
pdsHostname: args.PdsHostname,
···
80
AlsoKnownAs: []string{
81
"at://" + handle,
82
},
83
-
Services: map[string]OperationService{
84
"atproto_pds": {
85
Type: "AtprotoPersonalDataServer",
86
Endpoint: "https://" + c.pdsHostname,
···
15
16
"github.com/bluesky-social/indigo/atproto/crypto"
17
"github.com/bluesky-social/indigo/util"
18
+
"github.com/haileyok/cocoon/identity"
19
)
20
21
type Client struct {
···
26
}
27
28
type ClientArgs struct {
29
+
H *http.Client
30
Service string
31
RotationKey []byte
32
PdsHostname string
···
37
args.Service = "https://plc.directory"
38
}
39
40
+
if args.H == nil {
41
+
args.H = util.RobustHTTPClient()
42
+
}
43
+
44
rk, err := crypto.ParsePrivateBytesK256([]byte(args.RotationKey))
45
if err != nil {
46
return nil, err
47
}
48
49
return &Client{
50
+
h: args.H,
51
service: args.Service,
52
rotationKey: rk,
53
pdsHostname: args.PdsHostname,
···
86
AlsoKnownAs: []string{
87
"at://" + handle,
88
},
89
+
Services: map[string]identity.OperationService{
90
"atproto_pds": {
91
Type: "AtprotoPersonalDataServer",
92
Endpoint: "https://" + c.pdsHostname,
+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
+
}
+9
-1
server/common.go
+9
-1
server/common.go
···
20
return &repo, nil
21
}
22
23
func (s *Server) getRepoActorByDid(did string) (*models.RepoActor, error) {
24
var repo models.RepoActor
25
-
if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", did).Scan(&repo).Error; err != nil {
26
return nil, err
27
}
28
return &repo, nil
···
20
return &repo, nil
21
}
22
23
+
func (s *Server) getRepoActorByEmail(email string) (*models.RepoActor, error) {
24
+
var repo models.RepoActor
25
+
if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email= ?", nil, email).Scan(&repo).Error; err != nil {
26
+
return nil, err
27
+
}
28
+
return &repo, nil
29
+
}
30
+
31
func (s *Server) getRepoActorByDid(did string) (*models.RepoActor, error) {
32
var repo models.RepoActor
33
+
if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, did).Scan(&repo).Error; err != nil {
34
return nil, err
35
}
36
return &repo, nil
+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
+
}
+1
-1
server/handle_actor_get_preferences.go
+1
-1
server/handle_actor_get_preferences.go
+1
-1
server/handle_actor_put_preferences.go
+1
-1
server/handle_actor_put_preferences.go
+6
-2
server/handle_identity_update_handle.go
+6
-2
server/handle_identity_update_handle.go
···
39
ctx := context.WithValue(e.Request().Context(), "skip-cache", true)
40
41
if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
42
-
log, err := identity.FetchDidAuditLog(ctx, repo.Repo.Did)
43
if err != nil {
44
s.logger.Error("error fetching doc", "error", err)
45
return helpers.ServerError(e, nil)
···
81
}
82
}
83
84
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
85
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
86
Did: repo.Repo.Did,
···
99
},
100
})
101
102
-
if err := s.db.Exec("UPDATE actors SET handle = ? WHERE did = ?", req.Handle, repo.Repo.Did).Error; err != nil {
103
s.logger.Error("error updating handle in db", "error", err)
104
return helpers.ServerError(e, nil)
105
}
···
39
ctx := context.WithValue(e.Request().Context(), "skip-cache", true)
40
41
if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
42
+
log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
43
if err != nil {
44
s.logger.Error("error fetching doc", "error", err)
45
return helpers.ServerError(e, nil)
···
81
}
82
}
83
84
+
if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil {
85
+
s.logger.Warn("error busting did doc", "error", err)
86
+
}
87
+
88
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
89
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
90
Did: repo.Repo.Did,
···
103
},
104
})
105
106
+
if err := s.db.Exec("UPDATE actors SET handle = ? WHERE did = ?", nil, req.Handle, repo.Repo.Did).Error; err != nil {
107
s.logger.Error("error updating handle in db", "error", err)
108
return helpers.ServerError(e, nil)
109
}
+115
server/handle_import_repo.go
+115
server/handle_import_repo.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"io"
7
+
"slices"
8
+
"strings"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/bluesky-social/indigo/repo"
12
+
"github.com/haileyok/cocoon/internal/helpers"
13
+
"github.com/haileyok/cocoon/models"
14
+
blocks "github.com/ipfs/go-block-format"
15
+
"github.com/ipfs/go-cid"
16
+
"github.com/ipld/go-car"
17
+
"github.com/labstack/echo/v4"
18
+
)
19
+
20
+
func (s *Server) handleRepoImportRepo(e echo.Context) error {
21
+
urepo := e.Get("repo").(*models.RepoActor)
22
+
23
+
b, err := io.ReadAll(e.Request().Body)
24
+
if err != nil {
25
+
s.logger.Error("could not read bytes in import request", "error", err)
26
+
return helpers.ServerError(e, nil)
27
+
}
28
+
29
+
bs := s.getBlockstore(urepo.Repo.Did)
30
+
31
+
cs, err := car.NewCarReader(bytes.NewReader(b))
32
+
if err != nil {
33
+
s.logger.Error("could not read car in import request", "error", err)
34
+
return helpers.ServerError(e, nil)
35
+
}
36
+
37
+
orderedBlocks := []blocks.Block{}
38
+
currBlock, err := cs.Next()
39
+
if err != nil {
40
+
s.logger.Error("could not get first block from car", "error", err)
41
+
return helpers.ServerError(e, nil)
42
+
}
43
+
currBlockCt := 1
44
+
45
+
for currBlock != nil {
46
+
s.logger.Info("someone is importing their repo", "block", currBlockCt)
47
+
orderedBlocks = append(orderedBlocks, currBlock)
48
+
next, _ := cs.Next()
49
+
currBlock = next
50
+
currBlockCt++
51
+
}
52
+
53
+
slices.Reverse(orderedBlocks)
54
+
55
+
if err := bs.PutMany(context.TODO(), orderedBlocks); err != nil {
56
+
s.logger.Error("could not insert blocks", "error", err)
57
+
return helpers.ServerError(e, nil)
58
+
}
59
+
60
+
r, err := repo.OpenRepo(context.TODO(), bs, cs.Header.Roots[0])
61
+
if err != nil {
62
+
s.logger.Error("could not open repo", "error", err)
63
+
return helpers.ServerError(e, nil)
64
+
}
65
+
66
+
tx := s.db.BeginDangerously()
67
+
68
+
clock := syntax.NewTIDClock(0)
69
+
70
+
if err := r.ForEach(context.TODO(), "", func(key string, cid cid.Cid) error {
71
+
pts := strings.Split(key, "/")
72
+
nsid := pts[0]
73
+
rkey := pts[1]
74
+
cidStr := cid.String()
75
+
b, err := bs.Get(context.TODO(), cid)
76
+
if err != nil {
77
+
s.logger.Error("record bytes don't exist in blockstore", "error", err)
78
+
return helpers.ServerError(e, nil)
79
+
}
80
+
81
+
rec := models.Record{
82
+
Did: urepo.Repo.Did,
83
+
CreatedAt: clock.Next().String(),
84
+
Nsid: nsid,
85
+
Rkey: rkey,
86
+
Cid: cidStr,
87
+
Value: b.RawData(),
88
+
}
89
+
90
+
if err := tx.Create(rec).Error; err != nil {
91
+
return err
92
+
}
93
+
94
+
return nil
95
+
}); err != nil {
96
+
tx.Rollback()
97
+
s.logger.Error("record bytes don't exist in blockstore", "error", err)
98
+
return helpers.ServerError(e, nil)
99
+
}
100
+
101
+
tx.Commit()
102
+
103
+
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
104
+
if err != nil {
105
+
s.logger.Error("error committing", "error", err)
106
+
return helpers.ServerError(e, nil)
107
+
}
108
+
109
+
if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil {
110
+
s.logger.Error("error updating repo after commit", "error", err)
111
+
return helpers.ServerError(e, nil)
112
+
}
113
+
114
+
return nil
115
+
}
+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
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
18
)
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
-
28
svc := e.Request().Header.Get("atproto-proxy")
29
if svc == "" {
30
-
svc = "did:web:api.bsky.app#bsky_appview" // TODO: should be a config var probably
31
}
32
33
svcPts := strings.Split(svc, "#")
34
if len(svcPts) != 2 {
35
-
return fmt.Errorf("invalid service header")
36
}
37
38
svcDid := svcPts[0]
···
40
41
doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid)
42
if err != nil {
43
-
return err
44
}
45
46
var endpoint string
···
50
}
51
}
52
53
requrl := e.Request().URL
54
requrl.Host = strings.TrimPrefix(endpoint, "https://")
55
requrl.Scheme = "https"
···
78
}
79
hj, err := json.Marshal(header)
80
if err != nil {
81
-
s.logger.Error("error marshaling header", "error", err)
82
return helpers.ServerError(e, nil)
83
}
84
···
93
}
94
pj, err := json.Marshal(payload)
95
if err != nil {
96
-
s.logger.Error("error marashaling payload", "error", err)
97
return helpers.ServerError(e, nil)
98
}
99
···
104
105
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
106
if err != nil {
107
-
s.logger.Error("can't load private key", "error", err)
108
return err
109
}
110
111
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
112
if err != nil {
113
-
s.logger.Error("error signing", "error", err)
114
}
115
116
rBytes := R.Bytes()
···
17
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
18
)
19
20
+
func (s *Server) getAtprotoProxyEndpointFromRequest(e echo.Context) (string, string, error) {
21
svc := e.Request().Header.Get("atproto-proxy")
22
if svc == "" {
23
+
svc = s.config.DefaultAtprotoProxy
24
}
25
26
svcPts := strings.Split(svc, "#")
27
if len(svcPts) != 2 {
28
+
return "", "", fmt.Errorf("invalid service header")
29
}
30
31
svcDid := svcPts[0]
···
33
34
doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid)
35
if err != nil {
36
+
return "", "", err
37
}
38
39
var endpoint string
···
43
}
44
}
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
+
65
requrl := e.Request().URL
66
requrl.Host = strings.TrimPrefix(endpoint, "https://")
67
requrl.Scheme = "https"
···
90
}
91
hj, err := json.Marshal(header)
92
if err != nil {
93
+
lgr.Error("error marshaling header", "error", err)
94
return helpers.ServerError(e, nil)
95
}
96
···
105
}
106
pj, err := json.Marshal(payload)
107
if err != nil {
108
+
lgr.Error("error marashaling payload", "error", err)
109
return helpers.ServerError(e, nil)
110
}
111
···
116
117
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
118
if err != nil {
119
+
lgr.Error("can't load private key", "error", err)
120
return err
121
}
122
123
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
124
if err != nil {
125
+
lgr.Error("error signing", "error", err)
126
}
127
128
rBytes := R.Bytes()
+7
-1
server/handle_repo_apply_writes.go
+7
-1
server/handle_repo_apply_writes.go
+2
server/handle_repo_create_record.go
+2
server/handle_repo_create_record.go
+55
server/handle_repo_delete_record.go
+55
server/handle_repo_delete_record.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"github.com/haileyok/cocoon/internal/helpers"
5
+
"github.com/haileyok/cocoon/models"
6
+
"github.com/labstack/echo/v4"
7
+
)
8
+
9
+
type ComAtprotoRepoDeleteRecordRequest struct {
10
+
Repo string `json:"repo" validate:"required,atproto-did"`
11
+
Collection string `json:"collection" validate:"required,atproto-nsid"`
12
+
Rkey string `json:"rkey" validate:"required,atproto-rkey"`
13
+
SwapRecord *string `json:"swapRecord"`
14
+
SwapCommit *string `json:"swapCommit"`
15
+
}
16
+
17
+
func (s *Server) handleDeleteRecord(e echo.Context) error {
18
+
repo := e.Get("repo").(*models.RepoActor)
19
+
20
+
var req ComAtprotoRepoDeleteRecordRequest
21
+
if err := e.Bind(&req); err != nil {
22
+
s.logger.Error("error binding", "error", err)
23
+
return helpers.ServerError(e, nil)
24
+
}
25
+
26
+
if err := e.Validate(req); err != nil {
27
+
s.logger.Error("error validating", "error", err)
28
+
return helpers.InputError(e, nil)
29
+
}
30
+
31
+
if repo.Repo.Did != req.Repo {
32
+
s.logger.Warn("mismatched repo/auth")
33
+
return helpers.InputError(e, nil)
34
+
}
35
+
36
+
results, err := s.repoman.applyWrites(repo.Repo, []Op{
37
+
{
38
+
Type: OpTypeDelete,
39
+
Collection: req.Collection,
40
+
Rkey: &req.Rkey,
41
+
SwapRecord: req.SwapRecord,
42
+
},
43
+
}, req.SwapCommit)
44
+
if err != nil {
45
+
s.logger.Error("error applying writes", "error", err)
46
+
return helpers.ServerError(e, nil)
47
+
}
48
+
49
+
results[0].Type = nil
50
+
results[0].Uri = nil
51
+
results[0].Cid = nil
52
+
results[0].ValidationStatus = nil
53
+
54
+
return e.JSON(200, results[0])
55
+
}
+2
-2
server/handle_repo_describe_repo.go
+2
-2
server/handle_repo_describe_repo.go
···
64
}
65
66
var records []models.Record
67
-
if err := s.db.Raw("SELECT DISTINCT(nsid) FROM records WHERE did = ?", repo.Repo.Did).Scan(&records).Error; err != nil {
68
s.logger.Error("error getting collections", "error", err)
69
return helpers.ServerError(e, nil)
70
}
71
72
-
var collections []string
73
for _, r := range records {
74
collections = append(collections, r.Nsid)
75
}
···
64
}
65
66
var records []models.Record
67
+
if err := s.db.Raw("SELECT DISTINCT(nsid) FROM records WHERE did = ?", nil, repo.Repo.Did).Scan(&records).Error; err != nil {
68
s.logger.Error("error getting collections", "error", err)
69
return helpers.ServerError(e, nil)
70
}
71
72
+
var collections []string = make([]string, 0, len(records))
73
for _, r := range records {
74
collections = append(collections, r.Nsid)
75
}
+1
-1
server/handle_repo_get_record.go
+1
-1
server/handle_repo_get_record.go
+40
-11
server/handle_repo_list_records.go
+40
-11
server/handle_repo_list_records.go
···
2
3
import (
4
"strconv"
5
-
"strings"
6
7
"github.com/Azure/go-autorest/autorest/to"
8
"github.com/bluesky-social/indigo/atproto/data"
9
"github.com/haileyok/cocoon/internal/helpers"
10
"github.com/haileyok/cocoon/models"
11
"github.com/labstack/echo/v4"
12
)
13
14
type ComAtprotoRepoListRecordsResponse struct {
15
Cursor *string `json:"cursor,omitempty"`
···
38
}
39
40
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")
45
limit, err := getLimitFromContext(e, 50)
46
if err != nil {
47
return helpers.InputError(e, nil)
···
51
dir := "<"
52
cursorquery := ""
53
54
-
if strings.ToLower(reverse) == "true" {
55
sort = "ASC"
56
dir = ">"
57
}
58
59
-
params := []any{did, collection}
60
-
if cursor != "" {
61
-
params = append(params, cursor)
62
cursorquery = "AND created_at " + dir + " ?"
63
}
64
params = append(params, limit)
65
66
var records []models.Record
67
-
if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? "+cursorquery+" ORDER BY created_at "+sort+" limit ?", params...).Scan(&records).Error; err != nil {
68
s.logger.Error("error getting records", "error", err)
69
return helpers.ServerError(e, nil)
70
}
···
84
}
85
86
var newcursor *string
87
-
if len(records) == 50 {
88
newcursor = to.StringPtr(records[len(records)-1].CreatedAt)
89
}
90
···
2
3
import (
4
"strconv"
5
6
"github.com/Azure/go-autorest/autorest/to"
7
"github.com/bluesky-social/indigo/atproto/data"
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
"github.com/haileyok/cocoon/internal/helpers"
10
"github.com/haileyok/cocoon/models"
11
"github.com/labstack/echo/v4"
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
+
}
21
22
type ComAtprotoRepoListRecordsResponse struct {
23
Cursor *string `json:"cursor,omitempty"`
···
46
}
47
48
func (s *Server) handleListRecords(e echo.Context) error {
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
+
65
limit, err := getLimitFromContext(e, 50)
66
if err != nil {
67
return helpers.InputError(e, nil)
···
71
dir := "<"
72
cursorquery := ""
73
74
+
if req.Reverse {
75
sort = "ASC"
76
dir = ">"
77
}
78
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)
91
cursorquery = "AND created_at " + dir + " ?"
92
}
93
params = append(params, limit)
94
95
var records []models.Record
96
+
if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? "+cursorquery+" ORDER BY created_at "+sort+" limit ?", nil, params...).Scan(&records).Error; err != nil {
97
s.logger.Error("error getting records", "error", err)
98
return helpers.ServerError(e, nil)
99
}
···
113
}
114
115
var newcursor *string
116
+
if len(records) == limit {
117
newcursor = to.StringPtr(records[len(records)-1].CreatedAt)
118
}
119
+1
-1
server/handle_repo_list_repos.go
+1
-1
server/handle_repo_list_repos.go
+2
server/handle_repo_put_record.go
+2
server/handle_repo_put_record.go
+3
-3
server/handle_repo_upload_blob.go
+3
-3
server/handle_repo_upload_blob.go
···
40
CreatedAt: s.repoman.clock.Next().String(),
41
}
42
43
-
if err := s.db.Create(&blob).Error; err != nil {
44
s.logger.Error("error creating new blob in db", "error", err)
45
return helpers.ServerError(e, nil)
46
}
···
72
Data: data,
73
}
74
75
-
if err := s.db.Create(&blobPart).Error; err != nil {
76
s.logger.Error("error adding blob part to db", "error", err)
77
return helpers.ServerError(e, nil)
78
}
···
89
return helpers.ServerError(e, nil)
90
}
91
92
-
if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", c.Bytes(), blob.ID).Error; err != nil {
93
// there should probably be somme handling here if this fails...
94
s.logger.Error("error updating blob", "error", err)
95
return helpers.ServerError(e, nil)
···
40
CreatedAt: s.repoman.clock.Next().String(),
41
}
42
43
+
if err := s.db.Create(&blob, nil).Error; err != nil {
44
s.logger.Error("error creating new blob in db", "error", err)
45
return helpers.ServerError(e, nil)
46
}
···
72
Data: data,
73
}
74
75
+
if err := s.db.Create(&blobPart, nil).Error; err != nil {
76
s.logger.Error("error adding blob part to db", "error", err)
77
return helpers.ServerError(e, nil)
78
}
···
89
return helpers.ServerError(e, nil)
90
}
91
92
+
if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", nil, c.Bytes(), blob.ID).Error; err != nil {
93
// there should probably be somme handling here if this fails...
94
s.logger.Error("error updating blob", "error", err)
95
return helpers.ServerError(e, nil)
+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
+
}
+50
server/handle_server_confirm_email.go
+50
server/handle_server_confirm_email.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/models"
9
+
"github.com/labstack/echo/v4"
10
+
)
11
+
12
+
type ComAtprotoServerConfirmEmailRequest struct {
13
+
Email string `json:"email" validate:"required"`
14
+
Token string `json:"token" validate:"required"`
15
+
}
16
+
17
+
func (s *Server) handleServerConfirmEmail(e echo.Context) error {
18
+
urepo := e.Get("repo").(*models.RepoActor)
19
+
20
+
var req ComAtprotoServerConfirmEmailRequest
21
+
if err := e.Bind(&req); err != nil {
22
+
s.logger.Error("error binding", "error", err)
23
+
return helpers.ServerError(e, nil)
24
+
}
25
+
26
+
if err := e.Validate(req); err != nil {
27
+
return helpers.InputError(e, nil)
28
+
}
29
+
30
+
if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil {
31
+
return helpers.ExpiredTokenError(e)
32
+
}
33
+
34
+
if *urepo.EmailVerificationCode != req.Token {
35
+
return helpers.InputError(e, to.StringPtr("InvalidToken"))
36
+
}
37
+
38
+
if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) {
39
+
return helpers.ExpiredTokenError(e)
40
+
}
41
+
42
+
now := time.Now().UTC()
43
+
44
+
if err := s.db.Exec("UPDATE repos SET email_verification_code = NULL, email_verification_code_expires_at = NULL, email_confirmed_at = ? WHERE did = ?", nil, now, urepo.Repo.Did).Error; err != nil {
45
+
s.logger.Error("error updating user", "error", err)
46
+
return helpers.ServerError(e, nil)
47
+
}
48
+
49
+
return e.NoContent(200)
50
+
}
+80
-50
server/handle_server_create_account.go
+80
-50
server/handle_server_create_account.go
···
3
import (
4
"context"
5
"errors"
6
"strings"
7
"time"
8
9
"github.com/Azure/go-autorest/autorest/to"
10
"github.com/bluesky-social/indigo/api/atproto"
11
"github.com/bluesky-social/indigo/atproto/crypto"
12
"github.com/bluesky-social/indigo/events"
13
"github.com/bluesky-social/indigo/repo"
14
"github.com/bluesky-social/indigo/util"
15
-
"github.com/haileyok/cocoon/blockstore"
16
"github.com/haileyok/cocoon/internal/helpers"
17
"github.com/haileyok/cocoon/models"
18
"github.com/labstack/echo/v4"
···
38
func (s *Server) handleCreateAccount(e echo.Context) error {
39
var request ComAtprotoServerCreateAccountRequest
40
41
if err := e.Bind(&request); err != nil {
42
s.logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err)
43
return helpers.ServerError(e, nil)
···
84
}
85
86
var ic models.InviteCode
87
-
if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil {
88
if err == gorm.ErrRecordNotFound {
89
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
90
}
···
108
109
// TODO: unsupported domains
110
111
-
// TODO: did stuff
112
-
113
k, err := crypto.GeneratePrivateKeyK256()
114
if err != nil {
115
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
116
return helpers.ServerError(e, nil)
117
}
118
119
-
did, op, err := s.plcClient.CreateDID(e.Request().Context(), k, "", request.Handle)
120
-
if err != nil {
121
-
s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err)
122
-
return helpers.ServerError(e, nil)
123
-
}
124
125
-
if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil {
126
-
s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err)
127
-
return helpers.ServerError(e, nil)
128
}
129
130
hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10)
···
134
}
135
136
urepo := models.Repo{
137
-
Did: did,
138
-
CreatedAt: time.Now(),
139
-
Email: request.Email,
140
-
Password: string(hashed),
141
-
SigningKey: k.Bytes(),
142
}
143
144
actor := models.Actor{
145
-
Did: did,
146
Handle: request.Handle,
147
}
148
149
-
if err := s.db.Create(&urepo).Error; err != nil {
150
s.logger.Error("error inserting new repo", "error", err)
151
return helpers.ServerError(e, nil)
152
}
153
154
-
bs := blockstore.New(did, s.db)
155
-
r := repo.NewRepo(context.TODO(), did, bs)
156
-
157
-
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
158
-
if err != nil {
159
-
s.logger.Error("error committing", "error", err)
160
return helpers.ServerError(e, nil)
161
}
162
163
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
164
-
s.logger.Error("error updating repo after commit", "error", err)
165
-
return helpers.ServerError(e, nil)
166
-
}
167
168
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
169
-
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
170
-
Did: urepo.Did,
171
-
Handle: request.Handle,
172
-
Seq: time.Now().UnixMicro(), // TODO: no
173
-
Time: time.Now().Format(util.ISO8601),
174
-
},
175
-
})
176
177
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
178
-
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
179
-
Did: urepo.Did,
180
-
Handle: to.StringPtr(request.Handle),
181
-
Seq: time.Now().UnixMicro(), // TODO: no
182
-
Time: time.Now().Format(util.ISO8601),
183
-
},
184
-
})
185
186
-
if err := s.db.Create(&actor).Error; err != nil {
187
-
s.logger.Error("error inserting new actor", "error", err)
188
-
return helpers.ServerError(e, nil)
189
}
190
191
-
if err := s.db.Raw("UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil {
192
s.logger.Error("error decrementing use count", "error", err)
193
return helpers.ServerError(e, nil)
194
}
···
199
return helpers.ServerError(e, nil)
200
}
201
202
return e.JSON(200, ComAtprotoServerCreateAccountResponse{
203
AccessJwt: sess.AccessToken,
204
RefreshJwt: sess.RefreshToken,
205
Handle: request.Handle,
206
-
Did: did,
207
})
208
}
···
3
import (
4
"context"
5
"errors"
6
+
"fmt"
7
"strings"
8
"time"
9
10
"github.com/Azure/go-autorest/autorest/to"
11
"github.com/bluesky-social/indigo/api/atproto"
12
"github.com/bluesky-social/indigo/atproto/crypto"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
"github.com/bluesky-social/indigo/events"
15
"github.com/bluesky-social/indigo/repo"
16
"github.com/bluesky-social/indigo/util"
17
"github.com/haileyok/cocoon/internal/helpers"
18
"github.com/haileyok/cocoon/models"
19
"github.com/labstack/echo/v4"
···
39
func (s *Server) handleCreateAccount(e echo.Context) error {
40
var request ComAtprotoServerCreateAccountRequest
41
42
+
var signupDid string
43
+
customDidHeader := e.Request().Header.Get("authorization")
44
+
if customDidHeader != "" {
45
+
pts := strings.Split(customDidHeader, " ")
46
+
if len(pts) != 2 {
47
+
return helpers.InputError(e, to.StringPtr("InvalidDid"))
48
+
}
49
+
50
+
_, err := syntax.ParseDID(pts[1])
51
+
if err != nil {
52
+
return helpers.InputError(e, to.StringPtr("InvalidDid"))
53
+
}
54
+
55
+
signupDid = pts[1]
56
+
}
57
+
58
if err := e.Bind(&request); err != nil {
59
s.logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err)
60
return helpers.ServerError(e, nil)
···
101
}
102
103
var ic models.InviteCode
104
+
if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil {
105
if err == gorm.ErrRecordNotFound {
106
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
107
}
···
125
126
// TODO: unsupported domains
127
128
k, err := crypto.GeneratePrivateKeyK256()
129
if err != nil {
130
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
131
return helpers.ServerError(e, nil)
132
}
133
134
+
if signupDid == "" {
135
+
did, op, err := s.plcClient.CreateDID(k, "", request.Handle)
136
+
if err != nil {
137
+
s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err)
138
+
return helpers.ServerError(e, nil)
139
+
}
140
141
+
if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil {
142
+
s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err)
143
+
return helpers.ServerError(e, nil)
144
+
}
145
+
signupDid = did
146
}
147
148
hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10)
···
152
}
153
154
urepo := models.Repo{
155
+
Did: signupDid,
156
+
CreatedAt: time.Now(),
157
+
Email: request.Email,
158
+
EmailVerificationCode: to.StringPtr(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))),
159
+
Password: string(hashed),
160
+
SigningKey: k.Bytes(),
161
}
162
163
actor := models.Actor{
164
+
Did: signupDid,
165
Handle: request.Handle,
166
}
167
168
+
if err := s.db.Create(&urepo, nil).Error; err != nil {
169
s.logger.Error("error inserting new repo", "error", err)
170
return helpers.ServerError(e, nil)
171
}
172
173
+
if err := s.db.Create(&actor, nil).Error; err != nil {
174
+
s.logger.Error("error inserting new actor", "error", err)
175
return helpers.ServerError(e, nil)
176
}
177
178
+
if customDidHeader == "" {
179
+
bs := s.getBlockstore(signupDid)
180
+
r := repo.NewRepo(context.TODO(), signupDid, bs)
181
+
182
+
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
183
+
if err != nil {
184
+
s.logger.Error("error committing", "error", err)
185
+
return helpers.ServerError(e, nil)
186
+
}
187
188
+
if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil {
189
+
s.logger.Error("error updating repo after commit", "error", err)
190
+
return helpers.ServerError(e, nil)
191
+
}
192
193
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
194
+
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
195
+
Did: urepo.Did,
196
+
Handle: request.Handle,
197
+
Seq: time.Now().UnixMicro(), // TODO: no
198
+
Time: time.Now().Format(util.ISO8601),
199
+
},
200
+
})
201
202
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
203
+
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
204
+
Did: urepo.Did,
205
+
Handle: to.StringPtr(request.Handle),
206
+
Seq: time.Now().UnixMicro(), // TODO: no
207
+
Time: time.Now().Format(util.ISO8601),
208
+
},
209
+
})
210
}
211
212
+
if err := s.db.Raw("UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil {
213
s.logger.Error("error decrementing use count", "error", err)
214
return helpers.ServerError(e, nil)
215
}
···
220
return helpers.ServerError(e, nil)
221
}
222
223
+
go func() {
224
+
if err := s.sendEmailVerification(urepo.Email, actor.Handle, *urepo.EmailVerificationCode); err != nil {
225
+
s.logger.Error("error sending email verification email", "error", err)
226
+
}
227
+
if err := s.sendWelcomeMail(urepo.Email, actor.Handle); err != nil {
228
+
s.logger.Error("error sending welcome email", "error", err)
229
+
}
230
+
}()
231
+
232
return e.JSON(200, ComAtprotoServerCreateAccountResponse{
233
AccessJwt: sess.AccessToken,
234
RefreshJwt: sess.RefreshToken,
235
Handle: request.Handle,
236
+
Did: signupDid,
237
})
238
}
+39
-4
server/handle_server_create_invite_code.go
+39
-4
server/handle_server_create_invite_code.go
···
2
3
import (
4
"github.com/google/uuid"
5
"github.com/haileyok/cocoon/models"
6
"github.com/labstack/echo/v4"
7
)
8
9
func (s *Server) handleCreateInviteCode(e echo.Context) error {
10
-
ic := models.InviteCode{
11
-
Code: uuid.NewString(),
12
}
13
14
-
return e.JSON(200, map[string]string{
15
-
"code": ic.Code,
16
})
17
}
···
2
3
import (
4
"github.com/google/uuid"
5
+
"github.com/haileyok/cocoon/internal/helpers"
6
"github.com/haileyok/cocoon/models"
7
"github.com/labstack/echo/v4"
8
)
9
10
+
type ComAtprotoServerCreateInviteCodeRequest struct {
11
+
UseCount int `json:"useCount" validate:"required"`
12
+
ForAccount *string `json:"forAccount,omitempty"`
13
+
}
14
+
15
+
type ComAtprotoServerCreateInviteCodeResponse struct {
16
+
Code string `json:"code"`
17
+
}
18
+
19
func (s *Server) handleCreateInviteCode(e echo.Context) error {
20
+
var req ComAtprotoServerCreateInviteCodeRequest
21
+
if err := e.Bind(&req); err != nil {
22
+
s.logger.Error("error binding", "error", err)
23
+
return helpers.ServerError(e, nil)
24
+
}
25
+
26
+
if err := e.Validate(req); err != nil {
27
+
s.logger.Error("error validating", "error", err)
28
+
return helpers.InputError(e, nil)
29
+
}
30
+
31
+
ic := uuid.NewString()
32
+
33
+
var acc string
34
+
if req.ForAccount == nil {
35
+
acc = "admin"
36
+
} else {
37
+
acc = *req.ForAccount
38
+
}
39
+
40
+
if err := s.db.Create(&models.InviteCode{
41
+
Code: ic,
42
+
Did: acc,
43
+
RemainingUseCount: req.UseCount,
44
+
}, nil).Error; err != nil {
45
+
s.logger.Error("error creating invite code", "error", err)
46
+
return helpers.ServerError(e, nil)
47
}
48
49
+
return e.JSON(200, ComAtprotoServerCreateInviteCodeResponse{
50
+
Code: ic,
51
})
52
}
+70
server/handle_server_create_invite_codes.go
+70
server/handle_server_create_invite_codes.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"github.com/Azure/go-autorest/autorest/to"
5
+
"github.com/google/uuid"
6
+
"github.com/haileyok/cocoon/internal/helpers"
7
+
"github.com/haileyok/cocoon/models"
8
+
"github.com/labstack/echo/v4"
9
+
)
10
+
11
+
type ComAtprotoServerCreateInviteCodesRequest struct {
12
+
CodeCount *int `json:"codeCount,omitempty"`
13
+
UseCount int `json:"useCount" validate:"required"`
14
+
ForAccounts *[]string `json:"forAccounts,omitempty"`
15
+
}
16
+
17
+
type ComAtprotoServerCreateInviteCodesResponse []ComAtprotoServerCreateInviteCodesItem
18
+
19
+
type ComAtprotoServerCreateInviteCodesItem struct {
20
+
Account string `json:"account"`
21
+
Codes []string `json:"codes"`
22
+
}
23
+
24
+
func (s *Server) handleCreateInviteCodes(e echo.Context) error {
25
+
var req ComAtprotoServerCreateInviteCodesRequest
26
+
if err := e.Bind(&req); err != nil {
27
+
s.logger.Error("error binding", "error", err)
28
+
return helpers.ServerError(e, nil)
29
+
}
30
+
31
+
if err := e.Validate(req); err != nil {
32
+
s.logger.Error("error validating", "error", err)
33
+
return helpers.InputError(e, nil)
34
+
}
35
+
36
+
if req.CodeCount == nil {
37
+
req.CodeCount = to.IntPtr(1)
38
+
}
39
+
40
+
if req.ForAccounts == nil {
41
+
req.ForAccounts = to.StringSlicePtr([]string{"admin"})
42
+
}
43
+
44
+
var codes []ComAtprotoServerCreateInviteCodesItem
45
+
46
+
for _, did := range *req.ForAccounts {
47
+
var ics []string
48
+
49
+
for range *req.CodeCount {
50
+
ic := uuid.NewString()
51
+
ics = append(ics, ic)
52
+
53
+
if err := s.db.Create(&models.InviteCode{
54
+
Code: ic,
55
+
Did: did,
56
+
RemainingUseCount: req.UseCount,
57
+
}, nil).Error; err != nil {
58
+
s.logger.Error("error creating invite code", "error", err)
59
+
return helpers.ServerError(e, nil)
60
+
}
61
+
}
62
+
63
+
codes = append(codes, ComAtprotoServerCreateInviteCodesItem{
64
+
Account: did,
65
+
Codes: ics,
66
+
})
67
+
}
68
+
69
+
return e.JSON(200, codes)
70
+
}
+3
-3
server/handle_server_create_session.go
+3
-3
server/handle_server_create_session.go
···
65
var err error
66
switch idtype {
67
case "did":
68
-
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", req.Identifier).Scan(&repo).Error
69
case "handle":
70
-
err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", req.Identifier).Scan(&repo).Error
71
case "email":
72
-
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE a.email = ?", req.Identifier).Scan(&repo).Error
73
}
74
75
if err != nil {
···
65
var err error
66
switch idtype {
67
case "did":
68
+
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Identifier).Scan(&repo).Error
69
case "handle":
70
+
err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Identifier).Scan(&repo).Error
71
case "email":
72
+
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Identifier).Scan(&repo).Error
73
}
74
75
if err != nil {
+2
-2
server/handle_server_delete_session.go
+2
-2
server/handle_server_delete_session.go
···
10
token := e.Get("token").(string)
11
12
var acctok models.Token
13
-
if err := s.db.Raw("DELETE FROM tokens WHERE token = ? RETURNING *", token).Scan(&acctok).Error; err != nil {
14
s.logger.Error("error deleting access token from db", "error", err)
15
return helpers.ServerError(e, nil)
16
}
17
18
-
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", acctok.RefreshToken).Error; err != nil {
19
s.logger.Error("error deleting refresh token from db", "error", err)
20
return helpers.ServerError(e, nil)
21
}
···
10
token := e.Get("token").(string)
11
12
var acctok models.Token
13
+
if err := s.db.Raw("DELETE FROM tokens WHERE token = ? RETURNING *", nil, token).Scan(&acctok).Error; err != nil {
14
s.logger.Error("error deleting access token from db", "error", err)
15
return helpers.ServerError(e, nil)
16
}
17
18
+
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", nil, acctok.RefreshToken).Error; err != nil {
19
s.logger.Error("error deleting refresh token from db", "error", err)
20
return helpers.ServerError(e, nil)
21
}
+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_refresh_session.go
+2
-2
server/handle_server_refresh_session.go
···
19
token := e.Get("token").(string)
20
repo := e.Get("repo").(*models.RepoActor)
21
22
-
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", token).Error; err != nil {
23
s.logger.Error("error getting refresh token from db", "error", err)
24
return helpers.ServerError(e, nil)
25
}
26
27
-
if err := s.db.Exec("DELETE FROM tokens WHERE refresh_token = ?", token).Error; err != nil {
28
s.logger.Error("error deleting access token from db", "error", err)
29
return helpers.ServerError(e, nil)
30
}
···
19
token := e.Get("token").(string)
20
repo := e.Get("repo").(*models.RepoActor)
21
22
+
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", nil, token).Error; err != nil {
23
s.logger.Error("error getting refresh token from db", "error", err)
24
return helpers.ServerError(e, nil)
25
}
26
27
+
if err := s.db.Exec("DELETE FROM tokens WHERE refresh_token = ?", nil, token).Error; err != nil {
28
s.logger.Error("error deleting access token from db", "error", err)
29
return helpers.ServerError(e, nil)
30
}
+34
server/handle_server_request_email_confirmation.go
+34
server/handle_server_request_email_confirmation.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/Azure/go-autorest/autorest/to"
8
+
"github.com/haileyok/cocoon/internal/helpers"
9
+
"github.com/haileyok/cocoon/models"
10
+
"github.com/labstack/echo/v4"
11
+
)
12
+
13
+
func (s *Server) handleServerRequestEmailConfirmation(e echo.Context) error {
14
+
urepo := e.Get("repo").(*models.RepoActor)
15
+
16
+
if urepo.EmailConfirmedAt != nil {
17
+
return helpers.InputError(e, to.StringPtr("InvalidRequest"))
18
+
}
19
+
20
+
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
21
+
eat := time.Now().Add(10 * time.Minute).UTC()
22
+
23
+
if err := s.db.Exec("UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil {
24
+
s.logger.Error("error updating user", "error", err)
25
+
return helpers.ServerError(e, nil)
26
+
}
27
+
28
+
if err := s.sendEmailVerification(urepo.Email, urepo.Handle, code); err != nil {
29
+
s.logger.Error("error sending mail", "error", err)
30
+
return helpers.ServerError(e, nil)
31
+
}
32
+
33
+
return e.NoContent(200)
34
+
}
+37
server/handle_server_request_email_update.go
+37
server/handle_server_request_email_update.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/haileyok/cocoon/internal/helpers"
8
+
"github.com/haileyok/cocoon/models"
9
+
"github.com/labstack/echo/v4"
10
+
)
11
+
12
+
type ComAtprotoRequestEmailUpdateResponse struct {
13
+
TokenRequired bool `json:"tokenRequired"`
14
+
}
15
+
16
+
func (s *Server) handleServerRequestEmailUpdate(e echo.Context) error {
17
+
urepo := e.Get("repo").(*models.RepoActor)
18
+
19
+
if urepo.EmailConfirmedAt != nil {
20
+
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
21
+
eat := time.Now().Add(10 * time.Minute).UTC()
22
+
23
+
if err := s.db.Exec("UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil {
24
+
s.logger.Error("error updating repo", "error", err)
25
+
return helpers.ServerError(e, nil)
26
+
}
27
+
28
+
if err := s.sendEmailUpdate(urepo.Email, urepo.Handle, code); err != nil {
29
+
s.logger.Error("error sending email", "error", err)
30
+
return helpers.ServerError(e, nil)
31
+
}
32
+
}
33
+
34
+
return e.JSON(200, ComAtprotoRequestEmailUpdateResponse{
35
+
TokenRequired: urepo.EmailConfirmedAt != nil,
36
+
})
37
+
}
+50
server/handle_server_request_password_reset.go
+50
server/handle_server_request_password_reset.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/haileyok/cocoon/internal/helpers"
8
+
"github.com/haileyok/cocoon/models"
9
+
"github.com/labstack/echo/v4"
10
+
)
11
+
12
+
type ComAtprotoServerRequestPasswordResetRequest struct {
13
+
Email string `json:"email" validate:"required"`
14
+
}
15
+
16
+
func (s *Server) handleServerRequestPasswordReset(e echo.Context) error {
17
+
urepo, ok := e.Get("repo").(*models.RepoActor)
18
+
if !ok {
19
+
var req ComAtprotoServerRequestPasswordResetRequest
20
+
if err := e.Bind(&req); err != nil {
21
+
return err
22
+
}
23
+
24
+
if err := e.Validate(req); err != nil {
25
+
return err
26
+
}
27
+
28
+
murepo, err := s.getRepoActorByEmail(req.Email)
29
+
if err != nil {
30
+
return err
31
+
}
32
+
33
+
urepo = murepo
34
+
}
35
+
36
+
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
37
+
eat := time.Now().Add(10 * time.Minute).UTC()
38
+
39
+
if err := s.db.Exec("UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil {
40
+
s.logger.Error("error updating repo", "error", err)
41
+
return helpers.ServerError(e, nil)
42
+
}
43
+
44
+
if err := s.sendPasswordReset(urepo.Email, urepo.Handle, code); err != nil {
45
+
s.logger.Error("error sending email", "error", err)
46
+
return helpers.ServerError(e, nil)
47
+
}
48
+
49
+
return e.NoContent(200)
50
+
}
+55
server/handle_server_reset_password.go
+55
server/handle_server_reset_password.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/models"
9
+
"github.com/labstack/echo/v4"
10
+
"golang.org/x/crypto/bcrypt"
11
+
)
12
+
13
+
type ComAtprotoServerResetPasswordRequest struct {
14
+
Token string `json:"token" validate:"required"`
15
+
Password string `json:"password" validate:"required"`
16
+
}
17
+
18
+
func (s *Server) handleServerResetPassword(e echo.Context) error {
19
+
urepo := e.Get("repo").(*models.RepoActor)
20
+
21
+
var req ComAtprotoServerResetPasswordRequest
22
+
if err := e.Bind(&req); err != nil {
23
+
s.logger.Error("error binding", "error", err)
24
+
return helpers.ServerError(e, nil)
25
+
}
26
+
27
+
if err := e.Validate(req); err != nil {
28
+
return helpers.InputError(e, nil)
29
+
}
30
+
31
+
if urepo.PasswordResetCode == nil || urepo.PasswordResetCodeExpiresAt == nil {
32
+
return helpers.InputError(e, to.StringPtr("InvalidToken"))
33
+
}
34
+
35
+
if *urepo.PasswordResetCode != req.Token {
36
+
return helpers.InvalidTokenError(e)
37
+
}
38
+
39
+
if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) {
40
+
return helpers.ExpiredTokenError(e)
41
+
}
42
+
43
+
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
44
+
if err != nil {
45
+
s.logger.Error("error creating hash", "error", err)
46
+
return helpers.ServerError(e, nil)
47
+
}
48
+
49
+
if err := s.db.Exec("UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", nil, hash, urepo.Repo.Did).Error; err != nil {
50
+
s.logger.Error("error updating repo", "error", err)
51
+
return helpers.ServerError(e, nil)
52
+
}
53
+
54
+
return e.NoContent(200)
55
+
}
+48
server/handle_server_update_email.go
+48
server/handle_server_update_email.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/haileyok/cocoon/internal/helpers"
7
+
"github.com/haileyok/cocoon/models"
8
+
"github.com/labstack/echo/v4"
9
+
)
10
+
11
+
type ComAtprotoServerUpdateEmailRequest struct {
12
+
Email string `json:"email" validate:"required"`
13
+
EmailAuthFactor bool `json:"emailAuthFactor"`
14
+
Token string `json:"token" validate:"required"`
15
+
}
16
+
17
+
func (s *Server) handleServerUpdateEmail(e echo.Context) error {
18
+
urepo := e.Get("repo").(*models.RepoActor)
19
+
20
+
var req ComAtprotoServerUpdateEmailRequest
21
+
if err := e.Bind(&req); err != nil {
22
+
s.logger.Error("error binding", "error", err)
23
+
return helpers.ServerError(e, nil)
24
+
}
25
+
26
+
if err := e.Validate(req); err != nil {
27
+
return helpers.InputError(e, nil)
28
+
}
29
+
30
+
if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil {
31
+
return helpers.InvalidTokenError(e)
32
+
}
33
+
34
+
if *urepo.EmailUpdateCode != req.Token {
35
+
return helpers.InvalidTokenError(e)
36
+
}
37
+
38
+
if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) {
39
+
return helpers.ExpiredTokenError(e)
40
+
}
41
+
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 {
43
+
s.logger.Error("error updating repo", "error", err)
44
+
return helpers.ServerError(e, nil)
45
+
}
46
+
47
+
return e.NoContent(200)
48
+
}
+4
-2
server/handle_sync_get_blob.go
+4
-2
server/handle_sync_get_blob.go
···
26
}
27
28
var blob models.Blob
29
-
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", did, c.Bytes()).Scan(&blob).Error; err != nil {
30
s.logger.Error("error looking up blob", "error", err)
31
return helpers.ServerError(e, nil)
32
}
···
34
buf := new(bytes.Buffer)
35
36
var parts []models.BlobPart
37
-
if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", blob.ID).Scan(&parts).Error; err != nil {
38
s.logger.Error("error getting blob parts", "error", err)
39
return helpers.ServerError(e, nil)
40
}
···
43
for _, p := range parts {
44
buf.Write(p.Data)
45
}
46
47
return e.Stream(200, "application/octet-stream", buf)
48
}
···
26
}
27
28
var blob models.Blob
29
+
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&blob).Error; err != nil {
30
s.logger.Error("error looking up blob", "error", err)
31
return helpers.ServerError(e, nil)
32
}
···
34
buf := new(bytes.Buffer)
35
36
var parts []models.BlobPart
37
+
if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", nil, blob.ID).Scan(&parts).Error; err != nil {
38
s.logger.Error("error getting blob parts", "error", err)
39
return helpers.ServerError(e, nil)
40
}
···
43
for _, p := range parts {
44
buf.Write(p.Data)
45
}
46
+
47
+
e.Response().Header().Set(echo.HeaderContentDisposition, "attachment; filename="+c.String())
48
49
return e.Stream(200, "application/octet-stream", buf)
50
}
+1
-2
server/handle_sync_get_blocks.go
+1
-2
server/handle_sync_get_blocks.go
···
6
"strings"
7
8
"github.com/bluesky-social/indigo/carstore"
9
-
"github.com/haileyok/cocoon/blockstore"
10
"github.com/haileyok/cocoon/internal/helpers"
11
"github.com/ipfs/go-cid"
12
cbor "github.com/ipfs/go-ipld-cbor"
···
54
return helpers.ServerError(e, nil)
55
}
56
57
-
bs := blockstore.New(urepo.Repo.Did, s.db)
58
59
for _, c := range cids {
60
b, err := bs.Get(context.TODO(), c)
···
6
"strings"
7
8
"github.com/bluesky-social/indigo/carstore"
9
"github.com/haileyok/cocoon/internal/helpers"
10
"github.com/ipfs/go-cid"
11
cbor "github.com/ipfs/go-ipld-cbor"
···
53
return helpers.ServerError(e, nil)
54
}
55
56
+
bs := s.getBlockstore(urepo.Repo.Did)
57
58
for _, c := range cids {
59
b, err := bs.Get(context.TODO(), c)
+1
-1
server/handle_sync_get_record.go
+1
-1
server/handle_sync_get_record.go
+1
-1
server/handle_sync_get_repo.go
+1
-1
server/handle_sync_get_repo.go
+1
-1
server/handle_sync_list_blobs.go
+1
-1
server/handle_sync_list_blobs.go
···
35
params = append(params, limit)
36
37
var blobs []models.Blob
38
-
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", params...).Scan(&blobs).Error; err != nil {
39
s.logger.Error("error getting records", "error", err)
40
return helpers.ServerError(e, nil)
41
}
···
35
params = append(params, limit)
36
37
var blobs []models.Blob
38
+
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil {
39
s.logger.Error("error getting records", "error", err)
40
return helpers.ServerError(e, nil)
41
}
+88
server/handle_well_known.go
+88
server/handle_well_known.go
···
1
package server
2
3
import (
4
+
"fmt"
5
+
6
+
"github.com/Azure/go-autorest/autorest/to"
7
"github.com/labstack/echo/v4"
8
)
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
+
50
func (s *Server) handleWellKnown(e echo.Context) error {
51
return e.JSON(200, map[string]any{
52
"@context": []string{
···
62
},
63
})
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
+
}
+79
server/mail.go
+79
server/mail.go
···
···
1
+
package server
2
+
3
+
import "fmt"
4
+
5
+
func (s *Server) sendWelcomeMail(email, handle string) error {
6
+
if s.mail == nil {
7
+
return nil
8
+
}
9
+
10
+
s.mailLk.Lock()
11
+
defer s.mailLk.Unlock()
12
+
13
+
s.mail.To(email)
14
+
s.mail.Subject("Welcome to " + s.config.Hostname)
15
+
s.mail.Plain().Set(fmt.Sprintf("Welcome to %s! Your handle is %s.", email, handle))
16
+
17
+
if err := s.mail.Send(); err != nil {
18
+
return err
19
+
}
20
+
21
+
return nil
22
+
}
23
+
24
+
func (s *Server) sendPasswordReset(email, handle, code string) error {
25
+
if s.mail == nil {
26
+
return nil
27
+
}
28
+
29
+
s.mailLk.Lock()
30
+
defer s.mailLk.Unlock()
31
+
32
+
s.mail.To(email)
33
+
s.mail.Subject("Password reset for " + s.config.Hostname)
34
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your password reset code is %s. This code will expire in ten minutes.", handle, code))
35
+
36
+
if err := s.mail.Send(); err != nil {
37
+
return err
38
+
}
39
+
40
+
return nil
41
+
}
42
+
43
+
func (s *Server) sendEmailUpdate(email, handle, code string) error {
44
+
if s.mail == nil {
45
+
return nil
46
+
}
47
+
48
+
s.mailLk.Lock()
49
+
defer s.mailLk.Unlock()
50
+
51
+
s.mail.To(email)
52
+
s.mail.Subject("Email update for " + s.config.Hostname)
53
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email update code is %s. This code will expire in ten minutes.", handle, code))
54
+
55
+
if err := s.mail.Send(); err != nil {
56
+
return err
57
+
}
58
+
59
+
return nil
60
+
}
61
+
62
+
func (s *Server) sendEmailVerification(email, handle, code string) error {
63
+
if s.mail == nil {
64
+
return nil
65
+
}
66
+
67
+
s.mailLk.Lock()
68
+
defer s.mailLk.Unlock()
69
+
70
+
s.mail.To(email)
71
+
s.mail.Subject("Email verification for " + s.config.Hostname)
72
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email verification code is %s. This code will expire in ten minutes.", handle, code))
73
+
74
+
if err := s.mail.Send(); err != nil {
75
+
return err
76
+
}
77
+
78
+
return nil
79
+
}
+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
+
}
+144
-50
server/repo.go
+144
-50
server/repo.go
···
3
import (
4
"bytes"
5
"context"
6
"fmt"
7
"io"
8
"time"
···
15
"github.com/bluesky-social/indigo/events"
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
"github.com/bluesky-social/indigo/repo"
18
-
"github.com/bluesky-social/indigo/util"
19
-
"github.com/haileyok/cocoon/blockstore"
20
"github.com/haileyok/cocoon/models"
21
blocks "github.com/ipfs/go-block-format"
22
"github.com/ipfs/go-cid"
23
cbor "github.com/ipfs/go-ipld-cbor"
24
"github.com/ipld/go-car"
25
-
"gorm.io/gorm"
26
"gorm.io/gorm/clause"
27
)
28
29
type RepoMan struct {
30
-
db *gorm.DB
31
s *Server
32
clock *syntax.TIDClock
33
}
···
51
)
52
53
func (ot OpType) String() string {
54
-
return ot.String()
55
}
56
57
type Op struct {
···
83
}
84
85
type ApplyWriteResult struct {
86
-
Uri string `json:"uri"`
87
-
Cid string `json:"cid"`
88
-
Commit *RepoCommit `json:"commit"`
89
-
ValidationStatus *string `json:"validationStatus"`
90
}
91
92
type RepoCommit struct {
···
101
return nil, err
102
}
103
104
-
dbs := blockstore.New(urepo.Did, rm.db)
105
r, err := repo.OpenRepo(context.TODO(), dbs, rootcid)
106
107
entries := []models.Record{}
108
109
for i, op := range writes {
110
if op.Type != OpTypeCreate && op.Rkey == nil {
111
return nil, fmt.Errorf("invalid rkey")
112
} else if op.Rkey == nil {
113
op.Rkey = to.StringPtr(rm.clock.Next().String())
114
writes[i].Rkey = op.Rkey
···
121
122
switch op.Type {
123
case OpTypeCreate:
124
-
nc, err := r.PutRecord(context.TODO(), op.Collection+"/"+*op.Rkey, op.Record)
125
if err != nil {
126
return nil, err
127
}
128
-
129
-
d, _ := data.MarshalCBOR(*op.Record)
130
entries = append(entries, models.Record{
131
Did: urepo.Did,
132
CreatedAt: rm.clock.Next().String(),
···
135
Cid: nc.String(),
136
Value: d,
137
})
138
case OpTypeDelete:
139
err := r.DeleteRecord(context.TODO(), op.Collection+"/"+*op.Rkey)
140
if err != nil {
141
return nil, err
142
}
143
case OpTypeUpdate:
144
-
nc, err := r.UpdateRecord(context.TODO(), op.Collection+"/"+*op.Rkey, op.Record)
145
if err != nil {
146
return nil, err
147
}
148
-
149
-
d, _ := data.MarshalCBOR(*op.Record)
150
entries = append(entries, models.Record{
151
Did: urepo.Did,
152
CreatedAt: rm.clock.Next().String(),
···
155
Cid: nc.String(),
156
Value: d,
157
})
158
}
159
}
160
···
182
ops := make([]*atproto.SyncSubscribeRepos_RepoOp, 0, len(diffops))
183
184
for _, op := range diffops {
185
switch op.Op {
186
case "add", "mut":
187
kind := "create"
···
189
kind = "update"
190
}
191
192
ll := lexutil.LexLink(op.NewCid)
193
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
194
Action: kind,
···
197
})
198
199
case "del":
200
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
201
Action: "delete",
202
Path: op.Rpath,
203
Cid: nil,
204
})
205
}
206
207
-
blk, err := dbs.Get(context.TODO(), op.NewCid)
208
if err != nil {
209
return nil, err
210
}
···
214
}
215
}
216
217
-
for _, op := range dbs.GetLog() {
218
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
219
return nil, err
220
}
221
}
222
223
-
var results []ApplyWriteResult
224
-
225
var blobs []lexutil.LexLink
226
for _, entry := range entries {
227
-
if err := rm.s.db.Clauses(clause.OnConflict{
228
-
Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}},
229
-
UpdateAll: true,
230
-
}).Create(&entry).Error; err != nil {
231
-
return nil, err
232
-
}
233
234
-
// we should actually check the type (i.e. delete, create,., update) here but we'll do it later
235
-
cids, err := rm.incrementBlobRefs(urepo, entry.Value)
236
-
if err != nil {
237
-
return nil, err
238
}
239
240
for _, c := range cids {
241
blobs = append(blobs, lexutil.LexLink(c))
242
}
243
-
244
-
results = append(results, ApplyWriteResult{
245
-
Uri: "at://" + urepo.Did + "/" + entry.Nsid + "/" + entry.Rkey,
246
-
Cid: entry.Cid,
247
-
Commit: &RepoCommit{
248
-
Cid: newroot.String(),
249
-
Rev: rev,
250
-
},
251
-
ValidationStatus: to.StringPtr("valid"), // TODO: obviously this might not be true atm lol
252
-
})
253
}
254
255
rm.s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
···
260
Rev: rev,
261
Since: &urepo.Rev,
262
Commit: lexutil.LexLink(newroot),
263
-
Time: time.Now().Format(util.ISO8601),
264
Ops: ops,
265
TooBig: false,
266
},
267
})
268
269
-
if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil {
270
return nil, err
271
}
272
273
return results, nil
274
}
275
···
279
return cid.Undef, nil, err
280
}
281
282
-
dbs := blockstore.New(urepo.Did, rm.db)
283
-
bs := util.NewLoggingBstore(dbs)
284
285
r, err := repo.OpenRepo(context.TODO(), bs, c)
286
if err != nil {
···
292
return cid.Undef, nil, err
293
}
294
295
-
return c, bs.GetLoggedBlocks(), nil
296
}
297
298
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
···
302
}
303
304
for _, c := range cids {
305
-
if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", urepo.Did, c.Bytes()).Error; err != nil {
306
return nil, err
307
}
308
}
···
310
return cids, nil
311
}
312
313
// to be honest, we could just store both the cbor and non-cbor in []entries above to avoid an additional
314
// unmarshal here. this will work for now though
315
func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) {
···
320
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
321
}
322
323
-
var deepiter func(interface{}) error
324
-
deepiter = func(item interface{}) error {
325
switch val := item.(type) {
326
-
case map[string]interface{}:
327
if val["$type"] == "blob" {
328
if ref, ok := val["ref"].(string); ok {
329
c, err := cid.Parse(ref)
···
336
return deepiter(v)
337
}
338
}
339
-
case []interface{}:
340
for _, v := range val {
341
deepiter(v)
342
}
···
3
import (
4
"bytes"
5
"context"
6
+
"encoding/json"
7
"fmt"
8
"io"
9
"time"
···
16
"github.com/bluesky-social/indigo/events"
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
"github.com/bluesky-social/indigo/repo"
19
+
"github.com/haileyok/cocoon/internal/db"
20
"github.com/haileyok/cocoon/models"
21
+
"github.com/haileyok/cocoon/recording_blockstore"
22
blocks "github.com/ipfs/go-block-format"
23
"github.com/ipfs/go-cid"
24
cbor "github.com/ipfs/go-ipld-cbor"
25
"github.com/ipld/go-car"
26
"gorm.io/gorm/clause"
27
)
28
29
type RepoMan struct {
30
+
db *db.DB
31
s *Server
32
clock *syntax.TIDClock
33
}
···
51
)
52
53
func (ot OpType) String() string {
54
+
return string(ot)
55
}
56
57
type Op struct {
···
83
}
84
85
type ApplyWriteResult struct {
86
+
Type *string `json:"$type,omitempty"`
87
+
Uri *string `json:"uri,omitempty"`
88
+
Cid *string `json:"cid,omitempty"`
89
+
Commit *RepoCommit `json:"commit,omitempty"`
90
+
ValidationStatus *string `json:"validationStatus,omitempty"`
91
}
92
93
type RepoCommit struct {
···
102
return nil, err
103
}
104
105
+
dbs := rm.s.getBlockstore(urepo.Did)
106
+
bs := recording_blockstore.New(dbs)
107
r, err := repo.OpenRepo(context.TODO(), dbs, rootcid)
108
109
entries := []models.Record{}
110
+
var results []ApplyWriteResult
111
112
for i, op := range writes {
113
if op.Type != OpTypeCreate && op.Rkey == nil {
114
return nil, fmt.Errorf("invalid rkey")
115
+
} else if op.Type == OpTypeCreate && op.Rkey != nil {
116
+
_, _, err := r.GetRecord(context.TODO(), op.Collection+"/"+*op.Rkey)
117
+
if err == nil {
118
+
op.Type = OpTypeUpdate
119
+
}
120
} else if op.Rkey == nil {
121
op.Rkey = to.StringPtr(rm.clock.Next().String())
122
writes[i].Rkey = op.Rkey
···
129
130
switch op.Type {
131
case OpTypeCreate:
132
+
j, err := json.Marshal(*op.Record)
133
+
if err != nil {
134
+
return nil, err
135
+
}
136
+
out, err := data.UnmarshalJSON(j)
137
if err != nil {
138
return nil, err
139
}
140
+
mm := MarshalableMap(out)
141
+
nc, err := r.PutRecord(context.TODO(), op.Collection+"/"+*op.Rkey, &mm)
142
+
if err != nil {
143
+
return nil, err
144
+
}
145
+
d, err := data.MarshalCBOR(mm)
146
+
if err != nil {
147
+
return nil, err
148
+
}
149
entries = append(entries, models.Record{
150
Did: urepo.Did,
151
CreatedAt: rm.clock.Next().String(),
···
154
Cid: nc.String(),
155
Value: d,
156
})
157
+
results = append(results, ApplyWriteResult{
158
+
Type: to.StringPtr(OpTypeCreate.String()),
159
+
Uri: to.StringPtr("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey),
160
+
Cid: to.StringPtr(nc.String()),
161
+
ValidationStatus: to.StringPtr("valid"), // TODO: obviously this might not be true atm lol
162
+
})
163
case OpTypeDelete:
164
+
var old models.Record
165
+
if err := rm.db.Raw("SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", nil, urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil {
166
+
return nil, err
167
+
}
168
+
entries = append(entries, models.Record{
169
+
Did: urepo.Did,
170
+
Nsid: op.Collection,
171
+
Rkey: *op.Rkey,
172
+
Value: old.Value,
173
+
})
174
err := r.DeleteRecord(context.TODO(), op.Collection+"/"+*op.Rkey)
175
if err != nil {
176
return nil, err
177
}
178
+
results = append(results, ApplyWriteResult{
179
+
Type: to.StringPtr(OpTypeDelete.String()),
180
+
})
181
case OpTypeUpdate:
182
+
j, err := json.Marshal(*op.Record)
183
if err != nil {
184
return nil, err
185
}
186
+
out, err := data.UnmarshalJSON(j)
187
+
if err != nil {
188
+
return nil, err
189
+
}
190
+
mm := MarshalableMap(out)
191
+
nc, err := r.UpdateRecord(context.TODO(), op.Collection+"/"+*op.Rkey, &mm)
192
+
if err != nil {
193
+
return nil, err
194
+
}
195
+
d, err := data.MarshalCBOR(mm)
196
+
if err != nil {
197
+
return nil, err
198
+
}
199
entries = append(entries, models.Record{
200
Did: urepo.Did,
201
CreatedAt: rm.clock.Next().String(),
···
204
Cid: nc.String(),
205
Value: d,
206
})
207
+
results = append(results, ApplyWriteResult{
208
+
Type: to.StringPtr(OpTypeUpdate.String()),
209
+
Uri: to.StringPtr("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey),
210
+
Cid: to.StringPtr(nc.String()),
211
+
ValidationStatus: to.StringPtr("valid"), // TODO: obviously this might not be true atm lol
212
+
})
213
}
214
}
215
···
237
ops := make([]*atproto.SyncSubscribeRepos_RepoOp, 0, len(diffops))
238
239
for _, op := range diffops {
240
+
var c cid.Cid
241
switch op.Op {
242
case "add", "mut":
243
kind := "create"
···
245
kind = "update"
246
}
247
248
+
c = op.NewCid
249
ll := lexutil.LexLink(op.NewCid)
250
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
251
Action: kind,
···
254
})
255
256
case "del":
257
+
c = op.OldCid
258
+
ll := lexutil.LexLink(op.OldCid)
259
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
260
Action: "delete",
261
Path: op.Rpath,
262
Cid: nil,
263
+
Prev: &ll,
264
})
265
}
266
267
+
blk, err := dbs.Get(context.TODO(), c)
268
if err != nil {
269
return nil, err
270
}
···
274
}
275
}
276
277
+
for _, op := range bs.GetLogMap() {
278
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
279
return nil, err
280
}
281
}
282
283
var blobs []lexutil.LexLink
284
for _, entry := range entries {
285
+
var cids []cid.Cid
286
+
if entry.Cid != "" {
287
+
if err := rm.s.db.Create(&entry, []clause.Expression{clause.OnConflict{
288
+
Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}},
289
+
UpdateAll: true,
290
+
}}).Error; err != nil {
291
+
return nil, err
292
+
}
293
294
+
cids, err = rm.incrementBlobRefs(urepo, entry.Value)
295
+
if err != nil {
296
+
return nil, err
297
+
}
298
+
} else {
299
+
if err := rm.s.db.Delete(&entry, nil).Error; err != nil {
300
+
return nil, err
301
+
}
302
+
cids, err = rm.decrementBlobRefs(urepo, entry.Value)
303
+
if err != nil {
304
+
return nil, err
305
+
}
306
}
307
308
for _, c := range cids {
309
blobs = append(blobs, lexutil.LexLink(c))
310
}
311
}
312
313
rm.s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
···
318
Rev: rev,
319
Since: &urepo.Rev,
320
Commit: lexutil.LexLink(newroot),
321
+
Time: time.Now().Format(time.RFC3339Nano),
322
Ops: ops,
323
TooBig: false,
324
},
325
})
326
327
+
if err := rm.s.UpdateRepo(context.TODO(), urepo.Did, newroot, rev); err != nil {
328
return nil, err
329
}
330
331
+
for i := range results {
332
+
results[i].Type = to.StringPtr(*results[i].Type + "Result")
333
+
results[i].Commit = &RepoCommit{
334
+
Cid: newroot.String(),
335
+
Rev: rev,
336
+
}
337
+
}
338
+
339
return results, nil
340
}
341
···
345
return cid.Undef, nil, err
346
}
347
348
+
dbs := rm.s.getBlockstore(urepo.Did)
349
+
bs := recording_blockstore.New(dbs)
350
351
r, err := repo.OpenRepo(context.TODO(), bs, c)
352
if err != nil {
···
358
return cid.Undef, nil, err
359
}
360
361
+
return c, bs.GetLogArray(), nil
362
}
363
364
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
···
368
}
369
370
for _, c := range cids {
371
+
if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", nil, urepo.Did, c.Bytes()).Error; err != nil {
372
return nil, err
373
}
374
}
···
376
return cids, nil
377
}
378
379
+
func (rm *RepoMan) decrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
380
+
cids, err := getBlobCidsFromCbor(cbor)
381
+
if err != nil {
382
+
return nil, err
383
+
}
384
+
385
+
for _, c := range cids {
386
+
var res struct {
387
+
ID uint
388
+
Count int
389
+
}
390
+
if err := rm.db.Raw("UPDATE blobs SET ref_count = ref_count - 1 WHERE did = ? AND cid = ? RETURNING id, ref_count", nil, urepo.Did, c.Bytes()).Scan(&res).Error; err != nil {
391
+
return nil, err
392
+
}
393
+
394
+
if res.Count == 0 {
395
+
if err := rm.db.Exec("DELETE FROM blobs WHERE id = ?", nil, res.ID).Error; err != nil {
396
+
return nil, err
397
+
}
398
+
if err := rm.db.Exec("DELETE FROM blob_parts WHERE blob_id = ?", nil, res.ID).Error; err != nil {
399
+
return nil, err
400
+
}
401
+
}
402
+
}
403
+
404
+
return cids, nil
405
+
}
406
+
407
// to be honest, we could just store both the cbor and non-cbor in []entries above to avoid an additional
408
// unmarshal here. this will work for now though
409
func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) {
···
414
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
415
}
416
417
+
var deepiter func(any) error
418
+
deepiter = func(item any) error {
419
switch val := item.(type) {
420
+
case map[string]any:
421
if val["$type"] == "blob" {
422
if ref, ok := val["ref"].(string); ok {
423
c, err := cid.Parse(ref)
···
430
return deepiter(v)
431
}
432
}
433
+
case []any:
434
for _, v := range val {
435
deepiter(v)
436
}
+376
-122
server/server.go
+376
-122
server/server.go
···
1
package server
2
3
import (
4
"context"
5
"crypto/ecdsa"
6
"errors"
7
"fmt"
8
"log/slog"
9
"net/http"
10
"os"
11
-
"strings"
12
"time"
13
14
-
"github.com/Azure/go-autorest/autorest/to"
15
"github.com/bluesky-social/indigo/api/atproto"
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
"github.com/bluesky-social/indigo/events"
18
"github.com/bluesky-social/indigo/xrpc"
19
"github.com/go-playground/validator"
20
-
"github.com/golang-jwt/jwt/v4"
21
"github.com/haileyok/cocoon/identity"
22
"github.com/haileyok/cocoon/internal/helpers"
23
"github.com/haileyok/cocoon/models"
24
"github.com/haileyok/cocoon/plc"
25
"github.com/labstack/echo/v4"
26
"github.com/labstack/echo/v4/middleware"
27
-
"github.com/lestrrat-go/jwx/v2/jwk"
28
slogecho "github.com/samber/slog-echo"
29
"gorm.io/driver/sqlite"
30
"gorm.io/gorm"
31
)
32
33
type Server struct {
34
-
httpd *http.Server
35
-
echo *echo.Echo
36
-
db *gorm.DB
37
-
plcClient *plc.Client
38
-
logger *slog.Logger
39
-
config *config
40
-
privateKey *ecdsa.PrivateKey
41
-
repoman *RepoMan
42
-
evtman *events.EventManager
43
-
passport *identity.Passport
44
}
45
46
type Args struct {
···
54
JwkPath string
55
ContactEmail string
56
Relays []string
57
}
58
59
type config struct {
60
-
Version string
61
-
Did string
62
-
Hostname string
63
-
ContactEmail string
64
-
EnforcePeering bool
65
-
Relays []string
66
}
67
68
type CustomValidator struct {
···
93
return nil
94
}
95
96
-
func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
97
-
return func(e echo.Context) error {
98
-
authheader := e.Request().Header.Get("authorization")
99
-
if authheader == "" {
100
-
return e.JSON(401, map[string]string{"error": "Unauthorized"})
101
-
}
102
103
-
pts := strings.Split(authheader, " ")
104
-
if len(pts) != 2 {
105
-
return helpers.ServerError(e, nil)
106
-
}
107
108
-
tokenstr := pts[1]
109
110
-
token, err := new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
111
-
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
112
-
return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
113
-
}
114
-
115
-
return s.privateKey.Public(), nil
116
-
})
117
-
if err != nil {
118
-
s.logger.Error("error parsing jwt", "error", err)
119
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
120
-
}
121
-
122
-
claims, ok := token.Claims.(jwt.MapClaims)
123
-
if !ok || !token.Valid {
124
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
125
-
}
126
-
127
-
isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession"
128
-
scope := claims["scope"].(string)
129
-
130
-
if isRefresh && scope != "com.atproto.refresh" {
131
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
132
-
} else if !isRefresh && scope != "com.atproto.access" {
133
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
134
-
}
135
-
136
-
table := "tokens"
137
-
if isRefresh {
138
-
table = "refresh_tokens"
139
-
}
140
-
141
-
type Result struct {
142
-
Found bool
143
}
144
-
var result Result
145
-
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", tokenstr).Scan(&result).Error; err != nil {
146
-
if err == gorm.ErrRecordNotFound {
147
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
148
-
}
149
-
150
-
s.logger.Error("error getting token from db", "error", err)
151
-
return helpers.ServerError(e, nil)
152
}
153
-
154
-
if !result.Found {
155
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
156
-
}
157
-
158
-
exp, ok := claims["exp"].(float64)
159
-
if !ok {
160
-
s.logger.Error("error getting iat from token")
161
-
return helpers.ServerError(e, nil)
162
-
}
163
-
164
-
if exp < float64(time.Now().UTC().Unix()) {
165
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
166
-
}
167
-
168
-
e.Set("did", claims["sub"])
169
170
-
repo, err := s.getRepoActorByDid(claims["sub"].(string))
171
if err != nil {
172
-
s.logger.Error("error fetching repo", "error", err)
173
-
return helpers.ServerError(e, nil)
174
}
175
-
e.Set("repo", repo)
176
177
-
e.Set("token", tokenstr)
178
179
-
if err := next(e); err != nil {
180
-
e.Error(err)
181
-
}
182
-
183
-
return nil
184
-
}
185
}
186
187
func New(args *Args) (*Server, error) {
···
209
return nil, fmt.Errorf("cocoon hostname must be set")
210
}
211
212
if args.Logger == nil {
213
args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
214
}
215
216
e := echo.New()
217
218
e.Pre(middleware.RemoveTrailingSlash())
219
e.Pre(slogecho.New(args.Logger))
220
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
221
AllowOrigins: []string{"*"},
222
AllowHeaders: []string{"*"},
···
256
httpd := &http.Server{
257
Addr: args.Addr,
258
Handler: e,
259
}
260
261
-
db, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{})
262
if err != nil {
263
return nil, err
264
}
265
266
rkbytes, err := os.ReadFile(args.RotationKeyPath)
267
if err != nil {
268
return nil, err
269
}
270
271
plcClient, err := plc.NewClient(&plc.ClientArgs{
272
Service: "https://plc.directory",
273
PdsHostname: args.Hostname,
274
RotationKey: rkbytes,
···
282
return nil, err
283
}
284
285
-
key, err := jwk.ParseKey(jwkbytes)
286
if err != nil {
287
return nil, err
288
}
···
292
return nil, err
293
}
294
295
s := &Server{
296
httpd: httpd,
297
echo: e,
298
logger: args.Logger,
299
-
db: db,
300
plcClient: plcClient,
301
privateKey: &pkey,
302
config: &config{
303
-
Version: args.Version,
304
-
Did: args.Did,
305
-
Hostname: args.Hostname,
306
-
ContactEmail: args.ContactEmail,
307
-
EnforcePeering: false,
308
-
Relays: args.Relays,
309
},
310
evtman: events.NewEventManager(events.NewMemPersister()),
311
-
passport: identity.NewPassport(identity.NewMemCache(10_000)),
312
}
313
314
s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
315
316
return s, nil
317
}
318
319
func (s *Server) addRoutes() {
320
s.echo.GET("/", s.handleRoot)
321
s.echo.GET("/xrpc/_health", s.handleHealth)
322
s.echo.GET("/.well-known/did.json", s.handleWellKnown)
323
s.echo.GET("/robots.txt", s.handleRobots)
324
325
// public
···
342
s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
343
s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
344
345
// authed
346
-
s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware)
347
-
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware)
348
-
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware)
349
-
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware)
350
351
// repo
352
-
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware)
353
-
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware)
354
-
s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware)
355
-
s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware)
356
357
// stupid silly endpoints
358
-
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware)
359
-
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware)
360
361
-
s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
362
-
s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
363
}
364
365
func (s *Server) Serve(ctx context.Context) error {
···
377
&models.Record{},
378
&models.Blob{},
379
&models.BlobPart{},
380
)
381
382
s.logger.Info("starting cocoon")
···
386
panic(err)
387
}
388
}()
389
390
for _, relay := range s.config.Relays {
391
cli := xrpc.Client{Host: relay}
392
-
atproto.SyncRequestCrawl(context.TODO(), &cli, &atproto.SyncRequestCrawl_Input{
393
Hostname: s.config.Hostname,
394
})
395
}
···
400
401
return nil
402
}
···
1
package server
2
3
import (
4
+
"bytes"
5
"context"
6
"crypto/ecdsa"
7
+
"embed"
8
"errors"
9
"fmt"
10
+
"io"
11
"log/slog"
12
"net/http"
13
+
"net/smtp"
14
"os"
15
+
"path/filepath"
16
+
"sync"
17
+
"text/template"
18
"time"
19
20
+
"github.com/aws/aws-sdk-go/aws"
21
+
"github.com/aws/aws-sdk-go/aws/credentials"
22
+
"github.com/aws/aws-sdk-go/aws/session"
23
+
"github.com/aws/aws-sdk-go/service/s3"
24
"github.com/bluesky-social/indigo/api/atproto"
25
"github.com/bluesky-social/indigo/atproto/syntax"
26
"github.com/bluesky-social/indigo/events"
27
+
"github.com/bluesky-social/indigo/util"
28
"github.com/bluesky-social/indigo/xrpc"
29
+
"github.com/domodwyer/mailyak/v3"
30
"github.com/go-playground/validator"
31
+
"github.com/gorilla/sessions"
32
"github.com/haileyok/cocoon/identity"
33
+
"github.com/haileyok/cocoon/internal/db"
34
"github.com/haileyok/cocoon/internal/helpers"
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"
40
"github.com/haileyok/cocoon/plc"
41
+
"github.com/ipfs/go-cid"
42
+
echo_session "github.com/labstack/echo-contrib/session"
43
"github.com/labstack/echo/v4"
44
"github.com/labstack/echo/v4/middleware"
45
slogecho "github.com/samber/slog-echo"
46
"gorm.io/driver/sqlite"
47
"gorm.io/gorm"
48
)
49
50
+
const (
51
+
AccountSessionMaxAge = 30 * 24 * time.Hour // one week
52
+
)
53
+
54
+
type S3Config struct {
55
+
BackupsEnabled bool
56
+
Endpoint string
57
+
Region string
58
+
Bucket string
59
+
AccessKey string
60
+
SecretKey string
61
+
}
62
+
63
type Server struct {
64
+
http *http.Client
65
+
httpd *http.Server
66
+
mail *mailyak.MailYak
67
+
mailLk *sync.Mutex
68
+
echo *echo.Echo
69
+
db *db.DB
70
+
plcClient *plc.Client
71
+
logger *slog.Logger
72
+
config *config
73
+
privateKey *ecdsa.PrivateKey
74
+
repoman *RepoMan
75
+
oauthProvider *provider.Provider
76
+
evtman *events.EventManager
77
+
passport *identity.Passport
78
+
79
+
dbName string
80
+
s3Config *S3Config
81
}
82
83
type Args struct {
···
91
JwkPath string
92
ContactEmail string
93
Relays []string
94
+
AdminPassword string
95
+
96
+
SmtpUser string
97
+
SmtpPass string
98
+
SmtpHost string
99
+
SmtpPort string
100
+
SmtpEmail string
101
+
SmtpName string
102
+
103
+
S3Config *S3Config
104
+
105
+
SessionSecret string
106
+
107
+
DefaultAtprotoProxy string
108
+
109
+
BlockstoreVariant BlockstoreVariant
110
}
111
112
type config struct {
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
124
}
125
126
type CustomValidator struct {
···
151
return nil
152
}
153
154
+
//go:embed templates/*
155
+
var templateFS embed.FS
156
157
+
//go:embed static/*
158
+
var staticFS embed.FS
159
160
+
type TemplateRenderer struct {
161
+
templates *template.Template
162
+
isDev bool
163
+
templatePath string
164
+
}
165
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,
174
}
175
+
} else {
176
+
tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))
177
+
s.echo.Renderer = &TemplateRenderer{
178
+
templates: tmpl,
179
+
isDev: false,
180
}
181
+
}
182
+
}
183
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)
187
if err != nil {
188
+
return err
189
}
190
+
t.templates = tmpl
191
+
}
192
193
+
if viewContext, isMap := data.(map[string]any); isMap {
194
+
viewContext["reverse"] = c.Echo().Reverse
195
+
}
196
197
+
return t.templates.ExecuteTemplate(w, name, data)
198
}
199
200
func New(args *Args) (*Server, error) {
···
222
return nil, fmt.Errorf("cocoon hostname must be set")
223
}
224
225
+
if args.AdminPassword == "" {
226
+
return nil, fmt.Errorf("admin password must be set")
227
+
}
228
+
229
if args.Logger == nil {
230
args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
231
}
232
233
+
if args.SessionSecret == "" {
234
+
panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ")
235
+
}
236
+
237
e := echo.New()
238
239
e.Pre(middleware.RemoveTrailingSlash())
240
e.Pre(slogecho.New(args.Logger))
241
+
e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret))))
242
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
243
AllowOrigins: []string{"*"},
244
AllowHeaders: []string{"*"},
···
278
httpd := &http.Server{
279
Addr: args.Addr,
280
Handler: e,
281
+
// shitty defaults but okay for now, needed for import repo
282
+
ReadTimeout: 5 * time.Minute,
283
+
WriteTimeout: 5 * time.Minute,
284
+
IdleTimeout: 5 * time.Minute,
285
}
286
287
+
gdb, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{})
288
if err != nil {
289
return nil, err
290
}
291
+
dbw := db.NewDB(gdb)
292
293
rkbytes, err := os.ReadFile(args.RotationKeyPath)
294
if err != nil {
295
return nil, err
296
}
297
298
+
h := util.RobustHTTPClient()
299
+
300
plcClient, err := plc.NewClient(&plc.ClientArgs{
301
+
H: h,
302
Service: "https://plc.directory",
303
PdsHostname: args.Hostname,
304
RotationKey: rkbytes,
···
312
return nil, err
313
}
314
315
+
key, err := helpers.ParseJWKFromBytes(jwkbytes)
316
if err != nil {
317
return nil, err
318
}
···
322
return nil, err
323
}
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
+
337
s := &Server{
338
+
http: h,
339
httpd: httpd,
340
echo: e,
341
logger: args.Logger,
342
+
db: dbw,
343
plcClient: plcClient,
344
privateKey: &pkey,
345
config: &config{
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,
357
},
358
evtman: events.NewEventManager(events.NewMemPersister()),
359
+
passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
360
+
361
+
dbName: args.DbName,
362
+
s3Config: args.S3Config,
363
+
364
+
oauthProvider: provider.NewProvider(provider.Args{
365
+
Hostname: args.Hostname,
366
+
ClientManagerArgs: client.ManagerArgs{
367
+
Cli: oauthCli,
368
+
Logger: args.Logger,
369
+
},
370
+
DpopManagerArgs: dpop.ManagerArgs{
371
+
NonceSecret: nonceSecret,
372
+
NonceRotationInterval: constants.NonceMaxRotationInterval / 3,
373
+
OnNonceSecretCreated: func(newNonce []byte) {
374
+
if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil {
375
+
args.Logger.Error("error writing new nonce secret", "error", err)
376
+
}
377
+
},
378
+
Logger: args.Logger,
379
+
Hostname: args.Hostname,
380
+
},
381
+
}),
382
}
383
384
+
s.loadTemplates()
385
+
386
s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
387
388
+
// TODO: should validate these args
389
+
if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" {
390
+
args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.")
391
+
} else {
392
+
mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost))
393
+
mail.From(s.config.SmtpEmail)
394
+
mail.FromName(s.config.SmtpName)
395
+
396
+
s.mail = mail
397
+
s.mailLk = &sync.Mutex{}
398
+
}
399
+
400
return s, nil
401
}
402
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
+
411
+
// random stuff
412
s.echo.GET("/", s.handleRoot)
413
s.echo.GET("/xrpc/_health", s.handleHealth)
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)
417
s.echo.GET("/robots.txt", s.handleRobots)
418
419
// public
···
436
s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
437
s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
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
+
455
// authed
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)
462
+
s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE
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)
468
469
// repo
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)
476
477
// stupid silly endpoints
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)
480
+
481
+
// admin routes
482
+
s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware)
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)
488
}
489
490
func (s *Server) Serve(ctx context.Context) error {
···
502
&models.Record{},
503
&models.Blob{},
504
&models.BlobPart{},
505
+
&provider.OauthToken{},
506
+
&provider.OauthAuthorizationRequest{},
507
)
508
509
s.logger.Info("starting cocoon")
···
513
panic(err)
514
}
515
}()
516
+
517
+
go s.backupRoutine()
518
519
for _, relay := range s.config.Relays {
520
cli := xrpc.Client{Host: relay}
521
+
atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{
522
Hostname: s.config.Hostname,
523
})
524
}
···
529
530
return nil
531
}
532
+
533
+
func (s *Server) doBackup() {
534
+
start := time.Now()
535
+
536
+
s.logger.Info("beginning backup to s3...")
537
+
538
+
var buf bytes.Buffer
539
+
if err := func() error {
540
+
s.logger.Info("reading database bytes...")
541
+
s.db.Lock()
542
+
defer s.db.Unlock()
543
+
544
+
sf, err := os.Open(s.dbName)
545
+
if err != nil {
546
+
return fmt.Errorf("error opening database for backup: %w", err)
547
+
}
548
+
defer sf.Close()
549
+
550
+
if _, err := io.Copy(&buf, sf); err != nil {
551
+
return fmt.Errorf("error reading bytes of backup db: %w", err)
552
+
}
553
+
554
+
return nil
555
+
}(); err != nil {
556
+
s.logger.Error("error backing up database", "error", err)
557
+
return
558
+
}
559
+
560
+
if err := func() error {
561
+
s.logger.Info("sending to s3...")
562
+
563
+
currTime := time.Now().Format("2006-01-02_15-04-05")
564
+
key := "cocoon-backup-" + currTime + ".db"
565
+
566
+
config := &aws.Config{
567
+
Region: aws.String(s.s3Config.Region),
568
+
Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""),
569
+
}
570
+
571
+
if s.s3Config.Endpoint != "" {
572
+
config.Endpoint = aws.String(s.s3Config.Endpoint)
573
+
config.S3ForcePathStyle = aws.Bool(true)
574
+
}
575
+
576
+
sess, err := session.NewSession(config)
577
+
if err != nil {
578
+
return err
579
+
}
580
+
581
+
svc := s3.New(sess)
582
+
583
+
if _, err := svc.PutObject(&s3.PutObjectInput{
584
+
Bucket: aws.String(s.s3Config.Bucket),
585
+
Key: aws.String(key),
586
+
Body: bytes.NewReader(buf.Bytes()),
587
+
}); err != nil {
588
+
return fmt.Errorf("error uploading file to s3: %w", err)
589
+
}
590
+
591
+
s.logger.Info("finished uploading backup to s3", "key", key, "duration", time.Now().Sub(start).Seconds())
592
+
593
+
return nil
594
+
}(); err != nil {
595
+
s.logger.Error("error uploading database backup", "error", err)
596
+
return
597
+
}
598
+
599
+
os.WriteFile("last-backup.txt", []byte(time.Now().String()), 0644)
600
+
}
601
+
602
+
func (s *Server) backupRoutine() {
603
+
if s.s3Config == nil || !s.s3Config.BackupsEnabled {
604
+
return
605
+
}
606
+
607
+
if s.s3Config.Region == "" {
608
+
s.logger.Warn("no s3 region configured but backups are enabled. backups will not run.")
609
+
return
610
+
}
611
+
612
+
if s.s3Config.Bucket == "" {
613
+
s.logger.Warn("no s3 bucket configured but backups are enabled. backups will not run.")
614
+
return
615
+
}
616
+
617
+
if s.s3Config.AccessKey == "" {
618
+
s.logger.Warn("no s3 access key configured but backups are enabled. backups will not run.")
619
+
return
620
+
}
621
+
622
+
if s.s3Config.SecretKey == "" {
623
+
s.logger.Warn("no s3 secret key configured but backups are enabled. backups will not run.")
624
+
return
625
+
}
626
+
627
+
shouldBackupNow := false
628
+
lastBackupStr, err := os.ReadFile("last-backup.txt")
629
+
if err != nil {
630
+
shouldBackupNow = true
631
+
} else {
632
+
lastBackup, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(lastBackupStr))
633
+
if err != nil {
634
+
shouldBackupNow = true
635
+
} else if time.Now().Sub(lastBackup).Seconds() > 3600 {
636
+
shouldBackupNow = true
637
+
}
638
+
}
639
+
640
+
if shouldBackupNow {
641
+
go s.doBackup()
642
+
}
643
+
644
+
ticker := time.NewTicker(time.Hour)
645
+
for range ticker.C {
646
+
go s.doBackup()
647
+
}
648
+
}
649
+
650
+
func (s *Server) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error {
651
+
if err := s.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil {
652
+
return err
653
+
}
654
+
655
+
return nil
656
+
}
+2
-2
server/session.go
+2
-2
server/session.go
+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
+
}