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