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 }