package httpapi import ( "encoding/json" "net/http" "os" "gopkg.in/yaml.v3" ) // defaultVersionManifestPath is VERSION_MANIFEST's default when unset — mirrors // how server.env wires DOWNLOADS_DIR alongside DownloadsHandler's own built-in // default (see downloads.go / deploy/single-node/deploy.sh). const defaultVersionManifestPath = "/etc/pangolin/version.yaml" // Fallback values used ONLY when the manifest file itself is missing (fresh // box that hasn't run deploy/single-node/deploy.sh's manifest-install step // yet, or a local dev server). These are hand-set, not auto-derived — once a // box is deployed, the real source of truth is the on-disk manifest that // scripts/ci/release-client.sh overwrites on every client-v* release. const ( defaultManifestVersion = "1.0.48" defaultManifestBuildNumber = 10048 ) // changelogSection / changelogEntry mirror jiu's backend/config/version.yaml // shape (~/code/jiu/backend/internal/handler/version.go) so any future shared // tooling (e.g. a website changelog widget) can treat both services' // /version responses identically. Pangolin's release pipeline doesn't // populate Changelog yet (no CHANGELOG-client.md parsing wired in // scripts/ci/release-client.sh) — the field exists for shape-compatibility // and always serializes as [] rather than null. type changelogSection struct { Type string `yaml:"type" json:"type"` Items []string `yaml:"items" json:"items"` } type changelogEntry struct { Version string `yaml:"version" json:"version"` Date string `yaml:"date" json:"date"` Intro string `yaml:"intro" json:"intro"` Sections []changelogSection `yaml:"sections" json:"sections"` } // versionManifest is the on-disk (and wire) shape of the auto-update // manifest. download_urls keys in practice: macos, windows, ios, android // (web is intentionally not used — pangolin's client is native-only). type versionManifest struct { Version string `yaml:"version" json:"version"` BuildNumber int `yaml:"build_number" json:"build_number"` ForceUpdate bool `yaml:"force_update" json:"force_update"` ReleaseNotes string `yaml:"release_notes" json:"release_notes"` DownloadURLs map[string]string `yaml:"download_urls" json:"download_urls"` Changelog []changelogEntry `yaml:"changelog" json:"changelog"` } // VersionHandler serves GET /version (public, no auth — mounted directly in // main.go next to /healthz and /downloads/*). Unlike most handlers in this // package it re-reads its manifest file from disk on EVERY request rather // than caching it in memory: scripts/ci/release-client.sh rewrites // /etc/pangolin/version.yaml's version/build_number on each client-v* // release, and that must take effect immediately without a control-plane // restart or redeploy (mirrors jiu's loadVersionConfig()-per-request). type VersionHandler struct { path string } // NewVersionHandler builds a VersionHandler reading the manifest at path. // path=="" falls back to defaultVersionManifestPath (in production this is // overridden via the VERSION_MANIFEST env var — see main.go). func NewVersionHandler(path string) *VersionHandler { if path == "" { path = defaultVersionManifestPath } return &VersionHandler{path: path} } // defaultManifest is returned when the manifest file doesn't exist yet, so // GET /version still answers usefully (client update-checks shouldn't hard // fail just because deploy/single-node/deploy.sh hasn't run on this box). func defaultManifest() versionManifest { return versionManifest{ Version: defaultManifestVersion, BuildNumber: defaultManifestBuildNumber, ForceUpdate: false, ReleaseNotes: "", DownloadURLs: map[string]string{ "android": "https://api.yanmeiai.com/downloads/pangolin-android.apk", "windows": "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe", "macos": "", "ios": "", }, Changelog: []changelogEntry{}, } } // loadManifest reads+parses h.path. A missing file is NOT an error (falls // back to defaultManifest()); any other read/parse failure IS, so ServeHTTP // can 500 instead of silently masking a corrupt manifest written by a bad // release-client.sh run. func (h *VersionHandler) loadManifest() (versionManifest, error) { data, err := os.ReadFile(h.path) if err != nil { if os.IsNotExist(err) { return defaultManifest(), nil } return versionManifest{}, err } var m versionManifest if err := yaml.Unmarshal(data, &m); err != nil { return versionManifest{}, err } // Keep the JSON response shape stable (empty object/array, never null) // regardless of what the manifest on disk happens to omit. if m.DownloadURLs == nil { m.DownloadURLs = map[string]string{} } if m.Changelog == nil { m.Changelog = []changelogEntry{} } return m, nil } // Serve handles GET /version. Named Serve (not ServeHTTP) to match // DownloadsHandler's convention in this package (see downloads.go). func (h *VersionHandler) Serve(w http.ResponseWriter, r *http.Request) { m, err := h.loadManifest() if err != nil { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(map[string]string{"error": "version manifest unavailable"}) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(m) }