axe/internal/handlers/photos.go
2026-10-18 22:07:25 +03:00

375 lines
16 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 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
}