diff --git a/.env.example b/.env.example index 9e94876..2bf8465 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ MAX_UPLOAD_SIZE=26214400 # байт, по умолчанию 25 SHUTDOWN_TIMEOUT_SECONDS=20 # graceful shutdown DB_MAINTENANCE_INTERVAL_SECONDS=21600 # PRAGMA optimize раз в 6 часов +# --- чистка данных (Фаза 5) --- +RETENTION_INTERVAL_SECONDS=3600 # как часто запускать чистку (час) +RETENTION_AUDIT_DAYS=180 # сколько дней хранить журнал аудита (0 — всегда) +RETENTION_ORPHAN_HOURS=24 # через сколько часов удалять файлы без ссылок (0 — никогда) + # --- секреты (генерируются установщиком, никогда не коммитить) --- SESSION_PEPPER= # HMAC-pepper перед Argon2id MASTER_KEY= # AES-256-GCM для PII (hex, 32 байта) diff --git a/cmd/glchat/cli.go b/cmd/glchat/cli.go index f69c68d..033f856 100644 --- a/cmd/glchat/cli.go +++ b/cmd/glchat/cli.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "log/slog" "os" "time" @@ -14,6 +15,7 @@ import ( "glchat/internal/bootstrap" "glchat/internal/config" "glchat/internal/database" + "glchat/internal/retention" "glchat/internal/store" ) @@ -31,6 +33,10 @@ func runCLI(args []string) int { return cliTOTPSetup(args[1:]) case "totp-reset": return cliTOTPReset(args[1:]) + case "cleanup": + return cliCleanup(args[1:]) + case "reindex": + return cliReindex(args[1:]) default: fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0]) return 2 @@ -264,3 +270,43 @@ func cliSetInstanceAdmin(command string, args []string) int { return nil }) } + +// cliCleanup запускает чистку вручную: сессии, аудит и файлы без ссылок +// (AGENT.md 6.4, 10.7). +func cliCleanup(args []string) int { + flags := flag.NewFlagSet("cleanup", flag.ContinueOnError) + auditDays := flags.Int("audit-days", -1, "сколько дней хранить журнал аудита (по умолчанию из конфигурации)") + orphanHours := flags.Int("orphan-hours", -1, "через сколько часов удалять файлы без ссылок") + if err := flags.Parse(args); err != nil { + return 2 + } + return withServices(func(ctx context.Context, st *store.Store, _ *auth.Service, cfg config.Config) error { + options := retention.Options{ + DataDir: cfg.DataDir, + AuditDays: cfg.RetainAuditDays, + OrphanHours: cfg.RetainOrphanHours, + Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})), + } + if *auditDays >= 0 { + options.AuditDays = *auditDays + } + if *orphanHours >= 0 { + options.OrphanHours = *orphanHours + } + stats, err := retention.New(st, options).RunOnce(ctx) + fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d (%d байт)\n", + stats.Sessions, stats.Audit, stats.Files, stats.Bytes) + return err + }) +} + +// cliReindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7). +func cliReindex(_ []string) int { + return withServices(func(ctx context.Context, st *store.Store, _ *auth.Service, _ config.Config) error { + if err := st.RebuildSearchIndex(ctx); err != nil { + return err + } + fmt.Println("индекс поиска пересобран") + return nil + }) +} diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index 4911940..07204b2 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -18,6 +18,7 @@ import ( "glchat/internal/database" "glchat/internal/gateway" "glchat/internal/permissions" + "glchat/internal/retention" "glchat/internal/server" "glchat/internal/source" "glchat/internal/store" @@ -37,11 +38,12 @@ func main() { os.Exit(healthcheck()) } // Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password, - // make-admin, remove-admin, totp-setup, totp-reset. Остальные команды - // реализует shell-обёртка glchat. + // make-admin, remove-admin, totp-setup, totp-reset, cleanup, reindex. + // Остальные команды реализует shell-обёртка glchat. if len(os.Args) > 1 { switch os.Args[1] { - case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset": + case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset", + "cleanup", "reindex": os.Exit(runCLI(os.Args[1:])) } } @@ -163,6 +165,16 @@ func run() error { srv := server.New(cfg, db, logger, server.Deps{ Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator, }) + // Чистка данных инстанса: сессии, аудит и файлы без ссылок (AGENT.md 6.4). + cleaner := retention.New(st, retention.Options{ + DataDir: cfg.DataDir, + AuditDays: cfg.RetainAuditDays, + OrphanHours: cfg.RetainOrphanHours, + Logger: logger, + }) + stopRetention := cleaner.Run(ctx, cfg.RetainInterval) + defer stopRetention() + // Сторож голосовых состояний: убирает «призрачные» записи после сбоев. srv.StartVoiceWatchdog(context.Background(), 0) errCh := make(chan error, 1) diff --git a/deploy/glchat-cli.sh b/deploy/glchat-cli.sh index f5cab57..6b9b4d6 100755 --- a/deploy/glchat-cli.sh +++ b/deploy/glchat-cli.sh @@ -26,7 +26,8 @@ glchat — управление инстансом glchat fail2ban-status статус jails и забаненных IP glchat profile [tier] показать/применить профиль производительности glchat security-check аудит: SSH, fail2ban, порты, лимиты - glchat reindex перестроить FTS-индекс (фаза 2) + glchat reindex пересобрать индекс поиска (FTS5) + glchat cleanup чистка: сессии, аудит, файлы без ссылок glchat change-domain сменить домен glchat uninstall [--keep-data] удалить стек glchat version версия CLI и приложения @@ -151,8 +152,15 @@ cmd_version() { } cmd_reindex() { - echo "glchat reindex появится в фазе 2 (FTS5)." >&2 - exit 1 + require_stack + ${COMPOSE} exec -T app glchat reindex +} + +# cmd_cleanup запускает чистку данных вручную: те же правила, что у +# периодического обслуживания (AGENT.md 6.4). +cmd_cleanup() { + require_stack + ${COMPOSE} exec -T app glchat cleanup "$@" } main() { @@ -174,6 +182,7 @@ main() { profile) cmd_profile "$@" ;; security-check) cmd_security_check "$@" ;; reindex) cmd_reindex "$@" ;; + cleanup) cmd_cleanup "$@" ;; change-domain) cmd_change_domain "$@" ;; uninstall) cmd_uninstall "$@" ;; version) cmd_version "$@" ;; diff --git a/internal/config/config.go b/internal/config/config.go index 7555da4..9fabb0b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/retention/retention.go b/internal/retention/retention.go new file mode 100644 index 0000000..8d8fdd7 --- /dev/null +++ b/internal/retention/retention.go @@ -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 +} diff --git a/internal/retention/retention_test.go b/internal/retention/retention_test.go new file mode 100644 index 0000000..538bf5f --- /dev/null +++ b/internal/retention/retention_test.go @@ -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) + } +} diff --git a/internal/store/files.go b/internal/store/files.go index f29b699..4ba924d 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -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 } diff --git a/internal/store/retention.go b/internal/store/retention.go new file mode 100644 index 0000000..57344d7 --- /dev/null +++ b/internal/store/retention.go @@ -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 +} diff --git a/web/index.html b/web/index.html index dba6d0b..201f235 100644 --- a/web/index.html +++ b/web/index.html @@ -5,8 +5,13 @@ + + + glchat + +
diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 0000000..cecdad6 Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 0000000..8ac8835 Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/maskable-512.png b/web/public/icons/maskable-512.png new file mode 100644 index 0000000..eaa8cc1 Binary files /dev/null and b/web/public/icons/maskable-512.png differ diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..cf036f1 --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,29 @@ +{ + "name": "glchat", + "short_name": "glchat", + "description": "glchat — self-hosted платформа общения: серверы, комнаты, голос и файлы", + "lang": "ru", + "dir": "ltr", + "start_url": "/app", + "scope": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], + "orientation": "any", + "background_color": "#1a1b1e", + "theme_color": "#1a1b1e", + "categories": ["social", "communication"], + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { + "src": "/icons/maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { "name": "Друзья", "url": "/app/friends" }, + { "name": "Настройки", "url": "/settings/account/profile" } + ] +} diff --git a/web/public/offline.html b/web/public/offline.html new file mode 100644 index 0000000..75ab26a --- /dev/null +++ b/web/public/offline.html @@ -0,0 +1,60 @@ + + + + + + + glchat — нет сети + + + +
+

Нет соединения

+

+ glchat не смог связаться с сервером. Проверьте сеть — как только связь вернётся, приложение + откроется само. +

+ +
+ + diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..8ab42cb --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,91 @@ +/* + * Сервис-воркер glchat (AGENT.md, Фаза 5): офлайн-заглушка и кэш статики. + * + * Правила простые и безопасные: + * - навигация — сеть, при ошибке офлайн-страница (данные всегда свежие); + * - собранная статика `/assets/*` (хешированные имена) — из кэша; + * - API, шлюз и файлы никогда не кэшируются: это приватные данные. + */ +const VERSION = 'glchat-v1'; +const OFFLINE_URL = '/offline.html'; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(VERSION).then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest'])), + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))), + ) + .then(() => self.clients.claim()), + ); +}); + +/** Приватные запросы: их нельзя ни кэшировать, ни отдавать из кэша. */ +function isPrivate(url) { + return ( + url.pathname.startsWith('/api/') || + url.pathname.startsWith('/files/') || + url.pathname.startsWith('/gateway') || + url.pathname.startsWith('/rtc') + ); +} + +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') { + return; + } + const url = new URL(request.url); + if (url.origin !== self.location.origin || isPrivate(url)) { + return; + } + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request).catch(() => + caches.match(request).then((cached) => cached ?? caches.match(OFFLINE_URL)), + ), + ); + return; + } + + if (url.pathname.startsWith('/assets/')) { + event.respondWith( + caches.match(request).then( + (cached) => + cached ?? + fetch(request).then((response) => { + if (response.ok) { + const copy = response.clone(); + void caches.open(VERSION).then((cache) => cache.put(request, copy)); + } + return response; + }), + ), + ); + return; + } + + // Иконки, манифест и прочая статика: сеть, при недоступности — кэш. + event.respondWith( + fetch(request) + .then((response) => { + if ( + response.ok && + (url.pathname.startsWith('/icons/') || url.pathname === '/manifest.webmanifest') + ) { + const copy = response.clone(); + void caches.open(VERSION).then((cache) => cache.put(request, copy)); + } + return response; + }) + .catch(() => caches.match(request).then((cached) => cached ?? Response.error())), + ); +}); diff --git a/web/src/lib/serviceWorker.ts b/web/src/lib/serviceWorker.ts new file mode 100644 index 0000000..07fdf43 --- /dev/null +++ b/web/src/lib/serviceWorker.ts @@ -0,0 +1,23 @@ +/** + * Регистрация сервис-воркера (AGENT.md, Фаза 5): офлайн-заглушка и кэш + * собранной статики. В dev-режиме воркер не нужен — он мешал бы горячей + * перезагрузке, поэтому регистрируем только production-сборку. + */ + +/** registerServiceWorker включает офлайн-режим. Возвращает признак регистрации. */ +export function registerServiceWorker(): boolean { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + if (!('serviceWorker' in navigator)) { + return false; + } + if (!import.meta.env.PROD) { + return false; + } + // Регистрация не должна ломать запуск приложения: ошибки только в консоль. + void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch((error: unknown) => { + console.warn('service worker registration failed', error); + }); + return true; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 2d5242c..b06dfa1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,11 +4,13 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { App } from '@/App'; import { getQueryClient } from '@/lib/queryClient'; +import { registerServiceWorker } from '@/lib/serviceWorker'; import { applyTheme, useUiStore } from '@/stores/ui'; import '@/i18n'; import '@/index.css'; applyTheme(useUiStore.getState().theme); +registerServiceWorker(); const container = document.getElementById('root'); if (!container) { diff --git a/web/tests/serviceWorker.test.ts b/web/tests/serviceWorker.test.ts new file mode 100644 index 0000000..d54bb00 --- /dev/null +++ b/web/tests/serviceWorker.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { registerServiceWorker } from '@/lib/serviceWorker'; + +/** + * PWA (Фаза 5): сервис-воркер регистрируется только в production-сборке и + * только там, где браузер его поддерживает. + */ + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('регистрация сервис-воркера', () => { + it('в тестовом окружении (не production) ничего не делает', () => { + const register = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { serviceWorker: { register } }); + + expect(registerServiceWorker()).toBe(false); + expect(register).not.toHaveBeenCalled(); + }); +}); + +describe('манифест PWA', () => { + it('содержит обязательные поля и иконки нужных размеров', async () => { + const manifest = await import('../public/manifest.webmanifest?raw'); + const parsed = JSON.parse(manifest.default) as { + name: string; + start_url: string; + display: string; + icons: { sizes: string; type: string; purpose?: string }[]; + }; + expect(parsed.name).toBe('glchat'); + expect(parsed.start_url).toBe('/app'); + expect(parsed.display).toBe('standalone'); + const sizes = parsed.icons.map((icon) => icon.sizes); + expect(sizes).toContain('192x192'); + expect(sizes).toContain('512x512'); + expect(parsed.icons.every((icon) => icon.type === 'image/png')).toBe(true); + expect(parsed.icons.some((icon) => icon.purpose === 'maskable')).toBe(true); + }); +});