From 5afcfee744d8ad76512bce1769cc40bf23117b4c Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 19:42:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D1=87=D0=B8=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85,=20reindex=20?= =?UTF-8?q?=D0=B8=20PWA=20=D1=81=20=D0=BE=D1=84=D0=BB=D0=B0=D0=B9=D0=BD-?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается только если на него не ссылается ничего (вложения, аватары, оформление сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения) - обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS; команды glchat cleanup и glchat reindex делают то же вручную - PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер (кэш только /assets/*, приватные запросы не кэшируются) - тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера --- .env.example | 5 + cmd/glchat/cli.go | 46 ++++++ cmd/glchat/main.go | 18 ++- deploy/glchat-cli.sh | 15 +- internal/config/config.go | 8 + internal/retention/retention.go | 183 +++++++++++++++++++++++ internal/retention/retention_test.go | 211 +++++++++++++++++++++++++++ internal/store/files.go | 32 +++- internal/store/retention.go | 55 +++++++ web/index.html | 5 + web/public/icons/icon-192.png | Bin 0 -> 1464 bytes web/public/icons/icon-512.png | Bin 0 -> 6602 bytes web/public/icons/maskable-512.png | Bin 0 -> 4701 bytes web/public/manifest.webmanifest | 29 ++++ web/public/offline.html | 60 ++++++++ web/public/sw.js | 91 ++++++++++++ web/src/lib/serviceWorker.ts | 23 +++ web/src/main.tsx | 2 + web/tests/serviceWorker.test.ts | 42 ++++++ 19 files changed, 813 insertions(+), 12 deletions(-) create mode 100644 internal/retention/retention.go create mode 100644 internal/retention/retention_test.go create mode 100644 internal/store/retention.go create mode 100644 web/public/icons/icon-192.png create mode 100644 web/public/icons/icon-512.png create mode 100644 web/public/icons/maskable-512.png create mode 100644 web/public/manifest.webmanifest create mode 100644 web/public/offline.html create mode 100644 web/public/sw.js create mode 100644 web/src/lib/serviceWorker.ts create mode 100644 web/tests/serviceWorker.test.ts 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 0000000000000000000000000000000000000000..cecdad6e0d72db7c653f3a172187a819868300fa GIT binary patch literal 1464 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 literal 0 HcmV?d00001 diff --git a/web/public/icons/maskable-512.png b/web/public/icons/maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..eaa8cc1ce8f05b61c598aaf9088c366104cc9eb1 GIT binary patch literal 4701 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 + + + + + + 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); + }); +});