This commit is contained in:
Alex Shevchuk
2025-08-18 17:12:04 +03:00
commit d84487d238
157 changed files with 160686 additions and 0 deletions

612
internal/http/logo.go Normal file
View File

@@ -0,0 +1,612 @@
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()
}()
}