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