+10
-2
.github/workflows/docker-image.yml
+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
+10
Caddyfile.postgres
+44
-1
README.md
+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
+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
+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
+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
+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
+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...")