хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user