Compare commits
7
Commits
v0.1.4
..
39f4a4dff9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39f4a4dff9
|
||
|
|
8e8cc990c4
|
||
|
|
c3acb50fcc
|
||
|
|
13dafa2f68
|
||
|
|
5bbcfa18bc
|
||
|
|
22fe1fdf98
|
||
|
|
2ca838a50f
|
@@ -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
|
||||
@@ -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:-")
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
|
||||
mem := &search.Memory{
|
||||
Memo: memo,
|
||||
Tier: tier,
|
||||
Date: memo.DisplayTime,
|
||||
Date: memo.CreateTime,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -10,7 +10,6 @@ type Memo struct {
|
||||
Creator string `json:"creator"`
|
||||
CreateTime time.Time `json:"createTime"`
|
||||
UpdateTime time.Time `json:"updateTime"`
|
||||
DisplayTime time.Time `json:"displayTime"`
|
||||
Content string `json:"content"`
|
||||
Visibility string `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
@@ -124,7 +124,7 @@ func (s *Selector) tryTiers(
|
||||
// Build candidates
|
||||
candidates := make([]candidate, len(filtered))
|
||||
for i, m := range filtered {
|
||||
yearsAgo := today.Year() - m.DisplayTime.Year()
|
||||
yearsAgo := today.Year() - m.CreateTime.Year()
|
||||
if yearsAgo < 0 {
|
||||
yearsAgo = 0
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func (s *Selector) tryTiers(
|
||||
Tier: tier,
|
||||
YearsAgo: picked.yearsAgo,
|
||||
ShowCount: picked.showCount,
|
||||
Date: picked.memo.DisplayTime,
|
||||
Date: picked.memo.CreateTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ func (s *Selector) fullFallback(ctx context.Context) (*Memory, error) {
|
||||
return &Memory{
|
||||
Memo: picked.memo,
|
||||
Tier: 0,
|
||||
Date: picked.memo.DisplayTime,
|
||||
Date: picked.memo.CreateTime,
|
||||
}, 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 {
|
||||
|
||||
+71
-14
@@ -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)
|
||||
|
||||
@@ -29,16 +29,6 @@ func formatMemory(mem *search.Memory, publicURL string) string {
|
||||
// Content
|
||||
b.WriteString(escapeHTML(mem.Memo.Content))
|
||||
|
||||
// Tags
|
||||
if len(mem.Memo.Tags) > 0 {
|
||||
b.WriteString("\n\n")
|
||||
tags := make([]string, len(mem.Memo.Tags))
|
||||
for i, t := range mem.Memo.Tags {
|
||||
tags[i] = "#" + t
|
||||
}
|
||||
b.WriteString(strings.Join(tags, " "))
|
||||
}
|
||||
|
||||
// Link to original
|
||||
memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name)
|
||||
b.WriteString("\n\n<a href=\"" + memoURL + "\">Оригинал</a>")
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
+9
-6
@@ -11,20 +11,23 @@
|
||||
### Используемый эндпоинт
|
||||
|
||||
```
|
||||
GET /api/v1/memos?filter={CEL}&pageSize={N}&orderBy=display_time+desc
|
||||
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")
|
||||
```
|
||||
|
||||
Поле `display_time` (пользовательская дата) недоступно для фильтрации через CEL.
|
||||
Поэтому используем `created_ts` как основу, а `display_time` учитываем при
|
||||
пост-обработке, если он отличается от `created_ts`.
|
||||
В качестве даты заметки используем `create_time` (поле `createTime` в JSON).
|
||||
Поле `display_time` удалено из API в memos v0.28.0 (зарезервировано в proto),
|
||||
поэтому оно больше не доступно ни для фильтрации, ни для отображения.
|
||||
|
||||
### Ограничения
|
||||
|
||||
|
||||
Reference in New Issue
Block a user