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