package http import ( "crypto/sha256" "encoding/hex" "io/fs" "log/slog" "net/http" "path" "strconv" "strings" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/router" ) // Корни адресного пространства и отдельные адреса наблюдения. // // `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй — // панели владельца. Поменять их нельзя, это литералы библиотеки. const ( StorageRoot = "/api" PanelRoot = "/_" AuthRoot = "/auth" HealthPath = "/health" MetricsPath = "/metrics" ) // assetsDir — каталог, который наполняет сборщик приложения. // // Имена в нём строит он же и несёт в них отпечаток содержимого, поэтому // изменившийся ресурс приезжает под новым именем. Отсюда два правила разом: // такой ресурс отдаётся с долгим сроком хранения, а не совпавший с файлом путь // под этим каталогом отвечает отсутствием, а не разметкой. 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, authHandler *AuthHandler, metricsHandler http.Handler, ) []Mount { return []Mount{ {Path: StorageRoot}, {Path: PanelRoot}, {Path: AppRoot, Bind: appHandler.Register}, {Path: AuthRoot, Bind: authHandler.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 — что видит человек у бинарника без собранного приложения. // // Состояние возможно только у собранного мимо набора проверок: и набор // проверок, и сборка образа собирают приложение раньше бинарника. const notBuiltPage = `
Бинарник собран без приложения. Соберите его и соберите бинарник заново.
` // Исход раздачи для журнала. Сам путь в журнал не идёт: множеством его значений // распоряжается спрашивающий, а до появления раздачи такой путь ловил отказ // маршрутизатора и успешным ответом не был. const ( OutcomeMarkup = "markup" OutcomeAsset = "asset" 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 } // NewWebappHandler собирает раздачу приложения. // // Файловая система приходит параметром, а не тянется пакетом: так тест // подставляет свою сборку, не собирая приложение. func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, 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} } // Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от // «той». Вне набора проверок порядок шагов ничем не задан: `go run .` // вшивает то, что лежит с прошлого раза, а приложение при этом открывается и // ведёт себя как прежняя версия. Он же уходит меткой ответа с разметкой. fingerprint := buildFingerprint(dist) logger.Info("Webapp is embedded", "webapp.build", fingerprint) 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 { return "unknown" } sum := sha256.Sum256(markup) return hex.EncodeToString(sum[:])[:12] } // Register вешает раздачу на корень. // // Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама // вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и // второй всепокрывающий образец рядом с ним спорил бы с ним за путь. func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) { // Успешная раздача в журнал хранилища не пишется. // // Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека // кладёт путь запроса целиком вместе с адресом отправителя, храня строки // пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое // открытие приложения оставляло бы там его текст и его адрес. Готовая // раздача статики ставит тот же признак первой строкой; своя написана мимо // неё, и признак перенесён руками. // // Отказы записываются по-прежнему: признак снимает только успех. r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog()) } // 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) } if !h.built { e.Set(journalOutcomeKey, OutcomeFailure) return e.HTML(http.StatusServiceUnavailable, notBuiltPage) } name := strings.TrimPrefix(path.Clean(requestPath), "/") if name == "" || name == "." { return h.serveIndex(e) } 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) } // Разметка прежней сборки называет ресурсы прежней сборки. Подменить их // разметкой значит ответить `200` на то, чего нет: браузер отвергнет её по // типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса // не останется ничего. if underAssets(name) { e.Set(journalOutcomeKey, OutcomeFailure) return router.NewNotFoundError("", nil) } return h.serveIndex(e) } func (h *WebappHandler) serveIndex(e *core.RequestEvent) error { e.Set(journalOutcomeKey, OutcomeMarkup) h.setCacheHeader(e, "index.html") // Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку — // «спроси заново и получи подтверждение», — а вшитый файл не несёт времени // правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо // подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо. if h.fingerprint != "" { e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`) } return e.FileFS(h.dist, "index.html") } // setCacheHeader назначает срок хранения по каталогу, а не по виду файла. // // Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами — // иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не // отзывается со стороны сервиса ничем. Запросов он больше не увидит. // underAssets говорит, ведёт ли путь в каталог сборщика. // // Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам // каталог и всё, что под ним. По одному префиксу с косой чертой голый `assets` // не попал бы никуда и уехал бы разметкой с кодом `200` — то есть ровно тем // ответом, который правило запрещает. func underAssets(name string) bool { return name == assetsDir || strings.HasPrefix(name, assetsDir+"/") } func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) { if underAssets(name) { e.Response.Header().Set( "Cache-Control", "public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable", ) return } e.Response.Header().Set("Cache-Control", "no-cache") }