From 498201b4136d7f298fd2dc9704ac06701808c6f1 Mon Sep 17 00:00:00 2001 From: grendervill Date: Tue, 22 Sep 2026 23:17:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(server):=20=D0=B8=D0=BD=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=20=D0=BE=D1=82=D0=B4=D0=B0=D1=91=D1=82=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=84=D0=B5=D1=81=D1=82=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20desktop-=D0=BE=D0=B1=D1=91=D1=80=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обёртка с первого инкремента Фазы 6 запрашивает `GET /updates/{target}/{arch}/{current_version}`, но на инстансе этого пути не было: запрос попадал в SPA-заглушку, клиент получал HTML с кодом 200 и писал в лог «error decoding response body». Что сделано: - `internal/server/updates.go` — статика автообновления: каталог `UPDATES_DIR` (в контейнере `/app/updates:ro`), раскладка `//.json` + артефакт и `.sig`; версия без расширения отображается на `.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::манифест_разбирается_плагином_обновлений`). --- Makefile | 16 ++ deploy/compose.test.yaml.tpl | 4 + deploy/compose.yaml.tpl | 4 + deploy/install.sh | 16 +- internal/config/config.go | 17 +- internal/server/fallback.go | 3 +- internal/server/server.go | 12 +- internal/server/updates.go | 169 ++++++++++++++++++ internal/server/updates_test.go | 226 ++++++++++++++++++++++++ scripts/desktop-release.sh | 297 ++++++++++++++++++++++++++++++++ 10 files changed, 754 insertions(+), 10 deletions(-) create mode 100644 internal/server/updates.go create mode 100644 internal/server/updates_test.go create mode 100644 scripts/desktop-release.sh diff --git a/Makefile b/Makefile index 455ec5f..4ae7457 100644 --- a/Makefile +++ b/Makefile @@ -219,6 +219,22 @@ desktop-build: ## Собрать desktop-приложение (бандлы по desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=… open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(ARGS) +# Каталог обновлений инстанса и его публичный адрес (docs/client-tauri.md §4). +# Раскладку делает scripts/desktop-release.sh: манифест версии + latest.json, +# артефакт и подпись; приложение отдаёт их по /updates/{target}/{arch}/{version}. +DESKTOP_UPDATES_DIR ?= /opt/glchat/updates +DESKTOP_BASE_URL ?= https://gl.mhspx.su + +.PHONY: desktop-release +desktop-release: ## Разложить артефакты обновления в DESKTOP_UPDATES_DIR (нужен sudo) + bash scripts/desktop-release.sh --dir "$(DESKTOP_UPDATES_DIR)" \ + --base-url "$(DESKTOP_BASE_URL)" $(ARGS) + +.PHONY: desktop-release-upload +desktop-release-upload: ## Загрузить обновления на инстанс: make desktop-release-upload HOST=user@server + bash scripts/desktop-release.sh --remote "$(HOST)" \ + --base-url "$(DESKTOP_BASE_URL)" $(ARGS) + .PHONY: docker-build docker-build: ## Собрать образ приложения docker build \ diff --git a/deploy/compose.test.yaml.tpl b/deploy/compose.test.yaml.tpl index 08eff4d..626a0eb 100644 --- a/deploy/compose.test.yaml.tpl +++ b/deploy/compose.test.yaml.tpl @@ -15,6 +15,9 @@ x-app-env: &app-env LISTEN_ADDR: "0.0.0.0:8080" DATA_DIR: "/app/data" WEB_ROOT: "/app/web" + # Каталог обновлений desktop-обёртки: манифесты и артефакты релиза + # (docs/client-tauri.md §4); смонтирован только для чтения. + UPDATES_DIR: "/app/updates" LOG_LEVEL: "${LOG_LEVEL}" LOG_FORMAT: "json" SQLITE_READ_POOL: "${SQLITE_READ_POOL}" @@ -39,6 +42,7 @@ services: - "${GLCHAT_DATA_DIR}/db:/app/data/db" - "${GLCHAT_DATA_DIR}/files:/app/data/files" - "${GLCHAT_DATA_DIR}/backups:/app/data/backups" + - "${GLCHAT_UPDATES_DIR}:/app/updates:ro" logging: *default-logging deploy: resources: diff --git a/deploy/compose.yaml.tpl b/deploy/compose.yaml.tpl index c68dcc0..5e38e5e 100644 --- a/deploy/compose.yaml.tpl +++ b/deploy/compose.yaml.tpl @@ -16,6 +16,9 @@ x-app-env: &app-env LISTEN_ADDR: "0.0.0.0:8080" DATA_DIR: "/app/data" WEB_ROOT: "/app/web" + # Каталог обновлений desktop-обёртки: манифесты и артефакты релиза + # (docs/client-tauri.md §4); смонтирован только для чтения. + UPDATES_DIR: "/app/updates" LOG_LEVEL: "${LOG_LEVEL}" LOG_FORMAT: "json" SQLITE_READ_POOL: "${SQLITE_READ_POOL}" @@ -46,6 +49,7 @@ services: - "${GLCHAT_DATA_DIR}/db:/app/data/db" - "${GLCHAT_DATA_DIR}/files:/app/data/files" - "${GLCHAT_DATA_DIR}/backups:/app/data/backups" + - "${GLCHAT_UPDATES_DIR}:/app/updates:ro" logging: *default-logging deploy: resources: diff --git a/deploy/install.sh b/deploy/install.sh index 64963f0..5b73d42 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -122,6 +122,9 @@ GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}" TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" GLCHAT_ETC_DIR="$TARGET_DIR" GLCHAT_DATA_DIR="${TARGET_DIR}/data" +# Каталог обновлений desktop-обёртки: манифесты и артефакты раскладывает +# scripts/desktop-release.sh, приложение отдаёт их по /updates/… (docs/client-tauri.md §4). +GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR:-${TARGET_DIR}/updates}" GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log" GLCHAT_ENV_FILE="${TARGET_DIR}/.env" @@ -273,6 +276,7 @@ parse_args() { TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)" GLCHAT_ETC_DIR="$TARGET_DIR" GLCHAT_DATA_DIR="${TARGET_DIR}/data" + GLCHAT_UPDATES_DIR="${TARGET_DIR}/updates" GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log" GLCHAT_ENV_FILE="${TARGET_DIR}/.env" info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}" @@ -390,6 +394,7 @@ resolve_derived_values() { fi GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}" + GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR}" GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}" GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}" # TLS считается включённым и когда сертификаты обслуживает внешний прокси @@ -413,7 +418,7 @@ resolve_derived_values() { export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \ livekit_extra_codecs \ PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \ - GLCHAT_DATA_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \ + GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \ GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \ TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \ TURN_RELAY_USER TURN_RELAY_PASSWORD @@ -672,6 +677,10 @@ generate_secrets() { mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups} chmod 700 "$GLCHAT_DATA_DIR" + # Каталог обновлений монтируется в контейнер только для чтения: его + # наполняет релизный скрипт от root, приложению нужен лишь доступ на чтение. + mkdir -p "${GLCHAT_UPDATES_DIR}" + chmod 755 "${GLCHAT_UPDATES_DIR}" # Каталоги монтируются в контейнер, который работает не от root (UID 10001): # без смены владельца приложение не сможет создать базу (AGENT.md 4.3). set_data_ownership @@ -751,6 +760,7 @@ TLS_BY_PROXY=@TLS_BY_PROXY@ # --- интерполяция compose (пути, адреса привязки, профиль) --- GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@ +GLCHAT_UPDATES_DIR=@GLCHAT_UPDATES_DIR@ GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@ GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@ GLCHAT_APP_BIND=@GLCHAT_APP_BIND@ @@ -856,6 +866,7 @@ TLS_ENABLED=${TLS_ENABLED} SKIP_TLS=${SKIP_TLS} TLS_BY_PROXY=${TLS_BY_PROXY} GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR} +GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR} CADDY_PROFILE=${CADDY_PROFILE} GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR} GLCHAT_APP_BIND=${APP_BIND_ADDR} @@ -1113,7 +1124,8 @@ render_all_templates() { "PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" \ "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \ "LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \ - "GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}" "LOG_LEVEL=${LOG_LEVEL}" \ + "GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}" "GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR}" \ + "LOG_LEVEL=${LOG_LEVEL}" \ "SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}" \ "SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \ "SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \ diff --git a/internal/config/config.go b/internal/config/config.go index 9fabb0b..76aca3a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,12 +15,16 @@ const ( ) type Config struct { - Domain string - FilesDomain string - InstanceName string - ListenAddr string - DataDir string - WebRoot string + Domain string + FilesDomain string + InstanceName string + ListenAddr string + DataDir string + WebRoot string + // UpdatesDir — каталог с манифестами автообновления desktop-обёртки и + // артефактами релиза (docs/client-tauri.md §4). Наполняется релизным + // скриптом (scripts/desktop-release.sh), приложение только отдаёт файлы. + UpdatesDir string DatabasePath string SQLiteCache string SQLiteMmap string @@ -67,6 +71,7 @@ func Load() (Config, error) { ListenAddr: env("LISTEN_ADDR", DefaultListenAddr), DataDir: env("DATA_DIR", DefaultDataDir), WebRoot: env("WEB_ROOT", "/app/web"), + UpdatesDir: env("UPDATES_DIR", "/opt/glchat/updates"), DatabasePath: env("DATABASE_PATH", ""), SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"), SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"), diff --git a/internal/server/fallback.go b/internal/server/fallback.go index 77a7a82..2433f3a 100644 --- a/internal/server/fallback.go +++ b/internal/server/fallback.go @@ -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 { diff --git a/internal/server/server.go b/internal/server/server.go index 8041e75..dde0dde 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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") diff --git a/internal/server/updates.go b/internal/server/updates.go new file mode 100644 index 0000000..9213ec9 --- /dev/null +++ b/internal/server/updates.go @@ -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`: +// +// ///.json — манифест версии +// ///latest.json — манифест последнего релиза +// /// — артефакт (.app.tar.gz, .msi, …) +// ///.sig — подпись ed25519 (формат minisign) +// +// Обёртка запрашивает `/updates/{target}/{arch}/{current_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 отдаёт найденный файл с типом и кэшированием по его роли. +// Имя берётся у самого файла: запрос версии без расширения приводит к +// манифесту `.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")) +} diff --git a/internal/server/updates_test.go b/internal/server/updates_test.go new file mode 100644 index 0000000..18964f3 --- /dev/null +++ b/internal/server/updates_test.go @@ -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(), "///; +# 3. пишет манифест <каталог>///.json и его копию +# latest.json — её инстанс отдаёт в ответ на +# GET /updates/{target}/{arch}/{current_version} (обёртка приходит со +# своей установленной версией, а «новее ли релиз» решает клиент). +# +# Приватный ключ подписи не нужен: подпись уже лежит рядом с артефактом (её +# делает `make desktop-build`), сервер только отдаёт файлы, а проверяет +# подпись клиент вшитым публичным ключом. +# +# Примеры: +# # macOS-артефакт из target/release/bundle/macos → /opt/glchat/updates +# sudo bash scripts/desktop-release.sh +# # разложить в свой каталог и посмотреть план +# bash scripts/desktop-release.sh --dir /tmp/updates --dry-run +# # загрузить на стенд (sudo спросит пароль в терминале) +# bash scripts/desktop-release.sh --remote alex@192.168.1.247 +# # Windows/Linux-артефакты указываются явно +# bash scripts/desktop-release.sh --artifact bundle/nsis/glchat_0.1.0_x64-setup.nsis.zip +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TAURI_DIR="${REPO_DIR}/desktop/src-tauri" +BUNDLE_DIR="${TAURI_DIR}/target/release/bundle" + +# Каталог обновлений на инстансе: тот же путь, что в UPDATES_DIR приложения +# (в контейнере смонтирован в /app/updates только для чтения). +UPDATES_DIR="${GLCHAT_UPDATES_DIR:-/opt/glchat/updates}" +# Публичный адрес инстанса: из него собираются ссылки на артефакты в манифесте. +BASE_URL="${GLCHAT_BASE_URL:-https://gl.mhspx.su}" +VERSION="" +TARGET="" +ARCH="" +NOTES="" +REMOTE="" +DRY_RUN=0 +ARTIFACTS=() + +info() { printf ' %s\n' "$*"; } +ok() { printf ' ✓ %s\n' "$*"; } +warn() { printf ' ! %s\n' "$*" >&2; } +die() { + printf 'ошибка: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +desktop-release.sh — раскладка артефактов обновления desktop-обёртки на инстансе. + +Использование: bash scripts/desktop-release.sh [флаги] + +Флаги: + --artifact FILE файл артефакта (можно несколько); по умолчанию берётся + первый найденный артефакт текущей ОС в каталоге бандла + --version V версия релиза (по умолчанию — из desktop/src-tauri/tauri.conf.json) + --target T платформа обновления: darwin|windows|linux (по умолчанию — по имени файла) + --arch A архитектура: aarch64|x86_64|i686|armv7 (по умолчанию — по имени файла) + --dir DIR каталог обновлений (по умолчанию $GLCHAT_UPDATES_DIR или /opt/glchat/updates) + --base-url URL публичный адрес инстанса для ссылок (по умолчанию https://gl.mhspx.su) + --notes TEXT текст «что нового» в манифесте + --remote HOST разложить в --dir на удалённом хосте: rsync через sudo по SSH + --dry-run показать план, ничего не записывать + -h, --help эта справка +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --artifact) + [ $# -ge 2 ] || die "--artifact без значения" + ARTIFACTS+=("$2") + shift 2 + ;; + --version) + VERSION="${2:-}" + [ -n "$VERSION" ] || die "--version без значения" + shift 2 + ;; + --target) + TARGET="${2:-}" + shift 2 + ;; + --arch) + ARCH="${2:-}" + shift 2 + ;; + --dir) + UPDATES_DIR="${2:-}" + [ -n "$UPDATES_DIR" ] || die "--dir без значения" + shift 2 + ;; + --base-url) + BASE_URL="${2:-}" + [ -n "$BASE_URL" ] || die "--base-url без значения" + shift 2 + ;; + --notes) + NOTES="${2:-}" + shift 2 + ;; + --remote) + REMOTE="${2:-}" + [ -n "$REMOTE" ] || die "--remote без значения" + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) die "неизвестный флаг: $1 (см. --help)" ;; + esac +done + +have() { command -v "$1" >/dev/null 2>&1; } +have jq || die "нужен jq (генерация манифеста)" +BASE_URL="${BASE_URL%/}" + +# --- сбор артефактов ------------------------------------------------------ + +# Версия релиза: единственный источник правды — tauri.conf.json +# (docs/client-tauri.md §3: версия синхронизирована с сервером). +if [ -z "$VERSION" ]; then + VERSION="$(jq -r '.version' "${TAURI_DIR}/tauri.conf.json")" +fi +[ -n "$VERSION" ] && [ "$VERSION" != "null" ] || die "не удалось определить версию релиза" + +if [ "${#ARTIFACTS[@]}" -eq 0 ]; then + shopt -s nullglob + found=("${BUNDLE_DIR}"/macos/*.app.tar.gz "${BUNDLE_DIR}"/nsis/*.nsis.zip \ + "${BUNDLE_DIR}"/msi/*.msi "${BUNDLE_DIR}"/appimage/*.AppImage.tar.gz) + shopt -u nullglob + [ "${#found[@]}" -gt 0 ] || die "в ${BUNDLE_DIR} нет артефактов обновления: сначала соберите бандл (make desktop-build)" + ARTIFACTS=("${found[0]}") + if [ "${#found[@]}" -gt 1 ]; then + warn "найдено несколько артефактов, берём ${found[0]}; остальные укажите через --artifact" + fi +fi + +# platform_from_name разбирает имя артефакта Tauri: платформа и архитектура +# берутся из имени, если не заданы флагами (glchat_0.1.0_x64-setup.nsis.zip). +platform_from_name() { + case "$1" in + *.app.tar.gz) printf 'darwin' ;; + *.msi | *.nsis.zip) printf 'windows' ;; + *.AppImage.tar.gz) printf 'linux' ;; + *) die "не удалось определить платформу по имени $1: укажите --target" ;; + esac +} + +arch_from_name() { + case "$1" in + *aarch64* | *arm64*) printf 'aarch64'; return ;; + *x86_64* | *x64*) printf 'x86_64'; return ;; + *i686* | *i386* | *x86*) printf 'i686'; return ;; + *armv7*) printf 'armv7'; return ;; + esac + # Имя macOS-бандла архитектуру не содержит (glchat.app.tar.gz): берём её у + # машины, которая собрала бандл. Явный --arch всегда важнее. + case "$(uname -m)" in + arm64 | aarch64) printf 'aarch64' ;; + x86_64 | amd64) printf 'x86_64' ;; + i686 | i386) printf 'i686' ;; + armv7l) printf 'armv7' ;; + *) die "не удалось определить архитектуру по имени $1: укажите --arch" ;; + esac +} + +# Подготовленный каталог раскладки: //<файлы>. В него же +# собирается манифест, поэтому локальная запись и загрузка по SSH идут одним +# и тем же кодом. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +: >"${STAGE}/layout.txt" +PLATFORMS_FILE="${STAGE}/platforms.json" +printf '{}' >"$PLATFORMS_FILE" +DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +for artifact in "${ARTIFACTS[@]}"; do + [ -f "$artifact" ] || die "нет файла ${artifact}" + artifact_name="$(basename "$artifact")" + # У .sig-файла артефакт ищется рядом: имя без .sig. + case "$artifact_name" in + *.sig) artifact_name="${artifact_name%.sig}" ;; + esac + artifact_dir="$(cd "$(dirname "$artifact")" && pwd)" + artifact_path="${artifact_dir}/${artifact_name}" + signature_path="${artifact_path}.sig" + + target="${TARGET:-$(platform_from_name "$artifact_name")}" + arch="${ARCH:-$(arch_from_name "$artifact_name")}" + [ -f "$artifact_path" ] || die "нет артефакта ${artifact_path}" + [ -f "$signature_path" ] || die "нет подписи ${signature_path}: бандл собран без ключа обновлений (desktop/README.md)" + + install_dir="${UPDATES_DIR}/${target}/${arch}" + # В манифесте — только содержимое подписи (одна строка minisign). + signature="$(tr -d '\n' <"$signature_path")" + url="${BASE_URL}/updates/${target}/${arch}/${artifact_name}" + platform_key="${target}-${arch}" + + info "артефакт: ${artifact_path}" + info "подпись: ${signature_path}" + info "назначение: ${install_dir}/${artifact_name}" + + mkdir -p "${STAGE}/${target}/${arch}" + cp "$artifact_path" "${STAGE}/${target}/${arch}/${artifact_name}" + cp "$signature_path" "${STAGE}/${target}/${arch}/${artifact_name}.sig" + printf '%s %s %s\n' "$target" "$arch" "$artifact_name" >>"${STAGE}/layout.txt" + + # Манифест версии: по записи на платформу. Несколько платформ одной версии + # (macOS + Windows + Linux) складываются в один манифест, а не затирают + # друг друга. + jq --arg key "$platform_key" --arg url "$url" --arg signature "$signature" \ + --arg version "$VERSION" --arg notes "$NOTES" --arg date "$DATE" \ + '.version = $version + | .notes = (if $notes == "" then (.notes // ("Релиз glchat " + $version)) else $notes end) + | .pub_date = $date + | .platforms = ((.platforms // {}) + {($key): {signature: $signature, url: $url}})' \ + "$PLATFORMS_FILE" >"${PLATFORMS_FILE}.tmp" + mv "${PLATFORMS_FILE}.tmp" "$PLATFORMS_FILE" +done + +while read -r target arch artifact_name; do + install_dir="${UPDATES_DIR}/${target}/${arch}" + mkdir -p "${STAGE}/${target}/${arch}" + cp "$PLATFORMS_FILE" "${STAGE}/${target}/${arch}/${VERSION}.json" + cp "$PLATFORMS_FILE" "${STAGE}/${target}/${arch}/latest.json" + info "манифест: ${install_dir}/${VERSION}.json (+ latest.json)" +done <"${STAGE}/layout.txt" + +if [ "$DRY_RUN" = "1" ]; then + info "dry-run: файлы не записаны (${UPDATES_DIR})" + exit 0 +fi + +# --- раскладка ------------------------------------------------------------- + +if [ -z "$REMOTE" ]; then + while read -r target arch artifact_name; do + install_dir="${UPDATES_DIR}/${target}/${arch}" + mkdir -p "$install_dir" + cp -f "${STAGE}/${target}/${arch}/${artifact_name}" "${install_dir}/${artifact_name}" + cp -f "${STAGE}/${target}/${arch}/${artifact_name}.sig" "${install_dir}/${artifact_name}.sig" + # Манифест этой версии мог уже существовать (другая платформа той же + # версии): записи платформ объединяются. + if [ -f "${install_dir}/${VERSION}.json" ]; then + jq -s '.[0] * .[1]' "${install_dir}/${VERSION}.json" "$PLATFORMS_FILE" >"${install_dir}/${VERSION}.json.tmp" + mv "${install_dir}/${VERSION}.json.tmp" "${install_dir}/${VERSION}.json" + else + cp -f "$PLATFORMS_FILE" "${install_dir}/${VERSION}.json" + fi + # latest.json указывает на последнюю опубликованную версию для этой + # платформы: именно её отдаёт инстанс на запрос обёртки. + cp -f "$PLATFORMS_FILE" "${install_dir}/latest.json" + chmod 644 "${install_dir}/${artifact_name}" "${install_dir}/${artifact_name}.sig" \ + "${install_dir}/${VERSION}.json" "${install_dir}/latest.json" + ok "разложено в ${install_dir}" + done <"${STAGE}/layout.txt" + + cat <//${VERSION} | jq . + +Обёртка проверяет манифест сама (трей → «Проверить обновления…»); каталог +смонтирован в контейнер только для чтения, перезапуск приложения не нужен. +EOF + exit 0 +fi + +# --- доставка на удалённый инстанс --------------------------------------- + +# Каталог обновлений принадлежит root (его создаёт установщик), поэтому на +# удалённой стороне rsync запускается через sudo: пароль спросит ssh -t. +have rsync || die "нужен rsync для --remote" +info "загружаем на ${REMOTE}:${UPDATES_DIR} (sudo спросит пароль)" +rsync -az --rsync-path="sudo rsync" -e "ssh -t" "${STAGE}/" "${REMOTE}:${UPDATES_DIR}/" || + die "не удалось загрузить артефакты на ${REMOTE}" +ssh -t "$REMOTE" "sudo find '${UPDATES_DIR}' -type f -exec chmod 644 {} +" +ok "обновления на ${REMOTE}:${UPDATES_DIR}" +cat <//${VERSION} | jq . +EOF