diff --git a/.env.example b/.env.example index e30362b..70ee0e3 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,28 @@ -# MongoDB Configuration -MONGO_URI=mongodb://localhost:27017/neomovies - -# TMDB API Configuration +# Required +MONGO_URI= +MONGO_DB_NAME=database TMDB_ACCESS_TOKEN=your_tmdb_access_token - -# JWT Configuration JWT_SECRET=your_jwt_secret_key -# Email Configuration (для уведомлений) -GMAIL_USER=your_gmail@gmail.com -GMAIL_APP_PASSWORD=your_gmail_app_password - -# Players Configuration -LUMEX_URL=your_lumex_player_url -ALLOHA_TOKEN=your_alloha_token - -# Server Configuration +# Service PORT=3000 BASE_URL=http://localhost:3000 NODE_ENV=development -# Production Configuration (для Vercel) -# MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies -# BASE_URL=https://your-app.vercel.app -# NODE_ENV=production \ No newline at end of file +# Email (Gmail) +GMAIL_USER= +GMAIL_APP_PASSWORD=your_gmail_app_password + +# Players +LUMEX_URL= +ALLOHA_TOKEN=your_alloha_token + +# Torrents (RedAPI) +REDAPI_BASE_URL=http://redapi.cfhttp.top +REDAPI_KEY=your_redapi_key + +# Google OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback +FRONTEND_URL=http://localhost:3001 \ No newline at end of file diff --git a/README.md b/README.md index 2f2ab5d..7193ce3 100644 --- a/README.md +++ b/README.md @@ -50,22 +50,32 @@ API будет доступен на `http://localhost:3000` ```bash # Обязательные -MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies +MONGO_URI= +MONGO_DB_NAME=database TMDB_ACCESS_TOKEN=your_tmdb_access_token JWT_SECRET=your_jwt_secret_key -# Для email уведомлений (Gmail) -GMAIL_USER=your_gmail@gmail.com +# Сервис +PORT=3000 +BASE_URL=http://localhost:3000 +NODE_ENV=development + +# Email (Gmail) +GMAIL_USER= GMAIL_APP_PASSWORD=your_gmail_app_password -# Для плееров -LUMEX_URL=your_lumex_player_url +# Плееры +LUMEX_URL= ALLOHA_TOKEN=your_alloha_token -# Автоматические (Vercel) -PORT=3000 -BASE_URL=https://api.neomovies.ru -NODE_ENV=production +# Торренты (RedAPI) +REDAPI_BASE_URL=http://redapi.cfhttp.top +REDAPI_KEY=your_redapi_key + +# Google OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback ``` ## 📋 API Endpoints @@ -81,6 +91,8 @@ POST /api/v1/auth/register # Регистрация (отпр POST /api/v1/auth/verify # Подтверждение email кодом POST /api/v1/auth/resend-code # Повторная отправка кода POST /api/v1/auth/login # Авторизация +GET /api/v1/auth/google/login # Начало авторизации через Google (redirect) +GET /api/v1/auth/google/callback # Коллбек Google OAuth (возвращает JWT) # Поиск и категории GET /search/multi # Мультипоиск @@ -108,14 +120,14 @@ GET /api/v1/tv/{id}/recommendations # Рекомендации GET /api/v1/tv/{id}/similar # Похожие # Плееры -GET /api/v1/players/alloha # Alloha плеер -GET /api/v1/players/lumex # Lumex плеер +GET /api/v1/players/alloha/{imdb_id} # Alloha плеер по IMDb ID +GET /api/v1/players/lumex/{imdb_id} # Lumex плеер по IMDb ID # Торренты GET /api/v1/torrents/search/{imdbId} # Поиск торрентов # Реакции (публичные) -GET /api/v1/reactions/{type}/{id}/counts # Счетчики реакций +GET /api/v1/reactions/{mediaType}/{mediaId}/counts # Счетчики реакций # Изображения GET /api/v1/images/{size}/{path} # Прокси TMDB изображений @@ -134,10 +146,10 @@ POST /api/v1/favorites/{id} # Добавить в избран DELETE /api/v1/favorites/{id} # Удалить из избранного # Реакции (приватные) -GET /api/v1/reactions/{type}/{id}/my-reaction # Моя реакция -POST /api/v1/reactions/{type}/{id} # Установить реакцию -DELETE /api/v1/reactions/{type}/{id} # Удалить реакцию -GET /api/v1/reactions/my # Все мои реакции +GET /api/v1/reactions/{mediaType}/{mediaId}/my-reaction # Моя реакция +POST /api/v1/reactions/{mediaType}/{mediaId} # Установить реакцию +DELETE /api/v1/reactions/{mediaType}/{mediaId} # Удалить реакцию +GET /api/v1/reactions/my # Все мои реакции ``` ## 📖 Примеры использования diff --git a/api/index.go b/api/index.go index d7c4822..219136d 100644 --- a/api/index.go +++ b/api/index.go @@ -25,17 +25,12 @@ var ( ) func initializeApp() { - // Загружаем переменные окружения (в Vercel они уже установлены) - if err := godotenv.Load(); err != nil { - log.Println("Warning: .env file not found (normal for Vercel)") - } + if err := godotenv.Load(); err != nil { _ = err } - // Инициализируем конфигурацию globalCfg = config.New() - // Подключаемся к базе данных var err error - globalDB, err = database.Connect(globalCfg.MongoURI) + globalDB, err = database.Connect(globalCfg.MongoURI, globalCfg.MongoDBName) if err != nil { log.Printf("Failed to connect to database: %v", err) initError = err @@ -46,26 +41,23 @@ func initializeApp() { } func Handler(w http.ResponseWriter, r *http.Request) { - // Инициализируем приложение один раз initOnce.Do(initializeApp) - // Проверяем, была ли ошибка инициализации if initError != nil { log.Printf("Initialization error: %v", initError) http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError) return } - // Инициализируем сервисы tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken) emailService := services.NewEmailService(globalCfg) - authService := services.NewAuthService(globalDB, globalCfg.JWTSecret, emailService, globalCfg.BaseURL) + authService := services.NewAuthService(globalDB, globalCfg.JWTSecret, emailService, globalCfg.BaseURL, globalCfg.GoogleClientID, globalCfg.GoogleClientSecret, globalCfg.GoogleRedirectURL, globalCfg.FrontendURL) + movieService := services.NewMovieService(globalDB, tmdbService) tvService := services.NewTVService(globalDB, tmdbService) - torrentService := services.NewTorrentService() + torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey) reactionsService := services.NewReactionsService(globalDB) - // Создаем обработчики authHandler := handlersPkg.NewAuthHandler(authService) movieHandler := handlersPkg.NewMovieHandler(movieService) tvHandler := handlersPkg.NewTVHandler(tvService) @@ -77,35 +69,29 @@ func Handler(w http.ResponseWriter, r *http.Request) { reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService) imagesHandler := handlersPkg.NewImagesHandler() - // Настраиваем маршруты router := mux.NewRouter() - // Документация API на корневом пути router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") - // API маршруты api := router.PathPrefix("/api/v1").Subrouter() - // Публичные маршруты api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET") api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).Methods("POST") + api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") + api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET") - // Поиск router.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") - // Категории api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET") - // Плееры api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") - // Торренты api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") @@ -113,13 +99,10 @@ func Handler(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/torrents/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET") api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET") - // Реакции (публичные) api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET") - // Изображения (прокси для TMDB) api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET") - // Маршруты для фильмов api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET") api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET") api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET") @@ -130,7 +113,6 @@ func Handler(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET") api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET") - // Маршруты для сериалов api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET") api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET") api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET") @@ -141,28 +123,22 @@ func Handler(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET") api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET") - // Приватные маршруты (требуют авторизации) protected := api.PathPrefix("").Subrouter() protected.Use(middleware.JWTAuth(globalCfg.JWTSecret)) - // Избранное protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") - // Пользовательские данные protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") - // Новый маршрут удаления аккаунта protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE") - // Реакции (приватные) protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") - // CORS middleware corsHandler := handlers.CORS( handlers.AllowedOrigins([]string{"*"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), @@ -170,6 +146,5 @@ func Handler(w http.ResponseWriter, r *http.Request) { handlers.AllowCredentials(), ) - // Обрабатываем запрос corsHandler(router).ServeHTTP(w, r) } \ No newline at end of file diff --git a/go.mod b/go.mod index 506818f..1c7925f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module neomovies-api -go 1.22.0 +go 1.23.0 toolchain go1.24.2 @@ -13,9 +13,11 @@ require ( github.com/joho/godotenv v1.5.1 go.mongodb.org/mongo-driver v1.11.6 golang.org/x/crypto v0.17.0 + golang.org/x/oauth2 v0.30.0 ) require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/klauspost/compress v1.13.6 // indirect diff --git a/go.sum b/go.sum index be9c95d..99e68b2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM= github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -51,6 +53,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/main.go b/main.go index 4e167e2..8426f38 100644 --- a/main.go +++ b/main.go @@ -13,37 +13,31 @@ import ( "neomovies-api/pkg/database" appHandlers "neomovies-api/pkg/handlers" "neomovies-api/pkg/middleware" - "neomovies-api/pkg/monitor" + "neomovies-api/pkg/monitor" "neomovies-api/pkg/services" ) func main() { - // Загружаем переменные окружения - if err := godotenv.Load(); err != nil { - // Не выводим предупреждение в продакшене - } + if err := godotenv.Load(); err != nil { _ = err } - // Инициализируем конфигурацию cfg := config.New() - // Подключаемся к базе данных - db, err := database.Connect(cfg.MongoURI) + db, err := database.Connect(cfg.MongoURI, cfg.MongoDBName) if err != nil { fmt.Printf("❌ Failed to connect to database: %v\n", err) os.Exit(1) } defer database.Disconnect() - // Инициализируем сервисы tmdbService := services.NewTMDBService(cfg.TMDBAccessToken) emailService := services.NewEmailService(cfg) - authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL) + authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL, cfg.FrontendURL) + movieService := services.NewMovieService(db, tmdbService) tvService := services.NewTVService(db, tmdbService) - torrentService := services.NewTorrentService() + torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey) reactionsService := services.NewReactionsService(db) - // Создаем обработчики authHandler := appHandlers.NewAuthHandler(authService) movieHandler := appHandlers.NewMovieHandler(movieService) tvHandler := appHandlers.NewTVHandler(tvService) @@ -55,35 +49,29 @@ func main() { reactionsHandler := appHandlers.NewReactionsHandler(reactionsService) imagesHandler := appHandlers.NewImagesHandler() - // Настраиваем маршруты r := mux.NewRouter() - // Документация API на корневом пути r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") - // API маршруты api := r.PathPrefix("/api/v1").Subrouter() - // Публичные маршруты api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET") api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).Methods("POST") + api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") + api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET") - // Поиск r.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") - // Категории api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET") - // Плееры - ИСПРАВЛЕНО: добавлены параметры {imdb_id} api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") - // Торренты api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") @@ -91,25 +79,20 @@ func main() { api.HandleFunc("/torrents/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET") api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET") - // Реакции (публичные) api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET") - // Изображения (прокси для TMDB) api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET") - // Маршруты для фильмов api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET") api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET") api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET") api.HandleFunc("/movies/upcoming", movieHandler.Upcoming).Methods("GET") api.HandleFunc("/movies/now-playing", movieHandler.NowPlaying).Methods("GET") api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET") - -api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET") + api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET") api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET") api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET") - // Маршруты для сериалов api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET") api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET") api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET") @@ -120,44 +103,34 @@ api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations). api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET") api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET") - // Приватные маршруты (требуют авторизации) protected := api.PathPrefix("").Subrouter() protected.Use(middleware.JWTAuth(cfg.JWTSecret)) - // Избранное protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") - // Пользовательские данные protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") - // Новый маршрут удаления аккаунта protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE") - // Реакции (приватные) protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") - // CORS middleware corsHandler := handlers.CORS( - -handlers.AllowedOrigins([]string{"*"}), + handlers.AllowedOrigins([]string{"*"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), handlers.AllowCredentials(), ) - // Применяем мониторинг запросов только в development var finalHandler http.Handler if cfg.NodeEnv == "development" { - // Добавляем middleware для мониторинга запросов r.Use(monitor.RequestMonitor()) finalHandler = corsHandler(r) - - // Выводим заголовок мониторинга + fmt.Println("\n🚀 NeoMovies API Server") fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Printf("📡 Server: http://localhost:%s\n", cfg.Port) @@ -170,13 +143,9 @@ handlers.AllowedOrigins([]string{"*"}), fmt.Printf("✅ Server starting on port %s\n", cfg.Port) } - // Определяем порт port := cfg.Port - if port == "" { - port = "3000" - } + if port == "" { port = "3000" } - // Запускаем сервер if err := http.ListenAndServe(":"+port, finalHandler); err != nil { fmt.Printf("❌ Server failed to start: %v\n", err) os.Exit(1) diff --git a/pkg/config/config.go b/pkg/config/config.go index 22bfea6..eda0414 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,7 @@ import ( type Config struct { MongoURI string + MongoDBName string TMDBAccessToken string JWTSecret string Port string @@ -16,40 +17,45 @@ type Config struct { GmailPassword string LumexURL string AllohaToken string + RedAPIBaseURL string + RedAPIKey string + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string + FrontendURL string } func New() *Config { - // Добавляем отладочное логирование для Vercel mongoURI := getMongoURI() - log.Printf("DEBUG: MongoDB URI configured (length: %d)", len(mongoURI)) - + return &Config{ MongoURI: mongoURI, - TMDBAccessToken: getEnv("TMDB_ACCESS_TOKEN", ""), - JWTSecret: getEnv("JWT_SECRET", "your-secret-key"), - Port: getEnv("PORT", "3000"), - BaseURL: getEnv("BASE_URL", "http://localhost:3000"), - NodeEnv: getEnv("NODE_ENV", "development"), - GmailUser: getEnv("GMAIL_USER", ""), - GmailPassword: getEnv("GMAIL_APP_PASSWORD", ""), - LumexURL: getEnv("LUMEX_URL", ""), - AllohaToken: getEnv("ALLOHA_TOKEN", ""), + MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName), + TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""), + JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret), + Port: getEnv(EnvPort, DefaultPort), + BaseURL: getEnv(EnvBaseURL, DefaultBaseURL), + NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv), + GmailUser: getEnv(EnvGmailUser, ""), + GmailPassword: getEnv(EnvGmailPassword, ""), + LumexURL: getEnv(EnvLumexURL, ""), + AllohaToken: getEnv(EnvAllohaToken, ""), + RedAPIBaseURL: getEnv(EnvRedAPIBaseURL, DefaultRedAPIBase), + RedAPIKey: getEnv(EnvRedAPIKey, ""), + GoogleClientID: getEnv(EnvGoogleClientID, ""), + GoogleClientSecret: getEnv(EnvGoogleClientSecret, ""), + GoogleRedirectURL: getEnv(EnvGoogleRedirectURL, ""), + FrontendURL: getEnv(EnvFrontendURL, ""), } } -// getMongoURI проверяет различные варианты названий переменных для MongoDB URI func getMongoURI() string { - // Проверяем различные возможные названия переменных - envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} - - for _, envVar := range envVars { + for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} { if value := os.Getenv(envVar); value != "" { log.Printf("DEBUG: Using %s for MongoDB connection", envVar) return value } } - - // Если ни одна переменная не найдена, возвращаем пустую строку log.Printf("DEBUG: No MongoDB URI environment variable found") return "" } diff --git a/pkg/config/vars.go b/pkg/config/vars.go new file mode 100644 index 0000000..4541e9c --- /dev/null +++ b/pkg/config/vars.go @@ -0,0 +1,33 @@ +package config + +const ( + // Environment variable keys + EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN" + EnvJWTSecret = "JWT_SECRET" + EnvPort = "PORT" + EnvBaseURL = "BASE_URL" + EnvNodeEnv = "NODE_ENV" + EnvGmailUser = "GMAIL_USER" + EnvGmailPassword = "GMAIL_APP_PASSWORD" + EnvLumexURL = "LUMEX_URL" + EnvAllohaToken = "ALLOHA_TOKEN" + EnvRedAPIBaseURL = "REDAPI_BASE_URL" + EnvRedAPIKey = "REDAPI_KEY" + EnvMongoDBName = "MONGO_DB_NAME" + EnvGoogleClientID = "GOOGLE_CLIENT_ID" + EnvGoogleClientSecret= "GOOGLE_CLIENT_SECRET" + EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL" + EnvFrontendURL = "FRONTEND_URL" + + // Default values + DefaultJWTSecret = "your-secret-key" + DefaultPort = "3000" + DefaultBaseURL = "http://localhost:3000" + DefaultNodeEnv = "development" + DefaultRedAPIBase = "http://redapi.cfhttp.top" + DefaultMongoDBName = "database" + + // Static constants + TMDBImageBaseURL = "https://image.tmdb.org/t/p" + CubAPIBaseURL = "https://cub.rip/api" +) \ No newline at end of file diff --git a/pkg/database/connection.go b/pkg/database/connection.go index f88452e..9aad860 100644 --- a/pkg/database/connection.go +++ b/pkg/database/connection.go @@ -10,7 +10,7 @@ import ( var client *mongo.Client -func Connect(uri string) (*mongo.Database, error) { +func Connect(uri, dbName string) (*mongo.Database, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -20,13 +20,11 @@ func Connect(uri string) (*mongo.Database, error) { return nil, err } - // Проверяем соединение - err = client.Ping(ctx, nil) - if err != nil { + if err = client.Ping(ctx, nil); err != nil { return nil, err } - return client.Database("database"), nil + return client.Database(dbName), nil } func Disconnect() error { @@ -40,6 +38,4 @@ func Disconnect() error { return client.Disconnect(ctx) } -func GetClient() *mongo.Client { - return client -} \ No newline at end of file +func GetClient() *mongo.Client { return client } \ No newline at end of file diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index af35e34..5836268 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -3,6 +3,8 @@ package handlers import ( "encoding/json" "net/http" + "time" + "strings" "go.mongodb.org/mongo-driver/bson" @@ -16,9 +18,7 @@ type AuthHandler struct { } func NewAuthHandler(authService *services.AuthService) *AuthHandler { - return &AuthHandler{ - authService: authService, - } + return &AuthHandler{authService: authService} } func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { @@ -36,11 +36,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: response, - Message: "User registered successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"}) } func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { @@ -52,21 +48,82 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { response, err := h.authService.Login(req) if err != nil { - // Определяем правильный статус код в зависимости от ошибки statusCode := http.StatusBadRequest if err.Error() == "Account not activated. Please verify your email." { - statusCode = http.StatusForbidden // 403 для неверифицированного email + statusCode = http.StatusForbidden } http.Error(w, err.Error(), statusCode) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: response, - Message: "Login successful", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"}) +} + +func (h *AuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) { + state := generateState() + http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)}) + url, err := h.authService.GetGoogleLoginURL(state) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, url, http.StatusFound) +} + +func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + state := q.Get("state") + code := q.Get("code") + preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json") + cookie, _ := r.Cookie("oauth_state") + if cookie == nil || cookie.Value != state || code == "" { + if preferJSON { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"}) + return + } + redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state") + if ok { + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + http.Error(w, "invalid oauth state", http.StatusBadRequest) + return + } + + resp, err := h.authService.HandleGoogleCallback(r.Context(), code) + if err != nil { + if preferJSON { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()}) + return + } + redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed") + if ok { + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if preferJSON { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"}) + return + } + + redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "") + if ok { + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"}) } func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) { @@ -83,10 +140,7 @@ func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: user, - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user}) } func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { @@ -102,7 +156,6 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { return } - // Удаляем поля, которые нельзя обновлять через этот эндпоинт delete(updates, "password") delete(updates, "email") delete(updates, "_id") @@ -115,14 +168,9 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: user, - Message: "Profile updated successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"}) } -// Удаление аккаунта func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -136,12 +184,9 @@ func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Account deleted successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Account deleted successfully"}) } -// Подтверждение email + func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { var req models.VerifyEmailRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -159,7 +204,6 @@ func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -// Повторная отправка кода верификации func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) { var req models.ResendCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -175,4 +219,7 @@ func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Requ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) -} \ No newline at end of file +} + +// helpers +func generateState() string { return uuidNew() } \ No newline at end of file diff --git a/pkg/handlers/auth_helpers.go b/pkg/handlers/auth_helpers.go new file mode 100644 index 0000000..39c6b15 --- /dev/null +++ b/pkg/handlers/auth_helpers.go @@ -0,0 +1,7 @@ +package handlers + +import ( + "github.com/google/uuid" +) + +func uuidNew() string { return uuid.New().String() } \ No newline at end of file diff --git a/pkg/handlers/docs.go b/pkg/handlers/docs.go index 257a9aa..6d6531b 100644 --- a/pkg/handlers/docs.go +++ b/pkg/handlers/docs.go @@ -9,17 +9,13 @@ import ( "github.com/MarceloPetrucio/go-scalar-api-reference" ) -type DocsHandler struct { - // Убираем статическую спецификацию -} +type DocsHandler struct{} func NewDocsHandler() *DocsHandler { return &DocsHandler{} } func (h *DocsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Обслуживаем документацию для всех путей - // Это нужно для правильной работы Scalar API Reference h.ServeDocs(w, r) } @@ -28,7 +24,6 @@ func (h *DocsHandler) RedirectToDocs(w http.ResponseWriter, r *http.Request) { } func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) { - // Определяем baseURL динамически baseURL := os.Getenv("BASE_URL") if baseURL == "" { if r.TLS != nil { @@ -38,7 +33,6 @@ func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) { } } - // Генерируем спецификацию с правильным URL spec := getOpenAPISpecWithURL(baseURL) w.Header().Set("Content-Type", "application/json") @@ -1177,6 +1171,39 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, + "/api/v1/auth/google/login": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Google OAuth: начало", + "description": "Редирект на страницу авторизации Google", + "tags": []string{"Authentication"}, + "responses": map[string]interface{}{ + "302": map[string]interface{}{"description": "Redirect to Google"}, + "400": map[string]interface{}{"description": "OAuth не сконфигурирован"}, + }, + }, + }, + "/api/v1/auth/google/callback": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Google OAuth: коллбек", + "description": "Обработка кода авторизации и выдача JWT", + "tags": []string{"Authentication"}, + "parameters": []map[string]interface{}{ + {"name": "state", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, + {"name": "code", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Успешная авторизация через Google", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/AuthResponse"}, + }, + }, + }, + "400": map[string]interface{}{"description": "Неверный state или ошибка обмена кода"}, + }, + }, + }, }, Components: Components{ SecuritySchemes: map[string]SecurityScheme{ diff --git a/pkg/handlers/images.go b/pkg/handlers/images.go index 45cb347..47821f8 100644 --- a/pkg/handlers/images.go +++ b/pkg/handlers/images.go @@ -9,15 +9,12 @@ import ( "strings" "github.com/gorilla/mux" + "neomovies-api/pkg/config" ) type ImagesHandler struct{} -func NewImagesHandler() *ImagesHandler { - return &ImagesHandler{} -} - -const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p" +func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} } func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -29,22 +26,18 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { return } - // Если запрашивается placeholder, возвращаем локальный файл if imagePath == "placeholder.jpg" { h.servePlaceholder(w, r) return } - // Проверяем размер изображения validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"} if !h.isValidSize(size, validSizes) { size = "original" } - // Формируем URL изображения - imageURL := fmt.Sprintf("%s/%s/%s", TMDB_IMAGE_BASE_URL, size, imagePath) + imageURL := fmt.Sprintf("%s/%s/%s", config.TMDBImageBaseURL, size, imagePath) - // Получаем изображение resp, err := http.Get(imageURL) if err != nil { h.servePlaceholder(w, r) @@ -57,23 +50,19 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { return } - // Устанавливаем заголовки if contentType := resp.Header.Get("Content-Type"); contentType != "" { w.Header().Set("Content-Type", contentType) } - w.Header().Set("Cache-Control", "public, max-age=31536000") // кэшируем на 1 год + w.Header().Set("Cache-Control", "public, max-age=31536000") - // Передаем изображение клиенту _, err = io.Copy(w, resp.Body) if err != nil { - // Если ошибка при копировании, отдаем placeholder h.servePlaceholder(w, r) return } } func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) { - // Попробуем найти placeholder изображение placeholderPaths := []string{ "./assets/placeholder.jpg", "./public/images/placeholder.jpg", @@ -89,7 +78,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) } if placeholderPath == "" { - // Если placeholder не найден, создаем простую SVG заглушку h.serveSVGPlaceholder(w, r) return } @@ -101,7 +89,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) } defer file.Close() - // Определяем content-type по расширению ext := strings.ToLower(filepath.Ext(placeholderPath)) switch ext { case ".jpg", ".jpeg": @@ -116,7 +103,7 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "image/jpeg") } - w.Header().Set("Cache-Control", "public, max-age=3600") // кэшируем placeholder на 1 час + w.Header().Set("Cache-Control", "public, max-age=3600") _, err = io.Copy(w, file) if err != nil { diff --git a/pkg/handlers/reactions.go b/pkg/handlers/reactions.go index 27c94fd..8128d40 100644 --- a/pkg/handlers/reactions.go +++ b/pkg/handlers/reactions.go @@ -16,12 +16,9 @@ type ReactionsHandler struct { } func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler { - return &ReactionsHandler{ - reactionsService: reactionsService, - } + return &ReactionsHandler{reactionsService: reactionsService} } -// Получить счетчики реакций для медиа (публичный эндпоинт) func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) mediaType := vars["mediaType"] @@ -42,7 +39,6 @@ func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Requ json.NewEncoder(w).Encode(counts) } -// Получить реакцию текущего пользователя (требует авторизации) func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -59,21 +55,20 @@ func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) return } - reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID) + reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - if reaction == nil { + if reactionType == "" { json.NewEncoder(w).Encode(map[string]interface{}{}) } else { - json.NewEncoder(w).Encode(reaction) + json.NewEncoder(w).Encode(map[string]string{"type": reactionType}) } } -// Установить реакцию пользователя (требует авторизации) func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -90,34 +85,25 @@ func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) { return } - var request struct { - Type string `json:"type"` - } - + var request struct{ Type string `json:"type"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } - if request.Type == "" { http.Error(w, "Reaction type is required", http.StatusBadRequest) return } - err := h.reactionsService.SetUserReaction(userID, mediaType, mediaID, request.Type) - if err != nil { + if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Reaction set successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction set successfully"}) } -// Удалить реакцию пользователя (требует авторизации) func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -134,20 +120,15 @@ func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request return } - err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID) - if err != nil { + if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Reaction removed successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction removed successfully"}) } -// Получить все реакции пользователя (требует авторизации) func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -164,8 +145,5 @@ func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: reactions, - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: reactions}) } \ No newline at end of file diff --git a/pkg/models/user.go b/pkg/models/user.go index 0408b5b..3474acc 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -20,6 +20,8 @@ type User struct { AdminVerified bool `json:"adminVerified" bson:"adminVerified"` CreatedAt time.Time `json:"created_at" bson:"createdAt"` UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"` + Provider string `json:"provider,omitempty" bson:"provider,omitempty"` + GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"` } type LoginRequest struct { diff --git a/pkg/services/auth.go b/pkg/services/auth.go index b7300d4..2512a08 100644 --- a/pkg/services/auth.go +++ b/pkg/services/auth.go @@ -7,6 +7,7 @@ import ( "io" "math/rand" "net/http" + "net/url" "sync" "time" @@ -16,6 +17,9 @@ import ( "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "encoding/json" "neomovies-api/pkg/models" ) @@ -25,7 +29,11 @@ type AuthService struct { db *mongo.Database jwtSecret string emailService *EmailService - cubAPIURL string + baseURL string + googleClientID string + googleClientSecret string + googleRedirectURL string + frontendURL string } // Reaction represents a reaction entry in the database. @@ -36,17 +44,154 @@ type Reaction struct { } // NewAuthService creates and initializes a new AuthService. -func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, cubAPIURL string) *AuthService { +func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, baseURL string, googleClientID string, googleClientSecret string, googleRedirectURL string, frontendURL string) *AuthService { service := &AuthService{ db: db, jwtSecret: jwtSecret, emailService: emailService, - cubAPIURL: cubAPIURL, + baseURL: baseURL, + googleClientID: googleClientID, + googleClientSecret: googleClientSecret, + googleRedirectURL: googleRedirectURL, + frontendURL: frontendURL, } - return service } +func (s *AuthService) googleOAuthConfig() *oauth2.Config { + redirectURL := s.googleRedirectURL + if redirectURL == "" && s.baseURL != "" { + redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL) + } + return &oauth2.Config{ + ClientID: s.googleClientID, + ClientSecret: s.googleClientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "email", "profile"}, + Endpoint: google.Endpoint, + } +} + +func (s *AuthService) GetGoogleLoginURL(state string) (string, error) { + cfg := s.googleOAuthConfig() + if cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" { + return "", errors.New("google oauth not configured") + } + return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline), nil +} + +type googleUserInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` + EmailVerified bool `json:"email_verified"` +} + +// BuildFrontendRedirect builds frontend URL for redirect after OAuth; returns false if not configured +func (s *AuthService) BuildFrontendRedirect(token string, authErr string) (string, bool) { + if s.frontendURL == "" { + return "", false + } + if authErr != "" { + u, _ := url.Parse(s.frontendURL + "/login") + q := u.Query() + q.Set("oauth", "google") + q.Set("error", authErr) + u.RawQuery = q.Encode() + return u.String(), true + } + u, _ := url.Parse(s.frontendURL + "/auth/callback") + q := u.Query() + q.Set("provider", "google") + q.Set("token", token) + u.RawQuery = q.Encode() + return u.String(), true +} + +func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.AuthResponse, error) { + cfg := s.googleOAuthConfig() + tok, err := cfg.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + + client := cfg.Client(ctx, tok) + resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") + if err != nil { + return nil, fmt.Errorf("failed to fetch userinfo: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("userinfo request failed: status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var gUser googleUserInfo + if err := json.Unmarshal(body, &gUser); err != nil { + return nil, fmt.Errorf("failed to parse userinfo: %w", err) + } + if gUser.Email == "" { + return nil, errors.New("email not provided by Google") + } + + collection := s.db.Collection("users") + + // Try by googleId first + var user models.User + err = collection.FindOne(ctx, bson.M{"googleId": gUser.Sub}).Decode(&user) + if err == mongo.ErrNoDocuments { + // Try by email + err = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user) + } + if err == mongo.ErrNoDocuments { + // Create new user + user = models.User{ + ID: primitive.NewObjectID(), + Email: gUser.Email, + Password: "", + Name: gUser.Name, + Avatar: gUser.Picture, + Favorites: []string{}, + Verified: true, + IsAdmin: false, + AdminVerified: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Provider: "google", + GoogleID: gUser.Sub, + } + if _, err := collection.InsertOne(ctx, user); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + // Existing user: ensure fields + update := bson.M{ + "verified": true, + "provider": "google", + "googleId": gUser.Sub, + "updatedAt": time.Now(), + } + if user.Name == "" && gUser.Name != "" { update["name"] = gUser.Name } + if user.Avatar == "" && gUser.Picture != "" { update["avatar"] = gUser.Picture } + _, _ = collection.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": update}) + } + + // Generate JWT + if user.ID.IsZero() { + // If we created user above, we already have user.ID set; else fetch updated + _ = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user) + } + token, err := s.generateJWT(user.ID.Hex()) + if err != nil { return nil, err } + + return &models.AuthResponse{ Token: token, User: user }, nil +} + // generateVerificationCode creates a 6-digit verification code. func (s *AuthService) generateVerificationCode() string { return fmt.Sprintf("%06d", rand.Intn(900000)+100000) @@ -275,7 +420,7 @@ func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error { } // Step 1: Find user reactions and remove them from cub.rip - if s.cubAPIURL != "" { + if s.baseURL != "" { // Changed from cubAPIURL to baseURL reactionsCollection := s.db.Collection("reactions") var userReactions []Reaction cursor, err := reactionsCollection.Find(ctx, bson.M{"userId": objectID}) @@ -293,7 +438,7 @@ func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error { wg.Add(1) go func(r Reaction) { defer wg.Done() - url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.cubAPIURL, r.MediaID, r.Type) + url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.baseURL, r.MediaID, r.Type) // Changed from cubAPIURL to baseURL req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE" if err != nil { // Log the error but don't stop the process diff --git a/pkg/services/reactions.go b/pkg/services/reactions.go index 8968407..8ca2489 100644 --- a/pkg/services/reactions.go +++ b/pkg/services/reactions.go @@ -12,6 +12,7 @@ import ( "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "neomovies-api/pkg/config" "neomovies-api/pkg/models" ) @@ -27,17 +28,15 @@ func NewReactionsService(db *mongo.Database) *ReactionsService { } } -const CUB_API_URL = "https://cub.rip/api" - -var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"} +var validReactions = []string{"fire", "nice", "think", "bore", "shit"} // Получить счетчики реакций для медиа из внешнего API (cub.rip) func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) { cubID := fmt.Sprintf("%s_%s", mediaType, mediaID) - resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", CUB_API_URL, cubID)) + resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", config.CubAPIBaseURL, cubID)) if err != nil { - return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке + return &models.ReactionCounts{}, nil } defer resp.Body.Close() @@ -61,7 +60,6 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models return &models.ReactionCounts{}, nil } - // Преобразуем в нашу структуру counts := &models.ReactionCounts{} for _, reaction := range response.Result { switch reaction.Type { @@ -81,76 +79,58 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models return counts, nil } -// Получить реакцию пользователя для медиа -func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) { +func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (string, error) { collection := s.db.Collection("reactions") - fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) - - var reaction models.Reaction - err := collection.FindOne(context.Background(), bson.M{ - "userId": userID, - "mediaId": fullMediaID, - }).Decode(&reaction) - - if err == mongo.ErrNoDocuments { - return nil, nil // Реакции нет - } - - return &reaction, err -} - -// Установить реакцию пользователя -func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error { - // Проверяем валидность типа реакции - if !s.isValidReactionType(reactionType) { - return fmt.Errorf("invalid reaction type: %s", reactionType) - } - - collection := s.db.Collection("reactions") - fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) - - // Создаем или обновляем реакцию - filter := bson.M{ - "userId": userID, - "mediaId": fullMediaID, - } - - reaction := models.Reaction{ - UserID: userID, - MediaID: fullMediaID, - Type: reactionType, - Created: time.Now().Format(time.RFC3339), - } - - update := bson.M{ - "$set": reaction, - } - - upsert := true - _, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{ - Upsert: &upsert, - }) + ctx := context.Background() + var result struct{ Type string `bson:"type"` } + err := collection.FindOne(ctx, bson.M{ + "userId": userID, + "mediaType": mediaType, + "mediaId": mediaID, + }).Decode(&result) if err != nil { - return err + if err == mongo.ErrNoDocuments { + return "", nil + } + return "", err } - - // Отправляем реакцию в cub.rip API - go s.sendReactionToCub(fullMediaID, reactionType) - - return nil + return result.Type, nil } -// Удалить реакцию пользователя -func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error { - collection := s.db.Collection("reactions") - fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) +func (s *ReactionsService) SetReaction(userID, mediaType, mediaID, reactionType string) error { + if !s.isValidReactionType(reactionType) { + return fmt.Errorf("invalid reaction type") + } - _, err := collection.DeleteOne(context.Background(), bson.M{ - "userId": userID, - "mediaId": fullMediaID, + collection := s.db.Collection("reactions") + ctx := context.Background() + + _, err := collection.UpdateOne( + ctx, + bson.M{"userId": userID, "mediaType": mediaType, "mediaId": mediaID}, + bson.M{"$set": bson.M{"type": reactionType, "updatedAt": time.Now()}}, + options.Update().SetUpsert(true), + ) + if err == nil { + go s.sendReactionToCub(fmt.Sprintf("%s_%s", mediaType, mediaID), reactionType) + } + return err +} + +func (s *ReactionsService) RemoveReaction(userID, mediaType, mediaID string) error { + collection := s.db.Collection("reactions") + ctx := context.Background() + + _, err := collection.DeleteOne(ctx, bson.M{ + "userId": userID, + "mediaType": mediaType, + "mediaId": mediaID, }) + fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) + go s.sendReactionToCub(fullMediaID, "remove") + return err } @@ -174,7 +154,7 @@ func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models. } func (s *ReactionsService) isValidReactionType(reactionType string) bool { - for _, valid := range VALID_REACTIONS { + for _, valid := range validReactions { if valid == reactionType { return true } @@ -184,8 +164,7 @@ func (s *ReactionsService) isValidReactionType(reactionType string) bool { // Отправка реакции в cub.rip API (асинхронно) func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { - // Формируем запрос к cub.rip API - url := fmt.Sprintf("%s/reactions/set", CUB_API_URL) + url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL) data := map[string]string{ "mediaId": mediaID, @@ -197,15 +176,12 @@ func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { return } - // В данном случае мы отправляем простой POST запрос - // В будущем можно доработать для отправки JSON данных resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType)) if err != nil { return } defer resp.Body.Close() - // Логируем результат (в продакшене лучше использовать структурированное логирование) if resp.StatusCode == http.StatusOK { fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType) } diff --git a/pkg/services/torrent.go b/pkg/services/torrent.go index 66443a0..5a5064b 100644 --- a/pkg/services/torrent.go +++ b/pkg/services/torrent.go @@ -21,11 +21,19 @@ type TorrentService struct { apiKey string } +func NewTorrentServiceWithConfig(baseURL, apiKey string) *TorrentService { + return &TorrentService{ + client: &http.Client{Timeout: 8 * time.Second}, + baseURL: baseURL, + apiKey: apiKey, + } +} + func NewTorrentService() *TorrentService { return &TorrentService{ client: &http.Client{Timeout: 8 * time.Second}, baseURL: "http://redapi.cfhttp.top", - apiKey: "", // Может быть установлен через переменные окружения + apiKey: "", } } @@ -33,7 +41,6 @@ func NewTorrentService() *TorrentService { func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) { searchParams := url.Values{} - // Добавляем все параметры поиска for key, value := range params { if value != "" { if key == "category" { @@ -80,7 +87,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models var results []models.TorrentResult for _, torrent := range data.Results { - // Обрабатываем размер - может быть строкой или числом var sizeStr string switch v := torrent.Size.(type) { case string: @@ -106,9 +112,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models Source: "RedAPI", } - // Добавляем информацию из Info если она есть if torrent.Info != nil { - // Обрабатываем качество - может быть строкой или числом switch v := torrent.Info.Quality.(type) { case string: result.Quality = v @@ -123,7 +127,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models result.Seasons = torrent.Info.Seasons } - // Если качество не определено через Info, пытаемся извлечь из названия if result.Quality == "" { result.Quality = s.ExtractQuality(result.Title) } @@ -136,14 +139,11 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models // SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) { - // Получаем информацию о фильме/сериале из TMDB - // ИСПРАВЛЕНО: Теперь присваиваются все 4 возвращаемых значения title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType) if err != nil { return nil, fmt.Errorf("failed to get title from TMDB: %w", err) } - // Создаем параметры поиска для RedAPI params := map[string]string{ "imdb": imdbID, "query": title, @@ -151,7 +151,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID "year": year, } - // Определяем тип контента для API switch mediaType { case "movie": params["is_serial"] = "1" @@ -164,18 +163,15 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID params["category"] = "5070" } - // Добавляем сезон, если он указан if options != nil && options.Season != nil && *options.Season > 0 { params["season"] = strconv.Itoa(*options.Season) } - // Выполняем поиск response, err := s.SearchTorrents(params) if err != nil { return nil, err } - // Применяем фильтрацию if options != nil { response.Results = s.FilterByContentType(response.Results, options.ContentType) response.Results = s.FilterTorrents(response.Results, options) @@ -183,7 +179,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID } response.Total = len(response.Results) - // Fallback для сериалов, если результатов мало if len(response.Results) < 5 && (mediaType == "serial" || mediaType == "series" || mediaType == "tv") && options != nil && options.Season != nil { paramsNoSeason := map[string]string{ "imdb": imdbID, @@ -206,7 +201,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID } } response.Results = unique - response.Total = len(response.Results) } }