Compare commits
1
Commits
39f4a4dff9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf618d105
|
+9
-1
@@ -14,7 +14,15 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/rememb
|
|||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
RUN apk add --no-cache imagemagick tzdata
|
# Базовый пакет imagemagick идёт без кодеков: без них magick не читает и не
|
||||||
|
# пишет JPEG (а с телефонов приходят ещё HEIC и WebP).
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
imagemagick \
|
||||||
|
imagemagick-jpeg \
|
||||||
|
imagemagick-heic \
|
||||||
|
imagemagick-webp \
|
||||||
|
imagemagick-tiff \
|
||||||
|
tzdata
|
||||||
|
|
||||||
COPY --from=build /out/remembos /remembos
|
COPY --from=build /out/remembos /remembos
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,19 @@ func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error)
|
|||||||
cmd := exec.CommandContext(ctx, "magick", cmdArgs...)
|
cmd := exec.CommandContext(ctx, "magick", cmdArgs...)
|
||||||
cmd.Stdin = bytes.NewReader(data)
|
cmd.Stdin = bytes.NewReader(data)
|
||||||
|
|
||||||
|
// Без stderr от magick ошибка выглядит как голое "exit status 1"
|
||||||
|
// и не подсказывает, чего не хватает (обычно — кодека).
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("magick: %w", err)
|
return nil, fmt.Errorf("magick: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без кодека magick иногда завершается успешно, но не пишет ничего.
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("magick produced empty output: %s", strings.TrimSpace(stderr.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -267,9 +267,13 @@ func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srcBytes := len(data)
|
||||||
|
|
||||||
data, filename, err := media.CompressImage(ctx, data, att.Filename)
|
data, filename, err := media.CompressImage(ctx, data, att.Filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.logger.Warn("failed to compress image, skipping", "name", att.Name, "error", err)
|
b.logger.Warn("failed to compress image, skipping",
|
||||||
|
"name", att.Name, "filename", att.Filename, "mime", att.Type,
|
||||||
|
"src_bytes", srcBytes, "error", err)
|
||||||
skipped = true
|
skipped = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-3
@@ -2,11 +2,13 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/remembos/internal/media"
|
"git.vakhrushev.me/av/remembos/internal/media"
|
||||||
"git.vakhrushev.me/av/remembos/internal/memos"
|
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||||
@@ -21,6 +23,9 @@ const imagePathPrefix = "/image/"
|
|||||||
// чтобы не пережимать их заново на каждую перезагрузку страницы.
|
// чтобы не пережимать их заново на каждую перезагрузку страницы.
|
||||||
const imageCacheSize = 20
|
const imageCacheSize = 20
|
||||||
|
|
||||||
|
// imageCacheMaxBytes — верхняя граница размера одной записи кэша.
|
||||||
|
const imageCacheMaxBytes = 4 * 1024 * 1024
|
||||||
|
|
||||||
// attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}").
|
// attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}").
|
||||||
func attachmentUID(att memos.Attachment) string {
|
func attachmentUID(att memos.Attachment) string {
|
||||||
return strings.TrimPrefix(att.Name, "attachments/")
|
return strings.TrimPrefix(att.Name, "attachments/")
|
||||||
@@ -42,7 +47,10 @@ func (h *Handler) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
data, contentType, err := h.imageBytes(r.Context(), att)
|
data, contentType, err := h.imageBytes(r.Context(), att)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("failed to prepare image", "name", att.Name, "error", err)
|
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)
|
http.Error(w, "не удалось загрузить изображение", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -76,26 +84,55 @@ func (h *Handler) findAttachment(ctx context.Context, uid string) (memos.Attachm
|
|||||||
return att, true
|
return att, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обычно это устаревшая ссылка: страницу открыли до того, как воспоминание сменилось.
|
||||||
|
h.logger.Info("image not found in current memory", "uid", uid, "memo", mem.Memo.Name)
|
||||||
return memos.Attachment{}, false
|
return memos.Attachment{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageBytes downloads and resizes the attachment, caching the result.
|
// imageBytes downloads and resizes the attachment, caching the result.
|
||||||
func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) {
|
func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) {
|
||||||
if img, ok := h.images.get(att.Name); ok {
|
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
|
return img.data, img.contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
raw, err := h.client.DownloadAttachment(ctx, att)
|
raw, err := h.client.DownloadAttachment(ctx, att)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
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)
|
data, contentType, err := media.ResizeForWeb(ctx, raw, att.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
// Отдать картинку неужатой лучше, чем не отдать вовсе.
|
||||||
|
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.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
|
return data, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user