Сервер: миграция 001 со всей схемой storage.md, store на modernc.org/sqlite (WAL, foreign_keys, один писатель), фоновая чистка раз в час, argon2id с параметрами ADR-021 и сверкой constant-time, сессии по SHA-256 токена, cookie bare_session, глобальная проверка Origin, девять эндпоинтов аккаунта. Ник в журнал не попадает: для /api/ пишется шаблон маршрута. Клиент: crypto.js по crypto.md построчно — мастер из пароля, два независимых ключа из мастера, ключевой блоб с ником в AAD, отпечаток от сырой точки; db.js со всеми хранилищами версии 1; экран входа и регистрации, настройки со сменой пароля, выходом и удалением аккаунта. Пароль не покидает клиент: проверено на боевом сервере — ни пароля, ни priv.d ни в одном теле запроса, вход на втором устройстве даёт тот же отпечаток. ADR-027: код internal для 500, причина только в журнале. ADR-028: тексты состояний клиента сведены в ui.md. ADR-029: вход под другим ником стирает историю только после подтверждения. ADR-030: верхняя граница итераций KDF, проверка границ на обеих сторонах. ADR-031: служебный выход перед повторным входом не заканчивает сеанс. ADR-032: каталог состояния 0700, файлы базы 0600. Прямые зависимости: modernc.org/sqlite, golang.org/x/crypto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
70 lines
2.5 KiB
Go
70 lines
2.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Сроки хранения из docs/storage.md.
|
|
const (
|
|
queueTTL = 30 * 24 * time.Hour // недоставленное сообщение
|
|
deviceTTL = 90 * 24 * time.Hour // молчащее устройство
|
|
roomKeysKept = 2 // ключей комнаты на комнату
|
|
|
|
cleanupEvery = time.Hour
|
|
)
|
|
|
|
// RunCleanup чистит базу раз в час, пока не отменён ctx. Первый проход —
|
|
// сразу при старте: сервер, который перезапускают чаще раза в час, иначе
|
|
// не чистился бы никогда. Ошибку отдаёт report; nil — молчать.
|
|
func (s *Store) RunCleanup(ctx context.Context, report func(error)) {
|
|
tick := time.NewTicker(cleanupEvery)
|
|
defer tick.Stop()
|
|
for {
|
|
if err := s.Cleanup(ctx, time.Now()); err != nil && report != nil && ctx.Err() == nil {
|
|
report(err)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-tick.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cleanup выполняет один проход чистки (docs/storage.md, «Фоновая чистка»).
|
|
func (s *Store) Cleanup(ctx context.Context, now time.Time) error {
|
|
ms := now.UnixMilli()
|
|
steps := []struct {
|
|
what string
|
|
query string
|
|
args []any
|
|
}{
|
|
{"очередь", `DELETE FROM queue WHERE created_at < ?`, []any{ms - queueTTL.Milliseconds()}},
|
|
{"устройства", `DELETE FROM devices WHERE last_seen < ?`, []any{ms - deviceTTL.Milliseconds()}},
|
|
{"сессии", `DELETE FROM sessions WHERE expires_at < ?`, []any{ms}},
|
|
// Ключи комнат: у каждой комнаты остаются два последних key_id.
|
|
// Возраст key_id — время его самой поздней записи: ключ раздаётся
|
|
// участникам не одной строкой, а по строке на участника.
|
|
{"ключи комнат", `
|
|
DELETE FROM room_keys WHERE (room_id, key_id) NOT IN (
|
|
SELECT room_id, key_id FROM (
|
|
SELECT room_id, key_id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY room_id
|
|
ORDER BY MAX(created_at) DESC, key_id DESC
|
|
) AS rn
|
|
FROM room_keys
|
|
GROUP BY room_id, key_id
|
|
) WHERE rn <= ?
|
|
)`, []any{roomKeysKept}},
|
|
}
|
|
for _, step := range steps {
|
|
if _, err := s.db.ExecContext(ctx, step.query, step.args...); err != nil {
|
|
return fmt.Errorf("store: чистка (%s): %w", step.what, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|