Files
bare/internal/store/store.go
T
mayatnikovandClaude Opus 5 597c55301c Этап 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
2026-08-22 14:06:07 +03:00

150 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}