// Package handlers package handlers import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) const maxUploadSize = 10 << 20 // 10 MB type UploadHandler struct { minioClient *minio.Client bucket string endpoint string publicEndpoint string } func NewUploadHandler(endpoint, publicEndpoint, accessKey, secretKey, bucket string, useSSL bool) (*UploadHandler, error) { client, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Secure: useSSL, }) if err != nil { return nil, err } return &UploadHandler{ minioClient: client, bucket: bucket, endpoint: endpoint, publicEndpoint: publicEndpoint, }, nil } type presignedURLRequest struct { ContentType string `json:"content_type"` MaxSizeMB int `json:"max_size_mb"` } type presignedURLResponse struct { UploadURL string `json:"upload_url"` FileURL string `json:"file_url"` Fields map[string]string `json:"fields"` } func (h *UploadHandler) UploadFile(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) if err := r.ParseMultipartForm(maxUploadSize); err != nil { writeError(w, http.StatusBadRequest, "file too large or invalid multipart", err) return } file, header, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "file is required", err) return } defer file.Close() contentType := header.Header.Get("Content-Type") validContentTypes := map[string]bool{ "image/jpeg": true, "image/png": true, "image/webp": true, "image/heic": true, } if !validContentTypes[contentType] { writeError(w, http.StatusBadRequest, "unsupported content type", nil) return } // Заголовок Content-Type подделывается клиентом — проверяем реальную // сигнатуру файла по первым байтам, чтобы нельзя было залить произвольный // контент под видом изображения. if err := verifyImageSignature(file, contentType); err != nil { writeError(w, http.StatusBadRequest, "file content does not match declared image type", err) return } ext := ".jpg" switch contentType { case "image/png": ext = ".png" case "image/webp": ext = ".webp" case "image/heic": ext = ".heic" } objectName := "uploads/" + uuid.New().String() + ext _, err = h.minioClient.PutObject(r.Context(), h.bucket, objectName, file, header.Size, minio.PutObjectOptions{ContentType: contentType}, ) if err != nil { writeError(w, http.StatusInternalServerError, "failed to upload file", err) return } fileURL := fmt.Sprintf("%s/%s/%s", h.publicEndpoint, h.bucket, objectName) writeJSON(w, http.StatusOK, map[string]string{ "file_url": fileURL, }) } func (h *UploadHandler) PresignedURL(w http.ResponseWriter, r *http.Request) { var req presignedURLRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body", err) return } if req.MaxSizeMB <= 0 || req.MaxSizeMB > 50 { req.MaxSizeMB = 10 } validContentTypes := map[string]bool{ "image/jpeg": true, "image/png": true, "image/webp": true, "image/heic": true, } if !validContentTypes[req.ContentType] { writeError(w, http.StatusBadRequest, "unsupported content type", nil) return } fileID := uuid.New().String() ext := ".jpg" switch req.ContentType { case "image/png": ext = ".png" case "image/webp": ext = ".webp" case "image/heic": ext = ".heic" } objectName := "uploads/" + fileID + ext policy := minio.NewPostPolicy() policy.SetBucket(h.bucket) policy.SetKey(objectName) policy.SetExpires(time.Now().Add(15 * time.Minute)) policy.SetContentType(req.ContentType) policy.SetContentLengthRange(1, int64(req.MaxSizeMB)*1024*1024) uploadURL, formData, err := h.minioClient.PresignedPostPolicy(r.Context(), policy) if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate upload URL", err) return } uploadURLStr := h.publicEndpoint + uploadURL.Path + "?" + uploadURL.RawQuery publicUploadURL, err := url.Parse(uploadURLStr) if err != nil { writeError(w, http.StatusInternalServerError, "failed to build public upload URL", err) return } fileURL := h.publicEndpoint + "/" + h.bucket + "/" + objectName writeJSON(w, http.StatusOK, presignedURLResponse{ UploadURL: publicUploadURL.String(), FileURL: fileURL, Fields: formData, }) } // verifyImageSignature читает первые байты файла и проверяет, что реальная // сигнатура соответствует заявленному content-type. После проверки возвращает // курсор файла в начало, чтобы последующая загрузка читала его целиком. func verifyImageSignature(file io.ReadSeeker, declaredType string) error { head := make([]byte, 512) n, err := io.ReadFull(file, head) if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { return fmt.Errorf("read file header: %w", err) } head = head[:n] if _, err := file.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("seek file start: %w", err) } if !matchesImageType(head, declaredType) { return errors.New("file signature mismatch") } return nil } // matchesImageType сверяет байтовую сигнатуру с заявленным типом. // net/http.DetectContentType умеет распознавать jpeg/png/webp, но не heic, // поэтому для heic проверяем ISO-BMFF бокс ftyp вручную. func matchesImageType(head []byte, declaredType string) bool { if declaredType == "image/heic" { return isHEIC(head) } return http.DetectContentType(head) == declaredType } // isHEIC проверяет, что файл — ISO Base Media File Format с HEIF-брендом. func isHEIC(head []byte) bool { if len(head) < 12 || !bytes.Equal(head[4:8], []byte("ftyp")) { return false } brand := string(head[8:12]) switch brand { case "heic", "heix", "hevc", "heim", "heis", "mif1", "msf1": return true } return false } func (h *UploadHandler) DeleteObjects(ctx context.Context, urls []string) error { prefix := h.publicEndpoint + "/" + h.bucket + "/" for _, fileURL := range urls { if !strings.HasPrefix(fileURL, prefix) { continue } objectKey := strings.TrimPrefix(fileURL, prefix) if err := h.minioClient.RemoveObject(ctx, h.bucket, objectKey, minio.RemoveObjectOptions{}); err != nil { return fmt.Errorf("delete object %s: %w", objectKey, err) } } return nil }