- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
188 lines
8.0 KiB
Go
188 lines
8.0 KiB
Go
package http
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// assetsDir — каталог, который наполняет сборщик приложения.
|
|
//
|
|
// Имена в нём строит он же и несёт в них отпечаток содержимого, поэтому
|
|
// изменившийся ресурс приезжает под новым именем. Отсюда два правила разом:
|
|
// такой ресурс отдаётся с долгим сроком хранения, а не совпавший с файлом путь
|
|
// под этим каталогом отвечает отсутствием, а не разметкой.
|
|
const assetsDir = "assets"
|
|
|
|
// Срок хранения ресурса сборщика — год.
|
|
const assetMaxAgeSeconds = 31536000
|
|
|
|
// notBuiltPage — что видит человек у бинарника без собранного приложения.
|
|
//
|
|
// Состояние возможно только у собранного мимо набора проверок: и набор
|
|
// проверок, и сборка образа собирают приложение раньше бинарника.
|
|
const notBuiltPage = `<!doctype html>
|
|
<html lang="ru">
|
|
<head><meta charset="utf-8"><title>Приложение не собрано</title></head>
|
|
<body><h1>Приложение не собрано</h1>
|
|
<p>Бинарник собран без приложения. Соберите его и соберите бинарник заново.</p>
|
|
</body>
|
|
</html>`
|
|
|
|
// Исход раздачи для журнала. Сам путь в журнал не идёт: множеством его значений
|
|
// распоряжается спрашивающий, а до появления раздачи такой путь ловил отказ
|
|
// маршрутизатора и успешным ответом не был.
|
|
const (
|
|
OutcomeMarkup = "markup"
|
|
OutcomeAsset = "asset"
|
|
OutcomeFailure = "failure"
|
|
)
|
|
|
|
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
|
|
type WebappHandler struct {
|
|
dist fs.FS
|
|
built bool
|
|
fingerprint string
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewWebappHandler собирает раздачу приложения.
|
|
//
|
|
// Файловая система приходит параметром, а не тянется пакетом: так тест
|
|
// подставляет свою сборку, не собирая приложение.
|
|
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, logger: logger}
|
|
}
|
|
|
|
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
|
|
// «той». Вне набора проверок порядок шагов ничем не задан: `go run .`
|
|
// вшивает то, что лежит с прошлого раза, а приложение при этом открывается и
|
|
// ведёт себя как прежняя версия. Он же уходит меткой ответа с разметкой.
|
|
fingerprint := buildFingerprint(dist)
|
|
logger.Info("Webapp is embedded", "webapp.build", fingerprint)
|
|
|
|
return &WebappHandler{
|
|
dist: dist,
|
|
built: built,
|
|
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 вешает раздачу на корень.
|
|
//
|
|
// Путь, принадлежащий корню сервиса, до неё не доходит вовсе: его забирает
|
|
// маршрутизатор, у которого корень объявлен своим образцом. Отказ контракта
|
|
// поэтому остаётся отказом контракта, а не проваливается в разметку.
|
|
func (h *WebappHandler) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("/", h.Serve)
|
|
}
|
|
|
|
// 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 {
|
|
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(r.URL.Path), "/")
|
|
if name == "" || name == "." {
|
|
h.serveIndex(w, r)
|
|
return
|
|
}
|
|
|
|
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
|
|
noteWebappOutcome(r, OutcomeAsset)
|
|
h.setCacheHeader(w, name)
|
|
http.ServeFileFS(w, r, h.dist, name)
|
|
return
|
|
}
|
|
|
|
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
|
// разметкой значит ответить `200` на то, чего нет: браузер отвергнет её по
|
|
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
|
|
// не останется ничего.
|
|
if underAssets(name) {
|
|
noteWebappOutcome(r, OutcomeFailure)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
h.serveIndex(w, r)
|
|
}
|
|
|
|
func (h *WebappHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
|
noteWebappOutcome(r, OutcomeMarkup)
|
|
h.setCacheHeader(w, "index.html")
|
|
|
|
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
|
|
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
|
|
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
|
|
// подтверждения.
|
|
if h.fingerprint != "" {
|
|
w.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
|
}
|
|
|
|
http.ServeFileFS(w, r, h.dist, "index.html")
|
|
}
|
|
|
|
// underAssets говорит, ведёт ли путь в каталог сборщика.
|
|
//
|
|
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
|
|
// каталог и всё, что под ним. По одному префиксу с косой чертой голый `assets`
|
|
// не попал бы никуда и уехал бы разметкой с кодом `200` — то есть ровно тем
|
|
// ответом, который правило запрещает.
|
|
func underAssets(name string) bool {
|
|
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
|
|
}
|
|
|
|
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
|
//
|
|
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
|
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
|
// отзывается со стороны сервиса ничем.
|
|
func (h *WebappHandler) setCacheHeader(w http.ResponseWriter, name string) {
|
|
if underAssets(name) {
|
|
w.Header().Set(
|
|
"Cache-Control",
|
|
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
|
|
)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|