Netpulse_SasS/server/internal/httpapi/static.go
byrsapty 205dd5e079 Пакування: runner міграцій і вшитий у бінарник фронтенд
netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані
psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного
запуску — це половина дистрибутива на порожньому місці.

Міграції вшиті через embed і переїхали в server/migrations: embed не
бачить нічого за межами кореня свого модуля, а міграції поруч із
бінарником, який їх накочує, не можуть розійтися версіями.

Накочування під advisory-блокуванням: два інстанси при rolling update
інакше застосували б ту саму міграцію двічі. Кожен файл в одній
транзакції разом із записом у schema_migrations; виняток — continuous
aggregates, які TimescaleDB забороняє в транзакції. Змінена вже
застосована міграція зупиняє запуск: у різних інсталяціях інакше
опиниться різна схема під одним номером.

Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск
каже «схема актуальна».

Веб віддає сам API через embed: на self-hosted це прибирає з інструкції
встановлення цілий компонент. Три політики кешування — назавжди для
assets із хешем у імені, ніколи для index.html, коротко для решти.

Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з
index.html, і клієнт падав на розборі HTML як JSON замість чесного 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 01:01:55 +03:00

114 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
)
// StaticFS — зібраний веб-інтерфейс.
//
// Порожній за замовчуванням: API збирається й запускається без нього
// (розробка йде проти vite з його гарячим перезавантаженням). Реліз
// підкладає сюди справжню файлову систему через SetStatic.
var staticFS fs.FS
// SetStatic підключає зібраний фронтенд.
//
// Віддавати статику самим API, а не Nginx поруч: на self-hosted це
// прибирає з інструкції встановлення цілий компонент, який доведеться
// налаштовувати, оновлювати й лагодити тому, хто прийшов моніторити
// мережу, а не адмініструвати веб-сервер.
func SetStatic(f fs.FS) { staticFS = f }
// buildTime — момент запуску процесу.
//
// Слугує міткою для Last-Modified: точний час збірки файлу в embed.FS
// недоступний (усе нульове), а без будь-якої мітки браузер перепитує
// кожен ресурс щоразу.
var buildTime = time.Now()
// serveStatic віддає SPA.
//
// Три різні політики кешування, бо це три різні речі:
//
// - /assets/* — імена з хешем вмісту, тож кешуються назавжди;
// - index.html — ніколи, інакше після оновлення браузер тягне старий
// html зі старими іменами ассетів, яких на сервері вже немає;
// - решта (favicon, manifest) — недовго.
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
if staticFS == nil {
http.NotFound(w, r)
return
}
// Невідомий шлях під /api/ — це помилка виклику, а не маршрут SPA.
//
// Знайдено живим прогоном: /api/v1/невідомий-шлях віддавав 200 з
// index.html, і клієнт падав на розборі HTML як JSON — замість
// зрозумілого 404. Роутер у браузері про /api/ нічого не знає, тож
// віддавати йому ці шляхи немає жодних підстав.
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, "not_found", "невідомий шлях API")
return
}
upath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if upath == "" {
upath = "index.html"
}
f, err := staticFS.Open(upath)
if err != nil {
// Будь-який невідомий шлях — це маршрут SPA (/map, /devices…),
// а не відсутній файл: роутер живе в браузері, і сервер про
// його маршрути не знає й не має знати.
s.serveIndex(w, r)
return
}
defer func() { _ = f.Close() }()
st, err := f.Stat()
if err != nil || st.IsDir() {
s.serveIndex(w, r)
return
}
if strings.HasPrefix(upath, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=300")
}
rs, ok := f.(io.ReadSeeker)
if !ok {
s.serveIndex(w, r)
return
}
http.ServeContent(w, r, upath, buildTime, rs)
}
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
f, err := staticFS.Open("index.html")
if err != nil {
http.NotFound(w, r)
return
}
defer func() { _ = f.Close() }()
rs, ok := f.(io.ReadSeeker)
if !ok {
http.NotFound(w, r)
return
}
// index.html не кешується ніколи: він містить імена ассетів із
// хешами, і закешована копія після оновлення шле браузер по файли,
// яких уже немає.
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(w, r, "index.html", buildTime, rs)
}