web: картинки заметки отдаются через ресайз
Раньше страница ссылалась прямо на файлы Memos, и в браузер уезжали
исходники с телефона на десятки мегабайт. Теперь вложения идут через
эндпоинт /image/{uid}: скачиваем тем же клиентом, что и телеграм-часть,
ужимаем ImageMagick до 1600px по большей стороне и кэшируем результат
в памяти (FIFO на 20 картинок) плюс Cache-Control на час.
GIF и SVG отдаются без конверсии: анимация и вектор в JPEG теряются.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.vakhrushev.me/av/remembos/internal/media"
|
||||
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||
)
|
||||
|
||||
// imagePathPrefix — URL, по которому веб отдаёт вложения текущего воспоминания.
|
||||
// Картинки идут не напрямую из Memos, а через ресайз — как в телеграм-части,
|
||||
// иначе в браузер уезжают исходники с телефона на десятки мегабайт.
|
||||
const imagePathPrefix = "/image/"
|
||||
|
||||
// imageCacheSize — сколько обработанных картинок держим в памяти,
|
||||
// чтобы не пережимать их заново на каждую перезагрузку страницы.
|
||||
const imageCacheSize = 20
|
||||
|
||||
// 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, "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
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return img.data, img.contentType, nil
|
||||
}
|
||||
|
||||
raw, err := h.client.DownloadAttachment(ctx, att)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
data, contentType, err := media.ResizeForWeb(ctx, raw, att.Type)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
h.images.put(att.Name, cachedImage{data: data, contentType: contentType})
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user