Files
test_deploy/internal/http/logo.go
Alex Shevchuk 61fc0d2747
Some checks failed
Deploy Production / Deploy to Staging (push) Has been skipped
Go Linter / Run golangci-lint (api_gateway) (push) Failing after 2m31s
Go Linter / Build golang services (api_gateway) (push) Has been skipped
Go Linter / Tag Commit (push) Has been skipped
Go Linter / Push Docker Images (api_gateway) (push) Has been skipped
71
2025-09-17 14:32:06 +03:00

664 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ===============
// @Summary Получить логотип компании
// @Description Получение логотипа компании дистрибьютора
// @Tags distributors
// @Accept json
// @Produce json
// @Param distributor_id path string true "ID дистрибьютора"
// @Param company_id path string true "ID компании"
// @Success 200 {file} file "Логотип компании"
// @Failure 400 {object} map[string]string "Неверные параметры запроса"
// @Failure 404 {object} map[string]string "Логотип не найден"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Security BearerAuth
// @Router /api/v1/distributor/{distributor_id}/company/{company_id}/logo [get]
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))
}
}
// @Summary Создать логотип компании
// @Description Загрузка нового логотипа для компании дистрибьютора
// @Tags distributors
// @Accept multipart/form-data
// @Produce json
// @Param distributor_id path string true "ID дистрибьютора"
// @Param company_id path string true "ID компании"
// @Param logo formData file true "Файл логотипа"
// @Success 201 {object} map[string]string "Логотип загружен"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Security BearerAuth
// @Router /api/v1/distributor/{distributor_id}/company/{company_id}/logo [post]
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)
}
// @Summary Обновить логотип компании
// @Description Обновление логотипа компании дистрибьютора
// @Tags distributors
// @Accept multipart/form-data
// @Produce json
// @Param distributor_id path string true "ID дистрибьютора"
// @Param company_id path string true "ID компании"
// @Param logo formData file true "Новый файл логотипа"
// @Success 200 {object} map[string]string "Логотип обновлен"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Security BearerAuth
// @Router /api/v1/distributor/{distributor_id}/company/{company_id}/logo [put]
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)
}
// @Summary Удалить логотип компании
// @Description Удаление логотипа компании дистрибьютора
// @Tags distributors
// @Accept json
// @Produce json
// @Param distributor_id path string true "ID дистрибьютора"
// @Param company_id path string true "ID компании"
// @Success 200 {object} map[string]string "Логотип удален"
// @Failure 400 {object} map[string]string "Неверные параметры запроса"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Security BearerAuth
// @Router /api/v1/distributor/{distributor_id}/company/{company_id}/logo [delete]
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()
}()
}