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