mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-28 01:48:51 +05:00
Add Google OAuth
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user