- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
176 lines
7.8 KiB
Go
176 lines
7.8 KiB
Go
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)
|
|
}
|