Files
remembos/internal/web/images.go
T
avandClaude Opus 5 2bf618d105 media: кодеки для magick в образе и диагностика ресайза
Базовый пакет imagemagick в alpine идёт без кодеков, поэтому magick не мог
прочитать jpeg-вложение и падал с "exit status 1". Ставим imagemagick-jpeg,
-heic, -webp, -tiff.

Чтобы такое было видно из логов: runMagick подставляет stderr в ошибку и
отдельно ловит "код выхода 0, но пустой вывод" (так magick ведёт себя без
кодека на запись), а веб логирует формат, размеры до и после, время
скачивания и ресайза.

Если ресайз всё же не удался, отдаём оригинал вместо 502; в кэш не кладём
записи больше 4 МБ, чтобы неужатые оригиналы не съели память.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:37:57 +03:00

179 lines
5.6 KiB
Go

package web
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"git.vakhrushev.me/av/remembos/internal/media"
"git.vakhrushev.me/av/remembos/internal/memos"
)
// imagePathPrefix — URL, по которому веб отдаёт вложения текущего воспоминания.
// Картинки идут не напрямую из Memos, а через ресайз — как в телеграм-части,
// иначе в браузер уезжают исходники с телефона на десятки мегабайт.
const imagePathPrefix = "/image/"
// imageCacheSize — сколько обработанных картинок держим в памяти,
// чтобы не пережимать их заново на каждую перезагрузку страницы.
const imageCacheSize = 20
// imageCacheMaxBytes — верхняя граница размера одной записи кэша.
const imageCacheMaxBytes = 4 * 1024 * 1024
// attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}").
func attachmentUID(att memos.Attachment) string {
return strings.TrimPrefix(att.Name, "attachments/")
}
// imageURL builds the local URL for an attachment.
func imageURL(att memos.Attachment) string {
return imagePathPrefix + url.PathEscape(attachmentUID(att))
}
func (h *Handler) handleImage(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("uid")
att, ok := h.findAttachment(r.Context(), uid)
if !ok {
http.NotFound(w, r)
return
}
data, contentType, err := h.imageBytes(r.Context(), att)
if err != nil {
h.logger.Error("failed to prepare image",
"name", att.Name, "filename", att.Filename, "mime", att.Type,
"declared_bytes", att.Size, "external", att.ExternalLink != "",
"error", err)
http.Error(w, "не удалось загрузить изображение", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("Cache-Control", "private, max-age=3600")
if _, err := w.Write(data); err != nil {
h.logger.Debug("image write failed", "name", att.Name, "error", err)
}
}
// findAttachment looks the attachment up among the images of the current memory:
// отдаём только то, что сейчас показано на странице, а не любое вложение Memos.
func (h *Handler) findAttachment(ctx context.Context, uid string) (memos.Attachment, bool) {
if uid == "" {
return memos.Attachment{}, false
}
mem, err := h.service.GetTodayMemory(ctx)
if err != nil {
h.logger.Error("failed to get memory for image", "error", err)
return memos.Attachment{}, false
}
if mem == nil {
return memos.Attachment{}, false
}
for _, att := range mem.Memo.Attachments {
if att.IsImage() && attachmentUID(att) == uid {
return att, true
}
}
// Обычно это устаревшая ссылка: страницу открыли до того, как воспоминание сменилось.
h.logger.Info("image not found in current memory", "uid", uid, "memo", mem.Memo.Name)
return memos.Attachment{}, false
}
// imageBytes downloads and resizes the attachment, caching the result.
func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) {
if img, ok := h.images.get(att.Name); ok {
h.logger.Debug("image served from cache",
"name", att.Name, "mime", img.contentType, "bytes", len(img.data))
return img.data, img.contentType, nil
}
started := time.Now()
raw, err := h.client.DownloadAttachment(ctx, att)
if err != nil {
return nil, "", fmt.Errorf("download %s (%s): %w", att.Filename, att.Type, err)
}
downloaded := time.Since(started)
resizeStart := time.Now()
data, contentType, err := media.ResizeForWeb(ctx, raw, att.Type)
if err != nil {
// Отдать картинку неужатой лучше, чем не отдать вовсе.
h.logger.Warn("failed to resize image, serving original",
"name", att.Name, "filename", att.Filename, "mime", att.Type,
"src_bytes", len(raw), "error", err)
data, contentType = raw, att.Type
}
if contentType == "" {
contentType = "application/octet-stream"
}
// Оригиналы бывают на десятки мегабайт — такие в кэш не кладём.
cached := len(data) <= imageCacheMaxBytes
if cached {
h.images.put(att.Name, cachedImage{data: data, contentType: contentType})
}
h.logger.Info("image prepared",
"name", att.Name, "filename", att.Filename,
"src_mime", att.Type, "src_bytes", len(raw),
"out_mime", contentType, "out_bytes", len(data),
"download", downloaded.Round(time.Millisecond).String(),
"resize", time.Since(resizeStart).Round(time.Millisecond).String(),
"cached", cached)
return data, contentType, nil
}
type cachedImage struct {
data []byte
contentType string
}
// imageCache — маленький FIFO-кэш обработанных картинок.
type imageCache struct {
mu sync.Mutex
items map[string]cachedImage
order []string
}
func newImageCache() *imageCache {
return &imageCache{items: make(map[string]cachedImage)}
}
func (c *imageCache) get(key string) (cachedImage, bool) {
c.mu.Lock()
defer c.mu.Unlock()
img, ok := c.items[key]
return img, ok
}
func (c *imageCache) put(key string, img cachedImage) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.items[key]; ok {
c.items[key] = img
return
}
if len(c.order) >= imageCacheSize {
delete(c.items, c.order[0])
c.order = c.order[1:]
}
c.items[key] = img
c.order = append(c.order, key)
}