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

feat: postgresql support, fix github actions, implement ListMissingBlobs and more (#43)

* init

* implement listmissingblobs

* Update README to mark listMissingBlobs as completed

* e

* Update README.md

Co-authored-by: hailey <hailey@blueskyweb.xyz>

---------

Co-authored-by: hailey <hailey@blueskyweb.xyz>

authored by Scan hailey and committed by GitHub bf1a93fc 214d8b4d

+10 -2
.github/workflows/docker-image.yml
··· 5 5 push: 6 6 branches: 7 7 - main 8 + tags: 9 + - 'v*' 8 10 9 11 env: 10 12 REGISTRY: ghcr.io ··· 23 25 steps: 24 26 - name: Checkout repository 25 27 uses: actions/checkout@v4 28 + 26 29 # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 27 30 - name: Log in to the Container registry 28 - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 31 + uses: docker/login-action@v3 29 32 with: 30 33 registry: ${{ env.REGISTRY }} 31 34 username: ${{ github.actor }} 32 35 password: ${{ secrets.GITHUB_TOKEN }} 36 + 33 37 # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 34 38 - name: Extract metadata (tags, labels) for Docker 35 39 id: meta ··· 37 41 with: 38 42 images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 43 tags: | 44 + type=raw,value=latest,enable={{is_default_branch}} 40 45 type=sha 41 46 type=sha,format=long 47 + type=semver,pattern={{version}} 48 + type=semver,pattern={{major}}.{{minor}} 49 + 42 50 # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 43 51 # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. 44 52 # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 45 53 - name: Build and push Docker image 46 54 id: push 47 - uses: docker/build-push-action@v5 55 + uses: docker/build-push-action@v6 48 56 with: 49 57 context: . 50 58 push: true
+10
Caddyfile.postgres
··· 1 + {$COCOON_HOSTNAME} { 2 + reverse_proxy cocoon:8080 3 + 4 + encode gzip 5 + 6 + log { 7 + output file /data/access.log 8 + format json 9 + } 10 + }
+44 -1
README.md
··· 55 55 docker-compose up -d 56 56 ``` 57 57 58 + **For PostgreSQL deployment:** 59 + ```bash 60 + # Add POSTGRES_PASSWORD to your .env file first! 61 + docker-compose -f docker-compose.postgres.yaml up -d 62 + ``` 63 + 58 64 5. **Get your invite code** 59 65 60 66 On first run, an invite code is automatically created. View it with: ··· 96 102 97 103 ### Optional Configuration 98 104 105 + #### Database Configuration 106 + 107 + By default, Cocoon uses SQLite which requires no additional setup. For production deployments with higher traffic, you can use PostgreSQL: 108 + 109 + ```bash 110 + # Database type: sqlite (default) or postgres 111 + COCOON_DB_TYPE="postgres" 112 + 113 + # PostgreSQL connection string (required if db-type is postgres) 114 + # Format: postgres://user:password@host:port/database?sslmode=disable 115 + COCOON_DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable" 116 + 117 + # Or use the standard DATABASE_URL environment variable 118 + DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable" 119 + ``` 120 + 121 + For SQLite (default): 122 + ```bash 123 + COCOON_DB_TYPE="sqlite" 124 + COCOON_DB_NAME="/data/cocoon/cocoon.db" 125 + ``` 126 + 127 + > **Note**: When using PostgreSQL, database backups to S3 are not handled by Cocoon. Use `pg_dump` or your database provider's backup solution instead. 128 + 99 129 #### SMTP Email Settings 100 130 ```bash 101 131 COCOON_SMTP_USER="your-smtp-username" ··· 107 137 ``` 108 138 109 139 #### S3 Storage 140 + 141 + Cocoon supports S3-compatible storage for both database backups (SQLite only) and blob storage (images, videos, etc.): 142 + 110 143 ```bash 144 + # Enable S3 backups (SQLite databases only - hourly backups) 111 145 COCOON_S3_BACKUPS_ENABLED=true 146 + 147 + # Enable S3 for blob storage (images, videos, etc.) 148 + # When enabled, blobs are stored in S3 instead of the database 112 149 COCOON_S3_BLOBSTORE_ENABLED=true 150 + 151 + # S3 configuration (works with AWS S3, MinIO, Cloudflare R2, etc.) 113 152 COCOON_S3_REGION="us-east-1" 114 153 COCOON_S3_BUCKET="your-bucket" 115 154 COCOON_S3_ENDPOINT="https://s3.amazonaws.com" 116 155 COCOON_S3_ACCESS_KEY="your-access-key" 117 156 COCOON_S3_SECRET_KEY="your-secret-key" 118 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}` 119 162 120 163 ### Management Commands 121 164 ··· 160 203 - [x] `com.atproto.repo.getRecord` 161 204 - [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.) 162 205 - [x] `com.atproto.repo.listRecords` 163 - - [ ] `com.atproto.repo.listMissingBlobs` 206 + - [x] `com.atproto.repo.listMissingBlobs` (Not actually functional, but will return a response as if no blobs were missing) 164 207 165 208 ### Server 166 209
+36 -1
cmd/cocoon/main.go
··· 17 17 "github.com/lestrrat-go/jwx/v2/jwk" 18 18 "github.com/urfave/cli/v2" 19 19 "golang.org/x/crypto/bcrypt" 20 + "gorm.io/driver/postgres" 20 21 "gorm.io/driver/sqlite" 21 22 "gorm.io/gorm" 22 23 ) ··· 37 38 Name: "db-name", 38 39 Value: "cocoon.db", 39 40 EnvVars: []string{"COCOON_DB_NAME"}, 41 + }, 42 + &cli.StringFlag{ 43 + Name: "db-type", 44 + Value: "sqlite", 45 + Usage: "Database type: sqlite or postgres", 46 + EnvVars: []string{"COCOON_DB_TYPE"}, 47 + }, 48 + &cli.StringFlag{ 49 + Name: "database-url", 50 + Usage: "PostgreSQL connection string (required if db-type is postgres)", 51 + EnvVars: []string{"COCOON_DATABASE_URL", "DATABASE_URL"}, 40 52 }, 41 53 &cli.StringFlag{ 42 54 Name: "did", ··· 157 169 s, err := server.New(&server.Args{ 158 170 Addr: cmd.String("addr"), 159 171 DbName: cmd.String("db-name"), 172 + DbType: cmd.String("db-type"), 173 + DatabaseURL: cmd.String("database-url"), 160 174 Did: cmd.String("did"), 161 175 Hostname: cmd.String("hostname"), 162 176 RotationKeyPath: cmd.String("rotation-key-path"), ··· 346 360 } 347 361 348 362 func newDb() (*gorm.DB, error) { 349 - return gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{}) 363 + dbType := os.Getenv("COCOON_DB_TYPE") 364 + if dbType == "" { 365 + dbType = "sqlite" 366 + } 367 + 368 + switch dbType { 369 + case "postgres": 370 + databaseURL := os.Getenv("COCOON_DATABASE_URL") 371 + if databaseURL == "" { 372 + databaseURL = os.Getenv("DATABASE_URL") 373 + } 374 + if databaseURL == "" { 375 + return nil, fmt.Errorf("COCOON_DATABASE_URL or DATABASE_URL must be set when using postgres") 376 + } 377 + return gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) 378 + default: 379 + dbName := os.Getenv("COCOON_DB_NAME") 380 + if dbName == "" { 381 + dbName = "cocoon.db" 382 + } 383 + return gorm.Open(sqlite.Open(dbName), &gorm.Config{}) 384 + } 350 385 }
+158
docker-compose.postgres.yaml
··· 1 + # Docker Compose with PostgreSQL 2 + # 3 + # Usage: 4 + # docker-compose -f docker-compose.postgres.yaml up -d 5 + # 6 + # This file extends the base docker-compose.yaml with a PostgreSQL database. 7 + # Set the following in your .env file: 8 + # COCOON_DB_TYPE=postgres 9 + # POSTGRES_PASSWORD=your-secure-password 10 + 11 + version: '3.8' 12 + 13 + services: 14 + postgres: 15 + image: postgres:16-alpine 16 + container_name: cocoon-postgres 17 + environment: 18 + POSTGRES_USER: cocoon 19 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} 20 + POSTGRES_DB: cocoon 21 + volumes: 22 + - postgres_data:/var/lib/postgresql/data 23 + healthcheck: 24 + test: ["CMD-SHELL", "pg_isready -U cocoon -d cocoon"] 25 + interval: 10s 26 + timeout: 5s 27 + retries: 5 28 + restart: unless-stopped 29 + 30 + init-keys: 31 + build: 32 + context: . 33 + dockerfile: Dockerfile 34 + image: ghcr.io/haileyok/cocoon:latest 35 + container_name: cocoon-init-keys 36 + volumes: 37 + - ./keys:/keys 38 + - ./data:/data/cocoon 39 + - ./init-keys.sh:/init-keys.sh:ro 40 + environment: 41 + COCOON_DID: ${COCOON_DID} 42 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 43 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 44 + COCOON_JWK_PATH: /keys/jwk.key 45 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 46 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 47 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 48 + entrypoint: ["/bin/sh", "/init-keys.sh"] 49 + restart: "no" 50 + 51 + cocoon: 52 + build: 53 + context: . 54 + dockerfile: Dockerfile 55 + image: ghcr.io/haileyok/cocoon:latest 56 + container_name: cocoon-pds 57 + depends_on: 58 + init-keys: 59 + condition: service_completed_successfully 60 + postgres: 61 + condition: service_healthy 62 + ports: 63 + - "8080:8080" 64 + volumes: 65 + - ./data:/data/cocoon 66 + - ./keys/rotation.key:/keys/rotation.key:ro 67 + - ./keys/jwk.key:/keys/jwk.key:ro 68 + environment: 69 + # Required settings 70 + COCOON_DID: ${COCOON_DID} 71 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 72 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 73 + COCOON_JWK_PATH: /keys/jwk.key 74 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 75 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 76 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 77 + COCOON_SESSION_SECRET: ${COCOON_SESSION_SECRET} 78 + 79 + # Database configuration - PostgreSQL 80 + COCOON_ADDR: ":8080" 81 + COCOON_DB_TYPE: postgres 82 + COCOON_DATABASE_URL: postgres://cocoon:${POSTGRES_PASSWORD}@postgres:5432/cocoon?sslmode=disable 83 + COCOON_BLOCKSTORE_VARIANT: ${COCOON_BLOCKSTORE_VARIANT:-sqlite} 84 + 85 + # Optional: SMTP settings for email 86 + COCOON_SMTP_USER: ${COCOON_SMTP_USER:-} 87 + COCOON_SMTP_PASS: ${COCOON_SMTP_PASS:-} 88 + COCOON_SMTP_HOST: ${COCOON_SMTP_HOST:-} 89 + COCOON_SMTP_PORT: ${COCOON_SMTP_PORT:-} 90 + COCOON_SMTP_EMAIL: ${COCOON_SMTP_EMAIL:-} 91 + COCOON_SMTP_NAME: ${COCOON_SMTP_NAME:-} 92 + 93 + # Optional: S3 configuration 94 + COCOON_S3_BACKUPS_ENABLED: ${COCOON_S3_BACKUPS_ENABLED:-false} 95 + COCOON_S3_BLOBSTORE_ENABLED: ${COCOON_S3_BLOBSTORE_ENABLED:-false} 96 + COCOON_S3_REGION: ${COCOON_S3_REGION:-} 97 + COCOON_S3_BUCKET: ${COCOON_S3_BUCKET:-} 98 + COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-} 99 + COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-} 100 + COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-} 101 + 102 + # Optional: Fallback proxy 103 + COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-} 104 + restart: unless-stopped 105 + healthcheck: 106 + test: ["CMD", "curl", "-f", "http://localhost:8080/xrpc/_health"] 107 + interval: 30s 108 + timeout: 10s 109 + retries: 3 110 + start_period: 40s 111 + 112 + create-invite: 113 + build: 114 + context: . 115 + dockerfile: Dockerfile 116 + image: ghcr.io/haileyok/cocoon:latest 117 + container_name: cocoon-create-invite 118 + volumes: 119 + - ./keys:/keys 120 + - ./create-initial-invite.sh:/create-initial-invite.sh:ro 121 + environment: 122 + COCOON_DID: ${COCOON_DID} 123 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 124 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 125 + COCOON_JWK_PATH: /keys/jwk.key 126 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 127 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 128 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 129 + COCOON_DB_TYPE: postgres 130 + COCOON_DATABASE_URL: postgres://cocoon:${POSTGRES_PASSWORD}@postgres:5432/cocoon?sslmode=disable 131 + depends_on: 132 + cocoon: 133 + condition: service_healthy 134 + entrypoint: ["/bin/sh", "/create-initial-invite.sh"] 135 + restart: "no" 136 + 137 + caddy: 138 + image: caddy:2-alpine 139 + container_name: cocoon-caddy 140 + ports: 141 + - "80:80" 142 + - "443:443" 143 + volumes: 144 + - ./Caddyfile.postgres:/etc/caddy/Caddyfile:ro 145 + - caddy_data:/data 146 + - caddy_config:/config 147 + restart: unless-stopped 148 + environment: 149 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 150 + CADDY_ACME_EMAIL: ${COCOON_CONTACT_EMAIL:-} 151 + 152 + volumes: 153 + postgres_data: 154 + driver: local 155 + caddy_data: 156 + driver: local 157 + caddy_config: 158 + driver: local
+6 -2
docker-compose.yaml
··· 49 49 50 50 # Server configuration 51 51 COCOON_ADDR: ":8080" 52 - COCOON_DB_NAME: /data/cocoon/cocoon.db 52 + COCOON_DB_TYPE: ${COCOON_DB_TYPE:-sqlite} 53 + COCOON_DB_NAME: ${COCOON_DB_NAME:-/data/cocoon/cocoon.db} 54 + COCOON_DATABASE_URL: ${COCOON_DATABASE_URL:-} 53 55 COCOON_BLOCKSTORE_VARIANT: ${COCOON_BLOCKSTORE_VARIANT:-sqlite} 54 56 55 57 # Optional: SMTP settings for email ··· 97 99 COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 98 100 COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 99 101 COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 100 - COCOON_DB_NAME: /data/cocoon/cocoon.db 102 + COCOON_DB_TYPE: ${COCOON_DB_TYPE:-sqlite} 103 + COCOON_DB_NAME: ${COCOON_DB_NAME:-/data/cocoon/cocoon.db} 104 + COCOON_DATABASE_URL: ${COCOON_DATABASE_URL:-} 101 105 depends_on: 102 106 - init-keys 103 107 entrypoint: ["/bin/sh", "/create-initial-invite.sh"]
+21
server/handle_repo_list_missing_blobs.go
··· 1 + package server 2 + 3 + import ( 4 + "github.com/labstack/echo/v4" 5 + ) 6 + 7 + type ComAtprotoRepoListMissingBlobsResponse struct { 8 + Cursor *string `json:"cursor,omitempty"` 9 + Blobs []ComAtprotoRepoListMissingBlobsRecordBlob `json:"blobs"` 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 + }
+34 -3
server/server.go
··· 43 43 "github.com/labstack/echo/v4" 44 44 "github.com/labstack/echo/v4/middleware" 45 45 slogecho "github.com/samber/slog-echo" 46 + "gorm.io/driver/postgres" 46 47 "gorm.io/driver/sqlite" 47 48 "gorm.io/gorm" 48 49 ) ··· 82 83 requestCrawlMu sync.Mutex 83 84 84 85 dbName string 86 + dbType string 85 87 s3Config *S3Config 86 88 } 87 89 88 90 type Args struct { 89 91 Addr string 90 92 DbName string 93 + DbType string 94 + DatabaseURL string 91 95 Logger *slog.Logger 92 96 Version string 93 97 Did string ··· 288 292 IdleTimeout: 5 * time.Minute, 289 293 } 290 294 291 - gdb, err := gorm.Open(sqlite.Open(args.DbName), &gorm.Config{}) 292 - if err != nil { 293 - return nil, err 295 + dbType := args.DbType 296 + if dbType == "" { 297 + dbType = "sqlite" 298 + } 299 + 300 + var gdb *gorm.DB 301 + var err error 302 + switch dbType { 303 + case "postgres": 304 + if args.DatabaseURL == "" { 305 + return nil, fmt.Errorf("database-url must be set when using postgres") 306 + } 307 + gdb, err = gorm.Open(postgres.Open(args.DatabaseURL), &gorm.Config{}) 308 + if err != nil { 309 + return nil, fmt.Errorf("failed to connect to postgres: %w", err) 310 + } 311 + args.Logger.Info("connected to PostgreSQL database") 312 + default: 313 + gdb, err = gorm.Open(sqlite.Open(args.DbName), &gorm.Config{}) 314 + if err != nil { 315 + return nil, fmt.Errorf("failed to open sqlite database: %w", err) 316 + } 317 + args.Logger.Info("connected to SQLite database", "path", args.DbName) 294 318 } 295 319 dbw := db.NewDB(gdb) 296 320 ··· 363 387 passport: identity.NewPassport(h, identity.NewMemCache(10_000)), 364 388 365 389 dbName: args.DbName, 390 + dbType: dbType, 366 391 s3Config: args.S3Config, 367 392 368 393 oauthProvider: provider.NewProvider(provider.Args{ ··· 429 454 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo) 430 455 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos) 431 456 s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords) 457 + s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs) 432 458 s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord) 433 459 s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord) 434 460 s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks) ··· 569 595 } 570 596 571 597 func (s *Server) doBackup() { 598 + if s.dbType == "postgres" { 599 + s.logger.Info("skipping S3 backup - PostgreSQL backups should be handled externally (pg_dump, managed database backups, etc.)") 600 + return 601 + } 602 + 572 603 start := time.Now() 573 604 574 605 s.logger.Info("beginning backup to s3...")