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

@@ -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})
}