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:
@@ -219,6 +219,22 @@ desktop-build: ## Собрать desktop-приложение (бандлы по
|
|||||||
desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=…
|
desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=…
|
||||||
open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(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
|
.PHONY: docker-build
|
||||||
docker-build: ## Собрать образ приложения
|
docker-build: ## Собрать образ приложения
|
||||||
docker build \
|
docker build \
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ x-app-env: &app-env
|
|||||||
LISTEN_ADDR: "0.0.0.0:8080"
|
LISTEN_ADDR: "0.0.0.0:8080"
|
||||||
DATA_DIR: "/app/data"
|
DATA_DIR: "/app/data"
|
||||||
WEB_ROOT: "/app/web"
|
WEB_ROOT: "/app/web"
|
||||||
|
# Каталог обновлений desktop-обёртки: манифесты и артефакты релиза
|
||||||
|
# (docs/client-tauri.md §4); смонтирован только для чтения.
|
||||||
|
UPDATES_DIR: "/app/updates"
|
||||||
LOG_LEVEL: "${LOG_LEVEL}"
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
LOG_FORMAT: "json"
|
LOG_FORMAT: "json"
|
||||||
SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
|
SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
|
||||||
@@ -39,6 +42,7 @@ services:
|
|||||||
- "${GLCHAT_DATA_DIR}/db:/app/data/db"
|
- "${GLCHAT_DATA_DIR}/db:/app/data/db"
|
||||||
- "${GLCHAT_DATA_DIR}/files:/app/data/files"
|
- "${GLCHAT_DATA_DIR}/files:/app/data/files"
|
||||||
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
|
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
|
||||||
|
- "${GLCHAT_UPDATES_DIR}:/app/updates:ro"
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ x-app-env: &app-env
|
|||||||
LISTEN_ADDR: "0.0.0.0:8080"
|
LISTEN_ADDR: "0.0.0.0:8080"
|
||||||
DATA_DIR: "/app/data"
|
DATA_DIR: "/app/data"
|
||||||
WEB_ROOT: "/app/web"
|
WEB_ROOT: "/app/web"
|
||||||
|
# Каталог обновлений desktop-обёртки: манифесты и артефакты релиза
|
||||||
|
# (docs/client-tauri.md §4); смонтирован только для чтения.
|
||||||
|
UPDATES_DIR: "/app/updates"
|
||||||
LOG_LEVEL: "${LOG_LEVEL}"
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
LOG_FORMAT: "json"
|
LOG_FORMAT: "json"
|
||||||
SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
|
SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
|
||||||
@@ -46,6 +49,7 @@ services:
|
|||||||
- "${GLCHAT_DATA_DIR}/db:/app/data/db"
|
- "${GLCHAT_DATA_DIR}/db:/app/data/db"
|
||||||
- "${GLCHAT_DATA_DIR}/files:/app/data/files"
|
- "${GLCHAT_DATA_DIR}/files:/app/data/files"
|
||||||
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
|
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
|
||||||
|
- "${GLCHAT_UPDATES_DIR}:/app/updates:ro"
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+14
-2
@@ -122,6 +122,9 @@ GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}"
|
|||||||
TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
|
TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
|
||||||
GLCHAT_ETC_DIR="$TARGET_DIR"
|
GLCHAT_ETC_DIR="$TARGET_DIR"
|
||||||
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
|
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_INSTALL_LOG="${TARGET_DIR}/install.log"
|
||||||
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
|
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
|
||||||
|
|
||||||
@@ -273,6 +276,7 @@ parse_args() {
|
|||||||
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
|
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
|
||||||
GLCHAT_ETC_DIR="$TARGET_DIR"
|
GLCHAT_ETC_DIR="$TARGET_DIR"
|
||||||
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
|
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
|
||||||
|
GLCHAT_UPDATES_DIR="${TARGET_DIR}/updates"
|
||||||
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
|
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
|
||||||
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
|
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
|
||||||
info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}"
|
info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}"
|
||||||
@@ -390,6 +394,7 @@ resolve_derived_values() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}"
|
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}"
|
||||||
|
GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR}"
|
||||||
GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}"
|
GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}"
|
||||||
GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}"
|
GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}"
|
||||||
# TLS считается включённым и когда сертификаты обслуживает внешний прокси
|
# TLS считается включённым и когда сертификаты обслуживает внешний прокси
|
||||||
@@ -413,7 +418,7 @@ resolve_derived_values() {
|
|||||||
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
|
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
|
||||||
livekit_extra_codecs \
|
livekit_extra_codecs \
|
||||||
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
|
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 \
|
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \
|
||||||
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
|
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
|
||||||
TURN_RELAY_USER TURN_RELAY_PASSWORD
|
TURN_RELAY_USER TURN_RELAY_PASSWORD
|
||||||
@@ -672,6 +677,10 @@ generate_secrets() {
|
|||||||
|
|
||||||
mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups}
|
mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups}
|
||||||
chmod 700 "$GLCHAT_DATA_DIR"
|
chmod 700 "$GLCHAT_DATA_DIR"
|
||||||
|
# Каталог обновлений монтируется в контейнер только для чтения: его
|
||||||
|
# наполняет релизный скрипт от root, приложению нужен лишь доступ на чтение.
|
||||||
|
mkdir -p "${GLCHAT_UPDATES_DIR}"
|
||||||
|
chmod 755 "${GLCHAT_UPDATES_DIR}"
|
||||||
# Каталоги монтируются в контейнер, который работает не от root (UID 10001):
|
# Каталоги монтируются в контейнер, который работает не от root (UID 10001):
|
||||||
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
|
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
|
||||||
set_data_ownership
|
set_data_ownership
|
||||||
@@ -751,6 +760,7 @@ TLS_BY_PROXY=@TLS_BY_PROXY@
|
|||||||
|
|
||||||
# --- интерполяция compose (пути, адреса привязки, профиль) ---
|
# --- интерполяция compose (пути, адреса привязки, профиль) ---
|
||||||
GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@
|
GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@
|
||||||
|
GLCHAT_UPDATES_DIR=@GLCHAT_UPDATES_DIR@
|
||||||
GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@
|
GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@
|
||||||
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
|
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
|
||||||
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
|
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
|
||||||
@@ -856,6 +866,7 @@ TLS_ENABLED=${TLS_ENABLED}
|
|||||||
SKIP_TLS=${SKIP_TLS}
|
SKIP_TLS=${SKIP_TLS}
|
||||||
TLS_BY_PROXY=${TLS_BY_PROXY}
|
TLS_BY_PROXY=${TLS_BY_PROXY}
|
||||||
GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}
|
GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}
|
||||||
|
GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR}
|
||||||
CADDY_PROFILE=${CADDY_PROFILE}
|
CADDY_PROFILE=${CADDY_PROFILE}
|
||||||
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
|
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
|
||||||
GLCHAT_APP_BIND=${APP_BIND_ADDR}
|
GLCHAT_APP_BIND=${APP_BIND_ADDR}
|
||||||
@@ -1113,7 +1124,8 @@ render_all_templates() {
|
|||||||
"PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" \
|
"PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" \
|
||||||
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
|
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
|
||||||
"LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_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_READ_POOL=${PROFILE_SQLITE_READ_POOL}" \
|
||||||
"SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \
|
"SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \
|
||||||
"SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \
|
"SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Domain string
|
Domain string
|
||||||
FilesDomain string
|
FilesDomain string
|
||||||
InstanceName string
|
InstanceName string
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
DataDir string
|
DataDir string
|
||||||
WebRoot string
|
WebRoot string
|
||||||
|
// UpdatesDir — каталог с манифестами автообновления desktop-обёртки и
|
||||||
|
// артефактами релиза (docs/client-tauri.md §4). Наполняется релизным
|
||||||
|
// скриптом (scripts/desktop-release.sh), приложение только отдаёт файлы.
|
||||||
|
UpdatesDir string
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
SQLiteCache string
|
SQLiteCache string
|
||||||
SQLiteMmap string
|
SQLiteMmap string
|
||||||
@@ -67,6 +71,7 @@ func Load() (Config, error) {
|
|||||||
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
|
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
|
||||||
DataDir: env("DATA_DIR", DefaultDataDir),
|
DataDir: env("DATA_DIR", DefaultDataDir),
|
||||||
WebRoot: env("WEB_ROOT", "/app/web"),
|
WebRoot: env("WEB_ROOT", "/app/web"),
|
||||||
|
UpdatesDir: env("UPDATES_DIR", "/opt/glchat/updates"),
|
||||||
DatabasePath: env("DATABASE_PATH", ""),
|
DatabasePath: env("DATABASE_PATH", ""),
|
||||||
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
|
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
|
||||||
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
|
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
|
|
||||||
// reservedPrefixes are handled by the API, the gateway or the file CDN; an
|
// 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.
|
// 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 {
|
func isReservedPath(path string) bool {
|
||||||
for _, prefix := range reservedPrefixes {
|
for _, prefix := range reservedPrefixes {
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ type Server struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
http *http.Server
|
http *http.Server
|
||||||
static *staticHandler
|
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 {
|
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,
|
gateway: deps.Gateway,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
static: newStaticHandler(cfg.WebRoot),
|
static: newStaticHandler(cfg.WebRoot),
|
||||||
|
updates: newUpdatesHandler(cfg.UpdatesDir),
|
||||||
// 5 попыток входа в минуту на IP и 120 запросов API в минуту на
|
// 5 попыток входа в минуту на IP и 120 запросов API в минуту на
|
||||||
// пользователя/IP — дефолты AGENT.md 8.6.
|
// пользователя/IP — дефолты AGENT.md 8.6.
|
||||||
authLimiter: httpx.NewRateLimiter(5, 5),
|
authLimiter: httpx.NewRateLimiter(5, 5),
|
||||||
@@ -228,6 +231,13 @@ func (s *Server) registerRoutes(router chi.Router) {
|
|||||||
s.registerFileDownload(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.NotFound(s.handleFallback)
|
||||||
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# desktop-release.sh — раскладывает собранные артефакты desktop-обёртки по
|
||||||
|
# каталогу обновлений инстанса и генерирует манифест автообновления
|
||||||
|
# (docs/client-tauri.md §4, desktop/README.md).
|
||||||
|
#
|
||||||
|
# Что делает:
|
||||||
|
# 1. берёт артефакт обновления (`glchat.app.tar.gz` и его `.sig` для macOS,
|
||||||
|
# `.nsis.zip`/`.msi` для Windows, `.AppImage.tar.gz` для Linux);
|
||||||
|
# 2. копирует артефакт и подпись в <каталог>/<target>/<arch>/;
|
||||||
|
# 3. пишет манифест <каталог>/<target>/<arch>/<version>.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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Подготовленный каталог раскладки: <target>/<arch>/<файлы>. В него же
|
||||||
|
# собирается манифест, поэтому локальная запись и загрузка по 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 <<EOF
|
||||||
|
|
||||||
|
Готово. Проверка (подставьте target/arch из вывода выше):
|
||||||
|
curl -s ${BASE_URL}/updates/<target>/<arch>/${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 <<EOF
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
curl -s ${BASE_URL}/updates/<target>/<arch>/${VERSION} | jq .
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user