feat(ops): чистка данных, reindex и PWA с офлайн-режимом

- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и
  файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается
  только если на него не ссылается ничего (вложения, аватары, оформление
  сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения)
- обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS;
  команды glchat cleanup и glchat reindex делают то же вручную
- PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер
  (кэш только /assets/*, приватные запросы не кэшируются)
- тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты
  остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
This commit is contained in:
2026-09-21 19:42:12 +03:00
parent 72537e9ff9
commit 5afcfee744
19 changed files with 813 additions and 12 deletions
+8
View File
@@ -52,6 +52,11 @@ type Config struct {
BuildDate string
ShutdownTTL time.Duration
Maintenance time.Duration
// Retention (AGENT.md 6.4, Фаза 5): сколько хранить сессии, записи аудита и
// файлы без ссылок. Период проверки — RetainInterval.
RetainAuditDays int
RetainOrphanHours int
RetainInterval time.Duration
}
func Load() (Config, error) {
@@ -86,6 +91,9 @@ func Load() (Config, error) {
BuildDate: env("APP_BUILD_DATE", "unknown"),
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
RetainAuditDays: envInt("RETENTION_AUDIT_DAYS", 180),
RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24),
RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second,
}
if cfg.FilesDomain == "" {
cfg.FilesDomain = "files." + cfg.Domain
+183
View File
@@ -0,0 +1,183 @@
// 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
// Bytes — сколько освободили на диске.
Bytes int64
}
// 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
}
}
stats2 := stats
if len(errs) > 0 {
return stats2, errors.Join(errs...)
}
return stats2, nil
}
// 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("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).
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
}
+211
View File
@@ -0,0 +1,211 @@
package retention
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"glchat/internal/database"
"glchat/internal/store"
)
// Тесты чистки (AGENT.md 6.4): истёкшие сессии, старый аудит и файлы без
// ссылок удаляются, а используемые файлы остаются.
func newRetentionStore(t *testing.T) (*store.Store, *database.DB, string) {
t.Helper()
dir := t.TempDir()
db, err := database.Open(context.Background(), database.Options{
Path: filepath.Join(dir, "glchat.db"), ReadPool: 1, Migrate: true,
})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return store.New(db), db, dir
}
func TestCleanupRemovesOrphansAndKeepsReferences(t *testing.T) {
st, db, dataDir := newRetentionStore(t)
ctx := context.Background()
filesDir := filepath.Join(dataDir, "files")
if err := os.MkdirAll(filesDir, 0o700); err != nil {
t.Fatalf("prepare files dir: %v", err)
}
writeFile := func(name string) string {
path := filepath.Join(filesDir, name)
if err := os.WriteFile(path, []byte("данные"), 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
return path
}
user, err := st.CreateUser(ctx, store.CreateUserParams{
Username: "cleanup", EmailEnc: "enc", EmailIndex: "cleanup@example.com",
PasswordHash: "x", Locale: "ru",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
// Аватар пользователя: на файл есть ссылка, он должен остаться.
avatarPath := writeFile("avatar.bin")
avatar, err := st.CreateFile(ctx, store.CreateFileParams{
UploaderID: user.ID, Filename: "avatar.png", ContentType: "image/png",
SizeBytes: 6, StoragePath: avatarPath, SHA256: "a", Purpose: "avatar",
})
if err != nil {
t.Fatalf("create avatar file: %v", err)
}
if _, err := st.UpdateUser(ctx, user.ID, store.UpdateUserParams{AvatarFileID: &avatar.ID}); err != nil {
t.Fatalf("attach avatar: %v", err)
}
// Вложение сообщения: ссылка в attachments_json.
channelPath := writeFile("attachment.bin")
attachment, err := st.CreateFile(ctx, store.CreateFileParams{
UploaderID: user.ID, Filename: "note.txt", ContentType: "text/plain",
SizeBytes: 6, StoragePath: channelPath, SHA256: "b",
})
if err != nil {
t.Fatalf("create attachment: %v", err)
}
guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Чистка", OwnerID: user.ID})
if err != nil {
t.Fatalf("create guild: %v", err)
}
channel, err := st.CreateChannel(ctx, store.CreateChannelParams{
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
message, err := st.CreateMessage(ctx, store.CreateMessageParams{
ChannelID: channel.ID, AuthorID: user.ID, Content: "файл",
Attachments: []store.Attachment{{FileID: attachment.ID, Filename: "note.txt"}},
})
if err != nil {
t.Fatalf("create message: %v", err)
}
if err := st.AttachFileToMessage(ctx, attachment.ID, message.ID); err != nil {
t.Fatalf("attach file: %v", err)
}
// Сирота: файл без ссылок.
orphanPath := writeFile("orphan.bin")
orphan, err := st.CreateFile(ctx, store.CreateFileParams{
UploaderID: user.ID, Filename: "orphan.bin", ContentType: "application/octet-stream",
SizeBytes: 6, StoragePath: orphanPath, SHA256: "c",
})
if err != nil {
t.Fatalf("create orphan: %v", err)
}
// Сессии: одна истёкшая, одна действующая.
expired, err := st.CreateSession(ctx, user.ID, store.CreateSessionParams{
TokenHash: "expired", UserAgent: "test", IP: "127.0.0.1", TTL: time.Hour,
})
if err != nil {
t.Fatalf("create expired session: %v", err)
}
// Просрочиваем сессию напрямую: TTL меньше часа не принимается хранилищем.
if _, err := db.Writer.ExecContext(ctx,
`UPDATE sessions SET expires_at = ? WHERE token_hash = 'expired'`,
"2020-01-01T00:00:00.000Z"); err != nil {
t.Fatalf("expire session: %v", err)
}
active, err := st.CreateSession(ctx, user.ID, store.CreateSessionParams{
TokenHash: "active", UserAgent: "test", IP: "127.0.0.1", TTL: time.Hour,
})
if err != nil {
t.Fatalf("create active session: %v", err)
}
// Запись аудита: старую чистим.
if err := st.RecordAudit(ctx, store.AuditParams{
GuildID: &guild.ID, ActorID: &user.ID, Action: "test.old",
}); err != nil {
t.Fatalf("record audit: %v", err)
}
// Сироту состариваем: свежие файлы не удаляются (OrphanHours > 0).
if _, err := db.Writer.ExecContext(ctx,
`UPDATE files SET created_at = '2020-01-01T00:00:00.000Z' WHERE id = ?`,
int64(orphan.ID)); err != nil {
t.Fatalf("age orphan file: %v", err)
}
runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24})
if _, err := runner.RunOnce(ctx); err != nil {
t.Fatalf("cleanup: %v", err)
}
// Истёкшая сессия удалена, действующая осталась.
if _, err := st.GetSessionByTokenHash(ctx, "expired"); err == nil {
t.Fatal("истёкшая сессия не удалена")
}
if _, err := st.GetSessionByTokenHash(ctx, "active"); err != nil {
t.Fatalf("действующая сессия удалена: %v", err)
}
_ = expired
_ = active
// Файл-сирота удалён с диска и из базы, используемые файлы на месте.
if _, err := os.Stat(orphanPath); !os.IsNotExist(err) {
t.Fatalf("файл-сирота остался на диске: %v", err)
}
if _, err := st.GetFile(ctx, orphan.ID); err == nil {
t.Fatal("запись о сироте осталась в базе")
}
for _, kept := range []uint64{avatar.ID, attachment.ID} {
if _, err := st.GetFile(ctx, kept); err != nil {
t.Fatalf("используемый файл %d удалён: %v", kept, err)
}
}
if _, err := os.Stat(avatarPath); err != nil {
t.Fatalf("аватар удалён с диска: %v", err)
}
if _, err := os.Stat(channelPath); err != nil {
t.Fatalf("вложение удалено с диска: %v", err)
}
}
func TestCleanupKeepsFreshOrphansAndRecentAudit(t *testing.T) {
st, _, dataDir := newRetentionStore(t)
ctx := context.Background()
filesDir := filepath.Join(dataDir, "files")
if err := os.MkdirAll(filesDir, 0o700); err != nil {
t.Fatalf("prepare files dir: %v", err)
}
path := filepath.Join(filesDir, "fresh.bin")
if err := os.WriteFile(path, []byte("данные"), 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
user, err := st.CreateUser(ctx, store.CreateUserParams{
Username: "fresh", EmailEnc: "enc", EmailIndex: "fresh@example.com",
PasswordHash: "x", Locale: "ru",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
if _, err := st.CreateFile(ctx, store.CreateFileParams{
UploaderID: user.ID, Filename: "fresh.bin", ContentType: "text/plain",
SizeBytes: 6, StoragePath: path, SHA256: "d",
}); err != nil {
t.Fatalf("create file: %v", err)
}
// Свежий файл (OrphanHours = 24) не трогаем.
runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24})
stats, err := runner.RunOnce(ctx)
if err != nil {
t.Fatalf("cleanup: %v", err)
}
if stats.Files != 0 {
t.Fatalf("удалено файлов: %d, ожидалось 0", stats.Files)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("свежий файл удалён: %v", err)
}
}
+26 -6
View File
@@ -96,16 +96,36 @@ func (s *Store) DeleteFile(ctx context.Context, id uint64) error {
return nil
}
// ListOrphanFiles возвращает файлы без сообщения старше указанного времени:
// их удаляет обслуживание (AGENT.md 7.7).
// orphanFilesQuery — файлы, на которые никто не ссылается: вложения сообщений,
// аватары и баннеры пользователей, оформление сервера, роли и комнаты, эмодзи,
// звуки, вебхуки и приглашения (AGENT.md 6.4, 7.7).
const orphanFilesQuery = `
SELECT ` + fileColumns + `
FROM files f
WHERE f.created_at <= ?
AND f.message_id IS NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar_file_id = f.id OR u.banner_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guilds g WHERE g.icon_file_id = f.id OR g.banner_file_id = f.id OR g.splash_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM channels c WHERE c.background_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM invites i WHERE i.background_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guild_cosmetics gc WHERE gc.file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM webhooks w WHERE w.avatar_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guild_emojis ge WHERE ge.file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guild_sounds gs WHERE gs.file_id = f.id)
AND NOT EXISTS (
SELECT 1 FROM messages m, json_each(m.attachments_json) a
WHERE json_extract(a.value, '$.file_id') = f.id
)
ORDER BY f.id
LIMIT ?`
// ListOrphanFiles возвращает файлы без ссылок старше указанного времени: их
// удаляет обслуживание (AGENT.md 6.4, 7.7).
func (s *Store) ListOrphanFiles(ctx context.Context, olderThan time.Time, limit int) ([]File, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+fileColumns+` FROM files
WHERE message_id IS NULL AND created_at < ? ORDER BY id LIMIT ?`,
s.Timestamp(olderThan), limit)
rows, err := s.reader.QueryContext(ctx, orphanFilesQuery, s.Timestamp(olderThan), limit)
if err != nil {
return nil, err
}
+55
View File
@@ -0,0 +1,55 @@
package store
import (
"context"
"time"
)
// Retention-запросы (AGENT.md 6.4, Фаза 5): чистка сессий, аудита и файлов без
// ссылок. Все методы идемпотентны и работают порциями, чтобы не держать
// блокировку писателя надолго.
// DeleteSessionsBefore удаляет сессии, истёкшие раньше указанного времени.
func (s *Store) DeleteSessionsBefore(ctx context.Context, before time.Time) (int64, error) {
result, err := s.writer.ExecContext(ctx,
`DELETE FROM sessions WHERE expires_at <= ?`, s.Timestamp(before))
if err != nil {
return 0, mapError(err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return affected, nil
}
// DeleteAuditBefore удаляет записи аудита старше указанного времени.
func (s *Store) DeleteAuditBefore(ctx context.Context, before time.Time) (int64, error) {
result, err := s.writer.ExecContext(ctx,
`DELETE FROM audit_log WHERE created_at <= ?`, s.Timestamp(before))
if err != nil {
return 0, mapError(err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return affected, nil
}
// DeleteFileRecord удаляет запись о файле (содержимое удаляет вызывающий).
func (s *Store) DeleteFileRecord(ctx context.Context, id uint64) error {
if _, err := s.writer.ExecContext(ctx, `DELETE FROM files WHERE id = ?`, int64(id)); err != nil {
return mapError(err)
}
return nil
}
// RebuildSearchIndex пересобирает индекс полнотекстового поиска (FTS5).
func (s *Store) RebuildSearchIndex(ctx context.Context) error {
if _, err := s.writer.ExecContext(ctx,
`INSERT INTO messages_fts (messages_fts) VALUES ('rebuild')`); err != nil {
return mapError(err)
}
return nil
}