Compare commits
6
Commits
22fe1fdf98
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf618d105
|
||
|
|
39f4a4dff9
|
||
|
|
8e8cc990c4
|
||
|
|
c3acb50fcc
|
||
|
|
13dafa2f68
|
||
|
|
5bbcfa18bc
|
@@ -1,58 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: 'v2.13.2'
|
||||
distribution: goreleaser
|
||||
args: release --clean
|
||||
env:
|
||||
GITEA_TOKEN: '${{ secrets.RELEASE_TOKEN }}'
|
||||
|
||||
docker-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Yandex Cloud Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: cr.yandex
|
||||
username: oauth
|
||||
password: ${{ secrets.YANDEX_CLOUD_OAUTH_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: ./Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
cr.yandex/${{ secrets.YANDEX_CLOUD_REGISTRY_ID }}/remembos:${{ github.ref_name }}
|
||||
cr.yandex/${{ secrets.YANDEX_CLOUD_REGISTRY_ID }}/remembos:latest
|
||||
platforms: linux/amd64
|
||||
@@ -1,37 +0,0 @@
|
||||
version: 2
|
||||
|
||||
project_name: remembos
|
||||
|
||||
builds:
|
||||
- id: remembos
|
||||
main: ./cmd/remembos
|
||||
binary: remembos
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
archives:
|
||||
- id: remembos
|
||||
formats:
|
||||
- tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
files:
|
||||
- README.md
|
||||
- config.dist.toml
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
gitea_urls:
|
||||
api: https://git.vakhrushev.me/api/v1
|
||||
download: https://git.vakhrushev.me
|
||||
+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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
|
||||
- [Алгоритм поиска воспоминаний](spec/SEARCH.md)
|
||||
|
||||
## Сборка и деплой
|
||||
|
||||
Команды проекта — через [Task](https://taskfile.dev), список: `task --list`.
|
||||
|
||||
Деплоем занимается плейбук `playbook-remembos.yml` в `pet-project-server`:
|
||||
роль `app_image` собирает образ локально этим репозиторием и везёт его на
|
||||
сервер через `docker save`/`load` — реестр не участвует.
|
||||
|
||||
Контракт роли — задача `image`: она получает `BUILD_ID` через окружение и
|
||||
собирает `remembos:$BUILD_ID`. Без переменной (`task image`) собирается
|
||||
`remembos:dev` для локальной работы.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Go** — основной язык
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# yaml-language-server: $schema=https://taskfile.dev/schema.json
|
||||
#
|
||||
# Запуск команд проекта через Task (https://taskfile.dev).
|
||||
# Список задач: `task --list`.
|
||||
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
BINARY: remembos
|
||||
PKG: ./cmd/remembos
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Список доступных задач
|
||||
cmds:
|
||||
- task --list
|
||||
silent: true
|
||||
|
||||
run:
|
||||
desc: 'Локальный запуск (нужен ./config.toml)'
|
||||
cmds:
|
||||
- go run {{.PKG}} --config ./config.toml
|
||||
|
||||
build:
|
||||
desc: Статический бинарь linux/amd64
|
||||
cmds:
|
||||
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o {{.BINARY}} {{.PKG}}
|
||||
|
||||
test:
|
||||
desc: Прогон тестов
|
||||
cmds:
|
||||
- go test ./...
|
||||
|
||||
lint:
|
||||
desc: Запуск golangci-lint
|
||||
cmds:
|
||||
- golangci-lint run
|
||||
|
||||
tidy:
|
||||
desc: go mod tidy
|
||||
cmds:
|
||||
- go mod tidy
|
||||
|
||||
image:
|
||||
desc: 'Docker-образ (сборка Go внутри образа). Тег из $BUILD_ID, по умолчанию dev'
|
||||
cmds:
|
||||
- docker build -t {{.BINARY}}:${BUILD_ID:-dev} .
|
||||
|
||||
clean:
|
||||
desc: Удалить собранный бинарь
|
||||
cmds:
|
||||
- rm -f {{.BINARY}}
|
||||
@@ -69,18 +69,13 @@ 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. Авторизация в Telegram идёт в фоне (Bot.Run) с повторами:
|
||||
// недоступный api.telegram.org не должен мешать старту веб-части.
|
||||
var tgBot *telegram.Bot
|
||||
if cfg.Telegram.Enabled {
|
||||
var err error
|
||||
tgBot, err = telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, loc, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create telegram bot", "error", err)
|
||||
store.Close()
|
||||
os.Exit(1) //nolint:gocritic // store.Close() called above; linter doesn't track manual cleanup
|
||||
}
|
||||
tgBot = telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, loc, logger)
|
||||
}
|
||||
|
||||
// HTTP server
|
||||
|
||||
@@ -86,6 +86,8 @@ tier7 = 8
|
||||
[telegram]
|
||||
|
||||
# Включить Telegram-бот для ежедневной отправки воспоминаний.
|
||||
# Если api.telegram.org недоступен, приложение всё равно стартует: веб-часть
|
||||
# работает, а бот повторяет авторизацию в фоне, пока не появится связь.
|
||||
enabled = false
|
||||
|
||||
# Токен бота, полученный от @BotFather.
|
||||
|
||||
@@ -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:-")
|
||||
@@ -50,9 +84,19 @@ func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error)
|
||||
cmd := exec.CommandContext(ctx, "magick", cmdArgs...)
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
|
||||
// Без stderr от magick ошибка выглядит как голое "exit status 1"
|
||||
// и не подсказывает, чего не хватает (обычно — кодека).
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
out, err := cmd.Output()
|
||||
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
|
||||
|
||||
@@ -13,9 +13,16 @@ type DateRange struct {
|
||||
}
|
||||
|
||||
// BuildCELFilter creates a CEL filter string for a date range using created_ts.
|
||||
// Since memos 0.30 created_ts is a CEL timestamp, so plain Unix ints are not
|
||||
// comparable with it — the bounds must be wrapped in timestamp().
|
||||
func BuildCELFilter(dr DateRange) string {
|
||||
return fmt.Sprintf("created_ts >= %d && created_ts < %d",
|
||||
dr.Start.Unix(), dr.End.Unix())
|
||||
return fmt.Sprintf("created_ts >= timestamp(%q) && created_ts < timestamp(%q)",
|
||||
formatCELTimestamp(dr.Start), formatCELTimestamp(dr.End))
|
||||
}
|
||||
|
||||
// formatCELTimestamp renders a time as an RFC3339 literal in UTC.
|
||||
func formatCELTimestamp(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func isLeapYear(year int) bool {
|
||||
|
||||
+76
-15
@@ -2,8 +2,10 @@ package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,9 +19,16 @@ import (
|
||||
"git.vakhrushev.me/av/remembos/internal/search"
|
||||
)
|
||||
|
||||
// Интервалы повторной авторизации в Telegram: от 30 секунд с удвоением до 15 минут.
|
||||
const (
|
||||
connectRetryMin = 30 * time.Second
|
||||
connectRetryMax = 15 * time.Minute
|
||||
)
|
||||
|
||||
// Bot sends a daily memory via Telegram.
|
||||
type Bot struct {
|
||||
api *tgbotapi.BotAPI
|
||||
token string
|
||||
api *tgbotapi.BotAPI // назначается в connect, до этого бот не работает
|
||||
service *memory.Service
|
||||
client *memos.Client
|
||||
chatID int64
|
||||
@@ -30,7 +39,8 @@ type Bot struct {
|
||||
allowLoadMore bool
|
||||
}
|
||||
|
||||
// NewBot creates a new Telegram bot.
|
||||
// NewBot creates a new Telegram bot. В сеть не ходит: авторизация происходит
|
||||
// в Run, чтобы недоступный api.telegram.org не мешал старту приложения.
|
||||
func NewBot(
|
||||
cfg config.TelegramConfig,
|
||||
service *memory.Service,
|
||||
@@ -39,22 +49,15 @@ func NewBot(
|
||||
allowLoadMore bool,
|
||||
loc *time.Location,
|
||||
logger *slog.Logger,
|
||||
) (*Bot, error) {
|
||||
api, err := tgbotapi.NewBotAPI(cfg.Token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram bot: %w", err)
|
||||
}
|
||||
|
||||
) *Bot {
|
||||
pub := publicURL
|
||||
if pub == "" {
|
||||
pub = memosURL
|
||||
}
|
||||
pub = strings.TrimRight(pub, "/")
|
||||
|
||||
logger.Info("telegram bot authorized", "username", api.Self.UserName)
|
||||
|
||||
return &Bot{
|
||||
api: api,
|
||||
token: cfg.Token,
|
||||
service: service,
|
||||
client: client,
|
||||
chatID: cfg.ChatID,
|
||||
@@ -63,11 +66,15 @@ func NewBot(
|
||||
loc: loc,
|
||||
logger: logger,
|
||||
allowLoadMore: allowLoadMore,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the scheduling loop. It blocks until ctx is cancelled.
|
||||
// Run authorizes the bot and starts the scheduling loop. It blocks until ctx is cancelled.
|
||||
func (b *Bot) Run(ctx context.Context) {
|
||||
if !b.connect(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
if b.allowLoadMore {
|
||||
go b.listenForCommands(ctx)
|
||||
}
|
||||
@@ -87,6 +94,56 @@ func (b *Bot) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// connect авторизует бота, повторяя попытки до успеха или отмены ctx.
|
||||
// Сеть до api.telegram.org может быть недоступна (у сервера бывает заблокирован
|
||||
// исходящий трафик) — это не повод ронять приложение: веб-часть работает без бота,
|
||||
// а бот подхватится сам, когда связь появится.
|
||||
func (b *Bot) connect(ctx context.Context) bool {
|
||||
delay := connectRetryMin
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
api, err := tgbotapi.NewBotAPIWithClient(b.token, tgbotapi.APIEndpoint, newHTTPClient())
|
||||
if err == nil {
|
||||
b.api = api
|
||||
b.logger.Info("telegram bot authorized", "username", api.Self.UserName, "attempt", attempt)
|
||||
return true
|
||||
}
|
||||
|
||||
// Отказ самого Telegram (неверный токен) повторами не лечится — выключаем бота.
|
||||
var apiErr *tgbotapi.Error
|
||||
if errors.As(err, &apiErr) && (apiErr.Code == http.StatusUnauthorized || apiErr.Code == http.StatusNotFound) {
|
||||
b.logger.Error("telegram bot disabled: token rejected", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
b.logger.Warn("telegram authorization failed, will retry",
|
||||
// Duration в JSON-логе иначе печатается наносекундами.
|
||||
"attempt", attempt, "retry_in", delay.String(), "error", err)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
b.logger.Info("telegram bot stopped before authorization")
|
||||
return false
|
||||
case <-time.After(delay):
|
||||
}
|
||||
delay = min(delay*2, connectRetryMax)
|
||||
}
|
||||
}
|
||||
|
||||
// newHTTPClient — клиент для Telegram API. Общего таймаута намеренно нет:
|
||||
// long polling ждёт до 60 секунд, а заливка вложений может идти долго.
|
||||
// Ограничено только установление соединения, чтобы неудачная попытка
|
||||
// авторизации не висела минутами на TCP-ретраях.
|
||||
func newHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// nextSendTime returns the next occurrence of sendAt in the configured timezone.
|
||||
func (b *Bot) nextSendTime() time.Time {
|
||||
now := time.Now().In(b.loc)
|
||||
@@ -210,9 +267,13 @@ func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos
|
||||
continue
|
||||
}
|
||||
|
||||
srcBytes := len(data)
|
||||
|
||||
data, filename, err := media.CompressImage(ctx, data, att.Filename)
|
||||
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
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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)
|
||||
}
|
||||
+5
-2
@@ -16,10 +16,13 @@ GET /api/v1/memos?filter={CEL}&pageSize={N}
|
||||
|
||||
### Фильтрация по дате
|
||||
|
||||
API поддерживает CEL-фильтры по полю `created_ts` (Unix timestamp):
|
||||
API поддерживает CEL-фильтры по полю `created_ts`. Начиная с memos 0.30.0 это
|
||||
значение типа `timestamp`, поэтому границы диапазона задаются через
|
||||
`timestamp("<RFC3339>")` (в UTC); сравнение с голым Unix-числом больше не
|
||||
компилируется:
|
||||
|
||||
```
|
||||
created_ts >= 1707696000 && created_ts < 1707782400
|
||||
created_ts >= timestamp("2024-02-12T00:00:00Z") && created_ts < timestamp("2024-02-13T00:00:00Z")
|
||||
```
|
||||
|
||||
В качестве даты заметки используем `create_time` (поле `createTime` в JSON).
|
||||
|
||||
Reference in New Issue
Block a user