Parcourir la source

fix: загрузка файлов через backend (POST /upload), без прямой загрузки в MinIO

neyrogovnarik il y a 1 mois
Parent
commit
fedcd4304d

+ 1 - 0
backend/cmd/api/main.go

@@ -214,6 +214,7 @@ func main() {
 
 			// Upload
 			r.Post("/upload/presigned-url", uploadHandler.PresignedURL)
+			r.Post("/upload", uploadHandler.UploadFile)
 
 			// Bookings
 			r.Post("/bookings", bookingHandler.Create)

+ 53 - 0
backend/internal/handlers/upload.go

@@ -3,6 +3,8 @@ package handlers
 
 import (
 	"encoding/json"
+	"fmt"
+	"io"
 	"net/http"
 	"net/url"
 	"time"
@@ -12,6 +14,8 @@ import (
 	"github.com/minio/minio-go/v7/pkg/credentials"
 )
 
+const maxUploadSize = 10 << 20 // 10 MB
+
 type UploadHandler struct {
 	minioClient    *minio.Client
 	bucket         string
@@ -47,6 +51,55 @@ type presignedURLResponse struct {
 	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
+	}
+
+	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 {

+ 2 - 9
frontend/src/components/PlaceForm.tsx

@@ -74,17 +74,10 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
       let coverImage: string | undefined
 
       if (coverFile) {
-        const presigned = await api.post<{ upload_url: string; file_url: string; fields: Record<string, string> }>(
-          '/upload/presigned-url',
-          { content_type: coverFile.type, max_size_mb: 10 }
-        )
-
         const formData = new FormData()
-        Object.entries(presigned.fields).forEach(([k, v]) => formData.append(k, v))
         formData.append('file', coverFile)
-        await fetch(presigned.upload_url, { method: 'POST', body: formData })
-
-        coverImage = presigned.file_url
+        const res = await api.upload<{ file_url: string }>('/upload', formData)
+        coverImage = res.file_url
       }
 
       const body: Record<string, unknown> = {

+ 35 - 0
frontend/src/lib/api.ts

@@ -116,6 +116,10 @@ export const api = {
   post: <T>(path: string, body: unknown) =>
     request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
 
+  /** POST-запрос с multipart/form-data */
+  upload: <T>(path: string, formData: FormData) =>
+    requestFormData<T>(path, formData),
+
   /** PATCH-запрос с JSON-телом */
   patch: <T>(path: string, body: unknown) =>
     request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
@@ -125,4 +129,35 @@ export const api = {
     request<T>(path, { method: 'DELETE' }),
 }
 
+async function requestFormData<T>(path: string, formData: FormData): Promise<T> {
+  const headers: HeadersInit = {
+    ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
+  }
+
+  const res = await fetch(`${API_URL}${path}`, {
+    method: 'POST',
+    headers,
+    body: formData,
+    credentials: 'include',
+  })
+
+  if (res.status === 401 && accessToken) {
+    try {
+      const newToken = await refreshAccessToken()
+      accessToken = newToken
+      return requestFormData<T>(path, formData)
+    } catch {
+      accessToken = null
+      throw new ApiRequestError(401, 'Session expired', null)
+    }
+  }
+
+  if (!res.ok) {
+    const errBody = await res.json().catch(() => ({ error: res.statusText }))
+    throw new ApiRequestError(res.status, errBody.error || 'Upload failed', errBody)
+  }
+
+  return res.json()
+}
+
 export default api