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 = `
Бинарник собран без приложения. Соберите его и соберите бинарник заново.
` // Исход раздачи для журнала. Сам путь в журнал не идёт: множеством его значений // распоряжается спрашивающий, а до появления раздачи такой путь ловил отказ // маршрутизатора и успешным ответом не был. 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") }