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:
@@ -69,7 +69,7 @@ func main() {
|
|||||||
memorySvc := memory.NewService(selector, store, client, loc, logger)
|
memorySvc := memory.NewService(selector, store, client, loc, logger)
|
||||||
|
|
||||||
// Web handler
|
// Web handler
|
||||||
handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger)
|
handler := web.NewHandler(memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger)
|
||||||
|
|
||||||
// Telegram bot. Авторизация в Telegram идёт в фоне (Bot.Run) с повторами:
|
// Telegram bot. Авторизация в Telegram идёт в фоне (Bot.Run) с повторами:
|
||||||
// недоступный api.telegram.org не должен мешать старту веб-части.
|
// недоступный api.telegram.org не должен мешать старту веб-части.
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import (
|
|||||||
|
|
||||||
const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB
|
const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
|
// webImageMaxSide — максимальная сторона картинки, отдаваемой на веб-страницу.
|
||||||
|
// Экран всё равно не покажет больше, а исходники с телефона весят десятки мегабайт.
|
||||||
|
const webImageMaxSide = 1600
|
||||||
|
|
||||||
// CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit.
|
// CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit.
|
||||||
// It uses ImageMagick's magick command to convert via stdin/stdout.
|
// It uses ImageMagick's magick command to convert via stdin/stdout.
|
||||||
// Returns the (possibly compressed) data, updated filename, and any error.
|
// Returns the (possibly compressed) data, updated filename, and any error.
|
||||||
@@ -43,6 +47,36 @@ func CompressImage(ctx context.Context, data []byte, filename string) (out []byt
|
|||||||
return out, newFilename, nil
|
return out, newFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResizeForWeb downscales an image to webImageMaxSide and re-encodes it as JPEG.
|
||||||
|
// Images smaller than the limit are only re-encoded (ImageMagick's ">" modifier
|
||||||
|
// never enlarges). Returns the data and the resulting MIME type.
|
||||||
|
func ResizeForWeb(ctx context.Context, data []byte, mimeType string) ([]byte, string, error) {
|
||||||
|
// GIF (анимация) и SVG (вектор) при конверсии в JPEG теряют смысл — отдаём как есть.
|
||||||
|
if !resizableMIME(mimeType) {
|
||||||
|
return data, mimeType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := runMagick(ctx, data,
|
||||||
|
"-auto-orient",
|
||||||
|
"-resize", fmt.Sprintf("%dx%d>", webImageMaxSide, webImageMaxSide),
|
||||||
|
"-quality", "85",
|
||||||
|
"-strip",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("resize image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, "image/jpeg", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizableMIME(mimeType string) bool {
|
||||||
|
switch mimeType {
|
||||||
|
case "image/gif", "image/svg+xml":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) {
|
func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) {
|
||||||
cmdArgs := append([]string{"-"}, args...)
|
cmdArgs := append([]string{"-"}, args...)
|
||||||
cmdArgs = append(cmdArgs, "jpeg:-")
|
cmdArgs = append(cmdArgs, "jpeg:-")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/remembos/internal/memory"
|
"git.vakhrushev.me/av/remembos/internal/memory"
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.html
|
//go:embed templates/*.html
|
||||||
@@ -40,26 +41,31 @@ type errorData struct {
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *memory.Service
|
service *memory.Service
|
||||||
|
client *memos.Client
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
publicURL string // public Memos URL (for images and memo links)
|
images *imageCache
|
||||||
|
publicURL string // public Memos URL (for memo links)
|
||||||
allowLoadMore bool
|
allowLoadMore bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(service *memory.Service, memosURL, publicURL string, allowLoadMore bool, logger *slog.Logger) *Handler {
|
func NewHandler(service *memory.Service, client *memos.Client, memosURL, publicURL string, allowLoadMore bool, logger *slog.Logger) *Handler {
|
||||||
pub := publicURL
|
pub := publicURL
|
||||||
if pub == "" {
|
if pub == "" {
|
||||||
pub = memosURL
|
pub = memosURL
|
||||||
}
|
}
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
service: service,
|
service: service,
|
||||||
|
client: client,
|
||||||
publicURL: strings.TrimRight(pub, "/"),
|
publicURL: strings.TrimRight(pub, "/"),
|
||||||
allowLoadMore: allowLoadMore,
|
allowLoadMore: allowLoadMore,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
|
images: newImageCache(),
|
||||||
}
|
}
|
||||||
h.mux.HandleFunc("GET /", h.handleMemory)
|
h.mux.HandleFunc("GET /", h.handleMemory)
|
||||||
h.mux.HandleFunc("GET /health", h.handleHealth)
|
h.mux.HandleFunc("GET /health", h.handleHealth)
|
||||||
|
h.mux.HandleFunc("GET /image/{uid}", h.handleImage)
|
||||||
h.mux.HandleFunc("POST /more", h.handleLoadMore)
|
h.mux.HandleFunc("POST /more", h.handleLoadMore)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
@@ -101,13 +107,7 @@ func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !att.IsImage() {
|
if !att.IsImage() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var imgURL string
|
images = append(images, imageData{URL: imageURL(att), Alt: att.Filename})
|
||||||
if att.ExternalLink != "" {
|
|
||||||
imgURL = att.ExternalLink
|
|
||||||
} else {
|
|
||||||
imgURL = fmt.Sprintf("%s/file/%s/%s", h.publicURL, att.Name, att.Filename)
|
|
||||||
}
|
|
||||||
images = append(images, imageData{URL: imgURL, Alt: att.Filename})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link to original memo: {publicURL}/{memoName}
|
// Link to original memo: {publicURL}/{memoName}
|
||||||
|
|||||||
@@ -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