613 lines
18 KiB
Go
613 lines
18 KiB
Go
package http_router
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth"
|
||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth/keycloak"
|
||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/cache"
|
||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/constants"
|
||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/object_storage"
|
||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/types"
|
||
"github.com/aws/smithy-go/ptr"
|
||
"github.com/gorilla/mux"
|
||
)
|
||
|
||
// =============== FILE MANAGEMENT ===============
|
||
|
||
// TempFileManager управляет временными файлами с автоматической очисткой
|
||
type TempFileManager struct {
|
||
baseDir string
|
||
mu sync.Mutex
|
||
files map[string]*os.File
|
||
}
|
||
|
||
// Cleanup очищает все временные файлы при завершении работы
|
||
func (h *handler) Cleanup() {
|
||
h.tempFileManager.CleanupAll()
|
||
}
|
||
|
||
func NewTempFileManager(baseDir string) (*TempFileManager, error) {
|
||
if baseDir == "" {
|
||
baseDir = filepath.Join(os.TempDir(), "logo-uploads")
|
||
}
|
||
|
||
if err := os.MkdirAll(baseDir, constants.TempDirPermission); err != nil {
|
||
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||
}
|
||
|
||
return &TempFileManager{
|
||
baseDir: baseDir,
|
||
files: make(map[string]*os.File),
|
||
}, nil
|
||
}
|
||
|
||
func (tm *TempFileManager) CreateTempFile(pattern string) (*os.File, error) {
|
||
tempFile, err := os.CreateTemp(tm.baseDir, pattern)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
tm.mu.Lock()
|
||
tm.files[tempFile.Name()] = tempFile
|
||
tm.mu.Unlock()
|
||
|
||
return tempFile, nil
|
||
}
|
||
|
||
func (tm *TempFileManager) CleanupFile(file *os.File) error {
|
||
if file == nil {
|
||
return nil
|
||
}
|
||
|
||
tm.mu.Lock()
|
||
delete(tm.files, file.Name())
|
||
tm.mu.Unlock()
|
||
|
||
name := file.Name()
|
||
if err := file.Close(); err != nil {
|
||
slog.Warn("failed to close temp file", slog.String("file", name), slog.String("error", err.Error()))
|
||
}
|
||
|
||
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
|
||
return fmt.Errorf("failed to remove temp file %s: %w", name, err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (tm *TempFileManager) CleanupAll() {
|
||
tm.mu.Lock()
|
||
defer tm.mu.Unlock()
|
||
|
||
for name, file := range tm.files {
|
||
_ = file.Close()
|
||
_ = os.Remove(name)
|
||
}
|
||
|
||
tm.files = make(map[string]*os.File)
|
||
}
|
||
|
||
type LogoFile struct {
|
||
File *os.File
|
||
ContentType string
|
||
Size int64
|
||
manager *TempFileManager
|
||
}
|
||
|
||
// Close закрывает файл и удаляет временный файл
|
||
func (lf *LogoFile) Close() error {
|
||
if lf.manager != nil {
|
||
return lf.manager.CleanupFile(lf.File)
|
||
}
|
||
|
||
if lf.File != nil {
|
||
name := lf.File.Name()
|
||
_ = lf.File.Close()
|
||
|
||
return os.Remove(name)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Reset сбрасывает позицию чтения в начало файла
|
||
func (lf *LogoFile) Reset() error {
|
||
_, err := lf.File.Seek(0, io.SeekStart)
|
||
return err
|
||
}
|
||
|
||
// =============== LOGO HANDLERS ===============
|
||
|
||
func (h *handler) getCompanyLogoHandler(w http.ResponseWriter, r *http.Request) {
|
||
const handlerName = "getCompanyLogoHandler"
|
||
|
||
distributorID, companyID, token := h.extractLogoRequestData(r)
|
||
if err := h.validateUserAccess(token, distributorID); err != nil {
|
||
h.handleAccessError(w, err, handlerName)
|
||
return
|
||
}
|
||
|
||
logoLink, err := h.getLogoLinkWithCache(r.Context(), companyID)
|
||
if err != nil {
|
||
if errors.Is(err, object_storage.ErrObjectNotFound) {
|
||
response := types.GetLogoResponse{
|
||
Link: "",
|
||
ExpiresIn: time.Time{},
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
|
||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||
h.logger.Error("error encoding response",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
|
||
h.logger.Error("error getting logo link",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
|
||
return
|
||
}
|
||
|
||
response := types.GetLogoResponse{
|
||
Link: logoLink,
|
||
ExpiresIn: time.Now().Add(constants.LogoLinkTTL),
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
|
||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||
h.logger.Error("error encoding response",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
}
|
||
}
|
||
|
||
func (h *handler) createCompanyLogoHandler(w http.ResponseWriter, r *http.Request) {
|
||
const handlerName = "createCompanyLogoHandler"
|
||
|
||
distributorID, companyID, token := h.extractLogoRequestData(r)
|
||
|
||
if err := h.validateUserAccess(token, distributorID); err != nil {
|
||
h.handleAccessError(w, err, handlerName)
|
||
return
|
||
}
|
||
|
||
if err := h.validateUserPermissions(r.Context(), distributorID, keycloak.PermissionCompany, keycloak.PermissionLevelCanEdit); err != nil {
|
||
h.handleAccessError(w, err, handlerName)
|
||
return
|
||
}
|
||
|
||
logoFile, err := h.extractAndValidateLogoFile(r)
|
||
if err != nil {
|
||
h.handleUploadError(w, err, handlerName)
|
||
return
|
||
}
|
||
defer logoFile.Close()
|
||
|
||
// Сбрасываем позицию после валидации, чтобы не потерять данные
|
||
if err := logoFile.Reset(); err != nil {
|
||
h.logger.Error("failed to reset file position",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
|
||
|
||
return
|
||
}
|
||
|
||
// может стоит добавить таймаут записи в кэш
|
||
err = h.objectStorageClient.PutNewObject(
|
||
r.Context(),
|
||
companyID,
|
||
object_storage.LogoCategory,
|
||
logoFile.File,
|
||
object_storage.PutOptions{
|
||
ContentType: logoFile.ContentType,
|
||
},
|
||
)
|
||
if err != nil {
|
||
if errors.Is(err, object_storage.ErrObjectAlreadyExists) {
|
||
http.Error(w, "Logo already exists. Use PUT to update", http.StatusConflict)
|
||
return
|
||
}
|
||
|
||
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
|
||
h.logger.Error("error uploading logo",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
|
||
return
|
||
}
|
||
|
||
h.refreshLogoCacheAsync(companyID)
|
||
w.WriteHeader(http.StatusCreated)
|
||
}
|
||
|
||
func (h *handler) updateCompanyLogoHandler(w http.ResponseWriter, r *http.Request) {
|
||
const handlerName = "updateCompanyLogoHandler"
|
||
|
||
distributorID, companyID, token := h.extractLogoRequestData(r)
|
||
|
||
if err := h.validateUserAccess(token, distributorID); err != nil {
|
||
h.handleAccessError(w, err, handlerName)
|
||
return
|
||
}
|
||
|
||
if err := h.validateUserPermissions(r.Context(), distributorID, keycloak.PermissionCompany, keycloak.PermissionLevelCanEdit); err != nil {
|
||
h.handleAccessError(w, err, handlerName)
|
||
return
|
||
}
|
||
|
||
logoFile, err := h.extractAndValidateLogoFile(r)
|
||
if err != nil {
|
||
h.handleUploadError(w, err, handlerName)
|
||
return
|
||
}
|
||
defer logoFile.Close()
|
||
|
||
// Сбрасываем позицию после валидации, чтобы не потерять данные
|
||
if err := logoFile.Reset(); err != nil {
|
||
h.logger.Error("failed to reset file position",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
|
||
|
||
return
|
||
}
|
||
|
||
// Обновляем в хранилище
|
||
err = h.objectStorageClient.UpdateObject(
|
||
r.Context(),
|
||
companyID,
|
||
object_storage.LogoCategory,
|
||
logoFile.File,
|
||
object_storage.UpdateOptions{
|
||
ContentType: logoFile.ContentType,
|
||
},
|
||
)
|
||
if err != nil {
|
||
if errors.Is(err, object_storage.ErrObjectNotFound) {
|
||
http.Error(w, "Logo not found. Use POST to create", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
|
||
h.logger.Error("error updating logo",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
|
||
return
|
||
}
|
||
|
||
h.refreshLogoCacheAsync(companyID)
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (h *handler) deleteCompanyLogoHandler(w http.ResponseWriter, r *http.Request) {
|
||
const handlerName = "deleteCompanyLogoHandler"
|
||
|
||
distributorID, companyID, token := h.extractLogoRequestData(r)
|
||
|
||
if err := h.validateUserAccess(token, distributorID); err != nil {
|
||
h.handleAccessError(w, err, handlerName)
|
||
return
|
||
}
|
||
|
||
if err := h.validateUserPermissions(r.Context(), distributorID, keycloak.PermissionCompany, keycloak.PermissionLevelCanEdit); err != nil {
|
||
h.handleAccessError(w, err, handlerName)
|
||
return
|
||
}
|
||
|
||
err := h.objectStorageClient.DeleteObject(r.Context(), companyID, object_storage.LogoCategory)
|
||
if err != nil {
|
||
if errors.Is(err, object_storage.ErrObjectNotFound) {
|
||
http.Error(w, constants.ErrNotFound.Error(), http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
|
||
h.logger.Error("error deleting logo",
|
||
slog.String("error", err.Error()),
|
||
slog.String("handler", handlerName))
|
||
|
||
return
|
||
}
|
||
|
||
// Очищаем кэш
|
||
if err := h.cacheClient.Del(r.Context(), companyID, cache.LogoValueType); err != nil {
|
||
h.logger.Error("error deleting logo from cache",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// =============== CORE LOGIC FUNCTIONS ===============
|
||
|
||
// extractAndValidateLogoFile извлекает файл из запроса и сохраняет во временный файл
|
||
func (h *handler) extractAndValidateLogoFile(r *http.Request) (*LogoFile, error) {
|
||
err := r.ParseMultipartForm(constants.ParseMultipartFormAllFile) //сохраняем на диск весь файл
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %v", ErrMultipartParse, err)
|
||
}
|
||
|
||
defer func() { _ = r.MultipartForm.RemoveAll() }()
|
||
|
||
file, header, err := r.FormFile("logo")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %v", ErrFileNotFound, err)
|
||
}
|
||
defer file.Close()
|
||
|
||
// Проверяем размер файла
|
||
if header.Size == 0 {
|
||
return nil, ErrEmptyFile
|
||
}
|
||
|
||
if header.Size > constants.LogoMaxSize {
|
||
return nil, &FileTooLargeError{Size: header.Size, MaxSize: constants.LogoMaxSize}
|
||
}
|
||
|
||
contentType := header.Header.Get("Content-Type")
|
||
if contentType == "" {
|
||
contentType = "application/octet-stream"
|
||
}
|
||
|
||
// Создаем временный файл для копирования
|
||
tempFile, err := h.tempFileManager.CreateTempFile(constants.TempLogoFilePattern)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %v", ErrTempFileCreate, err)
|
||
}
|
||
|
||
// Копируем содержимое во временный файл
|
||
copiedBytes, err := io.CopyN(tempFile, file, constants.LogoMaxSize+1)
|
||
if err != nil && err != io.EOF {
|
||
_ = h.tempFileManager.CleanupFile(tempFile)
|
||
return nil, fmt.Errorf("%w: failed to copy file: %v", ErrFileOperation, err)
|
||
}
|
||
|
||
logoFile := &LogoFile{
|
||
File: tempFile,
|
||
ContentType: contentType,
|
||
Size: copiedBytes,
|
||
manager: h.tempFileManager,
|
||
}
|
||
|
||
if err := h.validateLogoFile(logoFile); err != nil {
|
||
_ = logoFile.Close()
|
||
return nil, err
|
||
}
|
||
|
||
return logoFile, nil
|
||
}
|
||
|
||
// validateLogoFile валидирует файл изображения
|
||
func (h *handler) validateLogoFile(logoFile *LogoFile) error {
|
||
// Проверяем заявленный тип
|
||
if !isValidImageType(logoFile.ContentType) {
|
||
h.logger.Error("invalid image type",
|
||
slog.String("image_type", logoFile.ContentType),
|
||
slog.String("handler", "validateLogoFile"))
|
||
|
||
return &InvalidImageTypeError{ContentType: logoFile.ContentType}
|
||
}
|
||
|
||
if err := logoFile.Reset(); err != nil {
|
||
return fmt.Errorf("%w: failed to reset file position before validation: %v", ErrFileOperation, err)
|
||
}
|
||
|
||
// Читаем первые 512 байт для определения реального типа
|
||
buffer := make([]byte, constants.BytesToDetectContentType)
|
||
|
||
firstBytes, err := logoFile.File.Read(buffer)
|
||
if err != nil && err != io.EOF {
|
||
return fmt.Errorf("%w: failed to read file header: %v", ErrFileOperation, err)
|
||
}
|
||
|
||
detectedType := http.DetectContentType(buffer[:firstBytes])
|
||
if !isValidImageType(detectedType) {
|
||
h.logger.Error("invalid image type after detecting",
|
||
slog.String("image_type", detectedType),
|
||
slog.String("handler", "validateLogoFile"))
|
||
|
||
return &InvalidImageTypeError{ContentType: detectedType}
|
||
}
|
||
|
||
// Возвращаем позицию в начало
|
||
if err := logoFile.Reset(); err != nil {
|
||
return fmt.Errorf("%w: failed to reset file position: %v", ErrFileOperation, err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// extractLogoRequestData извлекает данные из HTTP запроса
|
||
func (h *handler) extractLogoRequestData(r *http.Request) (distributorID, companyID, token string) {
|
||
vars := mux.Vars(r)
|
||
distributorID = vars["distributor_id"]
|
||
companyID = vars["company_id"]
|
||
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||
|
||
return
|
||
}
|
||
|
||
// =============== VALIDATION FUNCTIONS ===============
|
||
|
||
// validateUserAccess проверяет авторизацию и соответствие пользователя
|
||
func (h *handler) validateUserAccess(token, expectedUserID string) error {
|
||
if token == "" {
|
||
return constants.ErrUnauthorized
|
||
}
|
||
|
||
userInfo, err := h.jwtManager.GetUserInfoFromToken(token)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid token: %w", err)
|
||
}
|
||
|
||
if expectedUserID != userInfo.UserId {
|
||
return constants.ErrForbidden
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validateUserPermissions проверяет права доступа пользователя
|
||
func (h *handler) validateUserPermissions(ctx context.Context, userID, permission, level string) error {
|
||
ok, err := h.authManager.CheckPermissions(ctx, auth.CheckPermissionsRequest{
|
||
UserId: userID,
|
||
RequiredPermission: permission,
|
||
RequiredPermissionLevel: level,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("permission check failed: %w", err)
|
||
}
|
||
|
||
if !ok {
|
||
return constants.ErrForbidden
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// isValidImageType проверяет допустимость типа изображения
|
||
func isValidImageType(contentType string) bool {
|
||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||
|
||
if idx := strings.Index(contentType, ";"); idx != -1 {
|
||
contentType = contentType[:idx]
|
||
}
|
||
|
||
return constants.ValidImageTypes[contentType]
|
||
}
|
||
|
||
// =============== CACHE FUNCTIONS ===============
|
||
|
||
// refreshLogoCacheAsync асинхронно обновляет кэш логотипа
|
||
func (h *handler) refreshLogoCacheAsync(companyID string) {
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), constants.RefreshLogoCacheAsyncTimeout)
|
||
defer cancel()
|
||
|
||
// Получаем новую ссылку на логотип
|
||
logoLink, err := h.objectStorageClient.GetPresignedLink(ctx,
|
||
companyID, object_storage.LogoCategory, object_storage.LinkOptions{
|
||
TTL: ptr.Duration(constants.LogoLinkTTL),
|
||
})
|
||
if err != nil {
|
||
h.logger.Warn("failed to get logo link for cache refresh",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
|
||
return
|
||
}
|
||
|
||
if err = h.cacheClient.Del(ctx, companyID, cache.LogoValueType); err != nil {
|
||
h.logger.Warn("failed to delete logo from cache, it's ok if it first time creation",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
}
|
||
|
||
response := types.GetLogoResponse{
|
||
Link: logoLink,
|
||
ExpiresIn: time.Now().Add(constants.LogoLinkTTL),
|
||
}
|
||
|
||
body, err := json.Marshal(response)
|
||
if err != nil {
|
||
h.logger.Warn("failed to marshal logo response for cache",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
|
||
return
|
||
}
|
||
|
||
if err := h.cacheClient.Set(ctx, companyID, cache.LogoValueType, string(body), constants.LogoLinkTTL); err != nil {
|
||
h.logger.Warn("failed to update logo cache",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
} else {
|
||
h.logger.Debug("logo cache refreshed successfully",
|
||
slog.String("company_id", companyID))
|
||
}
|
||
}()
|
||
}
|
||
|
||
// getLogoLinkWithCache получает ссылку на логотип из кэша или хранилища
|
||
func (h *handler) getLogoLinkWithCache(ctx context.Context, companyID string) (string, error) {
|
||
// Проверяем кэш
|
||
cachedData, err := h.cacheClient.Get(ctx, companyID, cache.LogoValueType)
|
||
if err == nil {
|
||
var response types.GetLogoResponse
|
||
if err := json.Unmarshal([]byte(cachedData), &response); err == nil {
|
||
return response.Link, nil
|
||
}
|
||
|
||
h.logger.Warn("failed to unmarshal logo response from cache",
|
||
slog.String("company_id", companyID))
|
||
}
|
||
|
||
if !errors.Is(err, cache.ErrKeyNotFound) {
|
||
h.logger.Warn("failed to get logo from cache",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
}
|
||
|
||
// Получаем из хранилища ссылку на логотип
|
||
logoLink, err := h.objectStorageClient.GetPresignedLink(ctx,
|
||
companyID, object_storage.LogoCategory, object_storage.LinkOptions{
|
||
TTL: ptr.Duration(constants.LogoLinkTTL),
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
response := types.GetLogoResponse{
|
||
Link: logoLink,
|
||
ExpiresIn: time.Now().Add(constants.LogoLinkTTL),
|
||
}
|
||
|
||
body, err := json.Marshal(response)
|
||
if err != nil {
|
||
h.logger.Warn("failed to marshal logo response for cache",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
} else {
|
||
if err := h.cacheClient.Set(ctx, companyID, cache.LogoValueType, string(body), constants.LogoLinkTTL); err != nil {
|
||
h.logger.Warn("failed to cache logo response",
|
||
slog.String("error", err.Error()),
|
||
slog.String("company_id", companyID))
|
||
}
|
||
}
|
||
|
||
return logoLink, nil
|
||
}
|
||
|
||
// =============== GRACEFUL SHUTDOWN ===============
|
||
|
||
// SetupGracefulShutdown делает graceful shutdown)
|
||
func (h *handler) SetupGracefulShutdown(ctx context.Context) {
|
||
go func() {
|
||
<-ctx.Done()
|
||
h.logger.Info("shutting down, cleaning up temporary files")
|
||
h.Cleanup()
|
||
}()
|
||
}
|