diff --git a/cmd/remembos/main.go b/cmd/remembos/main.go index 2c704b0..40262b8 100644 --- a/cmd/remembos/main.go +++ b/cmd/remembos/main.go @@ -69,7 +69,7 @@ func main() { memorySvc := memory.NewService(selector, store, client, loc, logger) // 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) с повторами: // недоступный api.telegram.org не должен мешать старту веб-части. diff --git a/internal/media/convert.go b/internal/media/convert.go index 1ad3eb4..175b589 100644 --- a/internal/media/convert.go +++ b/internal/media/convert.go @@ -10,6 +10,10 @@ import ( const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB +// webImageMaxSide — максимальная сторона картинки, отдаваемой на веб-страницу. +// Экран всё равно не покажет больше, а исходники с телефона весят десятки мегабайт. +const webImageMaxSide = 1600 + // CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit. // It uses ImageMagick's magick command to convert via stdin/stdout. // 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 } +// 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) { cmdArgs := append([]string{"-"}, args...) cmdArgs = append(cmdArgs, "jpeg:-") diff --git a/internal/web/handler.go b/internal/web/handler.go index 16fa57e..3f84eed 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -10,6 +10,7 @@ import ( "time" "git.vakhrushev.me/av/remembos/internal/memory" + "git.vakhrushev.me/av/remembos/internal/memos" ) //go:embed templates/*.html @@ -40,26 +41,31 @@ type errorData struct { type Handler struct { service *memory.Service + client *memos.Client logger *slog.Logger 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 } -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 if pub == "" { pub = memosURL } h := &Handler{ service: service, + client: client, publicURL: strings.TrimRight(pub, "/"), allowLoadMore: allowLoadMore, logger: logger, mux: http.NewServeMux(), + images: newImageCache(), } h.mux.HandleFunc("GET /", h.handleMemory) h.mux.HandleFunc("GET /health", h.handleHealth) + h.mux.HandleFunc("GET /image/{uid}", h.handleImage) h.mux.HandleFunc("POST /more", h.handleLoadMore) return h } @@ -101,13 +107,7 @@ func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) { if !att.IsImage() { continue } - var imgURL string - 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}) + images = append(images, imageData{URL: imageURL(att), Alt: att.Filename}) } // Link to original memo: {publicURL}/{memoName} diff --git a/internal/web/images.go b/internal/web/images.go new file mode 100644 index 0000000..caa7da6 --- /dev/null +++ b/internal/web/images.go @@ -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) +}