335 lines
13 KiB
Go
335 lines
13 KiB
Go
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, pushService, assetService)
|
|
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService)
|
|
chatHandler := handlers.NewChatHandler(chatRepo, pushService, 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)
|
|
|
|
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.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)
|
|
users.DELETE("/notifications/device", userHandler.RemoveFCMToken)
|
|
users.POST("/notifications/device", userHandler.RegisterFCMToken)
|
|
}
|
|
|
|
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.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
|
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")
|
|
}
|