Add Google OAuth

This commit is contained in:
2025-08-08 16:47:02 +00:00
parent abed5d75c1
commit cd60dec5b8
18 changed files with 484 additions and 322 deletions

View File

@@ -7,6 +7,7 @@ import (
type Config struct {
MongoURI string
MongoDBName string
TMDBAccessToken string
JWTSecret string
Port string
@@ -16,40 +17,45 @@ type Config struct {
GmailPassword string
LumexURL string
AllohaToken string
RedAPIBaseURL string
RedAPIKey string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
FrontendURL string
}
func New() *Config {
// Добавляем отладочное логирование для Vercel
mongoURI := getMongoURI()
log.Printf("DEBUG: MongoDB URI configured (length: %d)", len(mongoURI))
return &Config{
MongoURI: mongoURI,
TMDBAccessToken: getEnv("TMDB_ACCESS_TOKEN", ""),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
Port: getEnv("PORT", "3000"),
BaseURL: getEnv("BASE_URL", "http://localhost:3000"),
NodeEnv: getEnv("NODE_ENV", "development"),
GmailUser: getEnv("GMAIL_USER", ""),
GmailPassword: getEnv("GMAIL_APP_PASSWORD", ""),
LumexURL: getEnv("LUMEX_URL", ""),
AllohaToken: getEnv("ALLOHA_TOKEN", ""),
MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName),
TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""),
JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret),
Port: getEnv(EnvPort, DefaultPort),
BaseURL: getEnv(EnvBaseURL, DefaultBaseURL),
NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv),
GmailUser: getEnv(EnvGmailUser, ""),
GmailPassword: getEnv(EnvGmailPassword, ""),
LumexURL: getEnv(EnvLumexURL, ""),
AllohaToken: getEnv(EnvAllohaToken, ""),
RedAPIBaseURL: getEnv(EnvRedAPIBaseURL, DefaultRedAPIBase),
RedAPIKey: getEnv(EnvRedAPIKey, ""),
GoogleClientID: getEnv(EnvGoogleClientID, ""),
GoogleClientSecret: getEnv(EnvGoogleClientSecret, ""),
GoogleRedirectURL: getEnv(EnvGoogleRedirectURL, ""),
FrontendURL: getEnv(EnvFrontendURL, ""),
}
}
// getMongoURI проверяет различные варианты названий переменных для MongoDB URI
func getMongoURI() string {
// Проверяем различные возможные названия переменных
envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"}
for _, envVar := range envVars {
for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} {
if value := os.Getenv(envVar); value != "" {
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
return value
}
}
// Если ни одна переменная не найдена, возвращаем пустую строку
log.Printf("DEBUG: No MongoDB URI environment variable found")
return ""
}

33
pkg/config/vars.go Normal file
View 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"
)

View File

@@ -10,7 +10,7 @@ import (
var client *mongo.Client
func Connect(uri string) (*mongo.Database, error) {
func Connect(uri, dbName string) (*mongo.Database, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -20,13 +20,11 @@ func Connect(uri string) (*mongo.Database, error) {
return nil, err
}
// Проверяем соединение
err = client.Ping(ctx, nil)
if err != nil {
if err = client.Ping(ctx, nil); err != nil {
return nil, err
}
return client.Database("database"), nil
return client.Database(dbName), nil
}
func Disconnect() error {
@@ -40,6 +38,4 @@ func Disconnect() error {
return client.Disconnect(ctx)
}
func GetClient() *mongo.Client {
return client
}
func GetClient() *mongo.Client { return client }

View File

@@ -3,6 +3,8 @@ package handlers
import (
"encoding/json"
"net/http"
"time"
"strings"
"go.mongodb.org/mongo-driver/bson"
@@ -16,9 +18,7 @@ type AuthHandler struct {
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
@@ -36,11 +36,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
Message: "User registered successfully",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"})
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
@@ -52,21 +48,82 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
response, err := h.authService.Login(req)
if err != nil {
// Определяем правильный статус код в зависимости от ошибки
statusCode := http.StatusBadRequest
if err.Error() == "Account not activated. Please verify your email." {
statusCode = http.StatusForbidden // 403 для неверифицированного email
statusCode = http.StatusForbidden
}
http.Error(w, err.Error(), statusCode)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
Message: "Login successful",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"})
}
func (h *AuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)})
url, err := h.authService.GetGoogleLoginURL(state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state := q.Get("state")
code := q.Get("code")
preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json")
cookie, _ := r.Cookie("oauth_state")
if cookie == nil || cookie.Value != state || code == "" {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, "invalid oauth state", http.StatusBadRequest)
return
}
resp, err := h.authService.HandleGoogleCallback(r.Context(), code)
if err != nil {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if preferJSON {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
}
func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
@@ -83,10 +140,7 @@ func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: user,
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user})
}
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
@@ -102,7 +156,6 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
return
}
// Удаляем поля, которые нельзя обновлять через этот эндпоинт
delete(updates, "password")
delete(updates, "email")
delete(updates, "_id")
@@ -115,14 +168,9 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: user,
Message: "Profile updated successfully",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"})
}
// Удаление аккаунта
func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
@@ -136,12 +184,9 @@ func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Account deleted successfully",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Account deleted successfully"})
}
// Подтверждение email
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req models.VerifyEmailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -159,7 +204,6 @@ func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response)
}
// Повторная отправка кода верификации
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
var req models.ResendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -175,4 +219,7 @@ func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Requ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// helpers
func generateState() string { return uuidNew() }

View File

@@ -0,0 +1,7 @@
package handlers
import (
"github.com/google/uuid"
)
func uuidNew() string { return uuid.New().String() }

View File

@@ -9,17 +9,13 @@ import (
"github.com/MarceloPetrucio/go-scalar-api-reference"
)
type DocsHandler struct {
// Убираем статическую спецификацию
}
type DocsHandler struct{}
func NewDocsHandler() *DocsHandler {
return &DocsHandler{}
}
func (h *DocsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Обслуживаем документацию для всех путей
// Это нужно для правильной работы Scalar API Reference
h.ServeDocs(w, r)
}
@@ -28,7 +24,6 @@ func (h *DocsHandler) RedirectToDocs(w http.ResponseWriter, r *http.Request) {
}
func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) {
// Определяем baseURL динамически
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
if r.TLS != nil {
@@ -38,7 +33,6 @@ func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) {
}
}
// Генерируем спецификацию с правильным URL
spec := getOpenAPISpecWithURL(baseURL)
w.Header().Set("Content-Type", "application/json")
@@ -1177,6 +1171,39 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
},
},
"/api/v1/auth/google/login": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Google OAuth: начало",
"description": "Редирект на страницу авторизации Google",
"tags": []string{"Authentication"},
"responses": map[string]interface{}{
"302": map[string]interface{}{"description": "Redirect to Google"},
"400": map[string]interface{}{"description": "OAuth не сконфигурирован"},
},
},
},
"/api/v1/auth/google/callback": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Google OAuth: коллбек",
"description": "Обработка кода авторизации и выдача JWT",
"tags": []string{"Authentication"},
"parameters": []map[string]interface{}{
{"name": "state", "in": "query", "required": true, "schema": map[string]string{"type": "string"}},
{"name": "code", "in": "query", "required": true, "schema": map[string]string{"type": "string"}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Успешная авторизация через Google",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AuthResponse"},
},
},
},
"400": map[string]interface{}{"description": "Неверный state или ошибка обмена кода"},
},
},
},
},
Components: Components{
SecuritySchemes: map[string]SecurityScheme{

View File

@@ -9,15 +9,12 @@ import (
"strings"
"github.com/gorilla/mux"
"neomovies-api/pkg/config"
)
type ImagesHandler struct{}
func NewImagesHandler() *ImagesHandler {
return &ImagesHandler{}
}
const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} }
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -29,22 +26,18 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
return
}
// Если запрашивается placeholder, возвращаем локальный файл
if imagePath == "placeholder.jpg" {
h.servePlaceholder(w, r)
return
}
// Проверяем размер изображения
validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"}
if !h.isValidSize(size, validSizes) {
size = "original"
}
// Формируем URL изображения
imageURL := fmt.Sprintf("%s/%s/%s", TMDB_IMAGE_BASE_URL, size, imagePath)
imageURL := fmt.Sprintf("%s/%s/%s", config.TMDBImageBaseURL, size, imagePath)
// Получаем изображение
resp, err := http.Get(imageURL)
if err != nil {
h.servePlaceholder(w, r)
@@ -57,23 +50,19 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
return
}
// Устанавливаем заголовки
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Cache-Control", "public, max-age=31536000") // кэшируем на 1 год
w.Header().Set("Cache-Control", "public, max-age=31536000")
// Передаем изображение клиенту
_, err = io.Copy(w, resp.Body)
if err != nil {
// Если ошибка при копировании, отдаем placeholder
h.servePlaceholder(w, r)
return
}
}
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
// Попробуем найти placeholder изображение
placeholderPaths := []string{
"./assets/placeholder.jpg",
"./public/images/placeholder.jpg",
@@ -89,7 +78,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request)
}
if placeholderPath == "" {
// Если placeholder не найден, создаем простую SVG заглушку
h.serveSVGPlaceholder(w, r)
return
}
@@ -101,7 +89,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request)
}
defer file.Close()
// Определяем content-type по расширению
ext := strings.ToLower(filepath.Ext(placeholderPath))
switch ext {
case ".jpg", ".jpeg":
@@ -116,7 +103,7 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request)
w.Header().Set("Content-Type", "image/jpeg")
}
w.Header().Set("Cache-Control", "public, max-age=3600") // кэшируем placeholder на 1 час
w.Header().Set("Cache-Control", "public, max-age=3600")
_, err = io.Copy(w, file)
if err != nil {

View File

@@ -16,12 +16,9 @@ type ReactionsHandler struct {
}
func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler {
return &ReactionsHandler{
reactionsService: reactionsService,
}
return &ReactionsHandler{reactionsService: reactionsService}
}
// Получить счетчики реакций для медиа (публичный эндпоинт)
func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mediaType := vars["mediaType"]
@@ -42,7 +39,6 @@ func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Requ
json.NewEncoder(w).Encode(counts)
}
// Получить реакцию текущего пользователя (требует авторизации)
func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
@@ -59,21 +55,20 @@ func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request)
return
}
reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID)
reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if reaction == nil {
if reactionType == "" {
json.NewEncoder(w).Encode(map[string]interface{}{})
} else {
json.NewEncoder(w).Encode(reaction)
json.NewEncoder(w).Encode(map[string]string{"type": reactionType})
}
}
// Установить реакцию пользователя (требует авторизации)
func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
@@ -90,34 +85,25 @@ func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
return
}
var request struct {
Type string `json:"type"`
}
var request struct{ Type string `json:"type"` }
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if request.Type == "" {
http.Error(w, "Reaction type is required", http.StatusBadRequest)
return
}
err := h.reactionsService.SetUserReaction(userID, mediaType, mediaID, request.Type)
if err != nil {
if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Reaction set successfully",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction set successfully"})
}
// Удалить реакцию пользователя (требует авторизации)
func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
@@ -134,20 +120,15 @@ func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request
return
}
err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID)
if err != nil {
if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Reaction removed successfully",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction removed successfully"})
}
// Получить все реакции пользователя (требует авторизации)
func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
@@ -164,8 +145,5 @@ func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: reactions,
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: reactions})
}

View File

@@ -20,6 +20,8 @@ type User struct {
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"`
}
type LoginRequest struct {

View File

@@ -7,6 +7,7 @@ import (
"io"
"math/rand"
"net/http"
"net/url"
"sync"
"time"
@@ -16,6 +17,9 @@ import (
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"encoding/json"
"neomovies-api/pkg/models"
)
@@ -25,7 +29,11 @@ type AuthService struct {
db *mongo.Database
jwtSecret string
emailService *EmailService
cubAPIURL string
baseURL string
googleClientID string
googleClientSecret string
googleRedirectURL string
frontendURL string
}
// Reaction represents a reaction entry in the database.
@@ -36,17 +44,154 @@ type Reaction struct {
}
// NewAuthService creates and initializes a new AuthService.
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, cubAPIURL string) *AuthService {
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, baseURL string, googleClientID string, googleClientSecret string, googleRedirectURL string, frontendURL string) *AuthService {
service := &AuthService{
db: db,
jwtSecret: jwtSecret,
emailService: emailService,
cubAPIURL: cubAPIURL,
baseURL: baseURL,
googleClientID: googleClientID,
googleClientSecret: googleClientSecret,
googleRedirectURL: googleRedirectURL,
frontendURL: frontendURL,
}
return service
}
func (s *AuthService) googleOAuthConfig() *oauth2.Config {
redirectURL := s.googleRedirectURL
if redirectURL == "" && s.baseURL != "" {
redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL)
}
return &oauth2.Config{
ClientID: s.googleClientID,
ClientSecret: s.googleClientSecret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
}
func (s *AuthService) GetGoogleLoginURL(state string) (string, error) {
cfg := s.googleOAuthConfig()
if cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" {
return "", errors.New("google oauth not configured")
}
return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
EmailVerified bool `json:"email_verified"`
}
// BuildFrontendRedirect builds frontend URL for redirect after OAuth; returns false if not configured
func (s *AuthService) BuildFrontendRedirect(token string, authErr string) (string, bool) {
if s.frontendURL == "" {
return "", false
}
if authErr != "" {
u, _ := url.Parse(s.frontendURL + "/login")
q := u.Query()
q.Set("oauth", "google")
q.Set("error", authErr)
u.RawQuery = q.Encode()
return u.String(), true
}
u, _ := url.Parse(s.frontendURL + "/auth/callback")
q := u.Query()
q.Set("provider", "google")
q.Set("token", token)
u.RawQuery = q.Encode()
return u.String(), true
}
func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.AuthResponse, error) {
cfg := s.googleOAuthConfig()
tok, err := cfg.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
client := cfg.Client(ctx, tok)
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
if err != nil {
return nil, fmt.Errorf("failed to fetch userinfo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("userinfo request failed: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var gUser googleUserInfo
if err := json.Unmarshal(body, &gUser); err != nil {
return nil, fmt.Errorf("failed to parse userinfo: %w", err)
}
if gUser.Email == "" {
return nil, errors.New("email not provided by Google")
}
collection := s.db.Collection("users")
// Try by googleId first
var user models.User
err = collection.FindOne(ctx, bson.M{"googleId": gUser.Sub}).Decode(&user)
if err == mongo.ErrNoDocuments {
// Try by email
err = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user)
}
if err == mongo.ErrNoDocuments {
// Create new user
user = models.User{
ID: primitive.NewObjectID(),
Email: gUser.Email,
Password: "",
Name: gUser.Name,
Avatar: gUser.Picture,
Favorites: []string{},
Verified: true,
IsAdmin: false,
AdminVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Provider: "google",
GoogleID: gUser.Sub,
}
if _, err := collection.InsertOne(ctx, user); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
} else {
// Existing user: ensure fields
update := bson.M{
"verified": true,
"provider": "google",
"googleId": gUser.Sub,
"updatedAt": time.Now(),
}
if user.Name == "" && gUser.Name != "" { update["name"] = gUser.Name }
if user.Avatar == "" && gUser.Picture != "" { update["avatar"] = gUser.Picture }
_, _ = collection.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": update})
}
// Generate JWT
if user.ID.IsZero() {
// If we created user above, we already have user.ID set; else fetch updated
_ = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user)
}
token, err := s.generateJWT(user.ID.Hex())
if err != nil { return nil, err }
return &models.AuthResponse{ Token: token, User: user }, nil
}
// generateVerificationCode creates a 6-digit verification code.
func (s *AuthService) generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
@@ -275,7 +420,7 @@ func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error {
}
// Step 1: Find user reactions and remove them from cub.rip
if s.cubAPIURL != "" {
if s.baseURL != "" { // Changed from cubAPIURL to baseURL
reactionsCollection := s.db.Collection("reactions")
var userReactions []Reaction
cursor, err := reactionsCollection.Find(ctx, bson.M{"userId": objectID})
@@ -293,7 +438,7 @@ func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error {
wg.Add(1)
go func(r Reaction) {
defer wg.Done()
url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.cubAPIURL, r.MediaID, r.Type)
url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.baseURL, r.MediaID, r.Type) // Changed from cubAPIURL to baseURL
req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE"
if err != nil {
// Log the error but don't stop the process

View File

@@ -12,6 +12,7 @@ import (
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"neomovies-api/pkg/config"
"neomovies-api/pkg/models"
)
@@ -27,17 +28,15 @@ func NewReactionsService(db *mongo.Database) *ReactionsService {
}
}
const CUB_API_URL = "https://cub.rip/api"
var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"}
var validReactions = []string{"fire", "nice", "think", "bore", "shit"}
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID)
resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", CUB_API_URL, cubID))
resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", config.CubAPIBaseURL, cubID))
if err != nil {
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке
return &models.ReactionCounts{}, nil
}
defer resp.Body.Close()
@@ -61,7 +60,6 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models
return &models.ReactionCounts{}, nil
}
// Преобразуем в нашу структуру
counts := &models.ReactionCounts{}
for _, reaction := range response.Result {
switch reaction.Type {
@@ -81,76 +79,58 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models
return counts, nil
}
// Получить реакцию пользователя для медиа
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (string, error) {
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
var reaction models.Reaction
err := collection.FindOne(context.Background(), bson.M{
"userId": userID,
"mediaId": fullMediaID,
}).Decode(&reaction)
if err == mongo.ErrNoDocuments {
return nil, nil // Реакции нет
}
return &reaction, err
}
// Установить реакцию пользователя
func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error {
// Проверяем валидность типа реакции
if !s.isValidReactionType(reactionType) {
return fmt.Errorf("invalid reaction type: %s", reactionType)
}
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
// Создаем или обновляем реакцию
filter := bson.M{
"userId": userID,
"mediaId": fullMediaID,
}
reaction := models.Reaction{
UserID: userID,
MediaID: fullMediaID,
Type: reactionType,
Created: time.Now().Format(time.RFC3339),
}
update := bson.M{
"$set": reaction,
}
upsert := true
_, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{
Upsert: &upsert,
})
ctx := context.Background()
var result struct{ Type string `bson:"type"` }
err := collection.FindOne(ctx, bson.M{
"userId": userID,
"mediaType": mediaType,
"mediaId": mediaID,
}).Decode(&result)
if err != nil {
return err
if err == mongo.ErrNoDocuments {
return "", nil
}
return "", err
}
// Отправляем реакцию в cub.rip API
go s.sendReactionToCub(fullMediaID, reactionType)
return nil
return result.Type, nil
}
// Удалить реакцию пользователя
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error {
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
func (s *ReactionsService) SetReaction(userID, mediaType, mediaID, reactionType string) error {
if !s.isValidReactionType(reactionType) {
return fmt.Errorf("invalid reaction type")
}
_, err := collection.DeleteOne(context.Background(), bson.M{
"userId": userID,
"mediaId": fullMediaID,
collection := s.db.Collection("reactions")
ctx := context.Background()
_, err := collection.UpdateOne(
ctx,
bson.M{"userId": userID, "mediaType": mediaType, "mediaId": mediaID},
bson.M{"$set": bson.M{"type": reactionType, "updatedAt": time.Now()}},
options.Update().SetUpsert(true),
)
if err == nil {
go s.sendReactionToCub(fmt.Sprintf("%s_%s", mediaType, mediaID), reactionType)
}
return err
}
func (s *ReactionsService) RemoveReaction(userID, mediaType, mediaID string) error {
collection := s.db.Collection("reactions")
ctx := context.Background()
_, err := collection.DeleteOne(ctx, bson.M{
"userId": userID,
"mediaType": mediaType,
"mediaId": mediaID,
})
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
go s.sendReactionToCub(fullMediaID, "remove")
return err
}
@@ -174,7 +154,7 @@ func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.
}
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
for _, valid := range VALID_REACTIONS {
for _, valid := range validReactions {
if valid == reactionType {
return true
}
@@ -184,8 +164,7 @@ func (s *ReactionsService) isValidReactionType(reactionType string) bool {
// Отправка реакции в cub.rip API (асинхронно)
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
// Формируем запрос к cub.rip API
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL)
data := map[string]string{
"mediaId": mediaID,
@@ -197,15 +176,12 @@ func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
return
}
// В данном случае мы отправляем простой POST запрос
// В будущем можно доработать для отправки JSON данных
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
if err != nil {
return
}
defer resp.Body.Close()
// Логируем результат (в продакшене лучше использовать структурированное логирование)
if resp.StatusCode == http.StatusOK {
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
}

View File

@@ -21,11 +21,19 @@ type TorrentService struct {
apiKey string
}
func NewTorrentServiceWithConfig(baseURL, apiKey string) *TorrentService {
return &TorrentService{
client: &http.Client{Timeout: 8 * time.Second},
baseURL: baseURL,
apiKey: apiKey,
}
}
func NewTorrentService() *TorrentService {
return &TorrentService{
client: &http.Client{Timeout: 8 * time.Second},
baseURL: "http://redapi.cfhttp.top",
apiKey: "", // Может быть установлен через переменные окружения
apiKey: "",
}
}
@@ -33,7 +41,6 @@ func NewTorrentService() *TorrentService {
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
searchParams := url.Values{}
// Добавляем все параметры поиска
for key, value := range params {
if value != "" {
if key == "category" {
@@ -80,7 +87,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
var results []models.TorrentResult
for _, torrent := range data.Results {
// Обрабатываем размер - может быть строкой или числом
var sizeStr string
switch v := torrent.Size.(type) {
case string:
@@ -106,9 +112,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
Source: "RedAPI",
}
// Добавляем информацию из Info если она есть
if torrent.Info != nil {
// Обрабатываем качество - может быть строкой или числом
switch v := torrent.Info.Quality.(type) {
case string:
result.Quality = v
@@ -123,7 +127,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
result.Seasons = torrent.Info.Seasons
}
// Если качество не определено через Info, пытаемся извлечь из названия
if result.Quality == "" {
result.Quality = s.ExtractQuality(result.Title)
}
@@ -136,14 +139,11 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
// Получаем информацию о фильме/сериале из TMDB
// ИСПРАВЛЕНО: Теперь присваиваются все 4 возвращаемых значения
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
if err != nil {
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
}
// Создаем параметры поиска для RedAPI
params := map[string]string{
"imdb": imdbID,
"query": title,
@@ -151,7 +151,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
"year": year,
}
// Определяем тип контента для API
switch mediaType {
case "movie":
params["is_serial"] = "1"
@@ -164,18 +163,15 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
params["category"] = "5070"
}
// Добавляем сезон, если он указан
if options != nil && options.Season != nil && *options.Season > 0 {
params["season"] = strconv.Itoa(*options.Season)
}
// Выполняем поиск
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
// Применяем фильтрацию
if options != nil {
response.Results = s.FilterByContentType(response.Results, options.ContentType)
response.Results = s.FilterTorrents(response.Results, options)
@@ -183,7 +179,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
}
response.Total = len(response.Results)
// Fallback для сериалов, если результатов мало
if len(response.Results) < 5 && (mediaType == "serial" || mediaType == "series" || mediaType == "tv") && options != nil && options.Season != nil {
paramsNoSeason := map[string]string{
"imdb": imdbID,
@@ -206,7 +201,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
}
}
response.Results = unique
response.Total = len(response.Results)
}
}