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")) }