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>
114 lines
4.2 KiB
Go
114 lines
4.2 KiB
Go
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)
|
||
}
|