feat(ops): проверка бэкапа и уборка файлов без записи
- glchat verify-backup: расшифровка age, integrity_check, foreign_key_check, схема и состав данных, сверка с манифестом; живые данные не трогает - чистка удаляет файлы на диске, у которых не осталось записи в базе (так оставались вложения удалённых серверов), и пустые подкаталоги; работает через os.Root, чтобы символическая ссылка не вывела за каталог файлов - удаление сервера теперь чистит и содержимое файлов, а не только записи - install.sh пишет значения .env с пробелами в кавычках: файл подключается через `set -a && . .env`, иначе имя главного сервера выполнялось как команда - тесты: Go на уборку файлов без записи (свежие не трогаются, пустые каталоги удаляются)
This commit is contained in:
+2
-2
@@ -294,8 +294,8 @@ func cliCleanup(args []string) int {
|
|||||||
options.OrphanHours = *orphanHours
|
options.OrphanHours = *orphanHours
|
||||||
}
|
}
|
||||||
stats, err := retention.New(st, options).RunOnce(ctx)
|
stats, err := retention.New(st, options).RunOnce(ctx)
|
||||||
fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d (%d байт)\n",
|
fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d (%d байт)\n",
|
||||||
stats.Sessions, stats.Audit, stats.Files, stats.Bytes)
|
stats.Sessions, stats.Audit, stats.Files, stats.Unknown, stats.Bytes)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ glchat — управление инстансом
|
|||||||
glchat restart [service] перезапуск стека или сервиса
|
glchat restart [service] перезапуск стека или сервиса
|
||||||
glchat backup бэкап БД и файлов (age-шифрование)
|
glchat backup бэкап БД и файлов (age-шифрование)
|
||||||
glchat restore <файл> восстановление из бэкапа
|
glchat restore <файл> восстановление из бэкапа
|
||||||
|
glchat verify-backup <файл> проверить бэкап, не трогая данные
|
||||||
glchat update [--channel X] обновление (бэкап → pull → up → smoke)
|
glchat update [--channel X] обновление (бэкап → pull → up → smoke)
|
||||||
glchat reset-password <email> сброс пароля пользователя (фаза 1)
|
glchat reset-password <email> сброс пароля пользователя (фаза 1)
|
||||||
glchat make-admin <email> выдать права инстанс-админа (фаза 1)
|
glchat make-admin <email> выдать права инстанс-админа (фаза 1)
|
||||||
@@ -151,6 +152,17 @@ cmd_version() {
|
|||||||
[ -f "$ENV_FILE" ] && grep -E '^(APP_VERSION|IMAGE_TAG|DOMAIN|PROFILE)=' "$ENV_FILE" || true
|
[ -f "$ENV_FILE" ] && grep -E '^(APP_VERSION|IMAGE_TAG|DOMAIN|PROFILE)=' "$ENV_FILE" || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# cmd_verify_backup проверяет архив бэкапа: расшифровка, целостность базы и
|
||||||
|
# состав данных (AGENT.md 10.8).
|
||||||
|
cmd_verify_backup() {
|
||||||
|
require_stack
|
||||||
|
[ -n "${1:-}" ] || {
|
||||||
|
echo "использование: glchat verify-backup <файл.tar.gz[.age]>" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR}" bash "${GLCHAT_ETC_DIR}/verify-backup.sh" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_reindex() {
|
cmd_reindex() {
|
||||||
require_stack
|
require_stack
|
||||||
${COMPOSE} exec -T app glchat reindex
|
${COMPOSE} exec -T app glchat reindex
|
||||||
@@ -182,6 +194,7 @@ main() {
|
|||||||
profile) cmd_profile "$@" ;;
|
profile) cmd_profile "$@" ;;
|
||||||
security-check) cmd_security_check "$@" ;;
|
security-check) cmd_security_check "$@" ;;
|
||||||
reindex) cmd_reindex "$@" ;;
|
reindex) cmd_reindex "$@" ;;
|
||||||
|
verify-backup) cmd_verify_backup "$@" ;;
|
||||||
cleanup) cmd_cleanup "$@" ;;
|
cleanup) cmd_cleanup "$@" ;;
|
||||||
change-domain) cmd_change_domain "$@" ;;
|
change-domain) cmd_change_domain "$@" ;;
|
||||||
uninstall) cmd_uninstall "$@" ;;
|
uninstall) cmd_uninstall "$@" ;;
|
||||||
|
|||||||
+4
-2
@@ -814,8 +814,10 @@ ENVEOF
|
|||||||
cat >"$pairs_file" <<PAIREOF
|
cat >"$pairs_file" <<PAIREOF
|
||||||
DOMAIN=${DOMAIN}
|
DOMAIN=${DOMAIN}
|
||||||
FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}
|
FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}
|
||||||
INSTANCE_NAME=${INSTANCE_NAME}
|
# Значения с пробелами — в кавычках: .env подключается через `set -a && . .env`,
|
||||||
MAIN_GUILD_NAME=${MAIN_GUILD_NAME}
|
# иначе оболочка попытается выполнить имя главного сервера как команду.
|
||||||
|
INSTANCE_NAME="${INSTANCE_NAME}"
|
||||||
|
MAIN_GUILD_NAME="${MAIN_GUILD_NAME}"
|
||||||
PROFILE=${PROFILE_RESOLVED}
|
PROFILE=${PROFILE_RESOLVED}
|
||||||
PROXY_MODE=${PROXY_MODE}
|
PROXY_MODE=${PROXY_MODE}
|
||||||
IMAGE_TAG=${IMAGE_TAG}
|
IMAGE_TAG=${IMAGE_TAG}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# glchat: проверка бэкапа без восстановления (AGENT.md 10.8).
|
||||||
|
#
|
||||||
|
# Расшифровывает архив, распаковывает во временный каталог, проверяет целостность
|
||||||
|
# базы (PRAGMA integrity_check), схему и состав данных, сверяет манифест.
|
||||||
|
# Живые данные не трогает: можно запускать на рабочем инстансе.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
|
||||||
|
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}"
|
||||||
|
ENV_FILE="${GLCHAT_ETC_DIR}/.env"
|
||||||
|
|
||||||
|
log() { printf '[verify] %s\n' "$*"; }
|
||||||
|
die() {
|
||||||
|
printf '[verify] ошибка: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ARCHIVE="${1:-}"
|
||||||
|
[ -n "$ARCHIVE" ] || die "использование: glchat verify-backup <файл.tar.gz[.age]>"
|
||||||
|
[ -f "$ARCHIVE" ] || die "файл не найден: $ARCHIVE"
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a && . "$ENV_FILE" && set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() { rm -rf "$WORK_DIR"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# 1. Расшифровка: age-архив требует приватный ключ.
|
||||||
|
case "$ARCHIVE" in
|
||||||
|
*.age)
|
||||||
|
command -v age >/dev/null 2>&1 || die "age не установлен"
|
||||||
|
KEY_FILE="${AGE_KEY_FILE:-${GLCHAT_DATA_DIR}/backups/age-key.txt}"
|
||||||
|
[ -f "$KEY_FILE" ] || die "не найден приватный age-ключ (${KEY_FILE}); укажите AGE_KEY_FILE"
|
||||||
|
log "расшифровываем $(basename "$ARCHIVE")"
|
||||||
|
age -d -i "$KEY_FILE" -o "${WORK_DIR}/backup.tar.gz" "$ARCHIVE" ||
|
||||||
|
die "не удалось расшифровать бэкап (проверьте ключ)"
|
||||||
|
;;
|
||||||
|
*) cp "$ARCHIVE" "${WORK_DIR}/backup.tar.gz" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 2. Распаковка и обязательные файлы.
|
||||||
|
log "распаковываем"
|
||||||
|
tar -xzf "${WORK_DIR}/backup.tar.gz" -C "$WORK_DIR" || die "архив повреждён"
|
||||||
|
[ -f "${WORK_DIR}/glchat.db" ] || die "в архиве нет glchat.db — это не бэкап glchat"
|
||||||
|
|
||||||
|
stats() { printf '%s' "$1"; }
|
||||||
|
DB="${WORK_DIR}/glchat.db"
|
||||||
|
|
||||||
|
# 3. Целостность базы: sqlite3 есть на хосте или в контейнере приложения.
|
||||||
|
sqlite_query() {
|
||||||
|
local query="$1"
|
||||||
|
if command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
sqlite3 -readonly "$DB" "$query"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q '^glchat-app$'; then
|
||||||
|
docker cp "$DB" glchat-app:/tmp/verify-backup.db >/dev/null
|
||||||
|
docker exec glchat-app sh -c "sqlite3 -readonly /tmp/verify-backup.db \"$query\""
|
||||||
|
docker exec glchat-app rm -f /tmp/verify-backup.db >/dev/null 2>&1 || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
die "нет sqlite3 ни на хосте, ни в контейнере glchat-app"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "проверяем целостность базы"
|
||||||
|
integrity="$(sqlite_query 'PRAGMA integrity_check;')"
|
||||||
|
[ "$integrity" = "ok" ] || die "integrity_check вернул: ${integrity}"
|
||||||
|
log "integrity_check: ok"
|
||||||
|
|
||||||
|
foreign_keys="$(sqlite_query 'PRAGMA foreign_key_check;' || true)"
|
||||||
|
[ -z "$foreign_keys" ] || die "нарушены внешние ключи: ${foreign_keys}"
|
||||||
|
log "foreign_key_check: чисто"
|
||||||
|
|
||||||
|
schema_version="$(sqlite_query 'SELECT MAX(version_id) FROM goose_db_version;' 2>/dev/null || echo '?')"
|
||||||
|
users="$(sqlite_query 'SELECT COUNT(*) FROM users WHERE deleted_at IS NULL;')"
|
||||||
|
guilds="$(sqlite_query 'SELECT COUNT(*) FROM guilds;')"
|
||||||
|
messages="$(sqlite_query 'SELECT COUNT(*) FROM messages;')"
|
||||||
|
files="$(sqlite_query 'SELECT COUNT(*) FROM files;')"
|
||||||
|
|
||||||
|
# 4. Манифест: подсказывает, с какого инстанса снят бэкап.
|
||||||
|
manifest="нет"
|
||||||
|
if [ -f "${WORK_DIR}/MANIFEST.txt" ]; then
|
||||||
|
manifest="$(tr '\n' ' ' <"${WORK_DIR}/MANIFEST.txt")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
files_on_disk=0
|
||||||
|
if [ -d "${WORK_DIR}/files" ]; then
|
||||||
|
files_on_disk="$(find "${WORK_DIR}/files" -type f | wc -l | tr -d ' ')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n=== проверка бэкапа ===\n'
|
||||||
|
printf ' архив: %s\n' "$(basename "$ARCHIVE")"
|
||||||
|
printf ' схема: %s\n' "$schema_version"
|
||||||
|
printf ' пользователи: %s\n' "$users"
|
||||||
|
printf ' серверы: %s\n' "$guilds"
|
||||||
|
printf ' сообщения: %s\n' "$messages"
|
||||||
|
printf ' файлов в БД: %s (на диске: %s)\n' "$files" "$files_on_disk"
|
||||||
|
printf ' манифест: %s\n' "$manifest"
|
||||||
|
|
||||||
|
# 5. Файлы: если в базе есть записи о файлах, каталог не должен быть пустым.
|
||||||
|
if [ "${files:-0}" -gt 0 ] && [ "${files_on_disk:-0}" -eq 0 ]; then
|
||||||
|
die "в базе ${files} файлов, а в архиве нет каталога files — бэкап неполный"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "бэкап пригоден к восстановлению"
|
||||||
@@ -36,6 +36,8 @@ type Stats struct {
|
|||||||
Sessions int64
|
Sessions int64
|
||||||
Audit int64
|
Audit int64
|
||||||
Files int64
|
Files int64
|
||||||
|
// Unknown — файлы на диске, у которых не осталось записи в базе.
|
||||||
|
Unknown int64
|
||||||
// Bytes — сколько освободили на диске.
|
// Bytes — сколько освободили на диске.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
@@ -104,6 +106,18 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Файлы на диске без записи в базе: так остаются вложения удалённых
|
||||||
|
// серверов — запись уходит каскадом, а содержимое лежит мусором.
|
||||||
|
if r.options.OrphanHours > 0 {
|
||||||
|
cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour)
|
||||||
|
removed, bytes, err := r.pruneUnknownFiles(ctx, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("unknown files: %w", err))
|
||||||
|
}
|
||||||
|
stats.Unknown = removed
|
||||||
|
stats.Bytes += bytes
|
||||||
|
}
|
||||||
|
|
||||||
stats2 := stats
|
stats2 := stats
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
return stats2, errors.Join(errs...)
|
return stats2, errors.Join(errs...)
|
||||||
@@ -111,6 +125,89 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
|||||||
return stats2, nil
|
return stats2, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pruneUnknownFiles удаляет с диска файлы, которых нет в базе, если они старше
|
||||||
|
// cutoff. Возвращает число файлов и освобождённые байты.
|
||||||
|
func (r *Runner) pruneUnknownFiles(ctx context.Context, cutoff time.Time) (int64, int64, error) {
|
||||||
|
known, err := r.store.FileStoragePaths(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
// Каталог файлов: пути в базе абсолютные, как их пишет загрузка.
|
||||||
|
root := filepath.Join(r.options.DataDir, "files")
|
||||||
|
// os.Root ограничивает работу каталогом файлов: подменённая символическая
|
||||||
|
// ссылка не выведет удаление за его пределы (AGENT.md 9.2).
|
||||||
|
dir, err := os.OpenRoot(root)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = dir.Close() }()
|
||||||
|
|
||||||
|
var (
|
||||||
|
removed int64
|
||||||
|
bytes int64
|
||||||
|
errs []error
|
||||||
|
)
|
||||||
|
walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.IsDir() || known[path] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.ModTime().After(cutoff) {
|
||||||
|
// Свежий файл мог ещё не получить запись: не трогаем.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := dir.Remove(relative); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
errs = append(errs, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
bytes += info.Size()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
errs = append(errs, walkErr)
|
||||||
|
}
|
||||||
|
// Пустые каталоги после уборки не нужны: их создаёт раскладка по префиксам.
|
||||||
|
if removed > 0 {
|
||||||
|
pruneEmptyDirs(dir, root)
|
||||||
|
}
|
||||||
|
return removed, bytes, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneEmptyDirs удаляет пустые подкаталоги, начиная с самых глубоких.
|
||||||
|
// Ошибки игнорируются: каталог мог исчезнуть или быть непустым — это норма.
|
||||||
|
func pruneEmptyDirs(dir *os.Root, root string) {
|
||||||
|
dirs := make([]string, 0, 16)
|
||||||
|
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err == nil && entry.IsDir() && path != root {
|
||||||
|
dirs = append(dirs, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for i := len(dirs) - 1; i >= 0; i-- {
|
||||||
|
relative, err := filepath.Rel(root, dirs[i])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = dir.Remove(relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run запускает чистку по расписанию; возвращает функцию остановки.
|
// Run запускает чистку по расписанию; возвращает функцию остановки.
|
||||||
func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
@@ -137,6 +234,7 @@ func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
|||||||
slog.Int64("sessions", stats.Sessions),
|
slog.Int64("sessions", stats.Sessions),
|
||||||
slog.Int64("audit", stats.Audit),
|
slog.Int64("audit", stats.Audit),
|
||||||
slog.Int64("files", stats.Files),
|
slog.Int64("files", stats.Files),
|
||||||
|
slog.Int64("unknown_files", stats.Unknown),
|
||||||
slog.Int64("bytes", stats.Bytes))
|
slog.Int64("bytes", stats.Bytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +267,8 @@ func (r *Runner) removeFile(file store.File) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// storagePath проверяет, что путь лежит внутри каталога данных (AGENT.md 9.2).
|
// storagePath проверяет, что путь лежит внутри каталога данных (AGENT.md 9.2).
|
||||||
|
// Файлы на диске лежат плоско в `<DataDir>/files`, поэтому проверка сводится
|
||||||
|
// к сравнению с этим каталогом.
|
||||||
func (r *Runner) storagePath(stored string) (string, error) {
|
func (r *Runner) storagePath(stored string) (string, error) {
|
||||||
root := filepath.Join(r.options.DataDir, "files")
|
root := filepath.Join(r.options.DataDir, "files")
|
||||||
clean := filepath.Clean(stored)
|
clean := filepath.Clean(stored)
|
||||||
|
|||||||
@@ -209,3 +209,49 @@ func TestCleanupKeepsFreshOrphansAndRecentAudit(t *testing.T) {
|
|||||||
t.Fatalf("свежий файл удалён: %v", err)
|
t.Fatalf("свежий файл удалён: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanupRemovesFilesWithoutRecords(t *testing.T) {
|
||||||
|
st, _, dataDir := newRetentionStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
filesDir := filepath.Join(dataDir, "files")
|
||||||
|
nested := filepath.Join(filesDir, "ab")
|
||||||
|
if err := os.MkdirAll(nested, 0o700); err != nil {
|
||||||
|
t.Fatalf("prepare files dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл на диске, записи о котором нет: так остаются вложения удалённых
|
||||||
|
// серверов (запись уходит каскадом, содержимое — нет).
|
||||||
|
orphan := filepath.Join(nested, "orphan.bin")
|
||||||
|
if err := os.WriteFile(orphan, []byte("мусор"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write orphan: %v", err)
|
||||||
|
}
|
||||||
|
old := time.Now().Add(-48 * time.Hour)
|
||||||
|
if err := os.Chtimes(orphan, old, old); err != nil {
|
||||||
|
t.Fatalf("touch orphan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежий файл без записи не трогаем: он может ещё не получить запись.
|
||||||
|
fresh := filepath.Join(filesDir, "fresh.bin")
|
||||||
|
if err := os.WriteFile(fresh, []byte("новый"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fresh: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Unknown != 1 {
|
||||||
|
t.Fatalf("удалено файлов без записи: %d, ожидался 1", stats.Unknown)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("файл без записи остался: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(fresh); err != nil {
|
||||||
|
t.Fatalf("свежий файл удалён: %v", err)
|
||||||
|
}
|
||||||
|
// Пустой подкаталог после уборки удалён.
|
||||||
|
if _, err := os.Stat(nested); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("пустой каталог остался: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -1686,9 +1687,22 @@ func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uin
|
|||||||
if guild.IsMain {
|
if guild.IsMain {
|
||||||
return humaErrorStatus(http.StatusForbidden, "guild.main_protected", "main guild cannot be deleted")
|
return humaErrorStatus(http.StatusForbidden, "guild.main_protected", "main guild cannot be deleted")
|
||||||
}
|
}
|
||||||
|
// Пути файлов собираем до удаления: записи уходят каскадом, а содержимое
|
||||||
|
// на диске осталось бы мусором (AGENT.md 6.4).
|
||||||
|
storagePaths, err := s.store.GuildStoragePaths(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "failed to list guild files",
|
||||||
|
slog.String("guild_id", formatSnowflake(guildID)), slog.Any("error", err))
|
||||||
|
}
|
||||||
if err := s.store.DeleteGuild(ctx, guildID); err != nil {
|
if err := s.store.DeleteGuild(ctx, guildID); err != nil {
|
||||||
return humaError(err)
|
return humaError(err)
|
||||||
}
|
}
|
||||||
|
for _, path := range storagePaths {
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
s.logger.WarnContext(ctx, "failed to remove guild file",
|
||||||
|
slog.String("path", path), slog.Any("error", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.recordAudit(ctx, actor, 0, "guild.delete", "guild", &guildID, "")
|
s.recordAudit(ctx, actor, 0, "guild.delete", "guild", &guildID, "")
|
||||||
if s.gateway != nil {
|
if s.gateway != nil {
|
||||||
|
|||||||
@@ -164,3 +164,44 @@ func scanFile(scanner interface{ Scan(...any) error }) (*File, error) {
|
|||||||
file.CreatedAt = parseTimestamp(createdAt)
|
file.CreatedAt = parseTimestamp(createdAt)
|
||||||
return &file, nil
|
return &file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileStoragePaths возвращает пути всех файлов, известных базе: по ним
|
||||||
|
// обслуживание понимает, какие файлы на диске остались без записи.
|
||||||
|
func (s *Store) FileStoragePaths(ctx context.Context) (map[string]bool, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `SELECT storage_path FROM files WHERE storage_path <> ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
paths := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var path string
|
||||||
|
if err := rows.Scan(&path); err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
paths[path] = true
|
||||||
|
}
|
||||||
|
return paths, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuildStoragePaths возвращает пути файлов сервера: их нужно удалить с диска
|
||||||
|
// перед удалением сервера, иначе содержимое останется мусором (AGENT.md 6.4).
|
||||||
|
func (s *Store) GuildStoragePaths(ctx context.Context, guildID uint64) ([]string, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
|
`SELECT storage_path FROM files WHERE guild_id = ? AND storage_path <> ''`, int64(guildID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
paths := make([]string, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
var path string
|
||||||
|
if err := rows.Scan(&path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return paths, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user