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) }