Files
glchat/internal/server/updates.go
T
grendervill 498201b413 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::манифест_разбирается_плагином_обновлений`).
2026-09-22 23:17:22 +03:00

170 lines
7.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 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"))
}