Files
test_deploy/internal/http/logo.go
Alex Shevchuk d84487d238 1
2025-08-18 17:12:04 +03:00

613 lines
18 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 ===============
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()
}()
}