An atproto PDS written in Go

feat: Properly implement ListMissingBlobs, getServiceAuth and implement reserveSigningKey, requestAccountDelete and deleteAccount (#44)

* feat: implement listMissingBlobs endpoint properly

* fix: properly extract blobs using atdata.ExtractBlobs

* actually fully functional now :p

* feat: complete and make reserveSigningKey, fix getServiceAuth based on atproto spec and mark it as done in the readme

* implement deleteAccount

* requestAccountDelete was also added

* THIS should actually fix it

* fix: update DPoP error handling to set WWW-Authenticate header

* Add COCOON_S3_CDN_URL for direct S3 blob redirects instead of proxying through the PDS

* This should be the last fix for this issue hopefully

* fix response so it gets a new access token after the token expires

authored by Scan and committed by GitHub 6ec2a2a5 113ced56

+15 -5
README.md
··· 154 COCOON_S3_ENDPOINT="https://s3.amazonaws.com" 155 COCOON_S3_ACCESS_KEY="your-access-key" 156 COCOON_S3_SECRET_KEY="your-secret-key" 157 ``` 158 159 **Blob Storage Options:** 160 - `COCOON_S3_BLOBSTORE_ENABLED=false` (default): Blobs stored in the database 161 - `COCOON_S3_BLOBSTORE_ENABLED=true`: Blobs stored in S3 bucket under `blobs/{did}/{cid}` 162 163 ### Management Commands 164 ··· 203 - [x] `com.atproto.repo.getRecord` 204 - [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.) 205 - [x] `com.atproto.repo.listRecords` 206 - - [x] `com.atproto.repo.listMissingBlobs` (Not actually functional, but will return a response as if no blobs were missing) 207 208 ### Server 209 ··· 214 - [x] `com.atproto.server.createInviteCode` 215 - [x] `com.atproto.server.createInviteCodes` 216 - [x] `com.atproto.server.deactivateAccount` 217 - - [ ] `com.atproto.server.deleteAccount` 218 - [x] `com.atproto.server.deleteSession` 219 - [x] `com.atproto.server.describeServer` 220 - [ ] `com.atproto.server.getAccountInviteCodes` 221 - - [ ] `com.atproto.server.getServiceAuth` 222 - ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords 223 - [x] `com.atproto.server.refreshSession` 224 - - [ ] `com.atproto.server.requestAccountDelete` 225 - [x] `com.atproto.server.requestEmailConfirmation` 226 - [x] `com.atproto.server.requestEmailUpdate` 227 - [x] `com.atproto.server.requestPasswordReset` 228 - - [ ] `com.atproto.server.reserveSigningKey` 229 - [x] `com.atproto.server.resetPassword` 230 - ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords 231 - [x] `com.atproto.server.updateEmail`
··· 154 COCOON_S3_ENDPOINT="https://s3.amazonaws.com" 155 COCOON_S3_ACCESS_KEY="your-access-key" 156 COCOON_S3_SECRET_KEY="your-secret-key" 157 + 158 + # Optional: CDN/public URL for blob redirects 159 + # When set, com.atproto.sync.getBlob redirects to this URL instead of proxying 160 + COCOON_S3_CDN_URL="https://cdn.example.com" 161 ``` 162 163 **Blob Storage Options:** 164 - `COCOON_S3_BLOBSTORE_ENABLED=false` (default): Blobs stored in the database 165 - `COCOON_S3_BLOBSTORE_ENABLED=true`: Blobs stored in S3 bucket under `blobs/{did}/{cid}` 166 + 167 + **Blob Serving Options:** 168 + - Without `COCOON_S3_CDN_URL`: Blobs are proxied through the PDS server 169 + - With `COCOON_S3_CDN_URL`: `getBlob` returns a 302 redirect to `{CDN_URL}/blobs/{did}/{cid}` 170 + 171 + > **Tip**: For Cloudflare R2, you can use the public bucket URL as the CDN URL. For AWS S3, you can use CloudFront or the S3 bucket URL directly if public access is enabled. 172 173 ### Management Commands 174 ··· 213 - [x] `com.atproto.repo.getRecord` 214 - [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.) 215 - [x] `com.atproto.repo.listRecords` 216 + - [x] `com.atproto.repo.listMissingBlobs` 217 218 ### Server 219 ··· 224 - [x] `com.atproto.server.createInviteCode` 225 - [x] `com.atproto.server.createInviteCodes` 226 - [x] `com.atproto.server.deactivateAccount` 227 + - [x] `com.atproto.server.deleteAccount` 228 - [x] `com.atproto.server.deleteSession` 229 - [x] `com.atproto.server.describeServer` 230 - [ ] `com.atproto.server.getAccountInviteCodes` 231 + - [x] `com.atproto.server.getServiceAuth` 232 - ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords 233 - [x] `com.atproto.server.refreshSession` 234 + - [x] `com.atproto.server.requestAccountDelete` 235 - [x] `com.atproto.server.requestEmailConfirmation` 236 - [x] `com.atproto.server.requestEmailUpdate` 237 - [x] `com.atproto.server.requestPasswordReset` 238 + - [x] `com.atproto.server.reserveSigningKey` 239 - [x] `com.atproto.server.resetPassword` 240 - ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords 241 - [x] `com.atproto.server.updateEmail`
+6
cmd/cocoon/main.go
··· 132 EnvVars: []string{"COCOON_S3_SECRET_KEY"}, 133 }, 134 &cli.StringFlag{ 135 Name: "session-secret", 136 EnvVars: []string{"COCOON_SESSION_SECRET"}, 137 }, ··· 194 Endpoint: cmd.String("s3-endpoint"), 195 AccessKey: cmd.String("s3-access-key"), 196 SecretKey: cmd.String("s3-secret-key"), 197 }, 198 SessionSecret: cmd.String("session-secret"), 199 BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
··· 132 EnvVars: []string{"COCOON_S3_SECRET_KEY"}, 133 }, 134 &cli.StringFlag{ 135 + Name: "s3-cdn-url", 136 + EnvVars: []string{"COCOON_S3_CDN_URL"}, 137 + Usage: "Public URL for S3 blob redirects (e.g., https://cdn.example.com). When set, getBlob redirects to this URL instead of proxying.", 138 + }, 139 + &cli.StringFlag{ 140 Name: "session-secret", 141 EnvVars: []string{"COCOON_SESSION_SECRET"}, 142 }, ··· 199 Endpoint: cmd.String("s3-endpoint"), 200 AccessKey: cmd.String("s3-access-key"), 201 SecretKey: cmd.String("s3-secret-key"), 202 + CDNUrl: cmd.String("s3-cdn-url"), 203 }, 204 SessionSecret: cmd.String("session-secret"), 205 BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
+1
docker-compose.yaml
··· 70 COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-} 71 COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-} 72 COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-} 73 74 # Optional: Fallback proxy 75 COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-}
··· 70 COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-} 71 COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-} 72 COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-} 73 + COCOON_S3_CDN_URL: ${COCOON_S3_CDN_URL:-} 74 75 # Optional: Fallback proxy 76 COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-}
+9
models/models.go
··· 21 PasswordResetCodeExpiresAt *time.Time 22 PlcOperationCode *string 23 PlcOperationCodeExpiresAt *time.Time 24 Password string 25 SigningKey []byte 26 Rev string ··· 117 Idx int `gorm:"primaryKey"` 118 Data []byte 119 }
··· 21 PasswordResetCodeExpiresAt *time.Time 22 PlcOperationCode *string 23 PlcOperationCodeExpiresAt *time.Time 24 + AccountDeleteCode *string 25 + AccountDeleteCodeExpiresAt *time.Time 26 Password string 27 SigningKey []byte 28 Rev string ··· 119 Idx int `gorm:"primaryKey"` 120 Data []byte 121 } 122 + 123 + type ReservedKey struct { 124 + KeyDid string `gorm:"primaryKey"` 125 + Did *string `gorm:"index"` 126 + PrivateKey []byte 127 + CreatedAt time.Time `gorm:"index"` 128 + }
+3 -2
oauth/dpop/nonce.go
··· 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 }
··· 102 } 103 104 func (n *Nonce) Check(nonce string) bool { 105 + n.mu.Lock() 106 + defer n.mu.Unlock() 107 + n.rotate() 108 return nonce == n.prev || nonce == n.curr || nonce == n.next 109 }
+5
server/handle_oauth_par.go
··· 34 dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil) 35 if err != nil { 36 if errors.Is(err, dpop.ErrUseDpopNonce) { 37 return e.JSON(400, map[string]string{ 38 "error": "use_dpop_nonce", 39 })
··· 34 dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil) 35 if err != nil { 36 if errors.Is(err, dpop.ErrUseDpopNonce) { 37 + nonce := s.oauthProvider.NextNonce() 38 + if nonce != "" { 39 + e.Response().Header().Set("DPoP-Nonce", nonce) 40 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 41 + } 42 return e.JSON(400, map[string]string{ 43 "error": "use_dpop_nonce", 44 })
+5
server/handle_oauth_token.go
··· 47 proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil) 48 if err != nil { 49 if errors.Is(err, dpop.ErrUseDpopNonce) { 50 return e.JSON(400, map[string]string{ 51 "error": "use_dpop_nonce", 52 })
··· 47 proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil) 48 if err != nil { 49 if errors.Is(err, dpop.ErrUseDpopNonce) { 50 + nonce := s.oauthProvider.NextNonce() 51 + if nonce != "" { 52 + e.Response().Header().Set("DPoP-Nonce", nonce) 53 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 54 + } 55 return e.JSON(400, map[string]string{ 56 "error": "use_dpop_nonce", 57 })
+94 -3
server/handle_repo_list_missing_blobs.go
··· 1 package server 2 3 import ( 4 "github.com/labstack/echo/v4" 5 ) 6 ··· 10 } 11 12 type ComAtprotoRepoListMissingBlobsRecordBlob struct { 13 - Cid string `json:"cid"` 14 - RecordUri string `json:"recordUri"` 15 } 16 17 func (s *Server) handleListMissingBlobs(e echo.Context) error { 18 return e.JSON(200, ComAtprotoRepoListMissingBlobsResponse{ 19 - Blobs: []ComAtprotoRepoListMissingBlobsRecordBlob{}, 20 }) 21 }
··· 1 package server 2 3 import ( 4 + "fmt" 5 + "strconv" 6 + 7 + "github.com/bluesky-social/indigo/atproto/atdata" 8 + "github.com/haileyok/cocoon/internal/helpers" 9 + "github.com/haileyok/cocoon/models" 10 + "github.com/ipfs/go-cid" 11 "github.com/labstack/echo/v4" 12 ) 13 ··· 17 } 18 19 type ComAtprotoRepoListMissingBlobsRecordBlob struct { 20 + Cid string `json:"cid"` 21 + RecordUri string `json:"recordUri"` 22 } 23 24 func (s *Server) handleListMissingBlobs(e echo.Context) error { 25 + urepo := e.Get("repo").(*models.RepoActor) 26 + 27 + limitStr := e.QueryParam("limit") 28 + cursor := e.QueryParam("cursor") 29 + 30 + limit := 500 31 + if limitStr != "" { 32 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 33 + limit = l 34 + } 35 + } 36 + 37 + var records []models.Record 38 + if err := s.db.Raw("SELECT * FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&records).Error; err != nil { 39 + s.logger.Error("failed to get records for listMissingBlobs", "error", err) 40 + return helpers.ServerError(e, nil) 41 + } 42 + 43 + type blobRef struct { 44 + cid cid.Cid 45 + recordUri string 46 + } 47 + var allBlobRefs []blobRef 48 + 49 + for _, rec := range records { 50 + blobs := getBlobsFromRecord(rec.Value) 51 + recordUri := fmt.Sprintf("at://%s/%s/%s", urepo.Repo.Did, rec.Nsid, rec.Rkey) 52 + for _, b := range blobs { 53 + allBlobRefs = append(allBlobRefs, blobRef{cid: cid.Cid(b.Ref), recordUri: recordUri}) 54 + } 55 + } 56 + 57 + missingBlobs := make([]ComAtprotoRepoListMissingBlobsRecordBlob, 0) 58 + seenCids := make(map[string]bool) 59 + 60 + for _, ref := range allBlobRefs { 61 + cidStr := ref.cid.String() 62 + 63 + if seenCids[cidStr] { 64 + continue 65 + } 66 + 67 + if cursor != "" && cidStr <= cursor { 68 + continue 69 + } 70 + 71 + var count int64 72 + if err := s.db.Raw("SELECT COUNT(*) FROM blobs WHERE did = ? AND cid = ?", nil, urepo.Repo.Did, ref.cid.Bytes()).Scan(&count).Error; err != nil { 73 + continue 74 + } 75 + 76 + if count == 0 { 77 + missingBlobs = append(missingBlobs, ComAtprotoRepoListMissingBlobsRecordBlob{ 78 + Cid: cidStr, 79 + RecordUri: ref.recordUri, 80 + }) 81 + seenCids[cidStr] = true 82 + 83 + if len(missingBlobs) >= limit { 84 + break 85 + } 86 + } 87 + } 88 + 89 + var nextCursor *string 90 + if len(missingBlobs) > 0 && len(missingBlobs) >= limit { 91 + lastCid := missingBlobs[len(missingBlobs)-1].Cid 92 + nextCursor = &lastCid 93 + } 94 + 95 return e.JSON(200, ComAtprotoRepoListMissingBlobsResponse{ 96 + Cursor: nextCursor, 97 + Blobs: missingBlobs, 98 }) 99 } 100 + 101 + func getBlobsFromRecord(data []byte) []atdata.Blob { 102 + if len(data) == 0 { 103 + return nil 104 + } 105 + 106 + decoded, err := atdata.UnmarshalCBOR(data) 107 + if err != nil { 108 + return nil 109 + } 110 + 111 + return atdata.ExtractBlobs(decoded) 112 + }
+28 -4
server/handle_server_create_account.go
··· 128 129 // TODO: unsupported domains 130 131 - k, err := atcrypto.GeneratePrivateKeyK256() 132 - if err != nil { 133 - s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 134 - return helpers.ServerError(e, nil) 135 } 136 137 if signupDid == "" {
··· 128 129 // TODO: unsupported domains 130 131 + var k *atcrypto.PrivateKeyK256 132 + 133 + if signupDid != "" { 134 + reservedKey, err := s.getReservedKey(signupDid) 135 + if err != nil { 136 + s.logger.Error("error looking up reserved key", "error", err) 137 + } 138 + if reservedKey != nil { 139 + k, err = atcrypto.ParsePrivateBytesK256(reservedKey.PrivateKey) 140 + if err != nil { 141 + s.logger.Error("error parsing reserved key", "error", err) 142 + k = nil 143 + } else { 144 + defer func() { 145 + if delErr := s.deleteReservedKey(reservedKey.KeyDid, reservedKey.Did); delErr != nil { 146 + s.logger.Error("error deleting reserved key", "error", delErr) 147 + } 148 + }() 149 + } 150 + } 151 + } 152 + 153 + if k == nil { 154 + k, err = atcrypto.GeneratePrivateKeyK256() 155 + if err != nil { 156 + s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 157 + return helpers.ServerError(e, nil) 158 + } 159 } 160 161 if signupDid == "" {
+125
server/handle_server_delete_account.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/Azure/go-autorest/autorest/to" 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/events" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/labstack/echo/v4" 13 + "golang.org/x/crypto/bcrypt" 14 + ) 15 + 16 + type ComAtprotoServerDeleteAccountRequest struct { 17 + Did string `json:"did" validate:"required"` 18 + Password string `json:"password" validate:"required"` 19 + Token string `json:"token" validate:"required"` 20 + } 21 + 22 + func (s *Server) handleServerDeleteAccount(e echo.Context) error { 23 + var req ComAtprotoServerDeleteAccountRequest 24 + if err := e.Bind(&req); err != nil { 25 + s.logger.Error("error binding", "error", err) 26 + return helpers.ServerError(e, nil) 27 + } 28 + 29 + if err := e.Validate(&req); err != nil { 30 + s.logger.Error("error validating", "error", err) 31 + return helpers.ServerError(e, nil) 32 + } 33 + 34 + urepo, err := s.getRepoActorByDid(req.Did) 35 + if err != nil { 36 + s.logger.Error("error getting repo", "error", err) 37 + return echo.NewHTTPError(400, "account not found") 38 + } 39 + 40 + if err := bcrypt.CompareHashAndPassword([]byte(urepo.Repo.Password), []byte(req.Password)); err != nil { 41 + s.logger.Error("password mismatch", "error", err) 42 + return echo.NewHTTPError(401, "Invalid did or password") 43 + } 44 + 45 + if urepo.Repo.AccountDeleteCode == nil || urepo.Repo.AccountDeleteCodeExpiresAt == nil { 46 + s.logger.Error("no deletion token found for account") 47 + return echo.NewHTTPError(400, map[string]interface{}{ 48 + "error": "InvalidToken", 49 + "message": "Token is invalid", 50 + }) 51 + } 52 + 53 + if *urepo.Repo.AccountDeleteCode != req.Token { 54 + s.logger.Error("deletion token mismatch") 55 + return echo.NewHTTPError(400, map[string]interface{}{ 56 + "error": "InvalidToken", 57 + "message": "Token is invalid", 58 + }) 59 + } 60 + 61 + if time.Now().UTC().After(*urepo.Repo.AccountDeleteCodeExpiresAt) { 62 + s.logger.Error("deletion token expired") 63 + return echo.NewHTTPError(400, map[string]interface{}{ 64 + "error": "ExpiredToken", 65 + "message": "Token is expired", 66 + }) 67 + } 68 + 69 + if err := s.db.Exec("DELETE FROM blocks WHERE did = ?", nil, req.Did).Error; err != nil { 70 + s.logger.Error("error deleting blocks", "error", err) 71 + return helpers.ServerError(e, nil) 72 + } 73 + 74 + if err := s.db.Exec("DELETE FROM records WHERE did = ?", nil, req.Did).Error; err != nil { 75 + s.logger.Error("error deleting records", "error", err) 76 + return helpers.ServerError(e, nil) 77 + } 78 + 79 + if err := s.db.Exec("DELETE FROM blobs WHERE did = ?", nil, req.Did).Error; err != nil { 80 + s.logger.Error("error deleting blobs", "error", err) 81 + return helpers.ServerError(e, nil) 82 + } 83 + 84 + if err := s.db.Exec("DELETE FROM tokens WHERE did = ?", nil, req.Did).Error; err != nil { 85 + s.logger.Error("error deleting tokens", "error", err) 86 + return helpers.ServerError(e, nil) 87 + } 88 + 89 + if err := s.db.Exec("DELETE FROM refresh_tokens WHERE did = ?", nil, req.Did).Error; err != nil { 90 + s.logger.Error("error deleting refresh tokens", "error", err) 91 + return helpers.ServerError(e, nil) 92 + } 93 + 94 + if err := s.db.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, req.Did).Error; err != nil { 95 + s.logger.Error("error deleting reserved keys", "error", err) 96 + return helpers.ServerError(e, nil) 97 + } 98 + 99 + if err := s.db.Exec("DELETE FROM invite_codes WHERE did = ?", nil, req.Did).Error; err != nil { 100 + s.logger.Error("error deleting invite codes", "error", err) 101 + return helpers.ServerError(e, nil) 102 + } 103 + 104 + if err := s.db.Exec("DELETE FROM actors WHERE did = ?", nil, req.Did).Error; err != nil { 105 + s.logger.Error("error deleting actor", "error", err) 106 + return helpers.ServerError(e, nil) 107 + } 108 + 109 + if err := s.db.Exec("DELETE FROM repos WHERE did = ?", nil, req.Did).Error; err != nil { 110 + s.logger.Error("error deleting repo", "error", err) 111 + return helpers.ServerError(e, nil) 112 + } 113 + 114 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 115 + RepoAccount: &atproto.SyncSubscribeRepos_Account{ 116 + Active: false, 117 + Did: req.Did, 118 + Status: to.StringPtr("deleted"), 119 + Seq: time.Now().UnixMicro(), 120 + Time: time.Now().Format(util.ISO8601), 121 + }, 122 + }) 123 + 124 + return e.NoContent(200) 125 + }
+10 -3
server/handle_server_get_service_auth.go
··· 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 { ··· 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 } ··· 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 {
··· 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"` 25 } 26 27 func (s *Server) handleServerGetServiceAuth(e echo.Context) error { ··· 45 return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively")) 46 } 47 48 + var maxExp int64 49 + if req.Lxm != "" { 50 + maxExp = now + (60 * 60) 51 + } else { 52 + maxExp = now + 60 53 + } 54 if exp > maxExp { 55 return helpers.InputError(e, to.StringPtr("expiration too big. smoller please")) 56 } ··· 73 payload := map[string]any{ 74 "iss": repo.Repo.Did, 75 "aud": req.Aud, 76 "jti": uuid.NewString(), 77 "exp": exp, 78 "iat": now, 79 + } 80 + if req.Lxm != "" { 81 + payload["lxm"] = req.Lxm 82 } 83 pj, err := json.Marshal(payload) 84 if err != nil {
+49
server/handle_server_request_account_delete.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 + func (s *Server) handleServerRequestAccountDelete(e echo.Context) error { 13 + urepo := e.Get("repo").(*models.RepoActor) 14 + 15 + token := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 16 + expiresAt := time.Now().UTC().Add(15 * time.Minute) 17 + 18 + if err := s.db.Exec("UPDATE repos SET account_delete_code = ?, account_delete_code_expires_at = ? WHERE did = ?", nil, token, expiresAt, urepo.Repo.Did).Error; err != nil { 19 + s.logger.Error("error setting deletion token", "error", err) 20 + return helpers.ServerError(e, nil) 21 + } 22 + 23 + if urepo.Email != "" { 24 + if err := s.sendAccountDeleteEmail(urepo.Email, urepo.Actor.Handle, token); err != nil { 25 + s.logger.Error("error sending account deletion email", "error", err) 26 + } 27 + } 28 + 29 + return e.NoContent(200) 30 + } 31 + 32 + func (s *Server) sendAccountDeleteEmail(email, handle, token string) error { 33 + if s.mail == nil { 34 + return nil 35 + } 36 + 37 + s.mailLk.Lock() 38 + defer s.mailLk.Unlock() 39 + 40 + s.mail.To(email) 41 + s.mail.Subject("Account Deletion Request for " + s.config.Hostname) 42 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your account deletion code is %s. This code will expire in fifteen minutes. If you did not request this, please ignore this email.", handle, token)) 43 + 44 + if err := s.mail.Send(); err != nil { 45 + return err 46 + } 47 + 48 + return nil 49 + }
+95
server/handle_server_reserve_signing_key.go
···
··· 1 + package server 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/atcrypto" 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/models" 9 + "github.com/labstack/echo/v4" 10 + ) 11 + 12 + type ServerReserveSigningKeyRequest struct { 13 + Did *string `json:"did"` 14 + } 15 + 16 + type ServerReserveSigningKeyResponse struct { 17 + SigningKey string `json:"signingKey"` 18 + } 19 + 20 + func (s *Server) handleServerReserveSigningKey(e echo.Context) error { 21 + var req ServerReserveSigningKeyRequest 22 + if err := e.Bind(&req); err != nil { 23 + s.logger.Error("could not bind reserve signing key request", "error", err) 24 + return helpers.ServerError(e, nil) 25 + } 26 + 27 + if req.Did != nil && *req.Did != "" { 28 + var existing models.ReservedKey 29 + if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, *req.Did).Scan(&existing).Error; err == nil && existing.KeyDid != "" { 30 + return e.JSON(200, ServerReserveSigningKeyResponse{ 31 + SigningKey: existing.KeyDid, 32 + }) 33 + } 34 + } 35 + 36 + k, err := atcrypto.GeneratePrivateKeyK256() 37 + if err != nil { 38 + s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 39 + return helpers.ServerError(e, nil) 40 + } 41 + 42 + pubKey, err := k.PublicKey() 43 + if err != nil { 44 + s.logger.Error("error getting public key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 45 + return helpers.ServerError(e, nil) 46 + } 47 + 48 + keyDid := pubKey.DIDKey() 49 + 50 + reservedKey := models.ReservedKey{ 51 + KeyDid: keyDid, 52 + Did: req.Did, 53 + PrivateKey: k.Bytes(), 54 + CreatedAt: time.Now(), 55 + } 56 + 57 + if err := s.db.Create(&reservedKey, nil).Error; err != nil { 58 + s.logger.Error("error storing reserved key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 59 + return helpers.ServerError(e, nil) 60 + } 61 + 62 + s.logger.Info("reserved signing key", "keyDid", keyDid, "forDid", req.Did) 63 + 64 + return e.JSON(200, ServerReserveSigningKeyResponse{ 65 + SigningKey: keyDid, 66 + }) 67 + } 68 + 69 + func (s *Server) getReservedKey(keyDidOrDid string) (*models.ReservedKey, error) { 70 + var reservedKey models.ReservedKey 71 + 72 + if err := s.db.Raw("SELECT * FROM reserved_keys WHERE key_did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" { 73 + return &reservedKey, nil 74 + } 75 + 76 + if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" { 77 + return &reservedKey, nil 78 + } 79 + 80 + return nil, nil 81 + } 82 + 83 + func (s *Server) deleteReservedKey(keyDid string, did *string) error { 84 + if err := s.db.Exec("DELETE FROM reserved_keys WHERE key_did = ?", nil, keyDid).Error; err != nil { 85 + return err 86 + } 87 + 88 + if did != nil && *did != "" { 89 + if err := s.db.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, *did).Error; err != nil { 90 + return err 91 + } 92 + } 93 + 94 + return nil 95 + }
+9 -2
server/handle_sync_get_blob.go
··· 65 buf.Write(p.Data) 66 } 67 } else if blob.Storage == "s3" { 68 - if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) { 69 s.logger.Error("s3 storage disabled") 70 return helpers.ServerError(e, nil) 71 } 72 73 config := &aws.Config{ ··· 89 svc := s3.New(sess) 90 if result, err := svc.GetObject(&s3.GetObjectInput{ 91 Bucket: aws.String(s.s3Config.Bucket), 92 - Key: aws.String(fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())), 93 }); err != nil { 94 s.logger.Error("error getting blob from s3", "error", err) 95 return helpers.ServerError(e, nil)
··· 65 buf.Write(p.Data) 66 } 67 } else if blob.Storage == "s3" { 68 + if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) { 69 s.logger.Error("s3 storage disabled") 70 return helpers.ServerError(e, nil) 71 + } 72 + 73 + blobKey := fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String()) 74 + 75 + if s.s3Config.CDNUrl != "" { 76 + redirectUrl := fmt.Sprintf("%s/%s", s.s3Config.CDNUrl, blobKey) 77 + return e.Redirect(302, redirectUrl) 78 } 79 80 config := &aws.Config{ ··· 96 svc := s3.New(sess) 97 if result, err := svc.GetObject(&s3.GetObjectInput{ 98 Bucket: aws.String(s.s3Config.Bucket), 99 + Key: aws.String(blobKey), 100 }); err != nil { 101 s.logger.Error("error getting blob from s3", "error", err) 102 return helpers.ServerError(e, nil)
+9 -2
server/middleware.go
··· 232 proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 233 if err != nil { 234 if errors.Is(err, dpop.ErrUseDpopNonce) { 235 - return e.JSON(400, map[string]string{ 236 "error": "use_dpop_nonce", 237 }) 238 } ··· 256 } 257 258 if time.Now().After(oauthToken.ExpiresAt) { 259 - return helpers.ExpiredTokenError(e) 260 } 261 262 repo, err := s.getRepoActorByDid(oauthToken.Sub)
··· 232 proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 233 if err != nil { 234 if errors.Is(err, dpop.ErrUseDpopNonce) { 235 + e.Response().Header().Set("WWW-Authenticate", `DPoP error="use_dpop_nonce"`) 236 + e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate") 237 + return e.JSON(401, map[string]string{ 238 "error": "use_dpop_nonce", 239 }) 240 } ··· 258 } 259 260 if time.Now().After(oauthToken.ExpiresAt) { 261 + e.Response().Header().Set("WWW-Authenticate", `DPoP error="invalid_token", error_description="Token expired"`) 262 + e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate") 263 + return e.JSON(401, map[string]string{ 264 + "error": "invalid_token", 265 + "error_description": "Token expired", 266 + }) 267 } 268 269 repo, err := s.getRepoActorByDid(oauthToken.Sub)
+6 -1
server/server.go
··· 60 Bucket string 61 AccessKey string 62 SecretKey string 63 } 64 65 type Server struct { ··· 450 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 451 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession) 452 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer) 453 454 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo) 455 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos) 456 s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords) 457 - s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs) 458 s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord) 459 s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord) 460 s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks) ··· 500 s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 501 s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 502 s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 503 504 // repo 505 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 506 s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 507 s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) ··· 538 &models.Record{}, 539 &models.Blob{}, 540 &models.BlobPart{}, 541 &provider.OauthToken{}, 542 &provider.OauthAuthorizationRequest{}, 543 )
··· 60 Bucket string 61 AccessKey string 62 SecretKey string 63 + CDNUrl string 64 } 65 66 type Server struct { ··· 451 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 452 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession) 453 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer) 454 + s.echo.POST("/xrpc/com.atproto.server.reserveSigningKey", s.handleServerReserveSigningKey) 455 456 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo) 457 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos) 458 s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords) 459 s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord) 460 s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord) 461 s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks) ··· 501 s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 502 s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 503 s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 504 + s.echo.POST("/xrpc/com.atproto.server.requestAccountDelete", s.handleServerRequestAccountDelete, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 505 + s.echo.POST("/xrpc/com.atproto.server.deleteAccount", s.handleServerDeleteAccount) 506 507 // repo 508 + s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 509 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 510 s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 511 s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) ··· 542 &models.Record{}, 543 &models.Blob{}, 544 &models.BlobPart{}, 545 + &models.ReservedKey{}, 546 &provider.OauthToken{}, 547 &provider.OauthAuthorizationRequest{}, 548 )