7afd23d6d4
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и подписывает запрос VAPID-ключом, поэтому уведомление приходит, даже когда клиент закрыт. Сервер: - миграция 00021: `push_subscriptions` (эндпоинт уникален, ключи, счётчик неудач, время последней доставки); - `internal/push` — VAPID-ключи (приватный PKCS#8 из конфига, публичный выводится из него), правила уведомлений повторяют `web/src/lib/desktopNotifications.ts` (упоминания и личные беседы, без своих и системных сообщений), очередь доставки, TTL 12 часов, Topic по комнате; - 404/410 от push-сервиса удаляют подписку сразу, 5 неудач подряд — тоже, иначе копились бы мёртвые эндпоинты; retention чистит «молчащие» подписки; - `internal/httpx/safeurl.go` — общий запрет внутренних адресов с проверкой адреса в момент подключения (DNS rebinding): эндпоинт подписки приходит от клиента, и без проверки сервер сам себе организует SSRF; - `internal/gateway/presence.go` — `IsUserOnline`: если получатель в клиенте, уведомление покажет клиент, дублировать на телефон не нужно; - ручки `GET /push/config`, `GET|POST|DELETE /push/subscriptions`, лимиты 10 подписок и 20 уведомлений в минуту; ключ p256dh проверяется как настоящая точка P-256; - установщик генерирует `VAPID_PRIVATE_KEY` (openssl, PKCS#8 DER в base64) и `VAPID_SUBJECT`, ключ переиспользуется при переустановке; для ручной установки есть `glchat vapid-keys`; `features.web_push_enabled` виден в `/meta`. Тесты: правила и VAPID, доставка с поддельным push-эндпоинтом (шифрование, заголовки VAPID/TTL/Topic, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД.
303 lines
10 KiB
Go
303 lines
10 KiB
Go
// Package retention обслуживает чистку данных инстанса (AGENT.md 6.4, Фаза 5):
|
|
// истёкшие сессии, старые записи аудита и файлы без ссылок. Запускается
|
|
// периодически вместе с обслуживанием базы и доступен командой `glchat cleanup`.
|
|
package retention
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"glchat/internal/store"
|
|
)
|
|
|
|
// Options — настройки чистки.
|
|
type Options struct {
|
|
// DataDir — каталог данных: файлы лежат в его подкаталоге `files`.
|
|
DataDir string
|
|
// AuditDays — сколько дней хранить журнал аудита (0 — хранить всегда).
|
|
AuditDays int
|
|
// OrphanHours — сколько часов файл может жить без ссылок.
|
|
OrphanHours int
|
|
// BatchLimit — сколько файлов удалять за один проход.
|
|
BatchLimit int
|
|
// Logger — журнал: чистка пишет итоги и предупреждения.
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// Stats — что сделала одна чистка.
|
|
type Stats struct {
|
|
Sessions int64
|
|
Audit int64
|
|
Files int64
|
|
// Unknown — файлы на диске, у которых не осталось записи в базе.
|
|
Unknown int64
|
|
// Bytes — сколько освободили на диске.
|
|
Bytes int64
|
|
// PushSubscriptions — мёртвые подписки Web Push (Фаза 7).
|
|
PushSubscriptions int64
|
|
}
|
|
|
|
// Мёртвые подписки Web Push (Фаза 7): подписка, которая не доставлялась
|
|
// неделю и накопила неудачи, больше не нужна.
|
|
const (
|
|
stalePushAfterDays = 7
|
|
stalePushMaxFailures = 5
|
|
)
|
|
|
|
// Runner выполняет чистку по расписанию.
|
|
type Runner struct {
|
|
store *store.Store
|
|
options Options
|
|
now func() time.Time
|
|
}
|
|
|
|
// New создаёт обслуживание чистки. Логгер по умолчанию — тихий.
|
|
func New(st *store.Store, options Options) *Runner {
|
|
if options.Logger == nil {
|
|
options.Logger = slog.New(slog.DiscardHandler)
|
|
}
|
|
if options.BatchLimit <= 0 {
|
|
options.BatchLimit = 200
|
|
}
|
|
return &Runner{store: st, options: options, now: func() time.Time { return time.Now().UTC() }}
|
|
}
|
|
|
|
// RunOnce выполняет один проход чистки и возвращает статистику.
|
|
func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
|
var stats Stats
|
|
var errs []error
|
|
|
|
// 1. Истёкшие сессии: вход по ним всё равно невозможен.
|
|
deleted, err := r.store.DeleteSessionsBefore(ctx, r.now())
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("sessions: %w", err))
|
|
}
|
|
stats.Sessions = deleted
|
|
|
|
// 2. Журнал аудита: хранится ограниченное время (настраивается).
|
|
if r.options.AuditDays > 0 {
|
|
cutoff := r.now().AddDate(0, 0, -r.options.AuditDays)
|
|
deleted, err := r.store.DeleteAuditBefore(ctx, cutoff)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("audit: %w", err))
|
|
}
|
|
stats.Audit = deleted
|
|
}
|
|
|
|
// 3. Файлы без ссылок: сначала с диска, потом из базы — иначе при сбое
|
|
// между шагами файл остался бы без записи и не чистился бы никогда.
|
|
if r.options.OrphanHours > 0 {
|
|
cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour)
|
|
orphans, err := r.store.ListOrphanFiles(ctx, cutoff, r.options.BatchLimit)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("orphan files: %w", err))
|
|
}
|
|
for i := range orphans {
|
|
file := orphans[i]
|
|
size, err := r.removeFile(file)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("remove file %d: %w", file.ID, err))
|
|
continue
|
|
}
|
|
if err := r.store.DeleteFileRecord(ctx, file.ID); err != nil {
|
|
errs = append(errs, fmt.Errorf("delete file record %d: %w", file.ID, err))
|
|
continue
|
|
}
|
|
stats.Files++
|
|
stats.Bytes += size
|
|
}
|
|
}
|
|
|
|
// 4. Файлы на диске без записи в базе: так остаются вложения удалённых
|
|
// серверов — запись уходит каскадом, а содержимое лежит мусором.
|
|
if r.options.OrphanHours > 0 {
|
|
cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour)
|
|
removed, bytes, err := r.pruneUnknownFiles(ctx, cutoff)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("unknown files: %w", err))
|
|
}
|
|
stats.Unknown = removed
|
|
stats.Bytes += bytes
|
|
}
|
|
|
|
// 5. Мёртвые подписки Web Push: браузер мог отозвать подписку, а
|
|
// push-сервис — перестать отвечать. Живые подписки не трогаем.
|
|
cutoff := r.now().AddDate(0, 0, -stalePushAfterDays)
|
|
removed, err := r.store.DeleteStalePushSubscriptions(ctx, cutoff, stalePushMaxFailures)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("push subscriptions: %w", err))
|
|
}
|
|
stats.PushSubscriptions = removed
|
|
|
|
stats2 := stats
|
|
if len(errs) > 0 {
|
|
return stats2, errors.Join(errs...)
|
|
}
|
|
return stats2, nil
|
|
}
|
|
|
|
// pruneUnknownFiles удаляет с диска файлы, которых нет в базе, если они старше
|
|
// cutoff. Возвращает число файлов и освобождённые байты.
|
|
func (r *Runner) pruneUnknownFiles(ctx context.Context, cutoff time.Time) (int64, int64, error) {
|
|
known, err := r.store.FileStoragePaths(ctx)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
// Каталог файлов: пути в базе абсолютные, как их пишет загрузка.
|
|
root := filepath.Join(r.options.DataDir, "files")
|
|
// os.Root ограничивает работу каталогом файлов: подменённая символическая
|
|
// ссылка не выведет удаление за его пределы (AGENT.md 9.2).
|
|
dir, err := os.OpenRoot(root)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return 0, 0, nil
|
|
}
|
|
return 0, 0, err
|
|
}
|
|
defer func() { _ = dir.Close() }()
|
|
|
|
var (
|
|
removed int64
|
|
bytes int64
|
|
errs []error
|
|
)
|
|
walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.IsDir() || known[path] {
|
|
return nil
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
return nil
|
|
}
|
|
if info.ModTime().After(cutoff) {
|
|
// Свежий файл мог ещё не получить запись: не трогаем.
|
|
return nil
|
|
}
|
|
relative, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
return nil
|
|
}
|
|
if err := dir.Remove(relative); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
errs = append(errs, err)
|
|
return nil
|
|
}
|
|
removed++
|
|
bytes += info.Size()
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
errs = append(errs, walkErr)
|
|
}
|
|
// Пустые каталоги после уборки не нужны: их создаёт раскладка по префиксам.
|
|
if removed > 0 {
|
|
pruneEmptyDirs(dir, root)
|
|
}
|
|
return removed, bytes, errors.Join(errs...)
|
|
}
|
|
|
|
// pruneEmptyDirs удаляет пустые подкаталоги, начиная с самых глубоких.
|
|
// Ошибки игнорируются: каталог мог исчезнуть или быть непустым — это норма.
|
|
func pruneEmptyDirs(dir *os.Root, root string) {
|
|
dirs := make([]string, 0, 16)
|
|
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
|
if err == nil && entry.IsDir() && path != root {
|
|
dirs = append(dirs, path)
|
|
}
|
|
return nil
|
|
})
|
|
for i := len(dirs) - 1; i >= 0; i-- {
|
|
relative, err := filepath.Rel(root, dirs[i])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
_ = dir.Remove(relative)
|
|
}
|
|
}
|
|
|
|
// Run запускает чистку по расписанию; возвращает функцию остановки.
|
|
func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
|
if interval <= 0 {
|
|
interval = time.Hour
|
|
}
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
stats, err := r.RunOnce(ctx)
|
|
if err != nil {
|
|
r.options.Logger.WarnContext(ctx, "retention failed", slog.Any("error", err))
|
|
continue
|
|
}
|
|
if stats != (Stats{}) {
|
|
r.options.Logger.InfoContext(ctx, "retention done",
|
|
slog.Int64("sessions", stats.Sessions),
|
|
slog.Int64("audit", stats.Audit),
|
|
slog.Int64("files", stats.Files),
|
|
slog.Int64("unknown_files", stats.Unknown),
|
|
slog.Int64("push_subscriptions", stats.PushSubscriptions),
|
|
slog.Int64("bytes", stats.Bytes))
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return func() {
|
|
cancel()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// removeFile удаляет содержимое файла и возвращает его размер. Отсутствие файла
|
|
// на диске не считается ошибкой: запись всё равно нужно убрать.
|
|
func (r *Runner) removeFile(file store.File) (int64, error) {
|
|
path, err := r.storagePath(file.StoragePath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return 0, err
|
|
}
|
|
return info.Size(), nil
|
|
}
|
|
|
|
// storagePath проверяет, что путь лежит внутри каталога данных (AGENT.md 9.2).
|
|
// Файлы на диске лежат плоско в `<DataDir>/files`, поэтому проверка сводится
|
|
// к сравнению с этим каталогом.
|
|
func (r *Runner) storagePath(stored string) (string, error) {
|
|
root := filepath.Join(r.options.DataDir, "files")
|
|
clean := filepath.Clean(stored)
|
|
relative, err := filepath.Rel(root, clean)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.HasPrefix(relative, "..") || filepath.IsAbs(relative) {
|
|
return "", fmt.Errorf("path %q is outside %q", clean, root)
|
|
}
|
|
return clean, nil
|
|
}
|