хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+192 -61
View File
@@ -1,17 +1,17 @@
package http
import (
"fmt"
"net/http"
"net/netip"
"strings"
"sync"
"time"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Своё правило ограничителя частоты под корнем приложения.
//
// Заводится потому, что правило хранилища настроено на **его** корень и наших
// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с
// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая —
// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле.
// Бюджет ограничителя частоты под корнем приложения.
//
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
@@ -22,71 +22,202 @@ const (
appRateWindowSec = 60
)
// ApplyAppRateLimit ставит правило ограничителя на корень приложения.
//
// Правило приводится к настройке при каждом подъёме, как и настройки провайдера:
// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и
// правило, положенное однажды, не пережило бы ни правки числа, ни чистого
// каталога данных.
func ApplyAppRateLimit(app core.App) error {
settings := app.Settings()
// staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта
// счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда
// спрашивающий кладёт по строке на каждый свой адрес.
const staleBudgetAge = 10 * time.Minute
rule := core.RateLimitRule{
Label: AppRoot + "/",
MaxRequests: appRateMaxRequests,
Duration: appRateWindowSec,
// RateLimit — свой ограничитель частоты под корнем приложения.
//
// Бюджет считается по адресу спрашивающего скользящим окном: столько-то запросов
// за столько-то секунд. Отказ уходит **нашей** формой тела — слой отвечает
// доменной ошибкой, а не телом библиотеки, и второй формы отказа на адресах
// приложения не появляется.
//
// **Адрес спрашивающего берётся из заголовка пересылки — и только тогда, когда
// соединение пришло с адреса из объявленного перечня доверенных.** Во всяком
// другом случае адресом считается адрес пира, а пришедший заголовок на ключ
// бюджета не влияет ничем.
//
// **Цепочка пересылки читается справа налево до первого недоверенного адреса.**
// Значением слева распоряжается тот, кто шлёт запрос: прокси заголовок
// дописывает, а не заменяет, и левое значение цепочки он берёт из присланного.
//
// Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси,
// и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на
// весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка —
// верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого
// он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
// запрос, и меняет он его на каждом запросе.
//
// **Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
// Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
// вообще. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в
// адресе соединения не виден вовсе.
//
// Слой стоит **перед** узнаванием: узнавание читает базу, а на новом имени ещё и
// пишет в неё; выполненное раньше ограничителя, оно работало бы на запросах,
// которые тот уже отверг, и поток отвергнутых обращений заводил бы учётные
// записи, которые потом не убираются ничем.
func RateLimit(trusted []netip.Prefix) func(http.Handler) http.Handler {
limiter := newRateLimiter(appRateMaxRequests, appRateWindowSec*time.Second)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.allow(clientAddress(r, trusted)) {
fail(w, contract.ErrTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// clientAddress — тот, кого ограничивают.
func clientAddress(r *http.Request, trusted []netip.Prefix) string {
peer, ok := peerAddress(r.RemoteAddr)
if !ok {
// Адрес пира не разобран — ключ берётся строкой как есть: общий бюджет
// лучше отсутствующего.
return r.RemoteAddr
}
replaced := false
for i, existing := range settings.RateLimits.Rules {
if existing.Label == rule.Label {
settings.RateLimits.Rules[i] = rule
replaced = true
break
if !isTrusted(trusted, peer) {
return peer.String()
}
if client, ok := forwardedClient(r, trusted); ok {
return client
}
// Прокси адреса не назвал либо назвал одними доверенными: счётчик падает
// обратно на адрес пира — то есть на общий. Лучше общий, чем ключ, который
// выбирает сам спрашивающий.
return peer.String()
}
// forwardedClient достаёт из цепочки пересылки того, кого ограничивают.
//
// **Цепочка читается справа налево, а доверенные адреса отбрасываются.**
// Значение слева пишет тот, кто шлёт запрос: прокси заголовок **дописывает**, а
// не заменяет, поэтому левым значением цепочки распоряжается аноним, и ключ
// бюджета, взятый оттуда, меняется у него на каждом запросе — бюджет обходится
// с первого. Справа же стоит адрес, который приписал ближайший к нам прокси, и
// каждый следующий шаг влево тем доверен, кто его приписал. Первый недоверенный
// справа и есть последний адрес, за который отвечает контур, а не спрашивающий.
//
// Заголовок читается **всеми** строками, а не одной: цепочка законно приходит
// несколькими заголовками, и разбор одной строки увидел бы только её часть — то
// есть снова кусок, которым распоряжается аноним.
func forwardedClient(r *http.Request, trusted []netip.Prefix) (string, bool) {
hops := forwardedChain(r)
for i := len(hops) - 1; i >= 0; i-- {
addr, err := netip.ParseAddr(hops[i])
if err != nil {
// Значение, которое не разбирается адресом, доверенным считать
// нечем: цепочка на нём кончается.
return "", false
}
addr = addr.Unmap()
if isTrusted(trusted, addr) {
continue
}
return addr.String(), true
}
return "", false
}
// forwardedChain — цепочка пересылки одним списком: все строки заголовка по
// порядку, каждая разобрана по запятым.
func forwardedChain(r *http.Request) []string {
var hops []string
for _, value := range r.Header.Values(ForwardedForHeader) {
for _, hop := range strings.Split(value, ",") {
hop = strings.TrimSpace(hop)
if hop != "" {
hops = append(hops, hop)
}
}
}
if !replaced {
settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule)
return hops
}
// rateLimiter — счётчики бюджета по ключу.
//
// Окно скользящее и хранится метками времени: счётчик с обнулением по границе
// окна пропустил бы двойной бюджет на стыке двух окон.
type rateLimiter struct {
mu sync.Mutex
max int
window time.Duration
budgets map[string]*budget
swept time.Time
}
type budget struct {
hits []time.Time
}
func newRateLimiter(maxRequests int, window time.Duration) *rateLimiter {
return &rateLimiter{
max: maxRequests,
window: window,
budgets: map[string]*budget{},
swept: clock.Now(),
}
}
func (l *rateLimiter) allow(key string) bool {
now := clock.Now()
l.mu.Lock()
defer l.mu.Unlock()
l.sweep(now)
b, ok := l.budgets[key]
if !ok {
b = &budget{}
l.budgets[key] = b
}
settings.RateLimits.Enabled = true
b.hits = fresh(b.hits, now.Add(-l.window))
if len(b.hits) >= l.max {
return false
}
if err := app.Save(settings); err != nil {
return fmt.Errorf("failed to apply app rate limit: %w", err)
}
return nil
b.hits = append(b.hits, now)
return true
}
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
// адрес спрашивающего.
//
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
// запроса. Норма при этом требует обратного — бюджет считается по адресу
// спрашивающего.
//
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
// счётчика сам и обходит ограничитель, меняя значение.
//
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
// по-прежнему решает адрес самого соединения.
func ApplyTrustedProxyHeaders(app core.App) error {
settings := app.Settings()
// sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза
// в окно: обход карты на каждом запросе стоил бы больше самого ограничителя.
func (l *rateLimiter) sweep(now time.Time) {
if now.Sub(l.swept) < l.window {
return
}
l.swept = now
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
// чем один пустой ключ на всех.
settings.TrustedProxy.UseLeftmostIP = true
for key, b := range l.budgets {
b.hits = fresh(b.hits, now.Add(-staleBudgetAge))
if len(b.hits) == 0 {
delete(l.budgets, key)
}
}
}
if err := app.Save(settings); err != nil {
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
// fresh отбрасывает метки старше границы. Метки лежат по возрастанию, поэтому
// достаточно найти первую свежую.
func fresh(hits []time.Time, edge time.Time) []time.Time {
for i, hit := range hits {
if hit.After(edge) {
return hits[i:]
}
}
return nil
}