хранилище переехало с 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
+82 -107
View File
@@ -1,121 +1,93 @@
package http
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"net/netip"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Заголовки, которыми обратный прокси называет пришедшего.
//
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
// написать неверно, выгоды не даёт.
// Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка,
// которая сама ставит и сама читает своё имя, этого не замечает. Контур уже
// пишет эти имена соседним сервисам, и настройкой они не делаются: второе место,
// где их можно написать неверно, выгоды не даёт.
const (
LoginHeader = "Remote-User"
NameHeader = "Remote-Name"
EmailHeader = "Remote-Email"
)
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
//
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
const FileTokenPath = StorageRoot + "/files/token"
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
// Читает его только ограничитель частоты: барьером узнавания он не служит и
// служить не может — кто пришёл, решает адрес самого соединения.
const ForwardedForHeader = "X-Forwarded-For"
// accountKey — ключ, под которым узнанная учётная запись живёт в контексте
// запроса. Свой тип, а не строка: чужой ключ с тем же текстом иначе перезаписал
// бы значение.
type accountKey struct{}
// AccountOf отдаёт учётную запись, от имени которой идёт запрос. Второе значение
// ложно у неузнанного.
func AccountOf(r *http.Request) (*contract.UserAccount, bool) {
account, ok := r.Context().Value(accountKey{}).(*contract.UserAccount)
return account, ok
}
// withAccount кладёт узнанную учётную запись в контекст запроса.
func withAccount(r *http.Request, account *contract.UserAccount) *http.Request {
return r.WithContext(context.WithValue(r.Context(), accountKey{}, account))
}
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
//
// # Область
//
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
// узнаёт он **только на объявленной области**: корень приложения плюс этот
// адрес. Область выводится из перечня адресного пространства, а не пишется
// вторым списком.
// Слой вешается на цепочку корня приложения и только на неё. Область поэтому
// выводится из объявленного адресного пространства сервиса, а не перечисляется
// вторым списком: корень, переехавший в перечне, уносит слой с собой.
//
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
// два барьера здесь именно потому, что прежний был один и держался на
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
//
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
// базе, а первый такой запрос с новым именем — записи в неё.
//
// # Кто побеждает
//
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
// учётной записью пользователя отобрала бы у него панель посреди работы.
// Протухший и негодный токен предъявленными не считаются: библиотека их не
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
// Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не
// срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе
// запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с
// новым именем — записи в неё.
//
// # Чего слой не делает
//
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
// учётной записи на адресах приложения.
//
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
// легла база.
// Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием
// учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а
// не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там,
// где легла база.
func TrustedHeaderIdentity(
app core.App,
mounts []Mount,
users contract.UserRepository,
trusted []netip.Prefix,
logger *slog.Logger,
) *hook.Handler[*core.RequestEvent] {
) func(http.Handler) http.Handler {
if logger == nil {
logger = slog.Default()
}
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberTrustedHeaderIdentity",
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
// убираются ничем.
//
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
// узнавание (−999) → требование учётной записи (−998) → предел тела
// (−990). Требование стоит перед пределом тела намеренно: отказ
// неузнанному обязан наступать до чтения тела.
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
Func: func(e *core.RequestEvent) error {
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
return e.Next()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Более одного значения — не выбор, а отказ. Прокси, настроенный
// добавлять заголовок вместо замены, оставляет рядом со своим
// значением присланное анонимом, и умолчание «берём первое» отдало
// бы вход анониму.
values := e.Request.Header.Values(LoginHeader)
values := r.Header.Values(LoginHeader)
if len(values) != 1 {
if len(values) > 1 {
// Уровень предупреждающий: два значения означают прокси,
// который заголовок **добавляет** вместо замены, — то есть
// ровно ту поломку контура, которую модель угроз называет
// главной. Отладочным уровнем она в бою не видна вовсе:
// боевой уровень журнала информационный.
// главной. Отладочным уровнем она в бою не видна вовсе.
logger.Warn("Request carries more than one login header",
"http.peer_addr", e.Request.RemoteAddr,
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
} else {
// Заголовка нет вовсе. В этом контуре это значит, что прокси
@@ -123,13 +95,14 @@ func TrustedHeaderIdentity(
// штатный — так выглядит и человек, которому провайдер
// отказал.
logger.Debug("Request carries no login header",
"http.peer_addr", e.Request.RemoteAddr,
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
}
return e.Next()
next.ServeHTTP(w, r)
return
}
peer, ok := peerAddress(e.Request.RemoteAddr)
peer, ok := peerAddress(r.RemoteAddr)
if !ok || !isTrusted(trusted, peer) {
// Уровень предупреждающий, а не отладочный, и это решение о
// цене. Заголовок с недоверенного адреса в этом контуре — не
@@ -143,31 +116,34 @@ func TrustedHeaderIdentity(
// видно, чья это поломка: своя (перечень) или контура (прокси
// заголовка не ставит).
logger.Warn("Login header came from an untrusted peer",
"http.peer_addr", e.Request.RemoteAddr,
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
return e.Next()
next.ServeHTTP(w, r)
return
}
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
account, created, err := users.EnsureUser(contract.Identity{
Login: values[0],
Name: e.Request.Header.Get(NameHeader),
Email: e.Request.Header.Get(EmailHeader),
Name: r.Header.Get(NameHeader),
Email: r.Header.Get(EmailHeader),
})
if err != nil {
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
if errors.Is(err, contract.ErrLoginNotAcceptable) {
// Негодный логин — это негодный ввод, а не поломка сервиса:
// пустой заголовок прокси шлёт штатно там, где никого не
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
// неузнанным.
logger.Debug("Login header value is not acceptable",
"http.peer_addr", e.Request.RemoteAddr,
"http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http")
return e.Next()
next.ServeHTTP(w, r)
return
}
logger.Error("Failed to resolve account by login header",
"error", err, "capability", "access", "transport", "http")
return fail(e, err)
fail(w, err)
return
}
if created {
@@ -181,36 +157,35 @@ func TrustedHeaderIdentity(
// чтобы стать этим человеком. Идут адрес пира и идентификатор
// записи — оба выданы не спрашивающим.
logger.Info("Account created from login header",
"http.peer_addr", e.Request.RemoteAddr,
"account_id", record.Id,
"http.peer_addr", r.RemoteAddr,
"account_id", account.ID,
"capability", "access", "transport", "http")
}
e.Auth = record
return e.Next()
},
next.ServeHTTP(w, withAccount(r, account))
})
}
}
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
// RequireUser — слой предъявления адресов приложения.
//
// Область — корень приложения из перечня адресного пространства плюс адрес
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
// он разошёлся бы с ней молча.
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
if requestPath == FileTokenPath {
return true
}
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
// уложенный файл — чего сервис не умеет вовсе.
//
// Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит
// учётную запись само, и предъявителя без неё не бывает.
func RequireUser() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := AccountOf(r); !ok {
fail(w, contract.ErrUnauthorized)
return
}
for _, mount := range mounts {
if mount.Path == AppRoot {
return mount.Covers(requestPath)
}
next.ServeHTTP(w, r)
})
}
return false
}
// peerAddress достаёт адрес того, кто открыл соединение.