package api import ( "crypto/rand" "encoding/hex" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "log/slog" "net/http" "os" "path" "path/filepath" "strings" "time" "dana/internal/storage" ) const maxUploadSize = 10 << 20 // 10 МБ // Разрешены только растровые картинки. SVG не пускаем: внутри него может быть // скрипт, и загруженный файл выполнился бы на домене сайта. var uploadTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif", } // adminUploadMedia принимает картинку или PDF документа (multipart, поле file) и регистрирует её // в медиатеке. Тип определяется по содержимому, а не по расширению из имени файла. func (a *API) adminUploadMedia(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { if a.mediaDir == "" { fail(w, http.StatusInternalServerError, "no_media_dir", "Не настроена папка для файлов") return } r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+(1<<20)) if err := r.ParseMultipartForm(maxUploadSize); err != nil { fail(w, http.StatusBadRequest, "bad_upload", "Файл не больше 10 МБ") return } file, _, err := r.FormFile("file") if err != nil { fail(w, http.StatusBadRequest, "no_file", "Не передан файл") return } defer file.Close() head := make([]byte, 512) n, _ := io.ReadFull(file, head) mime := http.DetectContentType(head[:n]) ext, allowed := uploadTypes[mime] // PDF принимаем только в поля документов (?accept=document), чтобы он не попал на место картинки. documentUpload := r.URL.Query().Get("accept") == "document" if mime == "application/pdf" && documentUpload { ext, allowed = ".pdf", true } if !allowed { msg := "Можно загрузить JPEG, PNG, WebP или GIF" if documentUpload { msg = "Можно загрузить PDF, JPEG, PNG, WebP или GIF" } fail(w, http.StatusUnprocessableEntity, "bad_type", msg) return } var width, height *int if _, err := file.Seek(0, io.SeekStart); err != nil { handleErr(w, err) return } if cfg, _, err := image.DecodeConfig(file); err == nil { width, height = &cfg.Width, &cfg.Height } if _, err := file.Seek(0, io.SeekStart); err != nil { handleErr(w, err) return } rnd := make([]byte, 16) if _, err := rand.Read(rnd); err != nil { handleErr(w, err) return } rel := path.Join("uploads", time.Now().Format("2006/01"), hex.EncodeToString(rnd)+ext) dst := filepath.Join(a.mediaDir, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { handleErr(w, err) return } out, err := os.Create(dst) if err != nil { handleErr(w, err) return } size, copyErr := io.Copy(out, file) closeErr := out.Close() if copyErr != nil || closeErr != nil { _ = os.Remove(dst) slog.Error("save upload", "copy", copyErr, "close", closeErr) fail(w, http.StatusInternalServerError, "save_failed", "Не удалось сохранить файл") return } webPath := "/media/" + rel alt := strings.TrimSpace(r.FormValue("alt")) id, err := a.store.CreateMedia(r.Context(), webPath, mime, size, width, height, alt) if err != nil { _ = os.Remove(dst) // файл без записи в медиатеке никому не нужен handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "create", "media", id, clientIP(r)) writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ "id": id, "path": webPath, "width": width, "height": height, }}) }