хранилище переехало с 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
+44 -197
View File
@@ -9,29 +9,6 @@ import (
"path"
"strconv"
"strings"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
// Корни адресного пространства и отдельные адреса наблюдения.
//
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
// панели владельца. Поменять их нельзя, это литералы библиотеки.
//
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
// стоит, ничем не отличается от любого другого свободного, а второй перечень
// «когда-то занятых корней» разошёлся бы с первым молча.
const (
StorageRoot = "/api"
PanelRoot = "/_"
HealthPath = "/health"
MetricsPath = "/metrics"
)
// assetsDir — каталог, который наполняет сборщик приложения.
@@ -45,104 +22,6 @@ const assetsDir = "assets"
// Срок хранения ресурса сборщика — год.
const assetMaxAgeSeconds = 31536000
// Mount — часть адресного пространства, принадлежащая сервису.
//
// Перечень этих частей — **единственное** описание того, что сервису
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
// контракта.
type Mount struct {
// Path — корень либо точный адрес.
Path string
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
// только сам себя, а `/app` — всё, что под ним.
Exact bool
// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
Bind func(r *router.Router[*core.RequestEvent])
}
// Covers говорит, принадлежит ли путь этой части адресного пространства.
//
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
// никому и уехал бы разметкой приложения.
func (m Mount) Covers(requestPath string) bool {
if m.Exact {
return requestPath == m.Path
}
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
}
// ServiceMounts перечисляет адресное пространство сервиса целиком.
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
return []Mount{
{Path: StorageRoot},
{Path: PanelRoot},
{Path: AppRoot, Bind: appHandler.Register},
{Path: HealthPath, Exact: true, Bind: bindHealth},
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
}
}
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
for _, mount := range mounts {
if mount.Bind != nil {
mount.Bind(r)
}
}
}
// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
func IsServiceAddress(mounts []Mount, requestPath string) bool {
for _, mount := range mounts {
if mount.Covers(requestPath) {
return true
}
}
return false
}
// IsObservationAddress говорит, что путь — адрес наблюдения.
//
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
// адресов разошлось бы с первым молча.
func IsObservationAddress(mounts []Mount, requestPath string) bool {
for _, mount := range mounts {
if mount.Exact && mount.Covers(requestPath) {
return true
}
}
return false
}
func bindHealth(r *router.Router[*core.RequestEvent]) {
r.GET(HealthPath, func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"status": "ok",
"message": "Transcriber service is running",
})
})
}
func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
return func(r *router.Router[*core.RequestEvent]) {
r.GET(MetricsPath, func(e *core.RequestEvent) error {
handler.ServeHTTP(e.Response, e.Request)
return nil
})
}
}
// notBuiltPage — что видит человек у бинарника без собранного приложения.
//
// Состояние возможно только у собранного мимо набора проверок: и набор
@@ -164,25 +43,10 @@ const (
OutcomeFailure = "failure"
)
// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
const journalOutcomeKey = "transcriberWebappOutcome"
// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
// запрос до неё не дошёл.
func WebappOutcome(e *core.RequestEvent) string {
outcome, ok := e.Get(journalOutcomeKey).(string)
if !ok {
return ""
}
return outcome
}
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
type WebappHandler struct {
dist fs.FS
built bool
mounts []Mount
fingerprint string
logger *slog.Logger
}
@@ -191,14 +55,14 @@ type WebappHandler struct {
//
// Файловая система приходит параметром, а не тянется пакетом: так тест
// подставляет свою сборку, не собирая приложение.
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler {
func NewWebappHandler(dist fs.FS, built bool, logger *slog.Logger) *WebappHandler {
if logger == nil {
logger = slog.Default()
}
if !built {
logger.Warn("Webapp is not built, service will answer with a placeholder page")
return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger}
return &WebappHandler{dist: dist, built: built, logger: logger}
}
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
@@ -211,17 +75,12 @@ func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logge
return &WebappHandler{
dist: dist,
built: built,
mounts: mounts,
fingerprint: fingerprint,
logger: logger,
}
}
// buildFingerprint — короткий отпечаток разметки вшитой сборки.
//
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
// ресурсов, то есть при всякой пересборке с изменениями.
func buildFingerprint(dist fs.FS) string {
markup, err := fs.ReadFile(dist, "index.html")
if err != nil {
@@ -235,54 +94,41 @@ func buildFingerprint(dist fs.FS) string {
// Register вешает раздачу на корень.
//
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь.
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) {
// Успешная раздача в журнал хранилища не пишется.
//
// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
// открытие приложения оставляло бы там его текст и его адрес. Готовая
// раздача статики ставит тот же признак первой строкой; своя написана мимо
// неё, и признак перенесён руками.
//
// Отказы записываются по-прежнему: признак снимает только успех.
r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
// Путь, принадлежащий корню сервиса, до неё не доходит вовсе: его забирает
// маршрутизатор, у которого корень объявлен своим образцом. Отказ контракта
// поэтому остаётся отказом контракта, а не проваливается в разметку.
func (h *WebappHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("/", h.Serve)
}
// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном.
func (h *WebappHandler) Serve(e *core.RequestEvent) error {
requestPath := e.Request.URL.Path
// Путь принадлежит сервису — значит под этим корнем такого адреса просто
// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень
// отвечает и сегодня: своя форма здесь была бы второй.
if IsServiceAddress(h.mounts, requestPath) {
e.Set(journalOutcomeKey, OutcomeFailure)
return router.NewNotFoundError("", nil)
}
if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
e.Set(journalOutcomeKey, OutcomeFailure)
return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
// Serve отдаёт приложение либо отказ.
func (h *WebappHandler) Serve(w http.ResponseWriter, r *http.Request) {
// Открывающими страницу считаются `GET` и `HEAD`, и только они.
if r.Method != http.MethodGet && r.Method != http.MethodHead {
noteWebappOutcome(r, OutcomeFailure)
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if !h.built {
e.Set(journalOutcomeKey, OutcomeFailure)
return e.HTML(http.StatusServiceUnavailable, notBuiltPage)
noteWebappOutcome(r, OutcomeFailure)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(notBuiltPage))
return
}
name := strings.TrimPrefix(path.Clean(requestPath), "/")
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name == "" || name == "." {
return h.serveIndex(e)
h.serveIndex(w, r)
return
}
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
e.Set(journalOutcomeKey, OutcomeAsset)
h.setCacheHeader(e, name)
return e.FileFS(h.dist, name)
noteWebappOutcome(r, OutcomeAsset)
h.setCacheHeader(w, name)
http.ServeFileFS(w, r, h.dist, name)
return
}
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
@@ -290,33 +136,29 @@ func (h *WebappHandler) Serve(e *core.RequestEvent) error {
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
// не останется ничего.
if underAssets(name) {
e.Set(journalOutcomeKey, OutcomeFailure)
return router.NewNotFoundError("", nil)
noteWebappOutcome(r, OutcomeFailure)
http.NotFound(w, r)
return
}
return h.serveIndex(e)
h.serveIndex(w, r)
}
func (h *WebappHandler) serveIndex(e *core.RequestEvent) error {
e.Set(journalOutcomeKey, OutcomeMarkup)
h.setCacheHeader(e, "index.html")
func (h *WebappHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
noteWebappOutcome(r, OutcomeMarkup)
h.setCacheHeader(w, "index.html")
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо.
// подтверждения.
if h.fingerprint != "" {
e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`)
w.Header().Set("ETag", `"`+h.fingerprint+`"`)
}
return e.FileFS(h.dist, "index.html")
http.ServeFileFS(w, r, h.dist, "index.html")
}
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
//
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
// underAssets говорит, ведёт ли путь в каталог сборщика.
//
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
@@ -327,14 +169,19 @@ func underAssets(name string) bool {
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
}
func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) {
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
//
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
// отзывается со стороны сервиса ничем.
func (h *WebappHandler) setCacheHeader(w http.ResponseWriter, name string) {
if underAssets(name) {
e.Response.Header().Set(
w.Header().Set(
"Cache-Control",
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
)
return
}
e.Response.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Cache-Control", "no-cache")
}