axe/internal/handlers/photos.go

375 lines
16 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
package handlers
import (
"bytes"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"building-mgmt/internal/middleware"
"building-mgmt/internal/models"
"building-mgmt/internal/repository"
"building-mgmt/internal/storage"
)
// photoPresignTTL — сколько живёт ссылка на чтение файла. Бакет
// приватный, поэтому наружу уходит только подписанная ссылка,
// а не ключ объекта; срок короткий, чтобы не расползаться.
const photoPresignTTL = 15 * time.Minute
// photoDefaultMaxBytes / photoDefaultMaxPer — дефолты лимитов,
// если PHOTO_MAX_BYTES / PHOTO_MAX_PER_TICKET не заданы (см. main).
const (
photoDefaultMaxBytes = int64(10 << 20) // 10 МБ на файл
photoDefaultMaxPer = 20 // файлов на заявку
photoMaxNameRunes = 100 // длина имени файла в журнале
photoMultipartSlackByte = 8 << 10 // запас MaxBytesReader на заголовки/границы multipart
)
// PhotoHandler — фотоотчёт заявки: загрузка (мастер/админ), просмотр
// (все, кто видит заявку), удаление (своё/админ). Байты уходят в
// объектное хранилище (storage.Store), в БД — только метаданные
// плюс событие в журнале, записываемое той же операцией.
type PhotoHandler struct {
tickets *repository.TicketRepo
photos *repository.PhotoRepo
// проекты — нужен canView: мастеру открыты свободные (new)
// заявки его проектов, членство проверяется в БД.
projects *repository.ProjectRepo
store storage.Store
maxBytes int64 // потолок одного файла (PHOTO_MAX_BYTES)
maxPer int // файлов на заявку (PHOTO_MAX_PER_TICKET)
}
// NewPhotoHandler собирает хендлер. Лимиты приходят из main
// (разбор env — там же, рядом с остальной конфигурацией).
func NewPhotoHandler(
tickets *repository.TicketRepo,
photos *repository.PhotoRepo,
projects *repository.ProjectRepo,
store storage.Store,
maxBytes int64,
maxPer int,
) *PhotoHandler {
if maxBytes <= 0 {
maxBytes = photoDefaultMaxBytes
}
if maxPer <= 0 {
maxPer = photoDefaultMaxPer
}
return &PhotoHandler{
tickets: tickets,
photos: photos,
projects: projects,
store: store,
maxBytes: maxBytes,
maxPer: maxPer,
}
}
// ticket — разобрать :id и загрузить заявку вместе с проверкой, что
// авторизованный вообще имеет право о ней знать. false — ответ
// (400/403/404/500) уже отправлен.
func (h *PhotoHandler) ticket(c *gin.Context) (*models.Ticket, bool) {
t, ok := loadTicket(c, h.tickets)
if !ok {
return nil, false
}
visible, err := canView(c.Request.Context(), c, t, h.projects)
if err != nil {
respondError(c, err)
return nil, false
}
if !visible {
forbidden(c)
return nil, false
}
return t, true
}
// Upload — POST /tickets/:id/photos. Файл приходит multipart-ом в поле file.
//
// Порядок операций выбран так, чтобы не оставлять мусора:
// 1. валидация (размер, сигнатура, precheck лимита) — ничего не пишем;
// 2. Put в хранилище — файл должен существовать до строки в БД;
// 3. INSERT строки + события одной SQL-операцией; при её сбое
// объект удаляется компенсаторно (иначе стал бы сиротой).
//
// @Summary Загрузить фото отчёта
// @Description multipart/form-data, поле file: только фото (jpeg/png/webp, определяется по сигнатуре байтов), не больше PHOTO_MAX_BYTES на файл и PHOTO_MAX_PER_TICKET файлов на заявку. Доступно мастеру (заявка in_progress — после «Работа выполнена» отчёт заморожен, мастер — активный участник проекта) или админу. Каждая загрузка оставляет событие photo_added в журнале заявки.
// @Tags tickets
// @Accept multipart/form-data
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Param file formData file true "Файл фотографии (jpeg/png/webp)"
// @Success 201 {object} models.TicketPhoto "фото загружено, url — временная ссылка на чтение"
// @Failure 400 {object} handlers.ErrorResponse "некорректный id, файл отсутствует, не фото или больше лимита"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker или admin; чужая заявка"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Failure 409 {object} handlers.ErrorResponse "лимит файлов исчерпан или заявка не в статусе in_progress"
// @Failure 500 {object} handlers.ErrorResponse "хранилище недоступно"
// @Router /tickets/{id}/photos [post]
func (h *PhotoHandler) Upload(c *gin.Context) {
t, ok := h.ticket(c)
if !ok {
return
}
// Потолок на всё тело запроса: MaxBytesReader обрывает его на
// границе — гигабайтный «файл» не успеет занять память/диск.
// +случай — запас на заголовки и границы multipart.
if c.Request.ContentLength > h.maxBytes+photoMultipartSlackByte {
c.JSON(http.StatusBadRequest, gin.H{"error": "photo too large"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body,
h.maxBytes+photoMultipartSlackByte)
file, header, err := c.Request.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
c.JSON(http.StatusBadRequest, gin.H{"error": "photo too large"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file"})
return
}
defer func() { _ = file.Close() }()
// LimitReader(max+1): header.Size клиент может врать, поэтому
// верим только прочитанным байтам — и не держим в памяти больше
// потолка.
data, err := io.ReadAll(io.LimitReader(file, h.maxBytes+1))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file"})
return
}
if int64(len(data)) > h.maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "photo too large"})
return
}
if len(data) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file"})
return
}
contentType, ext, ok := sniffImage(data)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported photo type"})
return
}
// Дешёвый precheck лимита — до заливки в хранилище. Гонку
// «последнее место» всё равно решает счётчик в SQL (AddPhoto),
// а здесь мы просто не тратим 10 МБ впустую.
count, err := h.photos.CountPhotos(c.Request.Context(), t.ID)
if err != nil {
respondError(c, err)
return
}
if count >= h.maxPer {
c.JSON(http.StatusConflict, gin.H{"error": "too many photos"})
return
}
key := fmt.Sprintf("tickets/%d/%s.%s", t.ID, uuid.NewString(), ext)
if err := h.store.Put(c.Request.Context(), key, data, contentType); err != nil {
// Детали хранилища — только в лог.
slog.Error("photo put failed",
"err", err,
"request_id", middleware.RequestIDFrom(c),
"ticket_id", t.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
photo, err := h.photos.AddPhoto(c.Request.Context(), repository.PhotoMeta{
TicketID: t.ID,
UploaderID: c.GetString("user_id"),
ObjectKey: key,
ContentType: contentType,
SizeBytes: int64(len(data)),
OriginalName: photoName(header.Filename),
MaxPerTicket: h.maxPer,
}, c.GetString("role"))
if err != nil {
// Строка не записалась — компенсируем: объект в хранилище
// стал бы сиротой без владельца в БД.
if delErr := h.store.Delete(c.Request.Context(), key); delErr != nil {
slog.Error("photo compensation delete failed",
"err", delErr, "key", key,
"request_id", middleware.RequestIDFrom(c))
}
if errors.Is(err, repository.ErrTooManyPhotos) {
c.JSON(http.StatusConflict, gin.H{"error": "too many photos"})
return
}
respondError(c, err)
return
}
photo.URL = h.presign(c, photo.ObjectKey)
c.JSON(http.StatusCreated, photo)
}
// presign — подписанная ссылка на чтение. Сбой подписи не должен
// прятать удавшуюся операцию: лог, пустой url (список подпишет заново).
func (h *PhotoHandler) presign(c *gin.Context, key string) string {
url, err := h.store.PresignGet(c.Request.Context(), key, photoPresignTTL)
if err != nil {
slog.Warn("photo presign failed",
"err", err,
"request_id", middleware.RequestIDFrom(c))
return ""
}
return url
}
// List — GET /tickets/:id/photos. Смотрят все, кому видна заявка.
//
// @Summary Список фото отчёта
// @Description Файлы заявки в хронологии (id ASC по умолчанию). В каждом элементе url — временная подписанная ссылка на чтение: бакет приватный, сам ключ наружу не уходит.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Param limit query int false "Строк на странице, 1..100" default(20)
// @Param offset query int false "Сдвиг от начала выборки" default(0)
// @Param sort query string false "Поле сортировки" Enums(id, created_at) Default(id)
// @Param order query string false "Направление сортировки" Enums(asc, desc)
// @Success 200 {object} handlers.PhotoListResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "чужая заявка"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Router /tickets/{id}/photos [get]
func (h *PhotoHandler) List(c *gin.Context) {
t, ok := h.ticket(c)
if !ok {
return
}
page, ok := parsePage(c, repository.PhotoSorts)
if !ok {
return
}
photos, total, err := h.photos.ListPhotos(c.Request.Context(), t.ID, page)
if err != nil {
respondError(c, err)
return
}
if photos == nil {
photos = []models.TicketPhoto{}
}
for i := range photos {
photos[i].URL = h.presign(c, photos[i].ObjectKey)
}
c.JSON(http.StatusOK, PhotoListResponse{
Items: photos, Total: total, Limit: page.Limit, Offset: page.Offset,
MaxPerTicket: h.maxPer,
})
}
// Delete — DELETE /tickets/:id/photos/:photo_id. Удаляет своё фото,
// пока заявка в работе (in_progress); админу — любое. Объект из хранилища убирается
// после записи в БД: если уборка сбойнёт, в логе останется ключ
// сироты, а журнал не разойдётся со строкой.
//
// @Summary Удалить фото
// @Description Своё фото удаляет автор, пока заявка в работе (in_progress): после «Работа выполнена» отчёт заморожен; админу — любое фото. Событие photo_removed пишется той же операцией, что и удаление.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Param photo_id path int true "ID фото"
// @Success 200 {object} handlers.OKResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный id или photo_id"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker или admin; чужая заявка"
// @Failure 404 {object} handlers.ErrorResponse "заявка или фото не найдены"
// @Failure 409 {object} handlers.ErrorResponse "чужое фото или заявка уже не в работе"
// @Router /tickets/{id}/photos/{photo_id} [delete]
func (h *PhotoHandler) Delete(c *gin.Context) {
t, ok := h.ticket(c)
if !ok {
return
}
photoID, err := strconv.ParseInt(c.Param("photo_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid photo id"})
return
}
key, err := h.photos.DeletePhoto(c.Request.Context(), t.ID, photoID,
c.GetString("user_id"), c.GetString("role"))
if err != nil {
if errors.Is(err, repository.ErrPhotoNotRemovable) {
c.JSON(http.StatusConflict, gin.H{"error": "photo cannot be removed"})
return
}
respondError(c, err)
return
}
// Строка и событие уже записаны: объект чистим отдельно: если
// хранилище недоступно — это лог и сирота, а не отмена удаления.
if err := h.store.Delete(c.Request.Context(), key); err != nil {
slog.Error("photo object delete failed",
"err", err, "key", key,
"request_id", middleware.RequestIDFrom(c))
}
c.JSON(http.StatusOK, OKResponse{OK: true})
}
// sniffImage — формат по сигнатуре байтов, а не по заголовку
// Content-Type: клиент мог подписать что угодно, а в БД (CHECK в 0008)
// и в браузер уйдёт ровно то, что здесь. Разрешены только фото —
// gif, svg и прочие форматы этап не принимает.
func sniffImage(b []byte) (contentType, ext string, ok bool) {
switch {
case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF: // JPEG
return "image/jpeg", "jpg", true
case len(b) >= 8 && bytes.Equal(b[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): // PNG
return "image/png", "png", true
case len(b) >= 12 && bytes.Equal(b[:4], []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP
return "image/webp", "webp", true
}
return "", "", false
}
// photoName — имя файла для журнала (comment события) и UI: без
// управляющих символов, без упоминаний пути («C:\evil.png» → «evil.png»),
// не длиннее photoMaxNameRunes. Пустое имя тоже допустимо — загрузчик
// мог его не передать.
func photoName(raw string) string {
base := path.Base(strings.ReplaceAll(raw, `\`, "/"))
var b strings.Builder
for _, r := range base {
if r < 0x20 || r == 0x7f { // управляющие — только в лог, не в UI
continue
}
b.WriteRune(r)
}
name := b.String()
if utf8.RuneCountInString(name) > photoMaxNameRunes {
name = string([]rune(name)[:photoMaxNameRunes])
}
if name == "." || name == "/" {
return ""
}
return name
}