Этап 1: аккаунты — argon2id, сессии, ключевой блоб, вход и регистрация
Сервер: миграция 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
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user