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