package main import ( "context" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" _ "github.com/example/sndit/backend/docs" "github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/config" "github.com/example/sndit/backend/internal/db" devtools "github.com/example/sndit/backend/internal/dev" "github.com/example/sndit/backend/internal/downloads" "github.com/example/sndit/backend/internal/galleries" "github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/storage" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) // @title Noah Bianchi Studio API // @version 1.0 // @description REST API for photographer galleries and client delivery. // @schemes http https // @BasePath / // @securityDefinitions.apikey studioSession // @in header // @name Cookie // @description Use the studio_session cookie as studio_session=. func main() { cfg := config.Load() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN()) if err != nil { log.Fatalf("database unavailable: %v", err) } defer database.Close() objectStorage, err := storage.NewMinIO(storage.Config{ Endpoint: cfg.StorageEndpoint, PublicEndpoint: cfg.StoragePublicEndpoint, AccessKey: cfg.StorageAccessKey, SecretKey: cfg.StorageSecretKey, Bucket: cfg.StorageBucket, UseSSL: cfg.StorageUseSSL, PublicUseSSL: true, }) if err != nil { log.Fatalf("storage unavailable: %v", err) } if err := objectStorage.EnsureBucket(ctx); err != nil { log.Fatalf("storage unavailable: %v", err) } authRepository := auth.NewRepository(database) authService, err := auth.NewService(authRepository, cfg.SessionSecret, cfg.CookieSecure) if err != nil { log.Fatalf("authentication unavailable: %v", err) } galleryRepository := galleries.NewRepository(database) mediaRepository := media.NewRepository(database) mediaProcessor := media.NewProcessor(mediaRepository, objectStorage, 2) defer mediaProcessor.Close() downloadRepository := downloads.NewRepository(database) downloadService := downloads.NewService(downloadRepository, mediaRepository, objectStorage, 1) defer downloadService.Close() authHandler := auth.NewHandler(authService) galleryHandler := galleries.NewHandler(galleryRepository, mediaRepository, objectStorage, authService) mediaHandler := media.NewHandler(mediaRepository, objectStorage, mediaProcessor, authService) downloadHandler := downloads.NewHandler(galleryRepository, mediaRepository, objectStorage, authService, downloadService) devHandler := devtools.NewHandler(database, objectStorage, authService, devtools.Config{ DBDriver: cfg.DBDriver, APIURL: cfg.APIURL, StorageEndpoint: cfg.StorageEndpoint, StorageBucket: cfg.StorageBucket, StorageUseSSL: cfg.StorageUseSSL, CORSOrigins: cfg.CORSOrigin, CookieSecure: cfg.CookieSecure, }) router := gin.New() router.Use(gin.Recovery(), withCORS(cfg.CORSOrigin), withLogging()) router.GET("/health", health) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.DocExpansion("none"), ginSwagger.PersistAuthorization(true), )) require := authService.Require() authHandler.RegisterRoutes(router) authHandler.RegisterProtectedRoutes(router, require) galleryHandler.RegisterProtectedRoutes(router, require) mediaHandler.RegisterRoutes(router, require) galleryHandler.RegisterPublicRoutes(router) downloadHandler.RegisterRoutes(router) devHandler.RegisterRoutes(router, require) server := &http.Server{ Addr: ":" + cfg.Port, Handler: router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } go func() { addr := cfg.APIURL if addr == "" { addr = "http://localhost" + server.Addr } log.Printf("API listening on %s", addr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server failed: %v", err) } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("server shutdown failed: %v", err) } } // health godoc // @Summary Check API health // @Tags system // @Produce json // @Success 200 {object} map[string]string // @Router /health [get] func health(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } func withCORS(allowedOrigin string) gin.HandlerFunc { return func(c *gin.Context) { origin := c.GetHeader("Origin") if origin != "" && originAllowed(origin, allowedOrigin) { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") c.Header("Vary", "Origin") } if c.Request.Method == http.MethodOptions { c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type") c.Status(http.StatusNoContent) c.Abort() return } c.Next() } } func originAllowed(origin, configured string) bool { for _, allowed := range strings.Split(configured, ",") { allowed = strings.TrimRight(strings.TrimSpace(allowed), "/") if allowed == "*" || allowed == origin { return true } } return false } func withLogging() gin.HandlerFunc { return func(c *gin.Context) { started := time.Now() c.Next() log.Printf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started).Round(time.Millisecond)) } }