+63
-59
README.md
+63
-59
README.md
···
5
5
6
6
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
7
7
8
-
### Impmlemented Endpoints
8
+
## Implemented Endpoints
9
9
10
10
> [!NOTE]
11
-
Just because something is implemented doesn't mean it is finisehd. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that.
11
+
Just because something is implemented doesn't mean it is finished. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that.
12
12
13
-
#### Identity
14
-
- [ ] com.atproto.identity.getRecommendedDidCredentials
15
-
- [ ] com.atproto.identity.requestPlcOperationSignature
16
-
- [x] com.atproto.identity.resolveHandle
17
-
- [ ] com.atproto.identity.signPlcOperation
18
-
- [ ] com.atproto.identity.submitPlcOperatioin
19
-
- [x] com.atproto.identity.updateHandle
13
+
### Identity
20
14
21
-
#### Repo
22
-
- [x] com.atproto.repo.applyWrites
23
-
- [x] com.atproto.repo.createRecord
24
-
- [x] com.atproto.repo.putRecord
25
-
- [x] com.atproto.repo.deleteRecord
26
-
- [x] com.atproto.repo.describeRepo
27
-
- [x] com.atproto.repo.getRecord
28
-
- [ ] com.atproto.repo.importRepo
29
-
- [x] com.atproto.repo.listRecords
30
-
- [ ] com.atproto.repo.listMissingBlobs
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`
31
21
32
-
#### Server
33
-
- [ ] com.atproto.server.activateAccount
34
-
- [x] com.atproto.server.checkAccountStatus
35
-
- [x] com.atproto.server.confirmEmail
36
-
- [x] com.atproto.server.createAccount
37
-
- [x] com.atproto.server.createInviteCode
38
-
- [x] com.atproto.server.createInviteCodes
39
-
- [ ] com.atproto.server.deactivateAccount
40
-
- [ ] com.atproto.server.deleteAccount
41
-
- [x] com.atproto.server.deleteSession
42
-
- [x] com.atproto.server.describeServer
43
-
- [ ] com.atproto.server.getAccountInviteCodes
44
-
- [ ] com.atproto.server.getServiceAuth
45
-
- ~[ ] com.atproto.server.listAppPasswords~ - not going to add app passwords
46
-
- [x] com.atproto.server.refreshSession
47
-
- [ ] com.atproto.server.requestAccountDelete
48
-
- [x] com.atproto.server.requestEmailConfirmation
49
-
- [x] com.atproto.server.requestEmailUpdate
50
-
- [x] com.atproto.server.requestPasswordReset
51
-
- [ ] com.atproto.server.reserveSigningKey
52
-
- [x] com.atproto.server.resetPassword
53
-
- ~[ ] com.atproto.server.revokeAppPassword~ - not going to add app passwords
54
-
- [x] com.atproto.server.updateEmail
22
+
### Repo
55
23
56
-
#### Sync
57
-
- [x] com.atproto.sync.getBlob
58
-
- [x] com.atproto.sync.getBlocks
59
-
- [x] com.atproto.sync.getLatestCommit
60
-
- [x] com.atproto.sync.getRecord
61
-
- [x] com.atproto.sync.getRepoStatus
62
-
- [x] com.atproto.sync.getRepo
63
-
- [x] com.atproto.sync.listBlobs
64
-
- [x] com.atproto.sync.listRepos
65
-
- ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol
66
-
- [x] com.atproto.sync.requestCrawl
67
-
- [x] com.atproto.sync.subscribeRepos
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`
68
58
69
-
#### Other
70
-
- [ ] com.atproto.label.queryLabels
71
-
- [ ] com.atproto.moderation.createReport
72
-
- [x] app.bsky.actor.getPreferences
73
-
- [x] app.bsky.actor.putPreferences
59
+
### Sync
74
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`
75
79
76
80
## License
77
81
-163
blockstore/blockstore.go
-163
blockstore/blockstore.go
···
1
-
package blockstore
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
7
-
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
"github.com/haileyok/cocoon/internal/db"
9
-
"github.com/haileyok/cocoon/models"
10
-
blocks "github.com/ipfs/go-block-format"
11
-
"github.com/ipfs/go-cid"
12
-
"gorm.io/gorm/clause"
13
-
)
14
-
15
-
type SqliteBlockstore struct {
16
-
db *db.DB
17
-
did string
18
-
readonly bool
19
-
inserts map[cid.Cid]blocks.Block
20
-
}
21
-
22
-
func New(did string, db *db.DB) *SqliteBlockstore {
23
-
return &SqliteBlockstore{
24
-
did: did,
25
-
db: db,
26
-
readonly: false,
27
-
inserts: map[cid.Cid]blocks.Block{},
28
-
}
29
-
}
30
-
31
-
func NewReadOnly(did string, db *db.DB) *SqliteBlockstore {
32
-
return &SqliteBlockstore{
33
-
did: did,
34
-
db: db,
35
-
readonly: true,
36
-
inserts: map[cid.Cid]blocks.Block{},
37
-
}
38
-
}
39
-
40
-
func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) {
41
-
var block models.Block
42
-
43
-
maybeBlock, ok := bs.inserts[cid]
44
-
if ok {
45
-
return maybeBlock, nil
46
-
}
47
-
48
-
if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", nil, bs.did, cid.Bytes()).Scan(&block).Error; err != nil {
49
-
return nil, err
50
-
}
51
-
52
-
b, err := blocks.NewBlockWithCid(block.Value, cid)
53
-
if err != nil {
54
-
return nil, err
55
-
}
56
-
57
-
return b, nil
58
-
}
59
-
60
-
func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error {
61
-
bs.inserts[block.Cid()] = block
62
-
63
-
if bs.readonly {
64
-
return nil
65
-
}
66
-
67
-
b := models.Block{
68
-
Did: bs.did,
69
-
Cid: block.Cid().Bytes(),
70
-
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
71
-
Value: block.RawData(),
72
-
}
73
-
74
-
if err := bs.db.Create(&b, []clause.Expression{clause.OnConflict{
75
-
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
76
-
UpdateAll: true,
77
-
}}).Error; err != nil {
78
-
return err
79
-
}
80
-
81
-
return nil
82
-
}
83
-
84
-
func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error {
85
-
panic("not implemented")
86
-
}
87
-
88
-
func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) {
89
-
panic("not implemented")
90
-
}
91
-
92
-
func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) {
93
-
panic("not implemented")
94
-
}
95
-
96
-
func (bs *SqliteBlockstore) PutMany(ctx context.Context, blocks []blocks.Block) error {
97
-
tx := bs.db.BeginDangerously()
98
-
99
-
for _, block := range blocks {
100
-
bs.inserts[block.Cid()] = block
101
-
102
-
if bs.readonly {
103
-
continue
104
-
}
105
-
106
-
b := models.Block{
107
-
Did: bs.did,
108
-
Cid: block.Cid().Bytes(),
109
-
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
110
-
Value: block.RawData(),
111
-
}
112
-
113
-
if err := tx.Clauses(clause.OnConflict{
114
-
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
115
-
UpdateAll: true,
116
-
}).Create(&b).Error; err != nil {
117
-
tx.Rollback()
118
-
return err
119
-
}
120
-
}
121
-
122
-
if bs.readonly {
123
-
return nil
124
-
}
125
-
126
-
tx.Commit()
127
-
128
-
return nil
129
-
}
130
-
131
-
func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
132
-
panic("not implemented")
133
-
}
134
-
135
-
func (bs *SqliteBlockstore) HashOnRead(enabled bool) {
136
-
panic("not implemented")
137
-
}
138
-
139
-
func (bs *SqliteBlockstore) UpdateRepo(ctx context.Context, root cid.Cid, rev string) error {
140
-
if err := bs.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, bs.did).Error; err != nil {
141
-
return err
142
-
}
143
-
144
-
return nil
145
-
}
146
-
147
-
func (bs *SqliteBlockstore) Execute(ctx context.Context) error {
148
-
if !bs.readonly {
149
-
return fmt.Errorf("blockstore was not readonly")
150
-
}
151
-
152
-
bs.readonly = false
153
-
for _, b := range bs.inserts {
154
-
bs.Put(ctx, b)
155
-
}
156
-
bs.readonly = true
157
-
158
-
return nil
159
-
}
160
-
161
-
func (bs *SqliteBlockstore) GetLog() map[cid.Cid]blocks.Block {
162
-
return bs.inserts
163
-
}
+14
-1
cmd/cocoon/main.go
+14
-1
cmd/cocoon/main.go
···
131
131
Name: "session-secret",
132
132
EnvVars: []string{"COCOON_SESSION_SECRET"},
133
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
+
},
134
144
},
135
145
Commands: []*cli.Command{
136
146
runServe,
···
153
163
Usage: "Start the cocoon PDS",
154
164
Flags: []cli.Flag{},
155
165
Action: func(cmd *cli.Context) error {
166
+
156
167
s, err := server.New(&server.Args{
157
168
Addr: cmd.String("addr"),
158
169
DbName: cmd.String("db-name"),
···
178
189
AccessKey: cmd.String("s3-access-key"),
179
190
SecretKey: cmd.String("s3-secret-key"),
180
191
},
181
-
SessionSecret: cmd.String("session-secret"),
192
+
SessionSecret: cmd.String("session-secret"),
193
+
DefaultAtprotoProxy: cmd.String("default-atproto-proxy"),
194
+
BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
182
195
})
183
196
if err != nil {
184
197
fmt.Printf("error creating cocoon: %v", err)
+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
+
}
+1
-2
go.mod
+1
-2
go.mod
···
14
14
github.com/google/uuid v1.4.0
15
15
github.com/gorilla/sessions v1.4.0
16
16
github.com/gorilla/websocket v1.5.1
17
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
17
18
github.com/hashicorp/golang-lru/v2 v2.0.7
18
19
github.com/ipfs/go-block-format v0.2.0
19
20
github.com/ipfs/go-cid v0.4.1
···
24
25
github.com/labstack/echo/v4 v4.13.3
25
26
github.com/lestrrat-go/jwx/v2 v2.0.12
26
27
github.com/multiformats/go-multihash v0.2.3
27
-
github.com/pquerna/otp v1.5.0
28
28
github.com/samber/slog-echo v1.16.1
29
29
github.com/urfave/cli/v2 v2.27.6
30
30
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
···
38
38
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
39
39
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
40
40
github.com/beorn7/perks v1.0.1 // indirect
41
-
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
42
41
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
43
42
github.com/cespare/xxhash/v2 v2.3.0 // indirect
44
43
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+2
-4
go.sum
+2
-4
go.sum
···
20
20
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY=
21
21
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
22
22
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
23
-
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
24
-
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
25
23
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
26
24
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
27
25
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
···
93
91
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
94
92
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
95
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
96
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
97
97
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
98
98
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
291
291
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
292
292
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
293
293
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
294
-
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
295
-
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
296
294
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
297
295
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
298
296
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+73
-54
identity/identity.go
+73
-54
identity/identity.go
···
13
13
"github.com/bluesky-social/indigo/util"
14
14
)
15
15
16
-
func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) {
17
-
if cli == nil {
18
-
cli = util.RobustHTTPClient()
19
-
}
20
-
21
-
var did string
22
-
23
-
_, err := syntax.ParseHandle(handle)
16
+
func ResolveHandleFromTXT(ctx context.Context, handle string) (string, error) {
17
+
name := fmt.Sprintf("_atproto.%s", handle)
18
+
recs, err := net.LookupTXT(name)
24
19
if err != nil {
25
-
return "", err
20
+
return "", fmt.Errorf("handle could not be resolved via txt: %w", err)
26
21
}
27
22
28
-
recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle))
29
-
if err == nil {
30
-
for _, rec := range recs {
31
-
if strings.HasPrefix(rec, "did=") {
32
-
did = strings.Split(rec, "did=")[1]
33
-
break
23
+
for _, rec := range recs {
24
+
if strings.HasPrefix(rec, "did=") {
25
+
maybeDid := strings.Split(rec, "did=")[1]
26
+
if _, err := syntax.ParseDID(maybeDid); err == nil {
27
+
return maybeDid, nil
34
28
}
35
29
}
36
-
} else {
37
-
fmt.Printf("erorr getting txt records: %v\n", err)
38
30
}
39
31
40
-
if did == "" {
41
-
req, err := http.NewRequestWithContext(
42
-
ctx,
43
-
"GET",
44
-
fmt.Sprintf("https://%s/.well-known/atproto-did", handle),
45
-
nil,
46
-
)
47
-
if err != nil {
48
-
return "", nil
49
-
}
32
+
return "", fmt.Errorf("handle could not be resolved via txt: no record found")
33
+
}
50
34
51
-
resp, err := http.DefaultClient.Do(req)
52
-
if err != nil {
53
-
return "", nil
54
-
}
55
-
defer resp.Body.Close()
35
+
func ResolveHandleFromWellKnown(ctx context.Context, cli *http.Client, handle string) (string, error) {
36
+
ustr := fmt.Sprintf("https://%s/.well=known/atproto-did", handle)
37
+
req, err := http.NewRequestWithContext(
38
+
ctx,
39
+
"GET",
40
+
ustr,
41
+
nil,
42
+
)
43
+
if err != nil {
44
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
45
+
}
56
46
57
-
if resp.StatusCode != http.StatusOK {
58
-
io.Copy(io.Discard, resp.Body)
59
-
return "", fmt.Errorf("unable to resolve handle")
60
-
}
47
+
resp, err := cli.Do(req)
48
+
if err != nil {
49
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
50
+
}
51
+
defer resp.Body.Close()
61
52
62
-
b, err := io.ReadAll(resp.Body)
63
-
if err != nil {
64
-
return "", err
65
-
}
53
+
b, err := io.ReadAll(resp.Body)
54
+
if err != nil {
55
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
56
+
}
66
57
67
-
maybeDid := string(b)
58
+
if resp.StatusCode != http.StatusOK {
59
+
return "", fmt.Errorf("handle could not be resolved via web: invalid status code %d", resp.StatusCode)
60
+
}
68
61
69
-
if _, err := syntax.ParseDID(maybeDid); err != nil {
70
-
return "", fmt.Errorf("unable to resolve handle")
71
-
}
62
+
maybeDid := string(b)
72
63
73
-
did = maybeDid
64
+
if _, err := syntax.ParseDID(maybeDid); err != nil {
65
+
return "", fmt.Errorf("handle could not be resolved via web: invalid did in document")
74
66
}
75
67
76
-
return did, nil
68
+
return maybeDid, nil
77
69
}
78
70
79
-
func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) {
71
+
func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) {
80
72
if cli == nil {
81
73
cli = util.RobustHTTPClient()
82
74
}
83
75
84
-
var ustr string
76
+
_, err := syntax.ParseHandle(handle)
77
+
if err != nil {
78
+
return "", err
79
+
}
80
+
81
+
if maybeDidFromTxt, err := ResolveHandleFromTXT(ctx, handle); err == nil {
82
+
return maybeDidFromTxt, nil
83
+
}
84
+
85
+
if maybeDidFromWeb, err := ResolveHandleFromWellKnown(ctx, cli, handle); err == nil {
86
+
return maybeDidFromWeb, nil
87
+
}
88
+
89
+
return "", fmt.Errorf("handle could not be resolved")
90
+
}
91
+
92
+
func DidToDocUrl(did string) (string, error) {
85
93
if strings.HasPrefix(did, "did:plc:") {
86
-
ustr = fmt.Sprintf("https://plc.directory/%s", did)
94
+
return fmt.Sprintf("https://plc.directory/%s", did), nil
87
95
} else if strings.HasPrefix(did, "did:web:") {
88
-
ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:"))
96
+
return fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")), nil
89
97
} else {
90
-
return nil, fmt.Errorf("did was not a supported did type")
98
+
return "", fmt.Errorf("did was not a supported did type")
99
+
}
100
+
}
101
+
102
+
func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) {
103
+
if cli == nil {
104
+
cli = util.RobustHTTPClient()
105
+
}
106
+
107
+
ustr, err := DidToDocUrl(did)
108
+
if err != nil {
109
+
return nil, err
91
110
}
92
111
93
112
req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
···
95
114
return nil, err
96
115
}
97
116
98
-
resp, err := http.DefaultClient.Do(req)
117
+
resp, err := cli.Do(req)
99
118
if err != nil {
100
119
return nil, err
101
120
}
···
103
122
104
123
if resp.StatusCode != 200 {
105
124
io.Copy(io.Discard, resp.Body)
106
-
return nil, fmt.Errorf("could not find identity in plc registry")
125
+
return nil, fmt.Errorf("unable to find did doc at url. did: %s. url: %s", did, ustr)
107
126
}
108
127
109
128
var diddoc DidDoc
···
127
146
return nil, err
128
147
}
129
148
130
-
resp, err := http.DefaultClient.Do(req)
149
+
resp, err := cli.Do(req)
131
150
if err != nil {
132
151
return nil, err
133
152
}
+16
-5
identity/passport.go
+16
-5
identity/passport.go
···
19
19
type Passport struct {
20
20
h *http.Client
21
21
bc BackingCache
22
-
lk sync.Mutex
22
+
mu sync.RWMutex
23
23
}
24
24
25
25
func NewPassport(h *http.Client, bc BackingCache) *Passport {
···
30
30
return &Passport{
31
31
h: h,
32
32
bc: bc,
33
-
lk: sync.Mutex{},
34
33
}
35
34
}
36
35
···
38
37
skipCache, _ := ctx.Value("skip-cache").(bool)
39
38
40
39
if !skipCache {
40
+
p.mu.RLock()
41
41
cached, ok := p.bc.GetDoc(did)
42
+
p.mu.RUnlock()
43
+
42
44
if ok {
43
45
return cached, nil
44
46
}
45
47
}
46
48
47
-
p.lk.Lock() // this is pretty pathetic, and i should rethink this. but for now, fuck it
48
-
defer p.lk.Unlock()
49
-
49
+
// TODO: should coalesce requests here
50
50
doc, err := FetchDidDoc(ctx, p.h, did)
51
51
if err != nil {
52
52
return nil, err
53
53
}
54
54
55
+
p.mu.Lock()
55
56
p.bc.PutDoc(did, doc)
57
+
p.mu.Unlock()
56
58
57
59
return doc, nil
58
60
}
···
61
63
skipCache, _ := ctx.Value("skip-cache").(bool)
62
64
63
65
if !skipCache {
66
+
p.mu.RLock()
64
67
cached, ok := p.bc.GetDid(handle)
68
+
p.mu.RUnlock()
69
+
65
70
if ok {
66
71
return cached, nil
67
72
}
···
72
77
return "", err
73
78
}
74
79
80
+
p.mu.Lock()
75
81
p.bc.PutDid(handle, did)
82
+
p.mu.Unlock()
76
83
77
84
return did, nil
78
85
}
79
86
80
87
func (p *Passport) BustDoc(ctx context.Context, did string) error {
88
+
p.mu.Lock()
89
+
defer p.mu.Unlock()
81
90
return p.bc.BustDoc(did)
82
91
}
83
92
84
93
func (p *Passport) BustDid(ctx context.Context, handle string) error {
94
+
p.mu.Lock()
95
+
defer p.mu.Unlock()
85
96
return p.bc.BustDid(handle)
86
97
}
+13
internal/helpers/helpers.go
+13
internal/helpers/helpers.go
···
7
7
"math/rand"
8
8
"net/url"
9
9
10
+
"github.com/Azure/go-autorest/autorest/to"
10
11
"github.com/labstack/echo/v4"
11
12
"github.com/lestrrat-go/jwx/v2/jwk"
12
13
)
···
29
30
msg += ". " + *suffix
30
31
}
31
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
+
})
32
45
}
33
46
34
47
func genericError(e echo.Context, code int, msg string) error {
-9
models/models.go
-9
models/models.go
···
7
7
"github.com/bluesky-social/indigo/atproto/crypto"
8
8
)
9
9
10
-
type TwoFactorType string
11
-
12
-
var (
13
-
TwoFactorTypeNone = TwoFactorType("none")
14
-
TwoFactorTypeTotp = TwoFactorType("totp")
15
-
)
16
-
17
10
type Repo struct {
18
11
Did string `gorm:"primaryKey"`
19
12
CreatedAt time.Time
···
30
23
Rev string
31
24
Root []byte
32
25
Preferences []byte
33
-
TwoFactorType TwoFactorType `gorm:"default:none"`
34
-
TotpSecret *string
35
26
}
36
27
37
28
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+1
-1
oauth/client/manager.go
+1
-1
oauth/client/manager.go
···
289
289
return nil, errors.New("at least one `redirect_uri` is required")
290
290
}
291
291
292
-
if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod == "none" {
292
+
if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod != "none" {
293
293
return nil, errors.New("native clients must authenticate using `none` method")
294
294
}
295
295
+32
oauth/helpers.go
+32
oauth/helpers.go
···
4
4
"errors"
5
5
"fmt"
6
6
"net/url"
7
+
"time"
7
8
8
9
"github.com/haileyok/cocoon/internal/helpers"
9
10
"github.com/haileyok/cocoon/oauth/constants"
11
+
"github.com/haileyok/cocoon/oauth/provider"
10
12
)
11
13
12
14
func GenerateCode() string {
···
46
48
47
49
return reqId, nil
48
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
+
}
+2
oauth/provider/models.go
+2
oauth/provider/models.go
+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
+
}
+37
-7
server/handle_account.go
+37
-7
server/handle_account.go
···
3
3
import (
4
4
"time"
5
5
6
+
"github.com/haileyok/cocoon/oauth"
7
+
"github.com/haileyok/cocoon/oauth/constants"
6
8
"github.com/haileyok/cocoon/oauth/provider"
9
+
"github.com/hako/durafmt"
7
10
"github.com/labstack/echo/v4"
8
11
)
9
12
10
13
func (s *Server) handleAccount(e echo.Context) error {
14
+
ctx := e.Request().Context()
11
15
repo, sess, err := s.getSessionRepoOrErr(e)
12
16
if err != nil {
13
17
return e.Redirect(303, "/account/signin")
14
18
}
15
19
16
-
now := time.Now()
20
+
oldestPossibleSession := time.Now().Add(constants.ConfidentialClientSessionLifetime)
17
21
18
22
var tokens []provider.OauthToken
19
-
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE sub = ? AND expires_at >= ? ORDER BY created_at ASC", nil, repo.Repo.Did, now).Scan(&tokens).Error; err != nil {
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 {
20
24
s.logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err)
21
25
sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error")
22
26
sess.Save(e.Request(), e.Response())
···
25
29
})
26
30
}
27
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
+
28
43
tokenInfo := []map[string]string{}
29
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
+
30
59
tokenInfo = append(tokenInfo, map[string]string{
31
-
"ClientId": t.ClientId,
32
-
"CreatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
33
-
"UpdatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
34
-
"ExpiresAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
35
-
"Token": t.Token,
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,
36
66
})
37
67
}
38
68
-99
server/handle_account_totp_enroll.go
-99
server/handle_account_totp_enroll.go
···
1
-
package server
2
-
3
-
import (
4
-
"bytes"
5
-
"encoding/base64"
6
-
"fmt"
7
-
"image/png"
8
-
9
-
"github.com/haileyok/cocoon/internal/helpers"
10
-
"github.com/haileyok/cocoon/models"
11
-
"github.com/labstack/echo/v4"
12
-
"github.com/pquerna/otp/totp"
13
-
)
14
-
15
-
func (s *Server) handleAccountTotpEnrollGet(e echo.Context) error {
16
-
urepo, sess, err := s.getSessionRepoOrErr(e)
17
-
if err != nil {
18
-
return e.Redirect(303, "/account/signin")
19
-
}
20
-
21
-
if urepo.TwoFactorType == models.TwoFactorTypeTotp {
22
-
sess.AddFlash("You have already enabled TOTP", "error")
23
-
sess.Save(e.Request(), e.Response())
24
-
return e.Redirect(303, "/account")
25
-
} else if urepo.TwoFactorType != models.TwoFactorTypeNone {
26
-
sess.AddFlash("You have already have another 2FA method enabled", "error")
27
-
sess.Save(e.Request(), e.Response())
28
-
return e.Redirect(303, "/account")
29
-
}
30
-
31
-
secret, err := totp.Generate(totp.GenerateOpts{
32
-
Issuer: s.config.Hostname,
33
-
AccountName: urepo.Repo.Did,
34
-
})
35
-
if err != nil {
36
-
s.logger.Error("error generating totp secret", "error", err)
37
-
return helpers.ServerError(e, nil)
38
-
}
39
-
40
-
sess.Values["totp-secret"] = secret.String()
41
-
if err := sess.Save(e.Request(), e.Response()); err != nil {
42
-
s.logger.Error("error saving session", "error", err)
43
-
return helpers.ServerError(e, nil)
44
-
}
45
-
46
-
var buf bytes.Buffer
47
-
img, err := secret.Image(200, 200)
48
-
if err != nil {
49
-
s.logger.Error("error generating image from secret", "error", err)
50
-
return helpers.ServerError(e, nil)
51
-
}
52
-
png.Encode(&buf, img)
53
-
54
-
b64img := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
55
-
56
-
return e.Render(200, "totp_enroll.html", map[string]any{
57
-
"flashes": getFlashesFromSession(e, sess),
58
-
"Image": b64img,
59
-
})
60
-
}
61
-
62
-
type TotpEnrollRequest struct {
63
-
Code string `form:"code"`
64
-
}
65
-
66
-
func (s *Server) handleAccountTotpEnrollPost(e echo.Context) error {
67
-
urepo, sess, err := s.getSessionRepoOrErr(e)
68
-
if err != nil {
69
-
return e.Redirect(303, "/account/signin")
70
-
}
71
-
72
-
var req TotpEnrollRequest
73
-
if err := e.Bind(&req); err != nil {
74
-
s.logger.Error("error binding request for enroll totp", "error", err)
75
-
return helpers.ServerError(e, nil)
76
-
}
77
-
78
-
secret, ok := sess.Values["totp-secret"].(string)
79
-
if !ok {
80
-
return helpers.InputError(e, nil)
81
-
}
82
-
83
-
if !totp.Validate(req.Code, secret) {
84
-
sess.AddFlash("The provided code was not valid.", "error")
85
-
sess.Save(e.Request(), e.Response())
86
-
return e.Redirect(303, "/account/totp-enroll")
87
-
}
88
-
89
-
if err := s.db.Exec("UPDATE repos SET two_factor_type = ?, totp_secret = ? WHERE did = ?", nil, models.TwoFactorTypeTotp, secret, urepo.Repo.Did).Error; err != nil {
90
-
s.logger.Error("error updating database with totp token", "error", err)
91
-
return helpers.ServerError(e, nil)
92
-
}
93
-
94
-
sess.AddFlash("You have successfully enrolled in TOTP!", "success")
95
-
delete(sess.Values, "totp-secret")
96
-
sess.Save(e.Request(), e.Response())
97
-
98
-
return e.Redirect(303, "/account")
99
-
}
+2
-3
server/handle_import_repo.go
+2
-3
server/handle_import_repo.go
···
9
9
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
"github.com/bluesky-social/indigo/repo"
12
-
"github.com/haileyok/cocoon/blockstore"
13
12
"github.com/haileyok/cocoon/internal/helpers"
14
13
"github.com/haileyok/cocoon/models"
15
14
blocks "github.com/ipfs/go-block-format"
···
27
26
return helpers.ServerError(e, nil)
28
27
}
29
28
30
-
bs := blockstore.New(urepo.Repo.Did, s.db)
29
+
bs := s.getBlockstore(urepo.Repo.Did)
31
30
32
31
cs, err := car.NewCarReader(bytes.NewReader(b))
33
32
if err != nil {
···
107
106
return helpers.ServerError(e, nil)
108
107
}
109
108
110
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
109
+
if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil {
111
110
s.logger.Error("error updating repo after commit", "error", err)
112
111
return helpers.ServerError(e, nil)
113
112
}
+4
-10
server/handle_oauth_token.go
+4
-10
server/handle_oauth_token.go
···
157
157
Code: *authReq.Code,
158
158
Token: accessString,
159
159
RefreshToken: refreshToken,
160
+
Ip: authReq.Ip,
160
161
}, nil).Error; err != nil {
161
162
s.logger.Error("error creating token in db", "error", err)
162
163
return helpers.ServerError(e, nil)
···
203
204
return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt"))
204
205
}
205
206
206
-
sessionLifetime := constants.PublicClientSessionLifetime
207
-
refreshLifetime := constants.PublicClientRefreshLifetime
208
-
if clientAuth.Method != "none" {
209
-
sessionLifetime = constants.ConfidentialClientSessionLifetime
210
-
refreshLifetime = constants.ConfidentialClientRefreshLifetime
211
-
}
207
+
ageRes := oauth.GetSessionAgeFromToken(oauthToken)
212
208
213
-
sessionAge := time.Since(oauthToken.CreatedAt)
214
-
if sessionAge > sessionLifetime {
209
+
if ageRes.SessionExpired {
215
210
return helpers.InputError(e, to.StringPtr("Session expired"))
216
211
}
217
212
218
-
refreshAge := time.Since(oauthToken.UpdatedAt)
219
-
if refreshAge > refreshLifetime {
213
+
if ageRes.RefreshExpired {
220
214
return helpers.InputError(e, to.StringPtr("Refresh token expired"))
221
215
}
222
216
+27
-15
server/handle_proxy.go
+27
-15
server/handle_proxy.go
···
17
17
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
18
18
)
19
19
20
-
func (s *Server) handleProxy(e echo.Context) error {
21
-
repo, isAuthed := e.Get("repo").(*models.RepoActor)
22
-
23
-
pts := strings.Split(e.Request().URL.Path, "/")
24
-
if len(pts) != 3 {
25
-
return fmt.Errorf("incorrect number of parts")
26
-
}
27
-
20
+
func (s *Server) getAtprotoProxyEndpointFromRequest(e echo.Context) (string, string, error) {
28
21
svc := e.Request().Header.Get("atproto-proxy")
29
22
if svc == "" {
30
-
svc = "did:web:api.bsky.app#bsky_appview" // TODO: should be a config var probably
23
+
svc = s.config.DefaultAtprotoProxy
31
24
}
32
25
33
26
svcPts := strings.Split(svc, "#")
34
27
if len(svcPts) != 2 {
35
-
return fmt.Errorf("invalid service header")
28
+
return "", "", fmt.Errorf("invalid service header")
36
29
}
37
30
38
31
svcDid := svcPts[0]
···
40
33
41
34
doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid)
42
35
if err != nil {
43
-
return err
36
+
return "", "", err
44
37
}
45
38
46
39
var endpoint string
···
50
43
}
51
44
}
52
45
46
+
return endpoint, svcDid, nil
47
+
}
48
+
49
+
func (s *Server) handleProxy(e echo.Context) error {
50
+
lgr := s.logger.With("handler", "handleProxy")
51
+
52
+
repo, isAuthed := e.Get("repo").(*models.RepoActor)
53
+
54
+
pts := strings.Split(e.Request().URL.Path, "/")
55
+
if len(pts) != 3 {
56
+
return fmt.Errorf("incorrect number of parts")
57
+
}
58
+
59
+
endpoint, svcDid, err := s.getAtprotoProxyEndpointFromRequest(e)
60
+
if err != nil {
61
+
lgr.Error("could not get atproto proxy", "error", err)
62
+
return helpers.ServerError(e, nil)
63
+
}
64
+
53
65
requrl := e.Request().URL
54
66
requrl.Host = strings.TrimPrefix(endpoint, "https://")
55
67
requrl.Scheme = "https"
···
78
90
}
79
91
hj, err := json.Marshal(header)
80
92
if err != nil {
81
-
s.logger.Error("error marshaling header", "error", err)
93
+
lgr.Error("error marshaling header", "error", err)
82
94
return helpers.ServerError(e, nil)
83
95
}
84
96
···
93
105
}
94
106
pj, err := json.Marshal(payload)
95
107
if err != nil {
96
-
s.logger.Error("error marashaling payload", "error", err)
108
+
lgr.Error("error marashaling payload", "error", err)
97
109
return helpers.ServerError(e, nil)
98
110
}
99
111
···
104
116
105
117
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
106
118
if err != nil {
107
-
s.logger.Error("can't load private key", "error", err)
119
+
lgr.Error("can't load private key", "error", err)
108
120
return err
109
121
}
110
122
111
123
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
112
124
if err != nil {
113
-
s.logger.Error("error signing", "error", err)
125
+
lgr.Error("error signing", "error", err)
114
126
}
115
127
116
128
rBytes := R.Bytes()
+2
-2
server/handle_server_confirm_email.go
+2
-2
server/handle_server_confirm_email.go
···
28
28
}
29
29
30
30
if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil {
31
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
31
+
return helpers.ExpiredTokenError(e)
32
32
}
33
33
34
34
if *urepo.EmailVerificationCode != req.Token {
···
36
36
}
37
37
38
38
if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) {
39
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
39
+
return helpers.ExpiredTokenError(e)
40
40
}
41
41
42
42
now := time.Now().UTC()
+2
-3
server/handle_server_create_account.go
+2
-3
server/handle_server_create_account.go
···
14
14
"github.com/bluesky-social/indigo/events"
15
15
"github.com/bluesky-social/indigo/repo"
16
16
"github.com/bluesky-social/indigo/util"
17
-
"github.com/haileyok/cocoon/blockstore"
18
17
"github.com/haileyok/cocoon/internal/helpers"
19
18
"github.com/haileyok/cocoon/models"
20
19
"github.com/labstack/echo/v4"
···
177
176
}
178
177
179
178
if customDidHeader == "" {
180
-
bs := blockstore.New(signupDid, s.db)
179
+
bs := s.getBlockstore(signupDid)
181
180
r := repo.NewRepo(context.TODO(), signupDid, bs)
182
181
183
182
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
···
186
185
return helpers.ServerError(e, nil)
187
186
}
188
187
189
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
188
+
if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil {
190
189
s.logger.Error("error updating repo after commit", "error", err)
191
190
return helpers.ServerError(e, nil)
192
191
}
+8
-6
server/handle_server_get_service_auth.go
+8
-6
server/handle_server_get_service_auth.go
···
19
19
20
20
type ServerGetServiceAuthRequest struct {
21
21
Aud string `query:"aud" validate:"required,atproto-did"`
22
-
Exp int64 `query:"exp"`
23
-
Lxm string `query:"lxm" validate:"required,atproto-nsid"`
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"`
24
25
}
25
26
26
27
func (s *Server) handleServerGetServiceAuth(e echo.Context) error {
···
34
35
return helpers.InputError(e, nil)
35
36
}
36
37
38
+
exp := int64(req.Exp)
37
39
now := time.Now().Unix()
38
-
if req.Exp == 0 {
39
-
req.Exp = now + 60 // default
40
+
if exp == 0 {
41
+
exp = now + 60 // default
40
42
}
41
43
42
44
if req.Lxm == "com.atproto.server.getServiceAuth" {
···
44
46
}
45
47
46
48
maxExp := now + (60 * 30)
47
-
if req.Exp > maxExp {
49
+
if exp > maxExp {
48
50
return helpers.InputError(e, to.StringPtr("expiration too big. smoller please"))
49
51
}
50
52
···
68
70
"aud": req.Aud,
69
71
"lxm": req.Lxm,
70
72
"jti": uuid.NewString(),
71
-
"exp": req.Exp,
73
+
"exp": exp,
72
74
"iat": now,
73
75
}
74
76
pj, err := json.Marshal(payload)
+2
-2
server/handle_server_reset_password.go
+2
-2
server/handle_server_reset_password.go
···
33
33
}
34
34
35
35
if *urepo.PasswordResetCode != req.Token {
36
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
36
+
return helpers.InvalidTokenError(e)
37
37
}
38
38
39
39
if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) {
40
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
40
+
return helpers.ExpiredTokenError(e)
41
41
}
42
42
43
43
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
+3
-4
server/handle_server_update_email.go
+3
-4
server/handle_server_update_email.go
···
3
3
import (
4
4
"time"
5
5
6
-
"github.com/Azure/go-autorest/autorest/to"
7
6
"github.com/haileyok/cocoon/internal/helpers"
8
7
"github.com/haileyok/cocoon/models"
9
8
"github.com/labstack/echo/v4"
···
29
28
}
30
29
31
30
if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil {
32
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
31
+
return helpers.InvalidTokenError(e)
33
32
}
34
33
35
34
if *urepo.EmailUpdateCode != req.Token {
36
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
35
+
return helpers.InvalidTokenError(e)
37
36
}
38
37
39
38
if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) {
40
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
39
+
return helpers.ExpiredTokenError(e)
41
40
}
42
41
43
42
if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", nil, req.Email, urepo.Repo.Did).Error; err != nil {
+1
-2
server/handle_sync_get_blocks.go
+1
-2
server/handle_sync_get_blocks.go
···
6
6
"strings"
7
7
8
8
"github.com/bluesky-social/indigo/carstore"
9
-
"github.com/haileyok/cocoon/blockstore"
10
9
"github.com/haileyok/cocoon/internal/helpers"
11
10
"github.com/ipfs/go-cid"
12
11
cbor "github.com/ipfs/go-ipld-cbor"
···
54
53
return helpers.ServerError(e, nil)
55
54
}
56
55
57
-
bs := blockstore.New(urepo.Repo.Did, s.db)
56
+
bs := s.getBlockstore(urepo.Repo.Did)
58
57
59
58
for _, c := range cids {
60
59
b, err := bs.Get(context.TODO(), c)
+268
server/middleware.go
+268
server/middleware.go
···
1
+
package server
2
+
3
+
import (
4
+
"crypto/sha256"
5
+
"encoding/base64"
6
+
"fmt"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/Azure/go-autorest/autorest/to"
11
+
"github.com/golang-jwt/jwt/v4"
12
+
"github.com/haileyok/cocoon/internal/helpers"
13
+
"github.com/haileyok/cocoon/models"
14
+
"github.com/haileyok/cocoon/oauth/provider"
15
+
"github.com/labstack/echo/v4"
16
+
"gitlab.com/yawning/secp256k1-voi"
17
+
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
18
+
"gorm.io/gorm"
19
+
)
20
+
21
+
func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
22
+
return func(e echo.Context) error {
23
+
username, password, ok := e.Request().BasicAuth()
24
+
if !ok || username != "admin" || password != s.config.AdminPassword {
25
+
return helpers.InputError(e, to.StringPtr("Unauthorized"))
26
+
}
27
+
28
+
if err := next(e); err != nil {
29
+
e.Error(err)
30
+
}
31
+
32
+
return nil
33
+
}
34
+
}
35
+
36
+
func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
37
+
return func(e echo.Context) error {
38
+
authheader := e.Request().Header.Get("authorization")
39
+
if authheader == "" {
40
+
return e.JSON(401, map[string]string{"error": "Unauthorized"})
41
+
}
42
+
43
+
pts := strings.Split(authheader, " ")
44
+
if len(pts) != 2 {
45
+
return helpers.ServerError(e, nil)
46
+
}
47
+
48
+
// move on to oauth session middleware if this is a dpop token
49
+
if pts[0] == "DPoP" {
50
+
return next(e)
51
+
}
52
+
53
+
tokenstr := pts[1]
54
+
token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{})
55
+
claims, ok := token.Claims.(jwt.MapClaims)
56
+
if !ok {
57
+
return helpers.InvalidTokenError(e)
58
+
}
59
+
60
+
var did string
61
+
var repo *models.RepoActor
62
+
63
+
// service auth tokens
64
+
lxm, hasLxm := claims["lxm"]
65
+
if hasLxm {
66
+
pts := strings.Split(e.Request().URL.String(), "/")
67
+
if lxm != pts[len(pts)-1] {
68
+
s.logger.Error("service auth lxm incorrect", "lxm", lxm, "expected", pts[len(pts)-1], "error", err)
69
+
return helpers.InputError(e, nil)
70
+
}
71
+
72
+
maybeDid, ok := claims["iss"].(string)
73
+
if !ok {
74
+
s.logger.Error("no iss in service auth token", "error", err)
75
+
return helpers.InputError(e, nil)
76
+
}
77
+
did = maybeDid
78
+
79
+
maybeRepo, err := s.getRepoActorByDid(did)
80
+
if err != nil {
81
+
s.logger.Error("error fetching repo", "error", err)
82
+
return helpers.ServerError(e, nil)
83
+
}
84
+
repo = maybeRepo
85
+
}
86
+
87
+
if token.Header["alg"] != "ES256K" {
88
+
token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
89
+
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
90
+
return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
91
+
}
92
+
return s.privateKey.Public(), nil
93
+
})
94
+
if err != nil {
95
+
s.logger.Error("error parsing jwt", "error", err)
96
+
return helpers.ExpiredTokenError(e)
97
+
}
98
+
99
+
if !token.Valid {
100
+
return helpers.InvalidTokenError(e)
101
+
}
102
+
} else {
103
+
kpts := strings.Split(tokenstr, ".")
104
+
signingInput := kpts[0] + "." + kpts[1]
105
+
hash := sha256.Sum256([]byte(signingInput))
106
+
sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2])
107
+
if err != nil {
108
+
s.logger.Error("error decoding signature bytes", "error", err)
109
+
return helpers.ServerError(e, nil)
110
+
}
111
+
112
+
if len(sigBytes) != 64 {
113
+
s.logger.Error("incorrect sigbytes length", "length", len(sigBytes))
114
+
return helpers.ServerError(e, nil)
115
+
}
116
+
117
+
rBytes := sigBytes[:32]
118
+
sBytes := sigBytes[32:]
119
+
rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes))
120
+
ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes))
121
+
122
+
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
123
+
if err != nil {
124
+
s.logger.Error("can't load private key", "error", err)
125
+
return err
126
+
}
127
+
128
+
pubKey, ok := sk.Public().(*secp256k1secec.PublicKey)
129
+
if !ok {
130
+
s.logger.Error("error getting public key from sk")
131
+
return helpers.ServerError(e, nil)
132
+
}
133
+
134
+
verified := pubKey.VerifyRaw(hash[:], rr, ss)
135
+
if !verified {
136
+
s.logger.Error("error verifying", "error", err)
137
+
return helpers.ServerError(e, nil)
138
+
}
139
+
}
140
+
141
+
isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession"
142
+
scope, _ := claims["scope"].(string)
143
+
144
+
if isRefresh && scope != "com.atproto.refresh" {
145
+
return helpers.InvalidTokenError(e)
146
+
} else if !hasLxm && !isRefresh && scope != "com.atproto.access" {
147
+
return helpers.InvalidTokenError(e)
148
+
}
149
+
150
+
table := "tokens"
151
+
if isRefresh {
152
+
table = "refresh_tokens"
153
+
}
154
+
155
+
if isRefresh {
156
+
type Result struct {
157
+
Found bool
158
+
}
159
+
var result Result
160
+
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil {
161
+
if err == gorm.ErrRecordNotFound {
162
+
return helpers.InvalidTokenError(e)
163
+
}
164
+
165
+
s.logger.Error("error getting token from db", "error", err)
166
+
return helpers.ServerError(e, nil)
167
+
}
168
+
169
+
if !result.Found {
170
+
return helpers.InvalidTokenError(e)
171
+
}
172
+
}
173
+
174
+
exp, ok := claims["exp"].(float64)
175
+
if !ok {
176
+
s.logger.Error("error getting iat from token")
177
+
return helpers.ServerError(e, nil)
178
+
}
179
+
180
+
if exp < float64(time.Now().UTC().Unix()) {
181
+
return helpers.ExpiredTokenError(e)
182
+
}
183
+
184
+
if repo == nil {
185
+
maybeRepo, err := s.getRepoActorByDid(claims["sub"].(string))
186
+
if err != nil {
187
+
s.logger.Error("error fetching repo", "error", err)
188
+
return helpers.ServerError(e, nil)
189
+
}
190
+
repo = maybeRepo
191
+
did = repo.Repo.Did
192
+
}
193
+
194
+
e.Set("repo", repo)
195
+
e.Set("did", did)
196
+
e.Set("token", tokenstr)
197
+
198
+
if err := next(e); err != nil {
199
+
return helpers.InvalidTokenError(e)
200
+
}
201
+
202
+
return nil
203
+
}
204
+
}
205
+
206
+
func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
207
+
return func(e echo.Context) error {
208
+
authheader := e.Request().Header.Get("authorization")
209
+
if authheader == "" {
210
+
return e.JSON(401, map[string]string{"error": "Unauthorized"})
211
+
}
212
+
213
+
pts := strings.Split(authheader, " ")
214
+
if len(pts) != 2 {
215
+
return helpers.ServerError(e, nil)
216
+
}
217
+
218
+
if pts[0] != "DPoP" {
219
+
return next(e)
220
+
}
221
+
222
+
accessToken := pts[1]
223
+
224
+
nonce := s.oauthProvider.NextNonce()
225
+
if nonce != "" {
226
+
e.Response().Header().Set("DPoP-Nonce", nonce)
227
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
228
+
}
229
+
230
+
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken))
231
+
if err != nil {
232
+
s.logger.Error("invalid dpop proof", "error", err)
233
+
return helpers.InputError(e, to.StringPtr(err.Error()))
234
+
}
235
+
236
+
var oauthToken provider.OauthToken
237
+
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil {
238
+
s.logger.Error("error finding access token in db", "error", err)
239
+
return helpers.InputError(e, nil)
240
+
}
241
+
242
+
if oauthToken.Token == "" {
243
+
return helpers.InvalidTokenError(e)
244
+
}
245
+
246
+
if *oauthToken.Parameters.DpopJkt != proof.JKT {
247
+
s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT)
248
+
return helpers.InputError(e, to.StringPtr("dpop jkt mismatch"))
249
+
}
250
+
251
+
if time.Now().After(oauthToken.ExpiresAt) {
252
+
return helpers.ExpiredTokenError(e)
253
+
}
254
+
255
+
repo, err := s.getRepoActorByDid(oauthToken.Sub)
256
+
if err != nil {
257
+
s.logger.Error("could not find actor in db", "error", err)
258
+
return helpers.ServerError(e, nil)
259
+
}
260
+
261
+
e.Set("repo", repo)
262
+
e.Set("did", repo.Repo.Did)
263
+
e.Set("token", accessToken)
264
+
e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " "))
265
+
266
+
return next(e)
267
+
}
268
+
}
+13
-13
server/repo.go
+13
-13
server/repo.go
···
16
16
"github.com/bluesky-social/indigo/events"
17
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
18
"github.com/bluesky-social/indigo/repo"
19
-
"github.com/bluesky-social/indigo/util"
20
-
"github.com/haileyok/cocoon/blockstore"
21
19
"github.com/haileyok/cocoon/internal/db"
22
20
"github.com/haileyok/cocoon/models"
21
+
"github.com/haileyok/cocoon/recording_blockstore"
23
22
blocks "github.com/ipfs/go-block-format"
24
23
"github.com/ipfs/go-cid"
25
24
cbor "github.com/ipfs/go-ipld-cbor"
···
103
102
return nil, err
104
103
}
105
104
106
-
dbs := blockstore.New(urepo.Did, rm.db)
105
+
dbs := rm.s.getBlockstore(urepo.Did)
106
+
bs := recording_blockstore.New(dbs)
107
107
r, err := repo.OpenRepo(context.TODO(), dbs, rootcid)
108
108
109
109
entries := []models.Record{}
···
274
274
}
275
275
}
276
276
277
-
for _, op := range dbs.GetLog() {
277
+
for _, op := range bs.GetLogMap() {
278
278
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
279
279
return nil, err
280
280
}
···
318
318
Rev: rev,
319
319
Since: &urepo.Rev,
320
320
Commit: lexutil.LexLink(newroot),
321
-
Time: time.Now().Format(util.ISO8601),
321
+
Time: time.Now().Format(time.RFC3339Nano),
322
322
Ops: ops,
323
323
TooBig: false,
324
324
},
325
325
})
326
326
327
-
if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil {
327
+
if err := rm.s.UpdateRepo(context.TODO(), urepo.Did, newroot, rev); err != nil {
328
328
return nil, err
329
329
}
330
330
···
345
345
return cid.Undef, nil, err
346
346
}
347
347
348
-
dbs := blockstore.New(urepo.Did, rm.db)
349
-
bs := util.NewLoggingBstore(dbs)
348
+
dbs := rm.s.getBlockstore(urepo.Did)
349
+
bs := recording_blockstore.New(dbs)
350
350
351
351
r, err := repo.OpenRepo(context.TODO(), bs, c)
352
352
if err != nil {
···
358
358
return cid.Undef, nil, err
359
359
}
360
360
361
-
return c, bs.GetLoggedBlocks(), nil
361
+
return c, bs.GetLogArray(), nil
362
362
}
363
363
364
364
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
···
414
414
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
415
415
}
416
416
417
-
var deepiter func(interface{}) error
418
-
deepiter = func(item interface{}) error {
417
+
var deepiter func(any) error
418
+
deepiter = func(item any) error {
419
419
switch val := item.(type) {
420
-
case map[string]interface{}:
420
+
case map[string]any:
421
421
if val["$type"] == "blob" {
422
422
if ref, ok := val["ref"].(string); ok {
423
423
c, err := cid.Parse(ref)
···
430
430
return deepiter(v)
431
431
}
432
432
}
433
-
case []interface{}:
433
+
case []any:
434
434
for _, v := range val {
435
435
deepiter(v)
436
436
}
+39
-281
server/server.go
+39
-281
server/server.go
···
4
4
"bytes"
5
5
"context"
6
6
"crypto/ecdsa"
7
-
"crypto/sha256"
8
7
"embed"
9
-
"encoding/base64"
10
8
"errors"
11
9
"fmt"
12
10
"io"
···
15
13
"net/smtp"
16
14
"os"
17
15
"path/filepath"
18
-
"strings"
19
16
"sync"
20
17
"text/template"
21
18
"time"
22
19
23
-
"github.com/Azure/go-autorest/autorest/to"
24
20
"github.com/aws/aws-sdk-go/aws"
25
21
"github.com/aws/aws-sdk-go/aws/credentials"
26
22
"github.com/aws/aws-sdk-go/aws/session"
···
32
28
"github.com/bluesky-social/indigo/xrpc"
33
29
"github.com/domodwyer/mailyak/v3"
34
30
"github.com/go-playground/validator"
35
-
"github.com/golang-jwt/jwt/v4"
36
31
"github.com/gorilla/sessions"
37
32
"github.com/haileyok/cocoon/identity"
38
33
"github.com/haileyok/cocoon/internal/db"
···
43
38
"github.com/haileyok/cocoon/oauth/dpop"
44
39
"github.com/haileyok/cocoon/oauth/provider"
45
40
"github.com/haileyok/cocoon/plc"
41
+
"github.com/ipfs/go-cid"
46
42
echo_session "github.com/labstack/echo-contrib/session"
47
43
"github.com/labstack/echo/v4"
48
44
"github.com/labstack/echo/v4/middleware"
49
45
slogecho "github.com/samber/slog-echo"
50
-
"gitlab.com/yawning/secp256k1-voi"
51
-
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
52
46
"gorm.io/driver/sqlite"
53
47
"gorm.io/gorm"
54
48
)
···
109
103
S3Config *S3Config
110
104
111
105
SessionSecret string
106
+
107
+
DefaultAtprotoProxy string
108
+
109
+
BlockstoreVariant BlockstoreVariant
112
110
}
113
111
114
112
type config struct {
115
-
Version string
116
-
Did string
117
-
Hostname string
118
-
ContactEmail string
119
-
EnforcePeering bool
120
-
Relays []string
121
-
AdminPassword string
122
-
SmtpEmail string
123
-
SmtpName string
113
+
Version string
114
+
Did string
115
+
Hostname string
116
+
ContactEmail string
117
+
EnforcePeering bool
118
+
Relays []string
119
+
AdminPassword string
120
+
SmtpEmail string
121
+
SmtpName string
122
+
DefaultAtprotoProxy string
123
+
BlockstoreVariant BlockstoreVariant
124
124
}
125
125
126
126
type CustomValidator struct {
···
197
197
return t.templates.ExecuteTemplate(w, name, data)
198
198
}
199
199
200
-
func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
201
-
return func(e echo.Context) error {
202
-
username, password, ok := e.Request().BasicAuth()
203
-
if !ok || username != "admin" || password != s.config.AdminPassword {
204
-
return helpers.InputError(e, to.StringPtr("Unauthorized"))
205
-
}
206
-
207
-
if err := next(e); err != nil {
208
-
e.Error(err)
209
-
}
210
-
211
-
return nil
212
-
}
213
-
}
214
-
215
-
func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
216
-
return func(e echo.Context) error {
217
-
authheader := e.Request().Header.Get("authorization")
218
-
if authheader == "" {
219
-
return e.JSON(401, map[string]string{"error": "Unauthorized"})
220
-
}
221
-
222
-
pts := strings.Split(authheader, " ")
223
-
if len(pts) != 2 {
224
-
return helpers.ServerError(e, nil)
225
-
}
226
-
227
-
// move on to oauth session middleware if this is a dpop token
228
-
if pts[0] == "DPoP" {
229
-
return next(e)
230
-
}
231
-
232
-
tokenstr := pts[1]
233
-
token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{})
234
-
claims, ok := token.Claims.(jwt.MapClaims)
235
-
if !ok {
236
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
237
-
}
238
-
239
-
var did string
240
-
var repo *models.RepoActor
241
-
242
-
// service auth tokens
243
-
lxm, hasLxm := claims["lxm"]
244
-
if hasLxm {
245
-
pts := strings.Split(e.Request().URL.String(), "/")
246
-
if lxm != pts[len(pts)-1] {
247
-
s.logger.Error("service auth lxm incorrect", "lxm", lxm, "expected", pts[len(pts)-1], "error", err)
248
-
return helpers.InputError(e, nil)
249
-
}
250
-
251
-
maybeDid, ok := claims["iss"].(string)
252
-
if !ok {
253
-
s.logger.Error("no iss in service auth token", "error", err)
254
-
return helpers.InputError(e, nil)
255
-
}
256
-
did = maybeDid
257
-
258
-
maybeRepo, err := s.getRepoActorByDid(did)
259
-
if err != nil {
260
-
s.logger.Error("error fetching repo", "error", err)
261
-
return helpers.ServerError(e, nil)
262
-
}
263
-
repo = maybeRepo
264
-
}
265
-
266
-
if token.Header["alg"] != "ES256K" {
267
-
token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
268
-
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
269
-
return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
270
-
}
271
-
return s.privateKey.Public(), nil
272
-
})
273
-
if err != nil {
274
-
s.logger.Error("error parsing jwt", "error", err)
275
-
// NOTE: https://github.com/bluesky-social/atproto/discussions/3319
276
-
return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"})
277
-
}
278
-
279
-
if !token.Valid {
280
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
281
-
}
282
-
} else {
283
-
kpts := strings.Split(tokenstr, ".")
284
-
signingInput := kpts[0] + "." + kpts[1]
285
-
hash := sha256.Sum256([]byte(signingInput))
286
-
sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2])
287
-
if err != nil {
288
-
s.logger.Error("error decoding signature bytes", "error", err)
289
-
return helpers.ServerError(e, nil)
290
-
}
291
-
292
-
if len(sigBytes) != 64 {
293
-
s.logger.Error("incorrect sigbytes length", "length", len(sigBytes))
294
-
return helpers.ServerError(e, nil)
295
-
}
296
-
297
-
rBytes := sigBytes[:32]
298
-
sBytes := sigBytes[32:]
299
-
rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes))
300
-
ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes))
301
-
302
-
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
303
-
if err != nil {
304
-
s.logger.Error("can't load private key", "error", err)
305
-
return err
306
-
}
307
-
308
-
pubKey, ok := sk.Public().(*secp256k1secec.PublicKey)
309
-
if !ok {
310
-
s.logger.Error("error getting public key from sk")
311
-
return helpers.ServerError(e, nil)
312
-
}
313
-
314
-
verified := pubKey.VerifyRaw(hash[:], rr, ss)
315
-
if !verified {
316
-
s.logger.Error("error verifying", "error", err)
317
-
return helpers.ServerError(e, nil)
318
-
}
319
-
}
320
-
321
-
isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession"
322
-
scope, _ := claims["scope"].(string)
323
-
324
-
if isRefresh && scope != "com.atproto.refresh" {
325
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
326
-
} else if !hasLxm && !isRefresh && scope != "com.atproto.access" {
327
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
328
-
}
329
-
330
-
table := "tokens"
331
-
if isRefresh {
332
-
table = "refresh_tokens"
333
-
}
334
-
335
-
if isRefresh {
336
-
type Result struct {
337
-
Found bool
338
-
}
339
-
var result Result
340
-
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil {
341
-
if err == gorm.ErrRecordNotFound {
342
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
343
-
}
344
-
345
-
s.logger.Error("error getting token from db", "error", err)
346
-
return helpers.ServerError(e, nil)
347
-
}
348
-
349
-
if !result.Found {
350
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
351
-
}
352
-
}
353
-
354
-
exp, ok := claims["exp"].(float64)
355
-
if !ok {
356
-
s.logger.Error("error getting iat from token")
357
-
return helpers.ServerError(e, nil)
358
-
}
359
-
360
-
if exp < float64(time.Now().UTC().Unix()) {
361
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
362
-
}
363
-
364
-
if repo == nil {
365
-
maybeRepo, err := s.getRepoActorByDid(claims["sub"].(string))
366
-
if err != nil {
367
-
s.logger.Error("error fetching repo", "error", err)
368
-
return helpers.ServerError(e, nil)
369
-
}
370
-
repo = maybeRepo
371
-
did = repo.Repo.Did
372
-
}
373
-
374
-
e.Set("repo", repo)
375
-
e.Set("did", did)
376
-
e.Set("token", tokenstr)
377
-
378
-
if err := next(e); err != nil {
379
-
e.Error(err)
380
-
}
381
-
382
-
return nil
383
-
}
384
-
}
385
-
386
-
func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
387
-
return func(e echo.Context) error {
388
-
authheader := e.Request().Header.Get("authorization")
389
-
if authheader == "" {
390
-
return e.JSON(401, map[string]string{"error": "Unauthorized"})
391
-
}
392
-
393
-
pts := strings.Split(authheader, " ")
394
-
if len(pts) != 2 {
395
-
return helpers.ServerError(e, nil)
396
-
}
397
-
398
-
if pts[0] != "DPoP" {
399
-
return next(e)
400
-
}
401
-
402
-
accessToken := pts[1]
403
-
404
-
nonce := s.oauthProvider.NextNonce()
405
-
if nonce != "" {
406
-
e.Response().Header().Set("DPoP-Nonce", nonce)
407
-
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
408
-
}
409
-
410
-
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken))
411
-
if err != nil {
412
-
s.logger.Error("invalid dpop proof", "error", err)
413
-
return helpers.InputError(e, to.StringPtr(err.Error()))
414
-
}
415
-
416
-
var oauthToken provider.OauthToken
417
-
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil {
418
-
s.logger.Error("error finding access token in db", "error", err)
419
-
return helpers.InputError(e, nil)
420
-
}
421
-
422
-
if oauthToken.Token == "" {
423
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
424
-
}
425
-
426
-
if *oauthToken.Parameters.DpopJkt != proof.JKT {
427
-
s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT)
428
-
return helpers.InputError(e, to.StringPtr("dpop jkt mismatch"))
429
-
}
430
-
431
-
if time.Now().After(oauthToken.ExpiresAt) {
432
-
return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"})
433
-
}
434
-
435
-
repo, err := s.getRepoActorByDid(oauthToken.Sub)
436
-
if err != nil {
437
-
s.logger.Error("could not find actor in db", "error", err)
438
-
return helpers.ServerError(e, nil)
439
-
}
440
-
441
-
e.Set("repo", repo)
442
-
e.Set("did", repo.Repo.Did)
443
-
e.Set("token", accessToken)
444
-
e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " "))
445
-
446
-
return next(e)
447
-
}
448
-
}
449
-
450
200
func New(args *Args) (*Server, error) {
451
201
if args.Addr == "" {
452
202
return nil, fmt.Errorf("addr must be set")
···
593
343
plcClient: plcClient,
594
344
privateKey: &pkey,
595
345
config: &config{
596
-
Version: args.Version,
597
-
Did: args.Did,
598
-
Hostname: args.Hostname,
599
-
ContactEmail: args.ContactEmail,
600
-
EnforcePeering: false,
601
-
Relays: args.Relays,
602
-
AdminPassword: args.AdminPassword,
603
-
SmtpName: args.SmtpName,
604
-
SmtpEmail: args.SmtpEmail,
346
+
Version: args.Version,
347
+
Did: args.Did,
348
+
Hostname: args.Hostname,
349
+
ContactEmail: args.ContactEmail,
350
+
EnforcePeering: false,
351
+
Relays: args.Relays,
352
+
AdminPassword: args.AdminPassword,
353
+
SmtpName: args.SmtpName,
354
+
SmtpEmail: args.SmtpEmail,
355
+
DefaultAtprotoProxy: args.DefaultAtprotoProxy,
356
+
BlockstoreVariant: args.BlockstoreVariant,
605
357
},
606
358
evtman: events.NewEventManager(events.NewMemPersister()),
607
359
passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
···
690
442
s.echo.GET("/account/signin", s.handleAccountSigninGet)
691
443
s.echo.POST("/account/signin", s.handleAccountSigninPost)
692
444
s.echo.GET("/account/signout", s.handleAccountSignout)
693
-
s.echo.GET("/account/totp-enroll", s.handleAccountTotpEnrollGet)
694
-
s.echo.POST("/account/totp-enroll", s.handleAccountTotpEnrollPost)
695
445
696
446
// oauth account
697
447
s.echo.GET("/oauth/jwks", s.handleOauthJwks)
···
728
478
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
729
479
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
730
480
731
-
// are there any routes that we should be allowing without auth? i dont think so but idk
732
-
s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
733
-
s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
734
-
735
481
// admin routes
736
482
s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware)
737
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)
738
488
}
739
489
740
490
func (s *Server) Serve(ctx context.Context) error {
···
896
646
go s.doBackup()
897
647
}
898
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
+
}
-9
server/static/style.css
-9
server/static/style.css
···
24
24
margin-bottom: 1.5em;
25
25
}
26
26
27
-
.center {
28
-
justify-content: center;
29
-
}
30
-
31
27
.centered-body {
32
28
min-height: 100vh;
33
29
justify-content: center;
···
85
81
.alert-danger {
86
82
background-color: var(--danger);
87
83
}
88
-
89
-
.totp-image {
90
-
height: 200;
91
-
width: 200;
92
-
}
+5
-5
server/templates/account.html
+5
-5
server/templates/account.html
···
12
12
<main class="container base-container authorize-container margin-top-xl">
13
13
<h2>Welcome, {{ .Repo.Handle }}</h2>
14
14
<ul>
15
-
<li><a href="/account/totp-enroll">Enroll in TOTP</a></li>
16
15
<li><a href="/account/signout">Sign Out</a></li>
17
16
</ul>
18
17
{{ if .flashes.successes }}
···
25
24
</div>
26
25
{{ else }} {{ range .Tokens }}
27
26
<div class="base-container">
28
-
<h4>{{ .ClientId }}</h4>
29
-
<p>Created: {{ .CreatedAt }}</p>
30
-
<p>Updated: {{ .UpdatedAt }}</p>
31
-
<p>Expires: {{ .ExpiresAt }}</p>
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
32
<form action="/account/revoke" method="post">
33
33
<input type="hidden" name="token" value="{{ .Token }}" />
34
34
<button type="submit" value="">Revoke</button>
-32
server/templates/totp_enroll.html
-32
server/templates/totp_enroll.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>TOTP Enrollment</title>
10
-
</head>
11
-
<body class="centered-body">
12
-
<main class="container base-container box-shadow-container login-container">
13
-
<h2>TOTP Enrollment</h2>
14
-
<p>
15
-
Enroll in TOTP by adding the below secret to your TOTP manager and
16
-
verifying the code.
17
-
</p>
18
-
{{ if .flashes.errors }}
19
-
<div class="alert alert-danger margin-bottom-xs">
20
-
<p>{{ index .flashes.errors 0 }}</p>
21
-
</div>
22
-
{{ end }}
23
-
<div class="center">
24
-
<img src="{{ .Image }}" class="totp-image" />
25
-
</div>
26
-
<form action="/account/totp-enroll" method="post">
27
-
<input name="code" id="code" placeholder="Code" />
28
-
<button class="primary" type="submit" value="Login">Enroll</button>
29
-
</form>
30
-
</main>
31
-
</body>
32
-
</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
+
}