Files

170 lines
7.2 KiB
Go
Raw Permalink Normal View History

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