Files
bare/internal/api/api.go
T
mayatnikovandClaude Opus 5 63a7a1ef52 Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство,
очередь per-device с фан-аутом без эха отправителю, POST /api/messages
с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди,
ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении,
лимит 30 сообщений в минуту.

Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD,
sync.js как единственный писатель в IndexedDB, ACK строго после записи,
список чатов, экран чата по эталону, разделители дат и «новые»,
pending и failed с повтором, полоса «нет соединения».

ADR-033: у неотправленного есть текст отказа — clock_skew стало видно.
ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает
открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть
до стирания своего присланного, чего «удалить у всех не существует» не допускает.
ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel):
две вкладки отбирали поток друг у друга и оставались без живой доставки.
ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, —
иначе потерянный ответ давал у собеседника два сообщения вместо одного.

Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка,
копия на второе устройство, очередь офлайн-устройству, ACK, подмена from
игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста
в базе и WAL ноль вхождений.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-22 18:18:04 +03:00

276 lines
11 KiB
Go

// Package api собирает маршруты и общие для всех ответов правила:
// заголовки безопасности (ADR-021), проверку Origin, лимит тела запроса,
// лог в stdout.
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/store"
)
// MaxBody — предел тела запроса, 32 КиБ (ADR-021).
const MaxBody = 32 << 10
// maxLogPath — сколько байт пути попадает в строку лога.
const maxLogPath = 256
// csp — политика из ADR-021. HSTS ставит nginx, здесь его нет.
const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
// server — общее для обработчиков: настройки, база, открытые потоки
// событий, лимиты, куда писать журнал.
type server struct {
cfg *config.Config
st *store.Store
hub *hub.Hub
msgs *buckets
logw io.Writer
}
// Handler — обработчик всех маршрутов и живые SSE-потоки за ним.
type Handler struct {
http.Handler
hub *hub.Hub
}
// Close закрывает открытые потоки событий. Без него остановка сервера
// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004).
func (h *Handler) Close() { h.hub.CloseAll() }
// New собирает обработчик: /api/, /healthz, всё остальное — статика.
// logw — куда писать строки запросов и причины отказов; nil отключает лог.
func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler {
s := &server{
cfg: cfg,
st: st,
hub: hub.New(),
msgs: newBuckets(messagesPerMinute, messagesBurst),
logw: logw,
}
fail := auth.Fail{Error: Error, Internal: s.internal}
// Сессия проверяется на всех непубличных маршрутах (docs/protocol.md).
private := auth.Require(st, fail)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.HandleFunc("GET /api/config", s.config)
mux.HandleFunc("GET /api/kdf", s.kdf)
mux.HandleFunc("POST /api/register", s.register)
mux.HandleFunc("POST /api/login", s.login)
mux.Handle("GET /api/me", private(http.HandlerFunc(s.me)))
mux.Handle("DELETE /api/me", private(http.HandlerFunc(s.deleteMe)))
mux.Handle("POST /api/logout", private(http.HandlerFunc(s.logout)))
mux.Handle("POST /api/password", private(http.HandlerFunc(s.password)))
mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user)))
mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice)))
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice)))
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact)))
mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact)))
mux.Handle("GET /api/events", private(http.HandlerFunc(s.events)))
mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage)))
mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack)))
// Всё прочее под /api/ — 404, включая неподдерживаемый метод известного
// пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт
// запросам к /api/ уходить в обработчик статики.
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) })
mux.Handle("/", static)
return &Handler{
Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))),
hub: s.hub,
}
}
func healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
io.WriteString(w, "ok")
}
// errorBody — единственная форма ошибки в протоколе.
type errorBody struct {
Error string `json:"error"`
Field string `json:"field,omitempty"`
Message string `json:"message"`
}
// Error пишет ошибку в форме протокола: {"error": код, "message": текст}.
// Единственное место, где эта форма собирается, — коды берутся из
// перечня в docs/protocol.md.
func Error(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorBody{Error: code, Message: message})
}
// Invalid — 400 invalid с полем, на котором остановилась валидация.
func Invalid(w http.ResponseWriter, field, message string) {
writeJSON(w, http.StatusBadRequest, errorBody{Error: "invalid", Field: field, Message: message})
}
// NotFound — ответ на неизвестный путь и на неподдерживаемый метод
// известного пути (ADR-026).
func NotFound(w http.ResponseWriter) {
Error(w, http.StatusNotFound, "not_found", "такого пути нет")
}
// internal — 500: сбой на нашей стороне. Клиенту уходит только код,
// причина — в журнал сервера (ADR-027).
func (s *server) internal(w http.ResponseWriter, r *http.Request, err error) {
s.report(r, err)
Error(w, http.StatusInternalServerError, "internal", "сервер не справился, попробуйте позже")
}
// report кладёт причину в журнал. Ник в строку не попадает: пишется
// шаблон маршрута (docs/deploy.md, «Логи»).
func (s *server) report(r *http.Request, err error) {
if s.logw == nil {
return
}
fmt.Fprintf(s.logw, "%s %s %s ошибка: %v\n",
time.Now().Format(time.RFC3339), r.Method, logTarget(r), err)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func noContent(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
// decode разбирает тело запроса в v. Ответ об ошибке уже написан,
// если вернулось false.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
var large *http.MaxBytesError
if errors.As(err, &large) {
Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ")
return false
}
Error(w, http.StatusBadRequest, "bad_json", "тело запроса — не json")
return false
}
return true
}
// headers ставит заголовки безопасности на каждый ответ, включая ошибки.
func headers(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
// limitBody отрезает тело на 32 КиБ. Заявленный размер сверх лимита
// отклоняется сразу, незаявленный — обрывается при чтении.
func limitBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > MaxBody {
Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ")
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, MaxBody)
}
next.ServeHTTP(w, r)
})
}
// logging пишет время, метод, путь, статус и длительность.
// Ни IP, ни ник, ни query в лог не попадают.
func logging(out io.Writer, next http.Handler) http.Handler {
if out == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
fmt.Fprintf(out, "%s %s %s %d %s\n",
start.Format(time.RFC3339),
r.Method,
logTarget(r),
rec.status,
time.Since(start).Round(time.Microsecond))
})
}
// logTarget — что пишется в журнал вместо пути. Для маршрутов /api/ —
// шаблон, а не путь: ник из GET /api/users/{nick} в журнал попадать
// не должен (docs/deploy.md, «Логи»). Для статики — сам путь: там
// пользовательских данных нет, а знать, какой файл не нашёлся, полезно.
// Шаблон известен после маршрутизации, поэтому вызывается после ответа.
//
// Шаблона может не быть вовсе: проверка Origin и предел тела отвечают
// раньше маршрутизации. Тогда для /api/ пишется голое "/api/" — путь
// с ником в журнал не уходит и в этом случае.
func logTarget(r *http.Request) string {
if p := patternPath(r.Pattern); strings.HasPrefix(p, "/api/") {
return p
}
if strings.HasPrefix(r.URL.Path, "/api/") {
return "/api/"
}
return logPath(r.URL)
}
// patternPath отрезает от шаблона метод: "GET /api/users/{nick}" → путь.
func patternPath(pattern string) string {
if i := strings.LastIndexByte(pattern, ' '); i >= 0 {
return pattern[i+1:]
}
return pattern
}
// logPath даёт путь в percent-форме: перевод строки, escape-последовательности
// и прочие управляющие байты в журнал не попадают — иначе любой запрос
// подделывал бы строки в journald. Длинный путь обрезается.
func logPath(u *url.URL) string {
p := u.EscapedPath()
if len(p) > maxLogPath {
return p[:maxLogPath] + "…"
}
return p
}
type recorder struct {
http.ResponseWriter
status int
}
func (r *recorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
// Unwrap отдаёт исходный ResponseWriter: через него http.ResponseController
// добирается до Flush и Hijack. Без этого SSE (docs/protocol.md, «События»)
// буферизовался бы — лог стоит самым внешним слоем и виден всем маршрутам.
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }