mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-27 17:38:51 +05:00
Add Google OAuth
This commit is contained in:
40
.env.example
40
.env.example
@@ -1,26 +1,28 @@
|
|||||||
# MongoDB Configuration
|
# Required
|
||||||
MONGO_URI=mongodb://localhost:27017/neomovies
|
MONGO_URI=
|
||||||
|
MONGO_DB_NAME=database
|
||||||
# TMDB API Configuration
|
|
||||||
TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
||||||
|
|
||||||
# JWT Configuration
|
|
||||||
JWT_SECRET=your_jwt_secret_key
|
JWT_SECRET=your_jwt_secret_key
|
||||||
|
|
||||||
# Email Configuration (для уведомлений)
|
# Service
|
||||||
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
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
BASE_URL=http://localhost:3000
|
BASE_URL=http://localhost:3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Production Configuration (для Vercel)
|
# Email (Gmail)
|
||||||
# MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies
|
GMAIL_USER=
|
||||||
# BASE_URL=https://your-app.vercel.app
|
GMAIL_APP_PASSWORD=your_gmail_app_password
|
||||||
# NODE_ENV=production
|
|
||||||
|
# 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
|
||||||
44
README.md
44
README.md
@@ -50,22 +50,32 @@ API будет доступен на `http://localhost:3000`
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Обязательные
|
# Обязательные
|
||||||
MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies
|
MONGO_URI=
|
||||||
|
MONGO_DB_NAME=database
|
||||||
TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
||||||
JWT_SECRET=your_jwt_secret_key
|
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
|
GMAIL_APP_PASSWORD=your_gmail_app_password
|
||||||
|
|
||||||
# Для плееров
|
# Плееры
|
||||||
LUMEX_URL=your_lumex_player_url
|
LUMEX_URL=
|
||||||
ALLOHA_TOKEN=your_alloha_token
|
ALLOHA_TOKEN=your_alloha_token
|
||||||
|
|
||||||
# Автоматические (Vercel)
|
# Торренты (RedAPI)
|
||||||
PORT=3000
|
REDAPI_BASE_URL=http://redapi.cfhttp.top
|
||||||
BASE_URL=https://api.neomovies.ru
|
REDAPI_KEY=your_redapi_key
|
||||||
NODE_ENV=production
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📋 API Endpoints
|
## 📋 API Endpoints
|
||||||
@@ -81,6 +91,8 @@ POST /api/v1/auth/register # Регистрация (отпр
|
|||||||
POST /api/v1/auth/verify # Подтверждение email кодом
|
POST /api/v1/auth/verify # Подтверждение email кодом
|
||||||
POST /api/v1/auth/resend-code # Повторная отправка кода
|
POST /api/v1/auth/resend-code # Повторная отправка кода
|
||||||
POST /api/v1/auth/login # Авторизация
|
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 # Мультипоиск
|
GET /search/multi # Мультипоиск
|
||||||
@@ -108,14 +120,14 @@ GET /api/v1/tv/{id}/recommendations # Рекомендации
|
|||||||
GET /api/v1/tv/{id}/similar # Похожие
|
GET /api/v1/tv/{id}/similar # Похожие
|
||||||
|
|
||||||
# Плееры
|
# Плееры
|
||||||
GET /api/v1/players/alloha # Alloha плеер
|
GET /api/v1/players/alloha/{imdb_id} # Alloha плеер по IMDb ID
|
||||||
GET /api/v1/players/lumex # Lumex плеер
|
GET /api/v1/players/lumex/{imdb_id} # Lumex плеер по IMDb ID
|
||||||
|
|
||||||
# Торренты
|
# Торренты
|
||||||
GET /api/v1/torrents/search/{imdbId} # Поиск торрентов
|
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 изображений
|
GET /api/v1/images/{size}/{path} # Прокси TMDB изображений
|
||||||
@@ -134,10 +146,10 @@ POST /api/v1/favorites/{id} # Добавить в избран
|
|||||||
DELETE /api/v1/favorites/{id} # Удалить из избранного
|
DELETE /api/v1/favorites/{id} # Удалить из избранного
|
||||||
|
|
||||||
# Реакции (приватные)
|
# Реакции (приватные)
|
||||||
GET /api/v1/reactions/{type}/{id}/my-reaction # Моя реакция
|
GET /api/v1/reactions/{mediaType}/{mediaId}/my-reaction # Моя реакция
|
||||||
POST /api/v1/reactions/{type}/{id} # Установить реакцию
|
POST /api/v1/reactions/{mediaType}/{mediaId} # Установить реакцию
|
||||||
DELETE /api/v1/reactions/{type}/{id} # Удалить реакцию
|
DELETE /api/v1/reactions/{mediaType}/{mediaId} # Удалить реакцию
|
||||||
GET /api/v1/reactions/my # Все мои реакции
|
GET /api/v1/reactions/my # Все мои реакции
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Примеры использования
|
## 📖 Примеры использования
|
||||||
|
|||||||
39
api/index.go
39
api/index.go
@@ -25,17 +25,12 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func initializeApp() {
|
func initializeApp() {
|
||||||
// Загружаем переменные окружения (в Vercel они уже установлены)
|
if err := godotenv.Load(); err != nil { _ = err }
|
||||||
if err := godotenv.Load(); err != nil {
|
|
||||||
log.Println("Warning: .env file not found (normal for Vercel)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инициализируем конфигурацию
|
|
||||||
globalCfg = config.New()
|
globalCfg = config.New()
|
||||||
|
|
||||||
// Подключаемся к базе данных
|
|
||||||
var err error
|
var err error
|
||||||
globalDB, err = database.Connect(globalCfg.MongoURI)
|
globalDB, err = database.Connect(globalCfg.MongoURI, globalCfg.MongoDBName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to connect to database: %v", err)
|
log.Printf("Failed to connect to database: %v", err)
|
||||||
initError = err
|
initError = err
|
||||||
@@ -46,26 +41,23 @@ func initializeApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Инициализируем приложение один раз
|
|
||||||
initOnce.Do(initializeApp)
|
initOnce.Do(initializeApp)
|
||||||
|
|
||||||
// Проверяем, была ли ошибка инициализации
|
|
||||||
if initError != nil {
|
if initError != nil {
|
||||||
log.Printf("Initialization error: %v", initError)
|
log.Printf("Initialization error: %v", initError)
|
||||||
http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError)
|
http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализируем сервисы
|
|
||||||
tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken)
|
tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken)
|
||||||
emailService := services.NewEmailService(globalCfg)
|
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)
|
movieService := services.NewMovieService(globalDB, tmdbService)
|
||||||
tvService := services.NewTVService(globalDB, tmdbService)
|
tvService := services.NewTVService(globalDB, tmdbService)
|
||||||
torrentService := services.NewTorrentService()
|
torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey)
|
||||||
reactionsService := services.NewReactionsService(globalDB)
|
reactionsService := services.NewReactionsService(globalDB)
|
||||||
|
|
||||||
// Создаем обработчики
|
|
||||||
authHandler := handlersPkg.NewAuthHandler(authService)
|
authHandler := handlersPkg.NewAuthHandler(authService)
|
||||||
movieHandler := handlersPkg.NewMovieHandler(movieService)
|
movieHandler := handlersPkg.NewMovieHandler(movieService)
|
||||||
tvHandler := handlersPkg.NewTVHandler(tvService)
|
tvHandler := handlersPkg.NewTVHandler(tvService)
|
||||||
@@ -77,35 +69,29 @@ func Handler(w http.ResponseWriter, r *http.Request) {
|
|||||||
reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService)
|
reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService)
|
||||||
imagesHandler := handlersPkg.NewImagesHandler()
|
imagesHandler := handlersPkg.NewImagesHandler()
|
||||||
|
|
||||||
// Настраиваем маршруты
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
|
||||||
// Документация API на корневом пути
|
|
||||||
router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
|
router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
|
||||||
router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
|
router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
|
||||||
|
|
||||||
// API маршруты
|
|
||||||
api := router.PathPrefix("/api/v1").Subrouter()
|
api := router.PathPrefix("/api/v1").Subrouter()
|
||||||
|
|
||||||
// Публичные маршруты
|
|
||||||
api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET")
|
api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET")
|
||||||
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
|
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
|
||||||
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
|
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
|
||||||
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
|
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
|
||||||
api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).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")
|
router.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||||
|
|
||||||
// Категории
|
|
||||||
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
||||||
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
|
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
|
||||||
|
|
||||||
// Плееры
|
|
||||||
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||||
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||||
|
|
||||||
// Торренты
|
|
||||||
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
|
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
|
||||||
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
|
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
|
||||||
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).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/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET")
|
||||||
api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET")
|
api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET")
|
||||||
|
|
||||||
// Реакции (публичные)
|
|
||||||
api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET")
|
api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET")
|
||||||
|
|
||||||
// Изображения (прокси для TMDB)
|
|
||||||
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
|
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
|
||||||
|
|
||||||
// Маршруты для фильмов
|
|
||||||
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
|
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
|
||||||
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
|
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
|
||||||
api.HandleFunc("/movies/top-rated", movieHandler.TopRated).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}/similar", movieHandler.GetSimilar).Methods("GET")
|
||||||
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
|
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
|
||||||
|
|
||||||
// Маршруты для сериалов
|
|
||||||
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
|
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
|
||||||
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
|
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
|
||||||
api.HandleFunc("/tv/top-rated", tvHandler.TopRated).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}/similar", tvHandler.GetSimilar).Methods("GET")
|
||||||
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
|
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
|
||||||
|
|
||||||
// Приватные маршруты (требуют авторизации)
|
|
||||||
protected := api.PathPrefix("").Subrouter()
|
protected := api.PathPrefix("").Subrouter()
|
||||||
protected.Use(middleware.JWTAuth(globalCfg.JWTSecret))
|
protected.Use(middleware.JWTAuth(globalCfg.JWTSecret))
|
||||||
|
|
||||||
// Избранное
|
|
||||||
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET")
|
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET")
|
||||||
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST")
|
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST")
|
||||||
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE")
|
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE")
|
||||||
|
|
||||||
// Пользовательские данные
|
|
||||||
protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET")
|
protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET")
|
||||||
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
||||||
// Новый маршрут удаления аккаунта
|
|
||||||
protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE")
|
protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE")
|
||||||
|
|
||||||
// Реакции (приватные)
|
|
||||||
protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET")
|
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.SetReaction).Methods("POST")
|
||||||
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
|
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
|
||||||
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
|
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
|
||||||
|
|
||||||
// CORS middleware
|
|
||||||
corsHandler := handlers.CORS(
|
corsHandler := handlers.CORS(
|
||||||
handlers.AllowedOrigins([]string{"*"}),
|
handlers.AllowedOrigins([]string{"*"}),
|
||||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||||
@@ -170,6 +146,5 @@ func Handler(w http.ResponseWriter, r *http.Request) {
|
|||||||
handlers.AllowCredentials(),
|
handlers.AllowCredentials(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Обрабатываем запрос
|
|
||||||
corsHandler(router).ServeHTTP(w, r)
|
corsHandler(router).ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
4
go.mod
4
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module neomovies-api
|
module neomovies-api
|
||||||
|
|
||||||
go 1.22.0
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.24.2
|
toolchain go1.24.2
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
go.mongodb.org/mongo-driver v1.11.6
|
go.mongodb.org/mongo-driver v1.11.6
|
||||||
golang.org/x/crypto v0.17.0
|
golang.org/x/crypto v0.17.0
|
||||||
|
golang.org/x/oauth2 v0.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||||
github.com/golang/snappy v0.0.1 // indirect
|
github.com/golang/snappy v0.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.13.6 // indirect
|
github.com/klauspost/compress v1.13.6 // indirect
|
||||||
|
|||||||
4
go.sum
4
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 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM=
|
||||||
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0=
|
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=
|
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 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
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/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 h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
55
main.go
55
main.go
@@ -13,37 +13,31 @@ import (
|
|||||||
"neomovies-api/pkg/database"
|
"neomovies-api/pkg/database"
|
||||||
appHandlers "neomovies-api/pkg/handlers"
|
appHandlers "neomovies-api/pkg/handlers"
|
||||||
"neomovies-api/pkg/middleware"
|
"neomovies-api/pkg/middleware"
|
||||||
"neomovies-api/pkg/monitor"
|
"neomovies-api/pkg/monitor"
|
||||||
"neomovies-api/pkg/services"
|
"neomovies-api/pkg/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Загружаем переменные окружения
|
if err := godotenv.Load(); err != nil { _ = err }
|
||||||
if err := godotenv.Load(); err != nil {
|
|
||||||
// Не выводим предупреждение в продакшене
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инициализируем конфигурацию
|
|
||||||
cfg := config.New()
|
cfg := config.New()
|
||||||
|
|
||||||
// Подключаемся к базе данных
|
db, err := database.Connect(cfg.MongoURI, cfg.MongoDBName)
|
||||||
db, err := database.Connect(cfg.MongoURI)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("❌ Failed to connect to database: %v\n", err)
|
fmt.Printf("❌ Failed to connect to database: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer database.Disconnect()
|
defer database.Disconnect()
|
||||||
|
|
||||||
// Инициализируем сервисы
|
|
||||||
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
|
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
|
||||||
emailService := services.NewEmailService(cfg)
|
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)
|
movieService := services.NewMovieService(db, tmdbService)
|
||||||
tvService := services.NewTVService(db, tmdbService)
|
tvService := services.NewTVService(db, tmdbService)
|
||||||
torrentService := services.NewTorrentService()
|
torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey)
|
||||||
reactionsService := services.NewReactionsService(db)
|
reactionsService := services.NewReactionsService(db)
|
||||||
|
|
||||||
// Создаем обработчики
|
|
||||||
authHandler := appHandlers.NewAuthHandler(authService)
|
authHandler := appHandlers.NewAuthHandler(authService)
|
||||||
movieHandler := appHandlers.NewMovieHandler(movieService)
|
movieHandler := appHandlers.NewMovieHandler(movieService)
|
||||||
tvHandler := appHandlers.NewTVHandler(tvService)
|
tvHandler := appHandlers.NewTVHandler(tvService)
|
||||||
@@ -55,35 +49,29 @@ func main() {
|
|||||||
reactionsHandler := appHandlers.NewReactionsHandler(reactionsService)
|
reactionsHandler := appHandlers.NewReactionsHandler(reactionsService)
|
||||||
imagesHandler := appHandlers.NewImagesHandler()
|
imagesHandler := appHandlers.NewImagesHandler()
|
||||||
|
|
||||||
// Настраиваем маршруты
|
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
// Документация API на корневом пути
|
|
||||||
r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
|
r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
|
||||||
r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
|
r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
|
||||||
|
|
||||||
// API маршруты
|
|
||||||
api := r.PathPrefix("/api/v1").Subrouter()
|
api := r.PathPrefix("/api/v1").Subrouter()
|
||||||
|
|
||||||
// Публичные маршруты
|
|
||||||
api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET")
|
api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET")
|
||||||
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
|
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
|
||||||
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
|
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
|
||||||
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
|
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
|
||||||
api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).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")
|
r.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||||
|
|
||||||
// Категории
|
|
||||||
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
||||||
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).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/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||||
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||||
|
|
||||||
// Торренты
|
|
||||||
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
|
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
|
||||||
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
|
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
|
||||||
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).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/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET")
|
||||||
api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET")
|
api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET")
|
||||||
|
|
||||||
// Реакции (публичные)
|
|
||||||
api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET")
|
api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET")
|
||||||
|
|
||||||
// Изображения (прокси для TMDB)
|
|
||||||
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
|
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
|
||||||
|
|
||||||
// Маршруты для фильмов
|
|
||||||
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
|
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
|
||||||
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
|
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
|
||||||
api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET")
|
api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET")
|
||||||
api.HandleFunc("/movies/upcoming", movieHandler.Upcoming).Methods("GET")
|
api.HandleFunc("/movies/upcoming", movieHandler.Upcoming).Methods("GET")
|
||||||
api.HandleFunc("/movies/now-playing", movieHandler.NowPlaying).Methods("GET")
|
api.HandleFunc("/movies/now-playing", movieHandler.NowPlaying).Methods("GET")
|
||||||
api.HandleFunc("/movies/{id}", movieHandler.GetByID).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}/similar", movieHandler.GetSimilar).Methods("GET")
|
||||||
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
|
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
|
||||||
|
|
||||||
// Маршруты для сериалов
|
|
||||||
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
|
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
|
||||||
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
|
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
|
||||||
api.HandleFunc("/tv/top-rated", tvHandler.TopRated).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}/similar", tvHandler.GetSimilar).Methods("GET")
|
||||||
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
|
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
|
||||||
|
|
||||||
// Приватные маршруты (требуют авторизации)
|
|
||||||
protected := api.PathPrefix("").Subrouter()
|
protected := api.PathPrefix("").Subrouter()
|
||||||
protected.Use(middleware.JWTAuth(cfg.JWTSecret))
|
protected.Use(middleware.JWTAuth(cfg.JWTSecret))
|
||||||
|
|
||||||
// Избранное
|
|
||||||
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET")
|
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET")
|
||||||
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST")
|
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST")
|
||||||
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE")
|
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE")
|
||||||
|
|
||||||
// Пользовательские данные
|
|
||||||
protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET")
|
protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET")
|
||||||
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
||||||
// Новый маршрут удаления аккаунта
|
|
||||||
protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE")
|
protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE")
|
||||||
|
|
||||||
// Реакции (приватные)
|
|
||||||
protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET")
|
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.SetReaction).Methods("POST")
|
||||||
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
|
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
|
||||||
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
|
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
|
||||||
|
|
||||||
// CORS middleware
|
|
||||||
corsHandler := handlers.CORS(
|
corsHandler := handlers.CORS(
|
||||||
|
handlers.AllowedOrigins([]string{"*"}),
|
||||||
handlers.AllowedOrigins([]string{"*"}),
|
|
||||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||||
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}),
|
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}),
|
||||||
handlers.AllowCredentials(),
|
handlers.AllowCredentials(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Применяем мониторинг запросов только в development
|
|
||||||
var finalHandler http.Handler
|
var finalHandler http.Handler
|
||||||
if cfg.NodeEnv == "development" {
|
if cfg.NodeEnv == "development" {
|
||||||
// Добавляем middleware для мониторинга запросов
|
|
||||||
r.Use(monitor.RequestMonitor())
|
r.Use(monitor.RequestMonitor())
|
||||||
finalHandler = corsHandler(r)
|
finalHandler = corsHandler(r)
|
||||||
|
|
||||||
// Выводим заголовок мониторинга
|
|
||||||
fmt.Println("\n🚀 NeoMovies API Server")
|
fmt.Println("\n🚀 NeoMovies API Server")
|
||||||
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
fmt.Printf("📡 Server: http://localhost:%s\n", cfg.Port)
|
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)
|
fmt.Printf("✅ Server starting on port %s\n", cfg.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Определяем порт
|
|
||||||
port := cfg.Port
|
port := cfg.Port
|
||||||
if port == "" {
|
if port == "" { port = "3000" }
|
||||||
port = "3000"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запускаем сервер
|
|
||||||
if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
|
if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
|
||||||
fmt.Printf("❌ Server failed to start: %v\n", err)
|
fmt.Printf("❌ Server failed to start: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
MongoURI string
|
MongoURI string
|
||||||
|
MongoDBName string
|
||||||
TMDBAccessToken string
|
TMDBAccessToken string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
Port string
|
Port string
|
||||||
@@ -16,40 +17,45 @@ type Config struct {
|
|||||||
GmailPassword string
|
GmailPassword string
|
||||||
LumexURL string
|
LumexURL string
|
||||||
AllohaToken string
|
AllohaToken string
|
||||||
|
RedAPIBaseURL string
|
||||||
|
RedAPIKey string
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
GoogleRedirectURL string
|
||||||
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Config {
|
func New() *Config {
|
||||||
// Добавляем отладочное логирование для Vercel
|
|
||||||
mongoURI := getMongoURI()
|
mongoURI := getMongoURI()
|
||||||
log.Printf("DEBUG: MongoDB URI configured (length: %d)", len(mongoURI))
|
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
MongoURI: mongoURI,
|
MongoURI: mongoURI,
|
||||||
TMDBAccessToken: getEnv("TMDB_ACCESS_TOKEN", ""),
|
MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
|
TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""),
|
||||||
Port: getEnv("PORT", "3000"),
|
JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret),
|
||||||
BaseURL: getEnv("BASE_URL", "http://localhost:3000"),
|
Port: getEnv(EnvPort, DefaultPort),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
BaseURL: getEnv(EnvBaseURL, DefaultBaseURL),
|
||||||
GmailUser: getEnv("GMAIL_USER", ""),
|
NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv),
|
||||||
GmailPassword: getEnv("GMAIL_APP_PASSWORD", ""),
|
GmailUser: getEnv(EnvGmailUser, ""),
|
||||||
LumexURL: getEnv("LUMEX_URL", ""),
|
GmailPassword: getEnv(EnvGmailPassword, ""),
|
||||||
AllohaToken: getEnv("ALLOHA_TOKEN", ""),
|
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 {
|
func getMongoURI() string {
|
||||||
// Проверяем различные возможные названия переменных
|
for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} {
|
||||||
envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"}
|
|
||||||
|
|
||||||
for _, envVar := range envVars {
|
|
||||||
if value := os.Getenv(envVar); value != "" {
|
if value := os.Getenv(envVar); value != "" {
|
||||||
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
|
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если ни одна переменная не найдена, возвращаем пустую строку
|
|
||||||
log.Printf("DEBUG: No MongoDB URI environment variable found")
|
log.Printf("DEBUG: No MongoDB URI environment variable found")
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
33
pkg/config/vars.go
Normal file
33
pkg/config/vars.go
Normal file
@@ -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"
|
||||||
|
)
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
var client *mongo.Client
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -20,13 +20,11 @@ func Connect(uri string) (*mongo.Database, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем соединение
|
if err = client.Ping(ctx, nil); err != nil {
|
||||||
err = client.Ping(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return client.Database("database"), nil
|
return client.Database(dbName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Disconnect() error {
|
func Disconnect() error {
|
||||||
@@ -40,6 +38,4 @@ func Disconnect() error {
|
|||||||
return client.Disconnect(ctx)
|
return client.Disconnect(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetClient() *mongo.Client {
|
func GetClient() *mongo.Client { return client }
|
||||||
return client
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
|
||||||
@@ -16,9 +18,7 @@ type AuthHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{authService: authService}
|
||||||
authService: authService,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"})
|
||||||
Success: true,
|
|
||||||
Data: response,
|
|
||||||
Message: "User registered successfully",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
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)
|
response, err := h.authService.Login(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Определяем правильный статус код в зависимости от ошибки
|
|
||||||
statusCode := http.StatusBadRequest
|
statusCode := http.StatusBadRequest
|
||||||
if err.Error() == "Account not activated. Please verify your email." {
|
if err.Error() == "Account not activated. Please verify your email." {
|
||||||
statusCode = http.StatusForbidden // 403 для неверифицированного email
|
statusCode = http.StatusForbidden
|
||||||
}
|
}
|
||||||
http.Error(w, err.Error(), statusCode)
|
http.Error(w, err.Error(), statusCode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"})
|
||||||
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) {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user})
|
||||||
Success: true,
|
|
||||||
Data: user,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаляем поля, которые нельзя обновлять через этот эндпоинт
|
|
||||||
delete(updates, "password")
|
delete(updates, "password")
|
||||||
delete(updates, "email")
|
delete(updates, "email")
|
||||||
delete(updates, "_id")
|
delete(updates, "_id")
|
||||||
@@ -115,14 +168,9 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"})
|
||||||
Success: true,
|
|
||||||
Data: user,
|
|
||||||
Message: "Profile updated successfully",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаление аккаунта
|
|
||||||
func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -136,12 +184,9 @@ func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Account deleted successfully"})
|
||||||
Success: true,
|
|
||||||
Message: "Account deleted successfully",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// Подтверждение email
|
|
||||||
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
var req models.VerifyEmailRequest
|
var req models.VerifyEmailRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Повторная отправка кода верификации
|
|
||||||
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
|
||||||
var req models.ResendCodeRequest
|
var req models.ResendCodeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
func generateState() string { return uuidNew() }
|
||||||
7
pkg/handlers/auth_helpers.go
Normal file
7
pkg/handlers/auth_helpers.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func uuidNew() string { return uuid.New().String() }
|
||||||
@@ -9,17 +9,13 @@ import (
|
|||||||
"github.com/MarceloPetrucio/go-scalar-api-reference"
|
"github.com/MarceloPetrucio/go-scalar-api-reference"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DocsHandler struct {
|
type DocsHandler struct{}
|
||||||
// Убираем статическую спецификацию
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDocsHandler() *DocsHandler {
|
func NewDocsHandler() *DocsHandler {
|
||||||
return &DocsHandler{}
|
return &DocsHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DocsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *DocsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// Обслуживаем документацию для всех путей
|
|
||||||
// Это нужно для правильной работы Scalar API Reference
|
|
||||||
h.ServeDocs(w, r)
|
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) {
|
func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||||
// Определяем baseURL динамически
|
|
||||||
baseURL := os.Getenv("BASE_URL")
|
baseURL := os.Getenv("BASE_URL")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
if r.TLS != nil {
|
if r.TLS != nil {
|
||||||
@@ -38,7 +33,6 @@ func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерируем спецификацию с правильным URL
|
|
||||||
spec := getOpenAPISpecWithURL(baseURL)
|
spec := getOpenAPISpecWithURL(baseURL)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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{
|
Components: Components{
|
||||||
SecuritySchemes: map[string]SecurityScheme{
|
SecuritySchemes: map[string]SecurityScheme{
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"neomovies-api/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImagesHandler struct{}
|
type ImagesHandler struct{}
|
||||||
|
|
||||||
func NewImagesHandler() *ImagesHandler {
|
func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} }
|
||||||
return &ImagesHandler{}
|
|
||||||
}
|
|
||||||
|
|
||||||
const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
|
||||||
|
|
||||||
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
|
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
@@ -29,22 +26,18 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если запрашивается placeholder, возвращаем локальный файл
|
|
||||||
if imagePath == "placeholder.jpg" {
|
if imagePath == "placeholder.jpg" {
|
||||||
h.servePlaceholder(w, r)
|
h.servePlaceholder(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем размер изображения
|
|
||||||
validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||||
if !h.isValidSize(size, validSizes) {
|
if !h.isValidSize(size, validSizes) {
|
||||||
size = "original"
|
size = "original"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Формируем URL изображения
|
imageURL := fmt.Sprintf("%s/%s/%s", config.TMDBImageBaseURL, size, imagePath)
|
||||||
imageURL := fmt.Sprintf("%s/%s/%s", TMDB_IMAGE_BASE_URL, size, imagePath)
|
|
||||||
|
|
||||||
// Получаем изображение
|
|
||||||
resp, err := http.Get(imageURL)
|
resp, err := http.Get(imageURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.servePlaceholder(w, r)
|
h.servePlaceholder(w, r)
|
||||||
@@ -57,23 +50,19 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем заголовки
|
|
||||||
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||||
w.Header().Set("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)
|
_, err = io.Copy(w, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Если ошибка при копировании, отдаем placeholder
|
|
||||||
h.servePlaceholder(w, r)
|
h.servePlaceholder(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
|
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
|
||||||
// Попробуем найти placeholder изображение
|
|
||||||
placeholderPaths := []string{
|
placeholderPaths := []string{
|
||||||
"./assets/placeholder.jpg",
|
"./assets/placeholder.jpg",
|
||||||
"./public/images/placeholder.jpg",
|
"./public/images/placeholder.jpg",
|
||||||
@@ -89,7 +78,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if placeholderPath == "" {
|
if placeholderPath == "" {
|
||||||
// Если placeholder не найден, создаем простую SVG заглушку
|
|
||||||
h.serveSVGPlaceholder(w, r)
|
h.serveSVGPlaceholder(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,7 +89,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Определяем content-type по расширению
|
|
||||||
ext := strings.ToLower(filepath.Ext(placeholderPath))
|
ext := strings.ToLower(filepath.Ext(placeholderPath))
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".jpg", ".jpeg":
|
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("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)
|
_, err = io.Copy(w, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ type ReactionsHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler {
|
func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler {
|
||||||
return &ReactionsHandler{
|
return &ReactionsHandler{reactionsService: reactionsService}
|
||||||
reactionsService: reactionsService,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить счетчики реакций для медиа (публичный эндпоинт)
|
|
||||||
func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) {
|
func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
mediaType := vars["mediaType"]
|
mediaType := vars["mediaType"]
|
||||||
@@ -42,7 +39,6 @@ func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Requ
|
|||||||
json.NewEncoder(w).Encode(counts)
|
json.NewEncoder(w).Encode(counts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить реакцию текущего пользователя (требует авторизации)
|
|
||||||
func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) {
|
func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -59,21 +55,20 @@ func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID)
|
reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if reaction == nil {
|
if reactionType == "" {
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{})
|
json.NewEncoder(w).Encode(map[string]interface{}{})
|
||||||
} else {
|
} 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) {
|
func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -90,34 +85,25 @@ func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var request struct {
|
var request struct{ Type string `json:"type"` }
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Type == "" {
|
if request.Type == "" {
|
||||||
http.Error(w, "Reaction type is required", http.StatusBadRequest)
|
http.Error(w, "Reaction type is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.reactionsService.SetUserReaction(userID, mediaType, mediaID, request.Type)
|
if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction set successfully"})
|
||||||
Success: true,
|
|
||||||
Message: "Reaction set successfully",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удалить реакцию пользователя (требует авторизации)
|
|
||||||
func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
|
func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -134,20 +120,15 @@ func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID)
|
if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction removed successfully"})
|
||||||
Success: true,
|
|
||||||
Message: "Reaction removed successfully",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить все реакции пользователя (требует авторизации)
|
|
||||||
func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) {
|
func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -164,8 +145,5 @@ func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.APIResponse{
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: reactions})
|
||||||
Success: true,
|
|
||||||
Data: reactions,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,8 @@ type User struct {
|
|||||||
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
|
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
|
||||||
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
|
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
|
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 {
|
type LoginRequest struct {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"neomovies-api/pkg/models"
|
"neomovies-api/pkg/models"
|
||||||
)
|
)
|
||||||
@@ -25,7 +29,11 @@ type AuthService struct {
|
|||||||
db *mongo.Database
|
db *mongo.Database
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
cubAPIURL string
|
baseURL string
|
||||||
|
googleClientID string
|
||||||
|
googleClientSecret string
|
||||||
|
googleRedirectURL string
|
||||||
|
frontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reaction represents a reaction entry in the database.
|
// Reaction represents a reaction entry in the database.
|
||||||
@@ -36,17 +44,154 @@ type Reaction struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService creates and initializes a new AuthService.
|
// 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{
|
service := &AuthService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
cubAPIURL: cubAPIURL,
|
baseURL: baseURL,
|
||||||
|
googleClientID: googleClientID,
|
||||||
|
googleClientSecret: googleClientSecret,
|
||||||
|
googleRedirectURL: googleRedirectURL,
|
||||||
|
frontendURL: frontendURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
return service
|
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.
|
// generateVerificationCode creates a 6-digit verification code.
|
||||||
func (s *AuthService) generateVerificationCode() string {
|
func (s *AuthService) generateVerificationCode() string {
|
||||||
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
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
|
// 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")
|
reactionsCollection := s.db.Collection("reactions")
|
||||||
var userReactions []Reaction
|
var userReactions []Reaction
|
||||||
cursor, err := reactionsCollection.Find(ctx, bson.M{"userId": objectID})
|
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)
|
wg.Add(1)
|
||||||
go func(r Reaction) {
|
go func(r Reaction) {
|
||||||
defer wg.Done()
|
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"
|
req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log the error but don't stop the process
|
// Log the error but don't stop the process
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/config"
|
||||||
"neomovies-api/pkg/models"
|
"neomovies-api/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,17 +28,15 @@ func NewReactionsService(db *mongo.Database) *ReactionsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUB_API_URL = "https://cub.rip/api"
|
var validReactions = []string{"fire", "nice", "think", "bore", "shit"}
|
||||||
|
|
||||||
var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"}
|
|
||||||
|
|
||||||
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
|
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
|
||||||
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
|
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
|
||||||
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
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 {
|
if err != nil {
|
||||||
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке
|
return &models.ReactionCounts{}, nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -61,7 +60,6 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models
|
|||||||
return &models.ReactionCounts{}, nil
|
return &models.ReactionCounts{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Преобразуем в нашу структуру
|
|
||||||
counts := &models.ReactionCounts{}
|
counts := &models.ReactionCounts{}
|
||||||
for _, reaction := range response.Result {
|
for _, reaction := range response.Result {
|
||||||
switch reaction.Type {
|
switch reaction.Type {
|
||||||
@@ -81,76 +79,58 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models
|
|||||||
return counts, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить реакцию пользователя для медиа
|
func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (string, error) {
|
||||||
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
|
|
||||||
collection := s.db.Collection("reactions")
|
collection := s.db.Collection("reactions")
|
||||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
ctx := context.Background()
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
var result struct{ Type string `bson:"type"` }
|
||||||
|
err := collection.FindOne(ctx, bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
"mediaType": mediaType,
|
||||||
|
"mediaId": mediaID,
|
||||||
|
}).Decode(&result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
return result.Type, nil
|
||||||
// Отправляем реакцию в cub.rip API
|
|
||||||
go s.sendReactionToCub(fullMediaID, reactionType)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удалить реакцию пользователя
|
func (s *ReactionsService) SetReaction(userID, mediaType, mediaID, reactionType string) error {
|
||||||
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error {
|
if !s.isValidReactionType(reactionType) {
|
||||||
collection := s.db.Collection("reactions")
|
return fmt.Errorf("invalid reaction type")
|
||||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
}
|
||||||
|
|
||||||
_, err := collection.DeleteOne(context.Background(), bson.M{
|
collection := s.db.Collection("reactions")
|
||||||
"userId": userID,
|
ctx := context.Background()
|
||||||
"mediaId": fullMediaID,
|
|
||||||
|
_, 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +154,7 @@ func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
|
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
|
||||||
for _, valid := range VALID_REACTIONS {
|
for _, valid := range validReactions {
|
||||||
if valid == reactionType {
|
if valid == reactionType {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -184,8 +164,7 @@ func (s *ReactionsService) isValidReactionType(reactionType string) bool {
|
|||||||
|
|
||||||
// Отправка реакции в cub.rip API (асинхронно)
|
// Отправка реакции в cub.rip API (асинхронно)
|
||||||
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
|
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
|
||||||
// Формируем запрос к cub.rip API
|
url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL)
|
||||||
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
|
|
||||||
|
|
||||||
data := map[string]string{
|
data := map[string]string{
|
||||||
"mediaId": mediaID,
|
"mediaId": mediaID,
|
||||||
@@ -197,15 +176,12 @@ func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// В данном случае мы отправляем простой POST запрос
|
|
||||||
// В будущем можно доработать для отправки JSON данных
|
|
||||||
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
|
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Логируем результат (в продакшене лучше использовать структурированное логирование)
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
|
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,19 @@ type TorrentService struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewTorrentServiceWithConfig(baseURL, apiKey string) *TorrentService {
|
||||||
|
return &TorrentService{
|
||||||
|
client: &http.Client{Timeout: 8 * time.Second},
|
||||||
|
baseURL: baseURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewTorrentService() *TorrentService {
|
func NewTorrentService() *TorrentService {
|
||||||
return &TorrentService{
|
return &TorrentService{
|
||||||
client: &http.Client{Timeout: 8 * time.Second},
|
client: &http.Client{Timeout: 8 * time.Second},
|
||||||
baseURL: "http://redapi.cfhttp.top",
|
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) {
|
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
|
||||||
searchParams := url.Values{}
|
searchParams := url.Values{}
|
||||||
|
|
||||||
// Добавляем все параметры поиска
|
|
||||||
for key, value := range params {
|
for key, value := range params {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
if key == "category" {
|
if key == "category" {
|
||||||
@@ -80,7 +87,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
|
|||||||
var results []models.TorrentResult
|
var results []models.TorrentResult
|
||||||
|
|
||||||
for _, torrent := range data.Results {
|
for _, torrent := range data.Results {
|
||||||
// Обрабатываем размер - может быть строкой или числом
|
|
||||||
var sizeStr string
|
var sizeStr string
|
||||||
switch v := torrent.Size.(type) {
|
switch v := torrent.Size.(type) {
|
||||||
case string:
|
case string:
|
||||||
@@ -106,9 +112,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
|
|||||||
Source: "RedAPI",
|
Source: "RedAPI",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем информацию из Info если она есть
|
|
||||||
if torrent.Info != nil {
|
if torrent.Info != nil {
|
||||||
// Обрабатываем качество - может быть строкой или числом
|
|
||||||
switch v := torrent.Info.Quality.(type) {
|
switch v := torrent.Info.Quality.(type) {
|
||||||
case string:
|
case string:
|
||||||
result.Quality = v
|
result.Quality = v
|
||||||
@@ -123,7 +127,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
|
|||||||
result.Seasons = torrent.Info.Seasons
|
result.Seasons = torrent.Info.Seasons
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если качество не определено через Info, пытаемся извлечь из названия
|
|
||||||
if result.Quality == "" {
|
if result.Quality == "" {
|
||||||
result.Quality = s.ExtractQuality(result.Title)
|
result.Quality = s.ExtractQuality(result.Title)
|
||||||
}
|
}
|
||||||
@@ -136,14 +139,11 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
|
|||||||
|
|
||||||
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
|
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
|
||||||
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
|
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)
|
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
|
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем параметры поиска для RedAPI
|
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"imdb": imdbID,
|
"imdb": imdbID,
|
||||||
"query": title,
|
"query": title,
|
||||||
@@ -151,7 +151,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
|
|||||||
"year": year,
|
"year": year,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Определяем тип контента для API
|
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case "movie":
|
case "movie":
|
||||||
params["is_serial"] = "1"
|
params["is_serial"] = "1"
|
||||||
@@ -164,18 +163,15 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
|
|||||||
params["category"] = "5070"
|
params["category"] = "5070"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем сезон, если он указан
|
|
||||||
if options != nil && options.Season != nil && *options.Season > 0 {
|
if options != nil && options.Season != nil && *options.Season > 0 {
|
||||||
params["season"] = strconv.Itoa(*options.Season)
|
params["season"] = strconv.Itoa(*options.Season)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выполняем поиск
|
|
||||||
response, err := s.SearchTorrents(params)
|
response, err := s.SearchTorrents(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Применяем фильтрацию
|
|
||||||
if options != nil {
|
if options != nil {
|
||||||
response.Results = s.FilterByContentType(response.Results, options.ContentType)
|
response.Results = s.FilterByContentType(response.Results, options.ContentType)
|
||||||
response.Results = s.FilterTorrents(response.Results, options)
|
response.Results = s.FilterTorrents(response.Results, options)
|
||||||
@@ -183,7 +179,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
|
|||||||
}
|
}
|
||||||
response.Total = len(response.Results)
|
response.Total = len(response.Results)
|
||||||
|
|
||||||
// Fallback для сериалов, если результатов мало
|
|
||||||
if len(response.Results) < 5 && (mediaType == "serial" || mediaType == "series" || mediaType == "tv") && options != nil && options.Season != nil {
|
if len(response.Results) < 5 && (mediaType == "serial" || mediaType == "series" || mediaType == "tv") && options != nil && options.Season != nil {
|
||||||
paramsNoSeason := map[string]string{
|
paramsNoSeason := map[string]string{
|
||||||
"imdb": imdbID,
|
"imdb": imdbID,
|
||||||
@@ -206,7 +201,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.Results = unique
|
response.Results = unique
|
||||||
response.Total = len(response.Results)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user