A fork of the Cocoon PDS but being made more distributed.
0

Configure Feed

Select the types of activity you want to include in your feed.

1package main 2 3import ( 4 "crypto/ecdsa" 5 "crypto/elliptic" 6 "crypto/rand" 7 "encoding/json" 8 "fmt" 9 "log/slog" 10 "os" 11 "strings" 12 "time" 13 14 "github.com/bluesky-social/go-util/pkg/telemetry" 15 "github.com/bluesky-social/indigo/atproto/atcrypto" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 "github.com/haileyok/cocoon/internal/helpers" 18 "github.com/haileyok/cocoon/server" 19 _ "github.com/joho/godotenv/autoload" 20 "github.com/lestrrat-go/jwx/v2/jwk" 21 "github.com/urfave/cli/v2" 22 "golang.org/x/crypto/bcrypt" 23 "gorm.io/driver/postgres" 24 "gorm.io/driver/sqlite" 25 "gorm.io/gorm" 26) 27 28var Version = "dev" 29 30func main() { 31 app := &cli.App{ 32 Name: "cocoon", 33 Usage: "An atproto PDS", 34 Flags: []cli.Flag{ 35 &cli.StringFlag{ 36 Name: "addr", 37 Value: ":8080", 38 EnvVars: []string{"COCOON_ADDR"}, 39 }, 40 &cli.StringFlag{ 41 Name: "db-name", 42 Value: "cocoon.db", 43 EnvVars: []string{"COCOON_DB_NAME"}, 44 }, 45 &cli.StringFlag{ 46 Name: "db-type", 47 Value: "sqlite", 48 Usage: "Database type: sqlite or postgres", 49 EnvVars: []string{"COCOON_DB_TYPE"}, 50 }, 51 &cli.StringFlag{ 52 Name: "database-url", 53 Aliases: []string{"db-url"}, 54 Usage: "PostgreSQL connection string (required if db-type is postgres)", 55 EnvVars: []string{"COCOON_DATABASE_URL", "DATABASE_URL"}, 56 }, 57 &cli.StringFlag{ 58 Name: "turso-token", 59 Usage: "Token for a cloud Turso instance", 60 EnvVars: []string{"COCOON_TURSO_TOKEN", "TURSO_TOKEN"}, 61 }, 62 &cli.StringFlag{ 63 Name: "did", 64 EnvVars: []string{"COCOON_DID"}, 65 }, 66 &cli.StringFlag{ 67 Name: "hostname", 68 EnvVars: []string{"COCOON_HOSTNAME"}, 69 }, 70 &cli.StringFlag{ 71 Name: "rotation-key-path", 72 EnvVars: []string{"COCOON_ROTATION_KEY_PATH"}, 73 }, 74 &cli.StringFlag{ 75 Name: "jwk-path", 76 EnvVars: []string{"COCOON_JWK_PATH"}, 77 }, 78 &cli.StringFlag{ 79 Name: "contact-email", 80 EnvVars: []string{"COCOON_CONTACT_EMAIL"}, 81 }, 82 &cli.StringSliceFlag{ 83 Name: "relays", 84 EnvVars: []string{"COCOON_RELAYS"}, 85 }, 86 &cli.StringFlag{ 87 Name: "admin-password", 88 EnvVars: []string{"COCOON_ADMIN_PASSWORD"}, 89 }, 90 &cli.BoolFlag{ 91 Name: "require-invite", 92 EnvVars: []string{"COCOON_REQUIRE_INVITE"}, 93 Value: true, 94 }, 95 &cli.StringFlag{ 96 Name: "smtp-user", 97 EnvVars: []string{"COCOON_SMTP_USER"}, 98 }, 99 &cli.StringFlag{ 100 Name: "smtp-pass", 101 EnvVars: []string{"COCOON_SMTP_PASS"}, 102 }, 103 &cli.StringFlag{ 104 Name: "smtp-host", 105 EnvVars: []string{"COCOON_SMTP_HOST"}, 106 }, 107 &cli.StringFlag{ 108 Name: "smtp-port", 109 EnvVars: []string{"COCOON_SMTP_PORT"}, 110 }, 111 &cli.StringFlag{ 112 Name: "smtp-email", 113 EnvVars: []string{"COCOON_SMTP_EMAIL"}, 114 }, 115 &cli.StringFlag{ 116 Name: "smtp-name", 117 EnvVars: []string{"COCOON_SMTP_NAME"}, 118 }, 119 &cli.BoolFlag{ 120 Name: "s3-backups-enabled", 121 EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"}, 122 }, 123 &cli.BoolFlag{ 124 Name: "s3-blobstore-enabled", 125 EnvVars: []string{"COCOON_S3_BLOBSTORE_ENABLED"}, 126 }, 127 &cli.StringFlag{ 128 Name: "s3-region", 129 EnvVars: []string{"COCOON_S3_REGION"}, 130 }, 131 &cli.StringFlag{ 132 Name: "s3-bucket", 133 EnvVars: []string{"COCOON_S3_BUCKET"}, 134 }, 135 &cli.StringFlag{ 136 Name: "s3-endpoint", 137 EnvVars: []string{"COCOON_S3_ENDPOINT"}, 138 }, 139 &cli.StringFlag{ 140 Name: "s3-access-key", 141 EnvVars: []string{"COCOON_S3_ACCESS_KEY"}, 142 }, 143 &cli.StringFlag{ 144 Name: "s3-secret-key", 145 EnvVars: []string{"COCOON_S3_SECRET_KEY"}, 146 }, 147 &cli.StringFlag{ 148 Name: "s3-cdn-url", 149 EnvVars: []string{"COCOON_S3_CDN_URL"}, 150 Usage: "Public URL for S3 blob redirects (e.g., https://cdn.example.com). When set, getBlob redirects to this URL instead of proxying.", 151 }, 152 &cli.StringFlag{ 153 Name: "session-secret", 154 EnvVars: []string{"COCOON_SESSION_SECRET"}, 155 }, 156 &cli.StringFlag{ 157 Name: "session-cookie-key", 158 EnvVars: []string{"COCOON_SESSION_COOKIE_KEY"}, 159 Value: "session", 160 }, 161 &cli.StringFlag{ 162 Name: "blockstore-variant", 163 EnvVars: []string{"COCOON_BLOCKSTORE_VARIANT"}, 164 Value: "sqlite", 165 }, 166 &cli.StringFlag{ 167 Name: "fallback-proxy", 168 EnvVars: []string{"COCOON_FALLBACK_PROXY"}, 169 }, 170 telemetry.CLIFlagDebug, 171 telemetry.CLIFlagMetricsListenAddress, 172 &cli.StringFlag{ 173 Name: "subscribe-repos-service-url", 174 EnvVars: []string{"SUBSCRIBE_REPOS_SERVICE_URL"}, 175 }, 176 &cli.BoolFlag{ 177 Name: "push-based-events", 178 EnvVars: []string{"PUSH_BASED_EVENTS"}, 179 }, 180 &cli.StringFlag{ 181 Name: "nonce-secret", 182 Usage: "To set a nonce secret", 183 EnvVars: []string{"COCOON_NONCE_SECRET"}, 184 }, 185 }, 186 Commands: []*cli.Command{ 187 runServe, 188 runCreateRotationKey, 189 runCreatePrivateJwk, 190 runCreateInviteCode, 191 runResetPassword, 192 }, 193 ErrWriter: os.Stdout, 194 Version: Version, 195 } 196 197 if err := app.Run(os.Args); err != nil { 198 fmt.Printf("Error: %v\n", err) 199 } 200} 201 202var runServe = &cli.Command{ 203 Name: "run", 204 Usage: "Start the cocoon PDS", 205 Flags: []cli.Flag{ 206 &cli.StringFlag{ 207 Name: "log-level", 208 Usage: "Log level: debug, info, warn, error", 209 EnvVars: []string{"COCOON_LOG_LEVEL", "LOG_LEVEL"}, 210 Value: "info", 211 }, 212 }, 213 Action: func(cmd *cli.Context) error { 214 215 logger := telemetry.StartLogger(cmd) 216 telemetry.StartMetrics(cmd) 217 218 var level slog.Level 219 switch strings.ToLower(cmd.String("log-level")) { 220 case "debug": 221 level = slog.LevelDebug 222 case "info": 223 level = slog.LevelInfo 224 case "warn": 225 level = slog.LevelWarn 226 case "error": 227 level = slog.LevelError 228 default: 229 level = slog.LevelInfo 230 } 231 232 s, err := server.New(&server.Args{ 233 Logger: logger, 234 LogLevel: level, 235 Addr: cmd.String("addr"), 236 DbName: cmd.String("db-name"), 237 DbType: cmd.String("db-type"), 238 DatabaseURL: cmd.String("database-url"), 239 TursoToken: cmd.String("turso-token"), 240 Did: cmd.String("did"), 241 Hostname: cmd.String("hostname"), 242 RotationKeyPath: cmd.String("rotation-key-path"), 243 JwkPath: cmd.String("jwk-path"), 244 ContactEmail: cmd.String("contact-email"), 245 Version: Version, 246 Relays: cmd.StringSlice("relays"), 247 AdminPassword: cmd.String("admin-password"), 248 RequireInvite: cmd.Bool("require-invite"), 249 NonceSecret: cmd.String("nonce-secret"), 250 SmtpUser: cmd.String("smtp-user"), 251 SmtpPass: cmd.String("smtp-pass"), 252 SmtpHost: cmd.String("smtp-host"), 253 SmtpPort: cmd.String("smtp-port"), 254 SmtpEmail: cmd.String("smtp-email"), 255 SmtpName: cmd.String("smtp-name"), 256 S3Config: &server.S3Config{ 257 BackupsEnabled: cmd.Bool("s3-backups-enabled"), 258 BlobstoreEnabled: cmd.Bool("s3-blobstore-enabled"), 259 Region: cmd.String("s3-region"), 260 Bucket: cmd.String("s3-bucket"), 261 Endpoint: cmd.String("s3-endpoint"), 262 AccessKey: cmd.String("s3-access-key"), 263 SecretKey: cmd.String("s3-secret-key"), 264 CDNUrl: cmd.String("s3-cdn-url"), 265 }, 266 SessionSecret: cmd.String("session-secret"), 267 SessionCookieKey: cmd.String("session-cookie-key"), 268 BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")), 269 FallbackProxy: cmd.String("fallback-proxy"), 270 PushBasedEvents: cmd.Bool("push-based-events"), 271 SubscribeReposServiceURL: cmd.String("subscribe-repos-service-url"), 272 }) 273 if err != nil { 274 fmt.Printf("error creating cocoon: %v", err) 275 return err 276 } 277 278 if err := s.Serve(cmd.Context); err != nil { 279 fmt.Printf("error starting cocoon: %v", err) 280 return err 281 } 282 283 return nil 284 }, 285} 286 287var runCreateRotationKey = &cli.Command{ 288 Name: "create-rotation-key", 289 Usage: "creates a rotation key for your pds", 290 Flags: []cli.Flag{ 291 &cli.StringFlag{ 292 Name: "out", 293 Required: true, 294 Usage: "output file for your rotation key", 295 }, 296 }, 297 Action: func(cmd *cli.Context) error { 298 key, err := atcrypto.GeneratePrivateKeyK256() 299 if err != nil { 300 return err 301 } 302 303 bytes := key.Bytes() 304 305 if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil { 306 return err 307 } 308 309 return nil 310 }, 311} 312 313var runCreatePrivateJwk = &cli.Command{ 314 Name: "create-private-jwk", 315 Usage: "creates a private jwk for your pds", 316 Flags: []cli.Flag{ 317 &cli.StringFlag{ 318 Name: "out", 319 Required: true, 320 Usage: "output file for your jwk", 321 }, 322 }, 323 Action: func(cmd *cli.Context) error { 324 privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 325 if err != nil { 326 return err 327 } 328 329 key, err := jwk.FromRaw(privKey) 330 if err != nil { 331 return err 332 } 333 334 kid := fmt.Sprintf("%d", time.Now().Unix()) 335 336 if err := key.Set(jwk.KeyIDKey, kid); err != nil { 337 return err 338 } 339 340 b, err := json.Marshal(key) 341 if err != nil { 342 return err 343 } 344 345 if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil { 346 return err 347 } 348 349 return nil 350 }, 351} 352 353var runCreateInviteCode = &cli.Command{ 354 Name: "create-invite-code", 355 Usage: "creates an invite code", 356 Flags: []cli.Flag{ 357 &cli.StringFlag{ 358 Name: "for", 359 Usage: "optional did to assign the invite code to", 360 }, 361 &cli.IntFlag{ 362 Name: "uses", 363 Usage: "number of times the invite code can be used", 364 Value: 1, 365 }, 366 }, 367 Action: func(cmd *cli.Context) error { 368 db, err := newDb(cmd) 369 if err != nil { 370 return err 371 } 372 373 forDid := "did:plc:123" 374 if cmd.String("for") != "" { 375 did, err := syntax.ParseDID(cmd.String("for")) 376 if err != nil { 377 return err 378 } 379 380 forDid = did.String() 381 } 382 383 uses := cmd.Int("uses") 384 385 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8)) 386 387 if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil { 388 return err 389 } 390 391 fmt.Printf("New invite code created with %d uses: %s\n", uses, code) 392 393 return nil 394 }, 395} 396 397var runResetPassword = &cli.Command{ 398 Name: "reset-password", 399 Usage: "resets a password", 400 Flags: []cli.Flag{ 401 &cli.StringFlag{ 402 Name: "did", 403 Usage: "did of the user who's password you want to reset", 404 }, 405 }, 406 Action: func(cmd *cli.Context) error { 407 db, err := newDb(cmd) 408 if err != nil { 409 return err 410 } 411 412 didStr := cmd.String("did") 413 did, err := syntax.ParseDID(didStr) 414 if err != nil { 415 return err 416 } 417 418 newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12)) 419 hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10) 420 if err != nil { 421 return err 422 } 423 424 if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil { 425 return err 426 } 427 428 fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass) 429 430 return nil 431 }, 432} 433 434func newDb(cmd *cli.Context) (*gorm.DB, error) { 435 dbType := cmd.String("db-type") 436 if dbType == "" { 437 dbType = "sqlite" 438 } 439 440 switch dbType { 441 case "postgres": 442 databaseURL := cmd.String("database-url") 443 if databaseURL == "" { 444 return nil, fmt.Errorf("COCOON_DATABASE_URL or DATABASE_URL must be set when using postgres") 445 } 446 return gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) 447 default: 448 dbName := cmd.String("db-name") 449 if dbName == "" { 450 dbName = "cocoon.db" 451 } 452 return gorm.Open(sqlite.Open(dbName), &gorm.Config{}) 453 } 454}