Сервер: миграция 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
150 lines
5.8 KiB
Go
150 lines
5.8 KiB
Go
// Package store — SQLite: открытие базы, миграции, запросы (ADR-020).
|
||
//
|
||
// В базе только шифротексты и метаданные: истории сообщений, плейнтекста
|
||
// и паролей здесь нет и не будет (docs/storage.md).
|
||
package store
|
||
|
||
import (
|
||
"database/sql"
|
||
"embed"
|
||
"errors"
|
||
"fmt"
|
||
"io/fs"
|
||
"net/url"
|
||
"sort"
|
||
"strings"
|
||
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
//go:embed migrations
|
||
var migrations embed.FS
|
||
|
||
// Ошибки, которые обработчикам нужно различать. Остальное — внутренние сбои.
|
||
var (
|
||
// ErrNotFound — строки нет.
|
||
ErrNotFound = errors.New("store: не найдено")
|
||
// ErrNickTaken — ник уже занят.
|
||
ErrNickTaken = errors.New("store: ник занят")
|
||
)
|
||
|
||
// Store — база и её единственное соединение на запись.
|
||
type Store struct {
|
||
db *sql.DB
|
||
applied []string
|
||
}
|
||
|
||
// Applied — миграции, применённые при этом открытии базы. Пусто, если
|
||
// схема уже была свежей.
|
||
func (s *Store) Applied() []string { return s.applied }
|
||
|
||
// Open открывает базу, ставит режим из docs/storage.md и применяет миграции.
|
||
func Open(path string) (*Store, error) {
|
||
db, err := sql.Open("sqlite", dsn(path))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("store: открытие %s: %w", path, err)
|
||
}
|
||
|
||
// Одно соединение на всю базу. modernc.org/sqlite, как и любой SQLite,
|
||
// допускает ровно одного писателя; при нескольких соединениях запись
|
||
// упирается в SQLITE_BUSY, а busy_timeout лечит это ожиданием, а не
|
||
// корректностью — «database is locked» всё равно возможен на upgrade
|
||
// транзакции из read в write. Пул из одной штуки убирает класс ошибок
|
||
// целиком: очередь выстраивает database/sql. Цена — чтения ждут запись;
|
||
// для чата на десятки человек это незаметно. SSE держит соединение
|
||
// с клиентом, а не с базой, поэтому поток событий пул не занимает.
|
||
db.SetMaxOpenConns(1)
|
||
db.SetMaxIdleConns(1)
|
||
|
||
if err := db.Ping(); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("store: %s недоступна: %w", path, err)
|
||
}
|
||
s := &Store{db: db}
|
||
if err := s.migrate(); err != nil {
|
||
db.Close()
|
||
return nil, err
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// Close закрывает базу.
|
||
func (s *Store) Close() error { return s.db.Close() }
|
||
|
||
// dsn собирает строку соединения с режимом из docs/storage.md.
|
||
// Прагмы применяются к каждому новому соединению; journal_mode=WAL
|
||
// хранится в самом файле, остальные — свойство соединения.
|
||
func dsn(path string) string {
|
||
q := url.Values{}
|
||
q.Add("_pragma", "journal_mode(WAL)")
|
||
q.Add("_pragma", "synchronous(NORMAL)")
|
||
q.Add("_pragma", "foreign_keys(1)")
|
||
q.Add("_pragma", "busy_timeout(5000)")
|
||
return "file:" + (&url.URL{Path: path}).EscapedPath() + "?" + q.Encode()
|
||
}
|
||
|
||
// migrate применяет недостающие миграции по порядку, каждую в своей
|
||
// транзакции. Версия схемы — PRAGMA user_version, она же номер последней
|
||
// применённой миграции. Откатов нет: ошибку правит следующая миграция.
|
||
func (s *Store) migrate() error {
|
||
files, err := migrationFiles()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var version int
|
||
if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||
return fmt.Errorf("store: чтение user_version: %w", err)
|
||
}
|
||
if version > len(files) {
|
||
return fmt.Errorf("store: база версии %d новее бинаря (%d миграций)", version, len(files))
|
||
}
|
||
for i := version; i < len(files); i++ {
|
||
name := files[i]
|
||
body, err := fs.ReadFile(migrations, "migrations/"+name)
|
||
if err != nil {
|
||
return fmt.Errorf("store: чтение миграции %s: %w", name, err)
|
||
}
|
||
if err := s.applyMigration(i+1, name, string(body)); err != nil {
|
||
return err
|
||
}
|
||
s.applied = append(s.applied, name)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) applyMigration(version int, name, body string) error {
|
||
tx, err := s.db.Begin()
|
||
if err != nil {
|
||
return fmt.Errorf("store: миграция %s: %w", name, err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if _, err := tx.Exec(body); err != nil {
|
||
return fmt.Errorf("store: миграция %s: %w", name, err)
|
||
}
|
||
// user_version не принимает подстановку, поэтому число подставляется
|
||
// форматированием; version — счётчик миграций, не пользовательские данные.
|
||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
|
||
return fmt.Errorf("store: миграция %s: %w", name, err)
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// migrationFiles отдаёт имена миграций в порядке номеров.
|
||
func migrationFiles() ([]string, error) {
|
||
entries, err := fs.ReadDir(migrations, "migrations")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("store: каталог миграций: %w", err)
|
||
}
|
||
names := make([]string, 0, len(entries))
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||
continue
|
||
}
|
||
names = append(names, e.Name())
|
||
}
|
||
// Имена вида NNN_*.sql: лексикографический порядок совпадает с числовым.
|
||
sort.Strings(names)
|
||
return names, nil
|
||
}
|