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:
@@ -26,6 +26,11 @@ MAX_UPLOAD_SIZE=26214400 # байт, по умолчанию 25
|
|||||||
SHUTDOWN_TIMEOUT_SECONDS=20 # graceful shutdown
|
SHUTDOWN_TIMEOUT_SECONDS=20 # graceful shutdown
|
||||||
DB_MAINTENANCE_INTERVAL_SECONDS=21600 # PRAGMA optimize раз в 6 часов
|
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
|
SESSION_PEPPER= # HMAC-pepper перед Argon2id
|
||||||
MASTER_KEY= # AES-256-GCM для PII (hex, 32 байта)
|
MASTER_KEY= # AES-256-GCM для PII (hex, 32 байта)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"glchat/internal/bootstrap"
|
"glchat/internal/bootstrap"
|
||||||
"glchat/internal/config"
|
"glchat/internal/config"
|
||||||
"glchat/internal/database"
|
"glchat/internal/database"
|
||||||
|
"glchat/internal/retention"
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ func runCLI(args []string) int {
|
|||||||
return cliTOTPSetup(args[1:])
|
return cliTOTPSetup(args[1:])
|
||||||
case "totp-reset":
|
case "totp-reset":
|
||||||
return cliTOTPReset(args[1:])
|
return cliTOTPReset(args[1:])
|
||||||
|
case "cleanup":
|
||||||
|
return cliCleanup(args[1:])
|
||||||
|
case "reindex":
|
||||||
|
return cliReindex(args[1:])
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
|
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
|
||||||
return 2
|
return 2
|
||||||
@@ -264,3 +270,43 @@ func cliSetInstanceAdmin(command string, args []string) int {
|
|||||||
return nil
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+15
-3
@@ -18,6 +18,7 @@ import (
|
|||||||
"glchat/internal/database"
|
"glchat/internal/database"
|
||||||
"glchat/internal/gateway"
|
"glchat/internal/gateway"
|
||||||
"glchat/internal/permissions"
|
"glchat/internal/permissions"
|
||||||
|
"glchat/internal/retention"
|
||||||
"glchat/internal/server"
|
"glchat/internal/server"
|
||||||
"glchat/internal/source"
|
"glchat/internal/source"
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
@@ -37,11 +38,12 @@ func main() {
|
|||||||
os.Exit(healthcheck())
|
os.Exit(healthcheck())
|
||||||
}
|
}
|
||||||
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
|
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
|
||||||
// make-admin, remove-admin, totp-setup, totp-reset. Остальные команды
|
// make-admin, remove-admin, totp-setup, totp-reset, cleanup, reindex.
|
||||||
// реализует shell-обёртка glchat.
|
// Остальные команды реализует shell-обёртка glchat.
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
switch 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:]))
|
os.Exit(runCLI(os.Args[1:]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +165,16 @@ func run() error {
|
|||||||
srv := server.New(cfg, db, logger, server.Deps{
|
srv := server.New(cfg, db, logger, server.Deps{
|
||||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
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)
|
srv.StartVoiceWatchdog(context.Background(), 0)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|||||||
+12
-3
@@ -26,7 +26,8 @@ glchat — управление инстансом
|
|||||||
glchat fail2ban-status статус jails и забаненных IP
|
glchat fail2ban-status статус jails и забаненных IP
|
||||||
glchat profile [tier] показать/применить профиль производительности
|
glchat profile [tier] показать/применить профиль производительности
|
||||||
glchat security-check аудит: SSH, fail2ban, порты, лимиты
|
glchat security-check аудит: SSH, fail2ban, порты, лимиты
|
||||||
glchat reindex перестроить FTS-индекс (фаза 2)
|
glchat reindex пересобрать индекс поиска (FTS5)
|
||||||
|
glchat cleanup чистка: сессии, аудит, файлы без ссылок
|
||||||
glchat change-domain <domain> сменить домен
|
glchat change-domain <domain> сменить домен
|
||||||
glchat uninstall [--keep-data] удалить стек
|
glchat uninstall [--keep-data] удалить стек
|
||||||
glchat version версия CLI и приложения
|
glchat version версия CLI и приложения
|
||||||
@@ -151,8 +152,15 @@ cmd_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_reindex() {
|
cmd_reindex() {
|
||||||
echo "glchat reindex появится в фазе 2 (FTS5)." >&2
|
require_stack
|
||||||
exit 1
|
${COMPOSE} exec -T app glchat reindex
|
||||||
|
}
|
||||||
|
|
||||||
|
# cmd_cleanup запускает чистку данных вручную: те же правила, что у
|
||||||
|
# периодического обслуживания (AGENT.md 6.4).
|
||||||
|
cmd_cleanup() {
|
||||||
|
require_stack
|
||||||
|
${COMPOSE} exec -T app glchat cleanup "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
@@ -174,6 +182,7 @@ main() {
|
|||||||
profile) cmd_profile "$@" ;;
|
profile) cmd_profile "$@" ;;
|
||||||
security-check) cmd_security_check "$@" ;;
|
security-check) cmd_security_check "$@" ;;
|
||||||
reindex) cmd_reindex "$@" ;;
|
reindex) cmd_reindex "$@" ;;
|
||||||
|
cleanup) cmd_cleanup "$@" ;;
|
||||||
change-domain) cmd_change_domain "$@" ;;
|
change-domain) cmd_change_domain "$@" ;;
|
||||||
uninstall) cmd_uninstall "$@" ;;
|
uninstall) cmd_uninstall "$@" ;;
|
||||||
version) cmd_version "$@" ;;
|
version) cmd_version "$@" ;;
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Config struct {
|
|||||||
BuildDate string
|
BuildDate string
|
||||||
ShutdownTTL time.Duration
|
ShutdownTTL time.Duration
|
||||||
Maintenance time.Duration
|
Maintenance time.Duration
|
||||||
|
// Retention (AGENT.md 6.4, Фаза 5): сколько хранить сессии, записи аудита и
|
||||||
|
// файлы без ссылок. Период проверки — RetainInterval.
|
||||||
|
RetainAuditDays int
|
||||||
|
RetainOrphanHours int
|
||||||
|
RetainInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -86,6 +91,9 @@ func Load() (Config, error) {
|
|||||||
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
||||||
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
|
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
|
||||||
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * 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 == "" {
|
if cfg.FilesDomain == "" {
|
||||||
cfg.FilesDomain = "files." + cfg.Domain
|
cfg.FilesDomain = "files." + cfg.Domain
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -96,16 +96,36 @@ func (s *Store) DeleteFile(ctx context.Context, id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListOrphanFiles возвращает файлы без сообщения старше указанного времени:
|
// orphanFilesQuery — файлы, на которые никто не ссылается: вложения сообщений,
|
||||||
// их удаляет обслуживание (AGENT.md 7.7).
|
// аватары и баннеры пользователей, оформление сервера, роли и комнаты, эмодзи,
|
||||||
|
// звуки, вебхуки и приглашения (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) {
|
func (s *Store) ListOrphanFiles(ctx context.Context, olderThan time.Time, limit int) ([]File, error) {
|
||||||
if limit <= 0 || limit > 500 {
|
if limit <= 0 || limit > 500 {
|
||||||
limit = 100
|
limit = 100
|
||||||
}
|
}
|
||||||
rows, err := s.reader.QueryContext(ctx, `
|
rows, err := s.reader.QueryContext(ctx, orphanFilesQuery, s.Timestamp(olderThan), limit)
|
||||||
SELECT `+fileColumns+` FROM files
|
|
||||||
WHERE message_id IS NULL AND created_at < ? ORDER BY id LIMIT ?`,
|
|
||||||
s.Timestamp(olderThan), limit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,8 +5,13 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<meta name="description" content="glchat — self-hosted платформа общения" />
|
<meta name="description" content="glchat — self-hosted платформа общения" />
|
||||||
|
<meta name="theme-color" content="#1a1b1e" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="glchat" />
|
||||||
<title>glchat</title>
|
<title>glchat</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<title>glchat — нет сети</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1a1b1e;
|
||||||
|
color: #f2f3f5;
|
||||||
|
font:
|
||||||
|
16px/1.5 system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 28rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
color: #a3a6ad;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #3a3c42;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #26282d;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Нет соединения</h1>
|
||||||
|
<p>
|
||||||
|
glchat не смог связаться с сервером. Проверьте сеть — как только связь вернётся, приложение
|
||||||
|
откроется само.
|
||||||
|
</p>
|
||||||
|
<button type="button" onclick="location.reload()">Повторить</button>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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())),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { App } from '@/App';
|
import { App } from '@/App';
|
||||||
import { getQueryClient } from '@/lib/queryClient';
|
import { getQueryClient } from '@/lib/queryClient';
|
||||||
|
import { registerServiceWorker } from '@/lib/serviceWorker';
|
||||||
import { applyTheme, useUiStore } from '@/stores/ui';
|
import { applyTheme, useUiStore } from '@/stores/ui';
|
||||||
import '@/i18n';
|
import '@/i18n';
|
||||||
import '@/index.css';
|
import '@/index.css';
|
||||||
|
|
||||||
applyTheme(useUiStore.getState().theme);
|
applyTheme(useUiStore.getState().theme);
|
||||||
|
registerServiceWorker();
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user