package main import ( "context" "net/http" "os" "os/signal" "strings" "syscall" "time" aws "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/patbritton/sojorn-backend/internal/config" "github.com/patbritton/sojorn-backend/internal/handlers" "github.com/patbritton/sojorn-backend/internal/middleware" "github.com/patbritton/sojorn-backend/internal/realtime" "github.com/patbritton/sojorn-backend/internal/repository" "github.com/patbritton/sojorn-backend/internal/services" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) func main() { cfg := config.LoadConfig() log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) if cfg.DatabaseURL == "" { log.Fatal().Msg("DATABASE_URL is not set") } pgxConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL) if err != nil { log.Fatal().Err(err).Msg("Unable to parse database config") } dbPool, err := pgxpool.NewWithConfig(context.Background(), pgxConfig) if err != nil { log.Fatal().Err(err).Msg("Unable to connect to database") } defer dbPool.Close() if err := dbPool.Ping(context.Background()); err != nil { log.Fatal().Err(err).Msg("Unable to ping database") } r := gin.Default() allowedOrigins := strings.Split(cfg.CORSOrigins, ",") allowAllOrigins := false allowedOriginSet := make(map[string]struct{}, len(allowedOrigins)) for _, origin := range allowedOrigins { trimmed := strings.TrimSpace(origin) if trimmed == "" { continue } if trimmed == "*" { allowAllOrigins = true break } allowedOriginSet[trimmed] = struct{}{} } r.Use(cors.New(cors.Config{ AllowOriginFunc: func(origin string) bool { log.Debug().Msgf("CORS origin: %s", origin) if allowAllOrigins { return true } if strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "https://localhost") || strings.HasPrefix(origin, "http://127.0.0.1") || strings.HasPrefix(origin, "https://127.0.0.1") { return true } _, ok := allowedOriginSet[origin] return ok }, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID", "X-Timestamp", "X-Signature", "X-Algorithm"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * time.Hour, })) r.NoRoute(func(c *gin.Context) { log.Debug().Msgf("No route found for %s %s", c.Request.Method, c.Request.URL.Path) c.JSON(404, gin.H{"error": "route not found", "path": c.Request.URL.Path, "method": c.Request.Method}) }) userRepo := repository.NewUserRepository(dbPool) postRepo := repository.NewPostRepository(dbPool) chatRepo := repository.NewChatRepository(dbPool) categoryRepo := repository.NewCategoryRepository(dbPool) notifRepo := repository.NewNotificationRepository(dbPool) tagRepo := repository.NewTagRepository(dbPool) assetService := services.NewAssetService(cfg.R2SigningSecret, cfg.R2PublicBaseURL, cfg.R2ImgDomain, cfg.R2VidDomain) feedService := services.NewFeedService(postRepo, assetService) pushService, err := services.NewPushService(userRepo, cfg.FirebaseCredentialsFile) if err != nil { log.Warn().Err(err).Msg("Failed to initialize PushService") } notificationService := services.NewNotificationService(notifRepo, pushService, userRepo) emailService := services.NewEmailService(cfg) moderationService := services.NewModerationService(dbPool) hub := realtime.NewHub() wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret) userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService) postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService) chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub) authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService) categoryHandler := handlers.NewCategoryHandler(categoryRepo) keyHandler := handlers.NewKeyHandler(userRepo) backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool)) settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo) analysisHandler := handlers.NewAnalysisHandler() var s3Client *s3.Client if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" { resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { return aws.Endpoint{URL: cfg.R2Endpoint, PartitionID: "aws", SigningRegion: "auto"}, nil }) awsCfg, err := awsconfig.LoadDefaultConfig( context.Background(), awsconfig.WithRegion("auto"), awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.R2AccessKey, cfg.R2SecretKey, "")), awsconfig.WithEndpointResolverWithOptions(resolver), ) if err != nil { log.Warn().Err(err).Msg("Failed to load AWS/R2 config, falling back to R2 API token flow") } else { s3Client = s3.NewFromConfig(awsCfg) } } mediaHandler := handlers.NewMediaHandler( s3Client, cfg.R2AccountID, cfg.R2APIToken, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain, ) r.GET("/ws", wsHandler.ServeWS) r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) r.HEAD("/health", func(c *gin.Context) { c.Status(200) }) v1 := r.Group("/api/v1") { auth := v1.Group("/auth") auth.Use(middleware.RateLimit(0.5, 3)) { auth.POST("/register", authHandler.Register) auth.POST("/signup", authHandler.Register) auth.POST("/login", authHandler.Login) auth.POST("/refresh", authHandler.RefreshSession) auth.POST("/resend-verification", authHandler.ResendVerificationEmail) auth.GET("/verify", authHandler.VerifyEmail) auth.POST("/forgot-password", authHandler.ForgotPassword) auth.POST("/reset-password", authHandler.ResetPassword) } authorized := v1.Group("") authorized.Use(middleware.AuthMiddleware(cfg.JWTSecret)) { authorized.GET("/profiles/:id", userHandler.GetProfile) authorized.GET("/profile", userHandler.GetProfile) authorized.PATCH("/profile", userHandler.UpdateProfile) authorized.POST("/complete-onboarding", authHandler.CompleteOnboarding) authorized.GET("/profile/trust-state", userHandler.GetTrustState) settings := authorized.Group("/settings") { settings.GET("/privacy", settingsHandler.GetPrivacySettings) settings.PATCH("/privacy", settingsHandler.UpdatePrivacySettings) settings.GET("/user", settingsHandler.GetUserSettings) settings.PATCH("/user", settingsHandler.UpdateUserSettings) } users := authorized.Group("/users") { users.POST("/:id/follow", userHandler.Follow) users.DELETE("/:id/follow", userHandler.Unfollow) users.POST("/:id/accept", userHandler.AcceptFollowRequest) users.DELETE("/:id/reject", userHandler.RejectFollowRequest) users.GET("/requests", userHandler.GetPendingFollowRequests) users.POST("/requests", userHandler.GetPendingFollowRequests) users.GET("/:id/posts", postHandler.GetProfilePosts) users.GET("/:id/saved", userHandler.GetSavedPosts) users.GET("/me/liked", userHandler.GetLikedPosts) users.POST("/:id/block", userHandler.BlockUser) users.DELETE("/:id/block", userHandler.UnblockUser) users.GET("/blocked", userHandler.GetBlockedUsers) users.POST("/report", userHandler.ReportUser) users.POST("/block_by_handle", userHandler.BlockUserByHandle) } authorized.POST("/posts", postHandler.CreatePost) authorized.GET("/posts/:id", postHandler.GetPost) authorized.GET("/posts/:id/chain", postHandler.GetPostChain) authorized.GET("/posts/:id/thread", postHandler.GetPostChain) authorized.GET("/posts/:id/focus-context", postHandler.GetPostFocusContext) authorized.PATCH("/posts/:id", postHandler.UpdatePost) authorized.DELETE("/posts/:id", postHandler.DeletePost) authorized.POST("/posts/:id/pin", postHandler.PinPost) authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility) authorized.POST("/posts/:id/like", postHandler.LikePost) authorized.DELETE("/posts/:id/like", postHandler.UnlikePost) authorized.POST("/posts/:id/save", postHandler.SavePost) authorized.DELETE("/posts/:id/save", postHandler.UnsavePost) authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction) authorized.POST("/posts/:id/comments", postHandler.CreateComment) authorized.GET("/feed", postHandler.GetFeed) authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons) authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon) authorized.POST("/beacons/:id/report", postHandler.ReportBeacon) authorized.DELETE("/beacons/:id/vouch", postHandler.RemoveBeaconVote) authorized.GET("/categories", categoryHandler.GetCategories) authorized.POST("/categories/settings", categoryHandler.SetUserCategorySettings) authorized.GET("/categories/settings", categoryHandler.GetUserCategorySettings) authorized.POST("/analysis/tone", analysisHandler.CheckTone) // Chat routes authorized.GET("/conversations", chatHandler.GetConversations) authorized.GET("/conversation", chatHandler.GetOrCreateConversation) authorized.POST("/messages", chatHandler.SendMessage) authorized.GET("/conversations/:id/messages", chatHandler.GetMessages) authorized.DELETE("/conversations/:id", chatHandler.DeleteConversation) authorized.DELETE("/messages/:id", chatHandler.DeleteMessage) authorized.GET("/mutual-follows", chatHandler.GetMutualFollows) // Key routes authorized.POST("/keys", keyHandler.PublishKeys) authorized.GET("/keys/:id", keyHandler.GetKeyBundle) authorized.DELETE("/keys/otk/:keyId", keyHandler.DeleteUsedOTK) backupGroup := authorized.Group("/backup") { backupGroup.POST("/sync/generate-code", backupHandler.GenerateSyncCode) backupGroup.POST("/sync/verify-code", backupHandler.VerifySyncCode) backupGroup.POST("/upload", backupHandler.UploadBackup) backupGroup.GET("/download", backupHandler.DownloadBackup) backupGroup.GET("/download/:backupId", backupHandler.DownloadBackup) backupGroup.GET("/list", backupHandler.ListBackups) backupGroup.DELETE("/:backupId", backupHandler.DeleteBackup) backupGroup.GET("/preferences", backupHandler.GetBackupPreferences) backupGroup.PUT("/preferences", backupHandler.UpdateBackupPreferences) } recoveryGroup := authorized.Group("/recovery") { recoveryGroup.POST("/social/setup", backupHandler.SetupSocialRecovery) recoveryGroup.POST("/initiate", backupHandler.InitiateRecovery) recoveryGroup.POST("/submit-shard", backupHandler.SubmitShard) recoveryGroup.POST("/complete/:sessionId", backupHandler.CompleteRecovery) } // Device management routes authorized.GET("/devices", backupHandler.GetUserDevices) // Media routes authorized.POST("/upload", mediaHandler.Upload) // Search & Discover routes discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService) authorized.GET("/search", discoverHandler.Search) authorized.GET("/discover", discoverHandler.GetDiscover) authorized.GET("/hashtags/trending", discoverHandler.GetTrendingHashtags) authorized.GET("/hashtags/following", discoverHandler.GetFollowedHashtags) authorized.GET("/hashtags/:name", discoverHandler.GetHashtagPage) authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag) authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag) // Notifications notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService) authorized.GET("/notifications", notificationHandler.GetNotifications) authorized.GET("/notifications/unread", notificationHandler.GetUnreadCount) authorized.GET("/notifications/badge", notificationHandler.GetBadgeCount) authorized.PUT("/notifications/:id/read", notificationHandler.MarkAsRead) authorized.POST("/notifications/read", notificationHandler.BulkMarkAsRead) authorized.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead) authorized.POST("/notifications/archive", notificationHandler.Archive) authorized.POST("/notifications/archive-all", notificationHandler.ArchiveAll) authorized.DELETE("/notifications/:id", notificationHandler.DeleteNotification) authorized.GET("/notifications/preferences", notificationHandler.GetNotificationPreferences) authorized.PUT("/notifications/preferences", notificationHandler.UpdateNotificationPreferences) authorized.POST("/notifications/device", notificationHandler.RegisterDevice) authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice) authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices) } } srv := &http.Server{ Addr: ":" + cfg.Port, Handler: r, } go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal().Err(err).Msg("Failed to start server") } }() log.Info().Msgf("Server started on port %s", cfg.Port) quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Info().Msg("Shutting down server...") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Fatal().Err(err).Msg("Server forced to shutdown") } log.Info().Msg("Server exiting") }