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:
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user