feat(server): инстанс отдаёт манифест автообновления desktop-обёртки

Обёртка с первого инкремента Фазы 6 запрашивает
`GET /updates/{target}/{arch}/{current_version}`, но на инстансе этого пути не
было: запрос попадал в SPA-заглушку, клиент получал HTML с кодом 200 и писал
в лог «error decoding response body».

Что сделано:

- `internal/server/updates.go` — статика автообновления: каталог `UPDATES_DIR`
  (в контейнере `/app/updates:ro`), раскладка `<target>/<arch>/<version>.json` +
  артефакт и `.sig`; версия без расширения отображается на `<version>.json`,
  а если его нет — на `latest.json` (обёртка приходит со своей установленной
  версией, «новее ли релиз» решает клиент сравнением semver). Для имён
  с расширением артефакта запасного варианта нет: иначе вместо архива клиент
  скачал бы манифест и подпись не сошлась бы. Сегменты пути проверяются по
  символам, выход за каталог невозможен; `/updates/` добавлен в reservedPrefixes,
  поэтому отсутствующий манифест — честный 404, а не HTML-шелл;
- `scripts/desktop-release.sh` и цели `make desktop-release` /
  `make desktop-release-upload HOST=…`: берут собранный `glchat.app.tar.gz` + `.sig`,
  определяют target/arch по имени артефакта, копируют их в каталог инстанса и
  собирают манифест (версия из `tauri.conf.json`), объединяя платформы одной
  версии; есть `--dry-run` и загрузка на инстанс по SSH;
- `deploy/install.sh` и шаблоны compose: каталог `${GLCHAT_UPDATES_DIR}`
  (`/opt/glchat/updates`) создаётся установщиком и монтируется в контейнер
  только для чтения; в `.env` добавлен `GLCHAT_UPDATES_DIR`.

Владельцу внешнего прокси добавлять ничего не нужно: `/updates/...` уходит в
catch-all `handle` и доходит до приложения, как статика клиента.

Проверено на стенде: `curl https://gl.mhspx.su/updates/darwin/aarch64/0.1.0`
→ 200 `application/json; charset=utf-8`, артефакт → 200 `application/gzip`,
отсутствующие платформа и версия → 404 (не HTML), `readyz` → 200, обёртка
в логе пишет «обновлений нет» вместо ошибки разбора. Тесты: 6 новых в
`internal/server/updates_test.go`, манифест разбирается типом самого плагина
обновлений (`updates::tests::манифест_разбирается_плагином_обновлений`).
This commit is contained in:
2026-09-22 23:17:22 +03:00
parent eeececaff8
commit 498201b413
10 changed files with 754 additions and 10 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ import (
// reservedPrefixes are handled by the API, the gateway or the file CDN; an
// unknown path under them is a real 404 and must not receive the SPA shell.
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"}
// `/updates/` — статика автообновления: обёртка ждёт JSON или 404, а не HTML.
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc", "/updates/"}
func isReservedPath(path string) bool {
for _, prefix := range reservedPrefixes {
+11 -1
View File
@@ -93,7 +93,9 @@ type Server struct {
logger *slog.Logger
http *http.Server
static *staticHandler
api huma.API
// updates отдаёт манифесты автообновления desktop-обёртки (docs/client-tauri.md §4).
updates *updatesHandler
api huma.API
}
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
@@ -105,6 +107,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
gateway: deps.Gateway,
logger: logger,
static: newStaticHandler(cfg.WebRoot),
updates: newUpdatesHandler(cfg.UpdatesDir),
// 5 попыток входа в минуту на IP и 120 запросов API в минуту на
// пользователя/IP — дефолты AGENT.md 8.6.
authLimiter: httpx.NewRateLimiter(5, 5),
@@ -228,6 +231,13 @@ func (s *Server) registerRoutes(router chi.Router) {
s.registerFileDownload(router)
}
// Манифест автообновления desktop-обёртки (docs/client-tauri.md §4): обычная
// статика без сессии — обёртка проверяет обновления до входа в аккаунт.
// Обработчик берётся через поле, а не значением метода: каталог обновлений
// задаётся конфигом и в тестах подменяется до первого запроса.
router.Get("/updates/*", func(w http.ResponseWriter, r *http.Request) { s.updates.serve(w, r) })
router.Get("/updates", func(w http.ResponseWriter, r *http.Request) { s.updates.serve(w, r) })
router.NotFound(s.handleFallback)
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
+169
View File
@@ -0,0 +1,169 @@
package server
import (
"errors"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"glchat/internal/httpx"
)
// updatesHandler отдаёт манифест автообновления desktop-обёртки и артефакты
// релиза (docs/client-tauri.md §4). Раскладку каталога создаёт релизный скрипт
// `scripts/desktop-release.sh`:
//
// <root>/<target>/<arch>/<version>.json — манифест версии
// <root>/<target>/<arch>/latest.json — манифест последнего релиза
// <root>/<target>/<arch>/<artifact> — артефакт (.app.tar.gz, .msi, …)
// <root>/<target>/<arch>/<artifact>.sig — подпись ed25519 (формат minisign)
//
// Обёртка запрашивает `/updates/{target}/{arch}/{current_version}`: версия без
// расширения отображается на файл `<version>.json`, а если его нет — на
// `latest.json` (см. resolve). Подпись проверяет клиент: публичный ключ вшит в
// сборку, сервер подписью не занимается и приватного ключа не знает. Ручка
// публичная (обёртка проверяет обновления до входа в аккаунт) и не входит в
// лимиты API: это статика.
type updatesHandler struct {
root string
}
func newUpdatesHandler(root string) *updatesHandler {
return &updatesHandler{root: root}
}
// serve обрабатывает GET|HEAD /updates/{target}/{arch}/{version|файл}.
func (h *updatesHandler) serve(w http.ResponseWriter, r *http.Request) {
// path.Clean собирает «..» и хвостовые слэши: запрос вне /updates/ после
// этого не совпадёт с тремя сегментами и получит 404.
rest := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/updates/")
segments := strings.Split(rest, "/")
if len(segments) != 3 {
writeUpdatesNotFound(w)
return
}
for _, segment := range segments {
if !validUpdateSegment(segment) {
writeUpdatesNotFound(w)
return
}
}
rel := path.Join(segments...)
if !filepath.IsLocal(filepath.FromSlash(rel)) {
writeUpdatesNotFound(w)
return
}
full, ok := h.resolve(filepath.FromSlash(rel))
if !ok {
writeUpdatesNotFound(w)
return
}
h.serveFile(w, r, full)
}
// resolve ищет файл в каталоге обновлений: имя без известного расширения
// считается версией из адреса обёртки (`0.1.0` → `0.1.0.json`), всё остальное —
// артефактом или подписью.
//
// Если манифеста запрошенной версии нет, отдаётся `latest.json` того же
// каталога: обёртка спрашивает `/updates/{target}/{arch}/{current_version}`,
// то есть приходит с версией, которая у неё установлена, а решение «обновление
// новее текущего» принимает клиент сравнением semver (tauri-plugin-updater).
// Без такого запасного варианта статика отдавала бы 404 всем, у кого
// установлена не последняя версия, и обновление не приходило бы никому.
// Для имён с расширением артефакта запасного варианта нет: иначе вместо
// архива клиент скачал бы манифест и подпись не сошлась бы.
func (h *updatesHandler) resolve(rel string) (string, bool) {
name := filepath.Base(rel)
candidates := []string{rel}
if updateContentType(name) == "" {
candidates = append(candidates, rel+".json")
if name != "latest" {
candidates = append(candidates, filepath.Join(filepath.Dir(rel), "latest.json"))
}
}
for _, candidate := range candidates {
full := filepath.Join(h.root, candidate)
info, err := os.Stat(full)
if err == nil {
if info.Mode().IsRegular() {
return full, true
}
continue
}
if !errors.Is(err, fs.ErrNotExist) {
// Нечитаемый файл не должен скрывать следующий вариант имени.
continue
}
}
return "", false
}
// serveFile отдаёт найденный файл с типом и кэшированием по его роли.
// Имя берётся у самого файла: запрос версии без расширения приводит к
// манифесту `<version>.json`, и кэшировать его как артефакт нельзя.
func (h *updatesHandler) serveFile(w http.ResponseWriter, r *http.Request, full string) {
name := filepath.Base(full)
if contentType := updateContentType(name); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
if strings.HasSuffix(name, ".json") {
// Манифест по одному и тому же адресу меняется от релиза к релизу.
w.Header().Set("Cache-Control", "no-cache")
} else {
// Артефакт версии неизменяем: адрес содержит версию и имя файла.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
http.ServeFile(w, r, full)
}
// validUpdateSegment пропускает только безопасные имена сегментов: без
// слэшей, «..» и посторонних символов, поэтому запрос не выйдет за каталог
// обновлений даже без дополнительных проверок пути.
func validUpdateSegment(segment string) bool {
if segment == "" || len(segment) > 64 || segment == "." || segment == ".." {
return false
}
for _, symbol := range segment {
switch {
case symbol >= 'a' && symbol <= 'z',
symbol >= 'A' && symbol <= 'Z',
symbol >= '0' && symbol <= '9':
case symbol == '.', symbol == '-', symbol == '_', symbol == '+':
default:
return false
}
}
return true
}
// updateContentType задаёт тип по расширению: в минимальном alpine-окружении
// mime-таблица знает не все расширения релизных артефактов.
func updateContentType(name string) string {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".json"):
return "application/json; charset=utf-8"
case strings.HasSuffix(lower, ".sig"):
return "text/plain; charset=utf-8"
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".gz"):
return "application/gzip"
case strings.HasSuffix(lower, ".zip"):
return "application/zip"
case strings.HasSuffix(lower, ".msi"):
return "application/x-msi"
case strings.HasSuffix(lower, ".exe"):
return "application/vnd.microsoft.portable-executable"
case strings.HasSuffix(lower, ".appimage"), strings.HasSuffix(lower, ".deb"), strings.HasSuffix(lower, ".rpm"):
return "application/octet-stream"
}
return ""
}
func writeUpdatesNotFound(w http.ResponseWriter) {
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "update not found"))
}
+226
View File
@@ -0,0 +1,226 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// updatesFixture раскладывает каталог обновлений так, как это делает
// scripts/desktop-release.sh, и возвращает сервер с этим каталогом.
func updatesFixture(t *testing.T) (*Server, string) {
t.Helper()
root := t.TempDir()
dir := filepath.Join(root, "darwin", "aarch64")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("create updates dir: %v", err)
}
manifest := `{"version":"0.2.0","notes":"тест","pub_date":"2026-09-22T12:00:00Z",` +
`"platforms":{"darwin-aarch64":{"signature":"dGVzdA==",` +
`"url":"https://gl.mhspx.su/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz"}}}`
files := map[string]string{
"0.2.0.json": manifest,
"glchat_0.2.0_aarch64.app.tar.gz": "архив",
"glchat_0.2.0_aarch64.app.tar.gz.sig": "dGVzdA==",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
srv, _ := newTestServer(t)
srv.updates = newUpdatesHandler(root)
return srv, root
}
func getUpdates(t *testing.T, srv *Server, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil))
return rec
}
// baseURLForTests — адрес инстанса в ссылках манифеста (как в релизном скрипте).
const baseURLForTests = "https://gl.mhspx.su"
// TestUpdatesServesManifestByVersion: обёртка запрашивает версию без
// расширения, сервер отдаёт соответствующий ей манифест (docs/client-tauri.md §4).
func TestUpdatesServesManifestByVersion(t *testing.T) {
srv, _ := updatesFixture(t)
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.2.0")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if contentType := rec.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "no-cache") {
t.Errorf("Cache-Control = %q, want no-cache для манифеста", cache)
}
var manifest struct {
Version string `json:"version"`
Platforms map[string]struct {
Signature string `json:"signature"`
URL string `json:"url"`
} `json:"platforms"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil {
t.Fatalf("decode manifest: %v", err)
}
if manifest.Version != "0.2.0" {
t.Errorf("version = %q, want 0.2.0", manifest.Version)
}
if _, ok := manifest.Platforms["darwin-aarch64"]; !ok {
t.Errorf("в манифесте нет платформы darwin-aarch64: %v", manifest.Platforms)
}
}
// TestUpdatesServesArtifactAndSignature: артефакт и его подпись лежат рядом с
// манифестом и отдаются как есть.
func TestUpdatesServesArtifactAndSignature(t *testing.T) {
srv, _ := updatesFixture(t)
rec := getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz")
if rec.Code != http.StatusOK || rec.Body.String() != "архив" {
t.Fatalf("артефакт: status = %d, body = %q", rec.Code, rec.Body.String())
}
if contentType := rec.Header().Get("Content-Type"); contentType != "application/gzip" {
t.Errorf("Content-Type = %q, want application/gzip", contentType)
}
if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "immutable") {
t.Errorf("Cache-Control = %q, want immutable для артефакта", cache)
}
rec = getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz.sig")
if rec.Code != http.StatusOK || rec.Body.String() != "dGVzdA==" {
t.Fatalf("подпись: status = %d, body = %q", rec.Code, rec.Body.String())
}
if contentType := rec.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") {
t.Errorf("Content-Type подписи = %q, want text/plain", contentType)
}
}
// TestUpdatesMissingManifestIsNotFound: неизвестная версия — честный 404, а не
// SPA-заглушка: иначе обёртка получила бы HTML вместо манифеста.
func TestUpdatesMissingManifestIsNotFound(t *testing.T) {
srv, _ := updatesFixture(t)
for _, path := range []string{
"/updates/darwin/aarch64/0.1.0", // манифеста версии нет
"/updates/windows/x86_64/0.2.0", // нет такого каталога
"/updates/darwin/aarch64", // мало сегментов
"/updates", // корень каталога обновлений
"/updates/", // корень со слэшем
} {
rec := getUpdates(t, srv, path)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s: status = %d, want 404", path, rec.Code)
}
if strings.Contains(rec.Body.String(), "<!doctype html") {
t.Errorf("GET %s: отдан HTML-шелл вместо 404", path)
}
}
}
// TestUpdatesRejectsTraversal: выход за каталог обновлений невозможен —
// сегменты проверяются по символам, а «..» отсекается.
func TestUpdatesRejectsTraversal(t *testing.T) {
srv, root := updatesFixture(t)
secret := filepath.Join(filepath.Dir(root), "secret.json")
if err := os.WriteFile(secret, []byte("секрет"), 0o644); err != nil {
t.Fatalf("write secret: %v", err)
}
for _, path := range []string{
"/updates/darwin/aarch64/..%2f..%2fsecret",
"/updates/darwin/aarch64/%2e%2e%2fsecret",
"/updates/..%2fdarwin/aarch64/0.2.0",
"/updates/darwin/aarch64/....//secret",
"/updates/darwin/aarch64/0.2.0.json%00",
} {
rec := getUpdates(t, srv, path)
if rec.Code == http.StatusOK {
t.Errorf("GET %s: status = 200, запрос должен быть отклонён (body %q)", path, rec.Body.String())
}
}
}
// TestUpdatesRejectsPost: метод кроме GET|HEAD не поддержан.
func TestUpdatesRejectsPost(t *testing.T) {
srv, _ := updatesFixture(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/updates/darwin/aarch64/0.2.0", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
// TestUpdatesFallsBackToLatest: обёртка приходит со своей установленной
// версией, поэтому запрос версии без собственного манифеста должен получить
// манифест последнего релиза — иначе обновление не пришло бы никому, кроме
// тех, у кого уже стоит последняя версия.
func TestUpdatesFallsBackToLatest(t *testing.T) {
srv, root := updatesFixture(t)
latest := `{"version":"0.3.0","notes":"свежий релиз","pub_date":"2026-09-23T10:00:00Z",` +
`"platforms":{"darwin-aarch64":{"signature":"bmV3","url":"` + baseURLForTests +
`/updates/darwin/aarch64/glchat_0.3.0_aarch64.app.tar.gz"}}}`
if err := os.WriteFile(filepath.Join(root, "darwin", "aarch64", "latest.json"), []byte(latest), 0o644); err != nil {
t.Fatalf("write latest.json: %v", err)
}
// Манифеста версии 0.1.0 нет — приходит последний релиз.
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.1.0")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"0.3.0"`) {
t.Errorf("в ответе нет версии 0.3.0: %q", rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") {
t.Errorf("Cache-Control = %q, want no-cache", rec.Header().Get("Cache-Control"))
}
// Собственный манифест версии важнее latest.json.
rec = getUpdates(t, srv, "/updates/darwin/aarch64/0.2.0")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"0.2.0"`) {
t.Fatalf("манифест версии 0.2.0 не отдан: status = %d, body = %q", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "0.3.0") {
t.Errorf("для версии 0.2.0 отдан latest.json: %q", rec.Body.String())
}
}
// TestUpdatesArtifactWithoutLatestFallback: отсутствующий артефакт — это 404, а
// не манифест: иначе клиент скачал бы JSON вместо архива и подпись не сошлась бы.
func TestUpdatesArtifactWithoutLatestFallback(t *testing.T) {
srv, root := updatesFixture(t)
latest := `{"version":"0.3.0","platforms":{"darwin-aarch64":{"signature":"bmV3","url":"` +
baseURLForTests + `/updates/darwin/aarch64/glchat_0.3.0_aarch64.app.tar.gz"}}}`
if err := os.WriteFile(filepath.Join(root, "darwin", "aarch64", "latest.json"), []byte(latest), 0o644); err != nil {
t.Fatalf("write latest.json: %v", err)
}
rec := getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.9.9_aarch64.app.tar.gz")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (body %q)", rec.Code, rec.Body.String())
}
}
// TestUpdatesDirectoryAbsent: инстанс без каталога обновлений (свежая
// установка до первого релиза) отвечает 404, а не падает.
func TestUpdatesDirectoryAbsent(t *testing.T) {
srv, _ := newTestServer(t)
srv.updates = newUpdatesHandler(filepath.Join(t.TempDir(), "нет-каталога"))
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.1.0")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 при отсутствующем каталоге", rec.Code)
}
}