dana-medical-site/internal/api/admin_media.go
2026-10-14 13:33:13 +00:00

121 lines
3.7 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 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,
}})
}