хранилище переехало с 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
+175
View File
@@ -0,0 +1,175 @@
package http
import (
"context"
"log/slog"
"net/http"
"time"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
const webappRoute = "<приложение>"
// journalNote — то, что обработчик оставляет слою журнала о своём запросе.
// Указателем в контексте: значение кладёт слой, а заполняет обработчик ниже.
type journalNote struct {
outcome string
}
type journalKey struct{}
// noteWebappOutcome оставляет исход раздачи слою журнала.
func noteWebappOutcome(r *http.Request, outcome string) {
if note, ok := r.Context().Value(journalKey{}).(*journalNote); ok {
note.outcome = outcome
}
}
// Journal пишет строку о каждом входящем запросе.
//
// **Путь, которым распоряжается спрашивающий, в журнал не идёт** — вместо него
// маршрут из закрытого перечня и длина: по ним видно, что происходит, а
// дословная запись сделала бы журнал местом, куда аноним пишет свой текст
// произвольной длины. Корень приложения от этого правила не изъят: путь под ним
// выбирает тот же спрашивающий.
//
// Журнал у сервиса **один**: второй, куда чужая библиотека клала путь целиком
// вместе с адресом отправителя, ушёл вместе с ней.
func Journal(mounts []Mount, logger *slog.Logger) func(http.Handler) http.Handler {
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := clock.Start()
note := &journalNote{}
ctx := context.WithValue(r.Context(), journalKey{}, note)
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r.WithContext(ctx))
level := slog.LevelInfo
if IsObservationAddress(mounts, r.URL.Path) {
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
level = slog.LevelDebug
}
attrs := []any{
"http.method", r.Method,
"http.route", JournalRoute(r, mounts),
"http.status_code", recorder.status,
"duration_ms", time.Since(start).Milliseconds(),
// Длина идёт на всякую строку, а не только на раздачу
// приложения: обобщённое значение маршрута теперь достаётся и
// путям под корнем приложения, и без длины по строке не видно,
// спросили короткий адрес или мегабайт текста.
"http.path_length", len(r.URL.Path),
"transport", "http",
}
if note.outcome != "" {
attrs = append(attrs, "webapp.outcome", note.outcome)
}
logger.Log(ctx, level, "Incoming request", attrs...)
})
}
}
// JournalRoute готовит путь запроса к записи в журнал.
//
// **Дословно в журнал идёт только то, что принадлежит закрытому перечню**:
// точные адреса наблюдения и образцы адресов приложения. Всё прочее — и путь
// вне корней сервиса, и путь под корнем приложения, не совпавший ни с одним
// образцом, — обозначается одним значением, а длина уходит отдельным полем.
//
// Прежнее правило судило по принадлежности пути сервису и оставляло зазор
// шириной в корень приложения: `/app/<произвольный текст>` принадлежит сервису,
// отвечает отказом неузнанному и при этом уезжал в журнал дословно. Множеством
// значений под корнем распоряжается спрашивающий ровно так же, как и вне его, —
// значит и правило одно на обе половины.
//
// Идентификатор записи из строки при этом не пропадает: его пишет обработчик
// полем `record_id`, и пишет он тот, что прочитал, а не тот, что попросили.
//
// Имя файла в хранилище из журнала выводимо быть не должно, и сегодня оно туда
// не попадает по построению: адрес копии записи назван идентификатором самой
// записи, а имя файла на диске в путь не входит вовсе.
func JournalRoute(r *http.Request, mounts []Mount) string {
if exact, ok := ExactAddressOf(mounts, r.URL.Path); ok {
return exact
}
if pattern, ok := appRoutePatternOf(r); ok {
return pattern
}
return webappRoute
}
// appRouteIndex — маршрутизатор, заведённый ради одного вопроса: какому образцу
// приложения отвечает этот запрос.
//
// Маршрутизатор, а не свой разбор пути: образец `{id}` разбирает стандартная
// библиотека, и второй разбор рядом с ней разошёлся бы с настоящей
// маршрутизацией молча. Образцы берутся тем же перечнем, которым вешаются
// обработчики.
var appRouteIndex = newAppRouteIndex()
func newAppRouteIndex() *http.ServeMux {
mux := http.NewServeMux()
for _, pattern := range AppRoutePatterns {
mux.Handle(pattern, http.NotFoundHandler())
}
return mux
}
// knownAppRoutes — тот же перечень множеством: ответ маршрутизатора сверяется с
// ним. Перенаправление на очищенный путь маршрутизатор отдаёт образцом,
// собранным из самого пути, и без сверки такой ответ уехал бы в журнал
// дословно — то есть ровно тем, чего правило не допускает.
var knownAppRoutes = knownAppRouteSet()
func knownAppRouteSet() map[string]struct{} {
out := make(map[string]struct{}, len(AppRoutePatterns))
for _, pattern := range AppRoutePatterns {
out[pattern] = struct{}{}
}
return out
}
// appRoutePatternOf называет образец адреса приложения. Второе значение ложно у
// всего, что ни одному образцу не отвечает.
func appRoutePatternOf(r *http.Request) (string, bool) {
_, pattern := appRouteIndex.Handler(r)
if _, ok := knownAppRoutes[pattern]; !ok {
return "", false
}
return pattern, true
}
// statusRecorder запоминает код ответа: журнал пишется после обработчика, а
// готовый код читать больше неоткуда.
type statusRecorder struct {
http.ResponseWriter
status int
written bool
}
func (w *statusRecorder) WriteHeader(status int) {
if !w.written {
w.status = status
w.written = true
}
w.ResponseWriter.WriteHeader(status)
}
func (w *statusRecorder) Write(p []byte) (int, error) {
w.written = true
return w.ResponseWriter.Write(p)
}