diff --git a/admin.html b/admin.html index f9ec102..0da6bcf 100644 --- a/admin.html +++ b/admin.html @@ -1,187 +1,269 @@ - + - - -Version Agent Admin - + + + Version Agent Admin + -
-

Version Agent Admin

-
- - -
will be saved in localStorage
-
-
- -
-

Configuration

-
+
- +

Version Agent Admin

+
Pflege mehrerer Produkte pro Vendor
-
- +
+
+ + +
lokal gespeichert
+
+
+ +
+ + +
+
wird in localStorage gemerkt
+
-
- +
+ +
+

Produkt-Konfiguration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - +
+ +
+

Pflege: Latest setzen

+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+ +
+
+

Assets

+
+ +
+
+

+  
 
-
-

Maintenance: Set Latest

-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-

Assets

-
- -
-
-
- -
-

-
+
+

Manifest (Produkt-spezifisch)

+
GET /v1/manifest?product=...
+

+  
-
-

Manifest

-
ETag-aware GET /v1/manifest
-

-
+
+

Neues Produkt anlegen

+
+
+
+
+
+
+
- - \ No newline at end of file + async function makeDefault(){ + const p = product(); if(!p){ alert('Kein Produkt gewählt'); return } + const payload = { DefaultProduct: p }; + const r = await api('/v1/config', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) }); + const txt = await r.text(); log(txt); await loadProducts(); + } + + async function createProduct(){ + const name = $('#newProdName').value.trim(); if(!name){ alert('Produkt-Name fehlt'); return } + const payload = { product: name, default_branch: $('#newProdBranch').value.trim(), default_channel: $('#newProdChannel').value }; + const r = await api('/v1/products', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) }); + const txt = await r.text(); log(txt); + await loadProducts(); + $('#product').value = name; localStorage.setItem('product', name); + await Promise.all([loadValues(), loadManifest()]); + } + + async function publish(){ + const p = product(); if(!p){ alert('Kein Produkt gewählt'); return } + const assets = Array.from(document.querySelectorAll('.asset-row')).map(row=>{ + const [url,sha,size,sig] = row.querySelectorAll('input'); + const a={ url:url.value.trim(), sha256:sha.value.trim() }; + if(size.value.trim()) a.size_bytes = parseInt(size.value.trim(),10); + if(sig.value.trim()) a.signature_url = sig.value.trim(); + return a; + }).filter(a=>a.url && a.sha256); + + const payload = { + branch: $('#branch').value.trim() || $('#defBranch').value.trim(), + channel: $('#channel').value, + arch: $('#arch').value, + bit: $('#bit').value, + os: $('#os').value, + release: { + version: $('#version').value.trim(), + build: $('#build').value.trim(), + released_at: $('#releasedAt').value.trim(), + notes_url: $('#notesUrl').value.trim(), + assets + } + }; + + const r = await api('/v1/publish?product='+encodeURIComponent(p), { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) }); + const txt = await r.text(); log(txt); await loadManifest(); + } + + async function loadLatest(){ + const p = product(); if(!p){ alert('Kein Produkt gewählt'); return } + const params = new URLSearchParams({ + product: p, + branch: $('#branch').value.trim() || $('#defBranch').value.trim(), + channel: $('#channel').value, + arch: $('#arch').value, + bit: $('#bit').value, + os: $('#os').value + }); + const r = await api('/v1/latest?'+params.toString()); + if(!r.ok){ log('not found'); return; } + const j = await r.json(); + $('#version').value = j.release.version || ''; + $('#build').value = j.release.build || ''; + $('#releasedAt').value = (j.release.released_at||''); + $('#notesUrl').value = j.release.notes_url || ''; + $('#assets').innerHTML=''; + (j.release.assets||[]).forEach(a=>addAssetRow({ url:a.url, sha256:a.sha256, size_bytes:a.size_bytes||'', signature_url:a.signature_url||'' })); + if((j.release.assets||[]).length===0) addAssetRow(); + } + + // init + (function(){ + $('#token').value = localStorage.getItem('apiToken')||''; + $('#token').addEventListener('input', e=> localStorage.setItem('apiToken', e.target.value)); + $('#product').addEventListener('change', async e=>{ localStorage.setItem('product', e.target.value); await Promise.all([loadValues(), loadManifest()]); }); + $('#addAsset').addEventListener('click', e=>{ e.preventDefault(); addAssetRow(); }); + $('#publish').addEventListener('click', e=>{ e.preventDefault(); publish(); }); + $('#loadLatest').addEventListener('click', e=>{ e.preventDefault(); loadLatest(); }); + $('#saveProductCfg').addEventListener('click', e=>{ e.preventDefault(); saveProductCfg(); }); + $('#saveVendorCfg').addEventListener('click', e=>{ e.preventDefault(); saveVendorCfg(); }); + $('#makeDefault').addEventListener('click', e=>{ e.preventDefault(); makeDefault(); }); + $('#createProduct').addEventListener('click', e=>{ e.preventDefault(); createProduct(); }); + addAssetRow(); loadProducts(); + })(); + + + diff --git a/main.go b/main.go index 008582e..3eca129 100644 --- a/main.go +++ b/main.go @@ -43,20 +43,28 @@ type Release struct { ChannelHint string `json:"channel,omitempty"` // echoed by server } -// Manifest nests by Branch→Channel→Arch→Bit→OS as requested. -// The innermost value is the latest Release for that tuple. +// ProductManifest nests by Branch→Channel→Arch→Bit→OS. // Example path: Releases["12.x"]["stable"]["amd64"]["64"]["windows"] -type Manifest struct { - Vendor string `json:"vendor"` +type ProductManifest struct { Product string `json:"product"` DefaultBranch string `json:"default_branch,omitempty"` DefaultChannel string `json:"default_channel,omitempty"` - UpdatedAt time.Time `json:"updated_at"` Releases map[string]map[string]map[string]map[string]map[string]Release `json:"releases"` } -// publishRequest is the payload for POST /v1/publish +// MultiManifest groups multiple products for the same vendor. +// Legacy endpoints continue to operate on DefaultProduct. +type MultiManifest struct { + Vendor string `json:"vendor"` + UpdatedAt time.Time `json:"updated_at"` + DefaultProduct string `json:"default_product,omitempty"` + Products map[string]*ProductManifest `json:"products"` +} + +// publishRequest is the payload for POST /v1/publish (and /v1/p/{product}/publish) +// "product" is optional when you hit the product-scoped endpoints; required for global ones. type publishRequest struct { + Product string `json:"product,omitempty"` Branch string `json:"branch"` Channel string `json:"channel"` // stable, beta, rc, nightly Arch string `json:"arch"` @@ -65,9 +73,9 @@ type publishRequest struct { Release Release `json:"release"` } -// latestResponse is returned by GET /v1/latest -// Mirrors the request tuple alongside the release for clarity. +// latestResponse mirrors the request tuple alongside the release for clarity. type latestResponse struct { + Product string `json:"product"` Branch string `json:"branch"` Channel string `json:"channel"` Arch string `json:"arch"` @@ -80,24 +88,29 @@ type latestResponse struct { type store struct { mu sync.RWMutex - manifest Manifest + manifest MultiManifest path string } -func newStore(path, vendor, product string) *store { - m := Manifest{ - Vendor: vendor, - Product: product, - DefaultBranch: "", - DefaultChannel: "stable", - UpdatedAt: time.Now().UTC(), - Releases: make(map[string]map[string]map[string]map[string]map[string]Release), +func newStore(path, vendor, defaultProduct string) *store { + m := MultiManifest{ + Vendor: vendor, + UpdatedAt: time.Now().UTC(), + Products: make(map[string]*ProductManifest), + } + if strings.TrimSpace(defaultProduct) != "" { + m.DefaultProduct = defaultProduct + m.Products[defaultProduct] = &ProductManifest{ + Product: defaultProduct, + DefaultChannel: "stable", + Releases: make(map[string]map[string]map[string]map[string]map[string]Release), + } } return &store{manifest: m, path: path} } -// oldManifest is used to migrate v1 manifests (without channels) → channels("stable"). -type oldManifest struct { +// oldV1Manifest (no channels) → used for deep migration to multi. +type oldV1Manifest struct { Vendor string `json:"vendor"` Product string `json:"product"` DefaultBranch string `json:"default_branch,omitempty"` @@ -105,6 +118,16 @@ type oldManifest struct { Releases map[string]map[string]map[string]map[string]Release `json:"releases"` } +// oldV2Single is the previous single-product structure with channels. +type oldV2Single struct { + Vendor string `json:"vendor"` + Product string `json:"product"` + DefaultBranch string `json:"default_branch,omitempty"` + DefaultChannel string `json:"default_channel,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + Releases map[string]map[string]map[string]map[string]map[string]Release `json:"releases"` +} + func (s *store) loadIfExists() error { b, err := os.ReadFile(s.path) if err != nil { @@ -113,32 +136,49 @@ func (s *store) loadIfExists() error { } return err } - var m Manifest - if err := json.Unmarshal(b, &m); err == nil && m.Releases != nil { - // Looks like v2 → accept. + // Try multi manifest directly + var multi MultiManifest + if err := json.Unmarshal(b, &multi); err == nil && multi.Products != nil { + if multi.Products == nil { + multi.Products = make(map[string]*ProductManifest) + } s.mu.Lock() - s.manifest = m + s.manifest = multi s.mu.Unlock() return nil } - // Try v1 → migrate into channel "stable". - var ov1 oldManifest - if err := json.Unmarshal(b, &ov1); err != nil { + // Try old V2 single-product + var v2 oldV2Single + if err := json.Unmarshal(b, &v2); err == nil && v2.Releases != nil { + mm := MultiManifest{ + Vendor: v2.Vendor, + UpdatedAt: time.Now().UTC(), + DefaultProduct: v2.Product, + Products: make(map[string]*ProductManifest), + } + pm := &ProductManifest{ + Product: v2.Product, + DefaultBranch: v2.DefaultBranch, + DefaultChannel: firstNonEmpty(v2.DefaultChannel, "stable"), + Releases: v2.Releases, + } + mm.Products[v2.Product] = pm + s.mu.Lock() + s.manifest = mm + s.mu.Unlock() + return nil + } + // Try old V1 (no channels) → migrate to stable channel, then into multi + var v1 oldV1Manifest + if err := json.Unmarshal(b, &v1); err != nil { return fmt.Errorf("invalid manifest json: %w", err) } - mig := Manifest{ - Vendor: ov1.Vendor, - Product: ov1.Product, - DefaultBranch: ov1.DefaultBranch, - DefaultChannel: "stable", - UpdatedAt: time.Now().UTC(), - Releases: make(map[string]map[string]map[string]map[string]map[string]Release), - } - for br, archs := range ov1.Releases { - if _, ok := mig.Releases[br]; !ok { - mig.Releases[br] = make(map[string]map[string]map[string]map[string]Release) + releases := make(map[string]map[string]map[string]map[string]map[string]Release) + for br, archs := range v1.Releases { + if _, ok := releases[br]; !ok { + releases[br] = make(map[string]map[string]map[string]map[string]Release) } - ch := mig.Releases[br] + ch := releases[br] if _, ok := ch["stable"]; !ok { ch["stable"] = make(map[string]map[string]map[string]Release) } @@ -151,13 +191,29 @@ func (s *store) loadIfExists() error { ch["stable"][arch][bit] = make(map[string]Release) } for osname, rel := range osmap { + rel.ChannelHint = "stable" ch["stable"][arch][bit][osname] = rel } } } } + mm := MultiManifest{ + Vendor: v1.Vendor, + UpdatedAt: time.Now().UTC(), + DefaultProduct: firstNonEmpty(v1.Product, s.manifest.DefaultProduct), + Products: make(map[string]*ProductManifest), + } + if mm.DefaultProduct == "" { + mm.DefaultProduct = "Product" + } + mm.Products[mm.DefaultProduct] = &ProductManifest{ + Product: mm.DefaultProduct, + DefaultBranch: v1.DefaultBranch, + DefaultChannel: "stable", + Releases: releases, + } s.mu.Lock() - s.manifest = mig + s.manifest = mm s.mu.Unlock() return nil } @@ -169,11 +225,9 @@ func (s *store) persistLocked() error { if err != nil { return err } - // Ensure dir if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } - // Write atomically tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o644); err != nil { return err @@ -181,6 +235,18 @@ func (s *store) persistLocked() error { return os.Rename(tmp, s.path) } +func (s *store) ensureProduct(name string) *ProductManifest { + p, ok := s.manifest.Products[name] + if !ok { + p = &ProductManifest{Product: name, DefaultChannel: "stable", Releases: make(map[string]map[string]map[string]map[string]map[string]Release)} + s.manifest.Products[name] = p + if s.manifest.DefaultProduct == "" { + s.manifest.DefaultProduct = name + } + } + return p +} + func (s *store) setLatest(pr publishRequest) error { if err := validateTuple(pr.Branch, pr.Channel, pr.Arch, pr.Bit, pr.OS); err != nil { return err @@ -188,13 +254,22 @@ func (s *store) setLatest(pr publishRequest) error { if err := validateRelease(pr.Release); err != nil { return err } + product := strings.TrimSpace(pr.Product) + if product == "" { + product = s.manifest.DefaultProduct + } + if product == "" { + return errors.New("product required (none configured as default)") + } + s.mu.Lock() defer s.mu.Unlock() + pm := s.ensureProduct(product) // Create levels if missing - lvl1, ok := s.manifest.Releases[pr.Branch] + lvl1, ok := pm.Releases[pr.Branch] if !ok { lvl1 = make(map[string]map[string]map[string]map[string]Release) - s.manifest.Releases[pr.Branch] = lvl1 + pm.Releases[pr.Branch] = lvl1 } lvlCh, ok := lvl1[pr.Channel] if !ok { @@ -217,13 +292,20 @@ func (s *store) setLatest(pr publishRequest) error { return s.persistLocked() } -func (s *store) getLatest(branch, channel, arch, bit, osname string) (Release, bool) { +func (s *store) getLatest(product, branch, channel, arch, bit, osname string) (Release, bool) { if err := validateTuple(branch, channel, arch, bit, osname); err != nil { return Release{}, false } s.mu.RLock() defer s.mu.RUnlock() - lvl1, ok := s.manifest.Releases[branch] + if product == "" { + product = s.manifest.DefaultProduct + } + pm, ok := s.manifest.Products[product] + if !ok { + return Release{}, false + } + lvl1, ok := pm.Releases[branch] if !ok { return Release{}, false } @@ -243,17 +325,63 @@ func (s *store) getLatest(branch, channel, arch, bit, osname string) (Release, b return rel, ok } -func (s *store) branches() []string { +func (s *store) branches(product string) []string { s.mu.RLock() defer s.mu.RUnlock() - out := make([]string, 0, len(s.manifest.Releases)) - for k := range s.manifest.Releases { + if product == "" { + product = s.manifest.DefaultProduct + } + pm, ok := s.manifest.Products[product] + if !ok { + return nil + } + out := make([]string, 0, len(pm.Releases)) + for k := range pm.Releases { out = append(out, k) } sort.Strings(out) return out } +func (s *store) listProducts() (names []string, def string) { + s.mu.RLock() + defer s.mu.RUnlock() + def = s.manifest.DefaultProduct + for k := range s.manifest.Products { + names = append(names, k) + } + sort.Strings(names) + return +} + +func (s *store) createOrUpdateProduct(name, defBranch, defChannel string) error { + if strings.TrimSpace(name) == "" { + return errors.New("product name required") + } + if defChannel != "" { + if _, ok := allowedChannels[defChannel]; !ok { + return fmt.Errorf("invalid default_channel: %s", defChannel) + } + } + s.mu.Lock() + defer s.mu.Unlock() + pm, ok := s.manifest.Products[name] + if !ok { + pm = &ProductManifest{Product: name, DefaultChannel: firstNonEmpty(defChannel, "stable"), Releases: make(map[string]map[string]map[string]map[string]map[string]Release)} + s.manifest.Products[name] = pm + if s.manifest.DefaultProduct == "" { + s.manifest.DefaultProduct = name + } + } + if defBranch != "" { + pm.DefaultBranch = defBranch + } + if defChannel != "" { + pm.DefaultChannel = defChannel + } + return s.persistLocked() +} + // ---- Validation ------------------------------------------------------------- var ( @@ -279,6 +407,17 @@ func validateTuple(branch, channel, arch, bit, osname string) error { if _, ok := allowedOS[osname]; !ok { return fmt.Errorf("invalid os: %s", osname) } + // Optional cross-check + switch arch { + case "386", "armv7": + if bit != "32" { + return fmt.Errorf("invalid bit %s for arch %s; expected 32", bit, arch) + } + case "amd64", "arm64", "ppc64le": + if bit != "64" { + return fmt.Errorf("invalid bit %s for arch %s; expected 64", bit, arch) + } + } return nil } @@ -316,7 +455,7 @@ func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) { etag := sha256.Sum256(b) etagStr := "\"" + hex.EncodeToString(etag[:]) + "\"" w.Header().Set("ETag", etagStr) - if inm := r.Header.Get("If-None-Match"); inm != "" && strings.Contains(inm, strings.Trim(etagStr, "\"")) { + if etagMatches(r, etagStr) { w.WriteHeader(http.StatusNotModified) return } @@ -324,16 +463,32 @@ func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) { _, _ = w.Write(b) } +func etagMatches(r *http.Request, etagQuoted string) bool { + in := r.Header.Get("If-None-Match") + if in == "" { + return false + } + want := strings.Trim(etagQuoted, "\"") + for _, part := range strings.Split(in, ",") { + p := strings.TrimSpace(part) + p = strings.TrimPrefix(p, "W/") + p = strings.Trim(p, "\"") + if p == want { + return true + } + } + return false +} + func parseJSON(r *http.Request, dst any) error { defer r.Body.Close() - lr := io.LimitReader(r.Body, 1<<20) // 1 MiB payload cap + lr := io.LimitReader(r.Body, 1<<20) dec := json.NewDecoder(lr) dec.DisallowUnknownFields() return dec.Decode(dst) } func cors(w http.ResponseWriter, r *http.Request) bool { - // Permissive CORS for simplicity (can be tightened later) w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Vary", "Origin") if r.Method == http.MethodOptions { @@ -349,7 +504,7 @@ func cors(w http.ResponseWriter, r *http.Request) bool { type server struct { st *store - apiToken string // optional; if set, required for POST /v1/publish & /v1/config + apiToken string // optional; if set, required for POST endpoints } func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -359,6 +514,7 @@ func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// Global (legacy) endpoints operate on DefaultProduct to avoid breaking clients. func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return @@ -367,12 +523,61 @@ func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + product := r.URL.Query().Get("product") s.st.mu.RLock() - m := s.st.manifest + pm := s.getProductUnsafe(product) + // If no product resolved, return the full multi-manifest at _all route semantics here + if pm == nil { + mm := s.st.manifest + s.st.mu.RUnlock() + writeJSON(w, r, http.StatusOK, mm) + return + } + m := *pm // copy (shallow) s.st.mu.RUnlock() writeJSON(w, r, http.StatusOK, m) } +func (s *server) handleValues(w http.ResponseWriter, r *http.Request) { + if cors(w, r) { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + product := r.URL.Query().Get("product") + s.st.mu.RLock() + pm := s.getProductUnsafe(product) + vendor := s.st.manifest.Vendor + defProduct := s.st.manifest.DefaultProduct + s.st.mu.RUnlock() + archs := keysOf(allowedArch) + bits := keysOf(allowedBit) + oss := keysOf(allowedOS) + chs := keysOf(allowedChannels) + resp := map[string]any{"arch": archs, "bit": bits, "os": oss, "channels": chs, "meta": map[string]string{"vendor": vendor}} + if pm != nil { + resp["defaults"] = map[string]string{"branch": pm.DefaultBranch, "channel": pm.DefaultChannel} + resp["product"] = pm.Product + } else { + resp["defaults"] = map[string]string{"branch": "", "channel": "stable"} + resp["product"] = defProduct + } + writeJSON(w, r, http.StatusOK, resp) +} + +func (s *server) getProductUnsafe(name string) *ProductManifest { + if strings.TrimSpace(name) == "" { + name = s.st.manifest.DefaultProduct + } + if name == "" { + return nil + } + pm := s.st.manifest.Products[name] + return pm +} + func (s *server) handleBranches(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return @@ -381,7 +586,8 @@ func (s *server) handleBranches(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - writeJSON(w, r, http.StatusOK, map[string]any{"branches": s.st.branches()}) + product := r.URL.Query().Get("product") + writeJSON(w, r, http.StatusOK, map[string]any{"branches": s.st.branches(product)}) } func (s *server) handleChannels(w http.ResponseWriter, r *http.Request) { @@ -392,39 +598,17 @@ func (s *server) handleChannels(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - keys := make([]string, 0, len(allowedChannels)) - for k := range allowedChannels { - keys = append(keys, k) - } - sort.Strings(keys) - writeJSON(w, r, http.StatusOK, map[string]any{"channels": keys, "default": s.st.manifest.DefaultChannel}) -} - -func (s *server) handleValues(w http.ResponseWriter, r *http.Request) { - // returns allowed enums + defaults to drive the UI - if cors(w, r) { - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - archs := keysOf(allowedArch) - bits := keysOf(allowedBit) - oss := keysOf(allowedOS) - chs := keysOf(allowedChannels) + keys := keysOfMap(allowedChannels) s.st.mu.RLock() - defBr, defCh := s.st.manifest.DefaultBranch, s.st.manifest.DefaultChannel - vendor, product := s.st.manifest.Vendor, s.st.manifest.Product + defCh := "stable" + if pm := s.getProductUnsafe(""); pm != nil { + defCh = pm.DefaultChannel + } s.st.mu.RUnlock() - writeJSON(w, r, http.StatusOK, map[string]any{ - "arch": archs, "bit": bits, "os": oss, "channels": chs, - "defaults": map[string]string{"branch": defBr, "channel": defCh}, - "meta": map[string]string{"vendor": vendor, "product": product}, - }) + writeJSON(w, r, http.StatusOK, map[string]any{"channels": keys, "default": defCh}) } -func keysOf(m map[string]struct{}) []string { +func keysOfMap(m map[string]struct{}) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) @@ -442,25 +626,40 @@ func (s *server) handleLatest(w http.ResponseWriter, r *http.Request) { return } q := r.URL.Query() - branch := firstNonEmpty(q.Get("branch"), s.st.manifest.DefaultBranch) - channel := firstNonEmpty(q.Get("channel"), s.st.manifest.DefaultChannel) + product := q.Get("product") + branch := q.Get("branch") + channel := q.Get("channel") arch := q.Get("arch") bit := q.Get("bit") osname := q.Get("os") - if branch == "" || channel == "" || arch == "" || bit == "" || osname == "" { - http.Error(w, "missing query params: branch, channel, arch, bit, os", http.StatusBadRequest) + + // Fill defaults from product + s.st.mu.RLock() + pm := s.getProductUnsafe(product) + s.st.mu.RUnlock() + if pm != nil { + if branch == "" { + branch = pm.DefaultBranch + } + if channel == "" { + channel = pm.DefaultChannel + } + product = pm.Product + } + if product == "" || branch == "" || channel == "" || arch == "" || bit == "" || osname == "" { + http.Error(w, "missing query params: product, branch, channel, arch, bit, os (or configure defaults)", http.StatusBadRequest) return } - rel, ok := s.st.getLatest(branch, channel, arch, bit, osname) + rel, ok := s.st.getLatest(product, branch, channel, arch, bit, osname) if !ok { http.Error(w, "not found", http.StatusNotFound) return } - writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel}) + writeJSON(w, r, http.StatusOK, latestResponse{Product: product, Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel}) } func (s *server) handleLatestPath(w http.ResponseWriter, r *http.Request) { - // /v1/latest/{branch}/{channel}/{arch}/{bit}/{os} + // /v1/latest/{product}/{branch}/{channel}/{arch}/{bit}/{os} if cors(w, r) { return } @@ -469,17 +668,17 @@ func (s *server) handleLatestPath(w http.ResponseWriter, r *http.Request) { return } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/latest/"), "/") - if len(parts) != 5 { - http.Error(w, "expected /v1/latest/{branch}/{channel}/{arch}/{bit}/{os}", http.StatusBadRequest) + if len(parts) != 6 { + http.Error(w, "expected /v1/latest/{product}/{branch}/{channel}/{arch}/{bit}/{os}", http.StatusBadRequest) return } - branch, channel, arch, bit, osname := parts[0], parts[1], parts[2], parts[3], parts[4] - rel, ok := s.st.getLatest(branch, channel, arch, bit, osname) + product, branch, channel, arch, bit, osname := parts[0], parts[1], parts[2], parts[3], parts[4], parts[5] + rel, ok := s.st.getLatest(product, branch, channel, arch, bit, osname) if !ok { http.Error(w, "not found", http.StatusNotFound) return } - writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel}) + writeJSON(w, r, http.StatusOK, latestResponse{Product: product, Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel}) } func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) { @@ -491,7 +690,6 @@ func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - // Auth (if token configured) if s.apiToken != "" { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken { @@ -504,6 +702,10 @@ func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest) return } + // product may also be supplied via query + if pr.Product == "" { + pr.Product = r.URL.Query().Get("product") + } if err := s.st.setLatest(pr); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -511,7 +713,8 @@ func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) { writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"}) } -// handleConfig allows updating vendor/product/defaults (token required if set) +// handleConfig allows updating vendor/product defaults. If ?product= is present, it +// updates that product; otherwise, updates vendor-level defaults (DefaultProduct). func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { cors(w, r) @@ -528,34 +731,54 @@ func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) { return } } - var req struct { - Vendor string `json:"vendor"` - Product string `json:"product"` - DefaultBranch string `json:"default_branch"` - DefaultChannel string `json:"default_channel"` + + product := r.URL.Query().Get("product") + if product != "" { + var req struct { + DefaultBranch string `json:"default_branch"` + DefaultChannel string `json:"default_channel"` + } + if err := parseJSON(r, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest) + return + } + if req.DefaultChannel != "" { + if _, ok := allowedChannels[req.DefaultChannel]; !ok { + http.Error(w, "invalid default_channel", http.StatusBadRequest) + return + } + } + s.st.mu.Lock() + pm := s.st.ensureProduct(product) + if req.DefaultBranch != "" { + pm.DefaultBranch = req.DefaultBranch + } + if req.DefaultChannel != "" { + pm.DefaultChannel = req.DefaultChannel + } + if err := s.st.persistLocked(); err != nil { + s.st.mu.Unlock() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.st.mu.Unlock() + writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"}) + return } + // Vendor/global + var req struct{ Vendor, DefaultProduct string } if err := parseJSON(r, &req); err != nil { http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest) return } - if req.DefaultChannel != "" { - if _, ok := allowedChannels[req.DefaultChannel]; !ok { - http.Error(w, "invalid default_channel", http.StatusBadRequest) - return - } - } s.st.mu.Lock() - if req.Vendor != "" { + if strings.TrimSpace(req.Vendor) != "" { s.st.manifest.Vendor = req.Vendor } - if req.Product != "" { - s.st.manifest.Product = req.Product - } - if req.DefaultBranch != "" { - s.st.manifest.DefaultBranch = req.DefaultBranch - } - if req.DefaultChannel != "" { - s.st.manifest.DefaultChannel = req.DefaultChannel + if strings.TrimSpace(req.DefaultProduct) != "" { + if _, ok := s.st.manifest.Products[req.DefaultProduct]; ok { + s.st.manifest.DefaultProduct = req.DefaultProduct + } } if err := s.st.persistLocked(); err != nil { s.st.mu.Unlock() @@ -566,6 +789,42 @@ func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"}) } +// Products management +func (s *server) handleProducts(w http.ResponseWriter, r *http.Request) { + if cors(w, r) { + return + } + switch r.Method { + case http.MethodGet: + names, def := s.st.listProducts() + writeJSON(w, r, http.StatusOK, map[string]any{"products": names, "default": def}) + case http.MethodPost: + if s.apiToken != "" { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + var req struct { + Product string `json:"product"` + DefaultBranch string `json:"default_branch"` + DefaultChannel string `json:"default_channel"` + } + if err := parseJSON(r, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest) + return + } + if err := s.st.createOrUpdateProduct(req.Product, req.DefaultBranch, req.DefaultChannel); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + // ---- Admin UI --------------------------------------------------------------- //go:embed admin.html @@ -578,6 +837,13 @@ func (s *server) handleAdmin(w http.ResponseWriter, r *http.Request) { http.Error(w, "admin.html not embedded; ensure //go:embed admin.html and file exists: "+err.Error(), http.StatusInternalServerError) return } + sum := sha256.Sum256(b) + etag := "\"" + hex.EncodeToString(sum[:]) + "\"" + w.Header().Set("ETag", etag) + if etagMatches(r, etag) { + w.WriteHeader(http.StatusNotModified) + return + } w.WriteHeader(http.StatusOK) _, _ = w.Write(b) } @@ -588,33 +854,31 @@ func main() { addr := envOr("HTTP_PUBLIC", ":8080") manifestPath := envOr("MANIFEST_PATH", "/data/manifest.json") vendor := envOr("APP_VENDOR", "YourVendor") - product := envOr("APP_PRODUCT", "YourProduct") + defaultProduct := envOr("APP_PRODUCT", "YourProduct") token := envOr("API_TOKEN", "") // optional; if set, required for POST - st := newStore(manifestPath, vendor, product) + st := newStore(manifestPath, vendor, defaultProduct) if err := st.loadIfExists(); err != nil { log.Fatalf("load manifest: %v", err) } srv := &server{st: st, apiToken: token} - //Default Route - http.Handle("/", http.RedirectHandler("/v1/manifest", http.StatusSeeOther)) - http.HandleFunc("/healthz", srv.handleHealth) http.HandleFunc("/admin", srv.handleAdmin) - // Data API - http.HandleFunc("/v1/manifest", srv.handleManifest) + // Product management & global (legacy-compatible) API + http.HandleFunc("/v1/products", srv.handleProducts) + http.HandleFunc("/v1/manifest", srv.handleManifest) // returns default product manifest or multi when no default http.HandleFunc("/v1/values", srv.handleValues) http.HandleFunc("/v1/branches", srv.handleBranches) http.HandleFunc("/v1/channels", srv.handleChannels) http.HandleFunc("/v1/latest", srv.handleLatest) - http.HandleFunc("/v1/latest/", srv.handleLatestPath) - http.HandleFunc("/v1/publish", srv.handlePublish) - http.HandleFunc("/v1/config", srv.handleConfig) + // Path variant now includes product as first segment + http.HandleFunc("/v1/latest/", srv.handleLatestPath) // /v1/latest/{product}/{branch}/{channel}/{arch}/{bit}/{os} + http.HandleFunc("/v1/publish", srv.handlePublish) // accepts product in JSON or ?product= + http.HandleFunc("/v1/config", srv.handleConfig) // vendor or ?product= - fmt.Println(addr, manifestPath, vendor, product, token) log.Printf("agent listening on %s (admin UI at /admin)", addr) log.Fatal(http.ListenAndServe(addr, nil)) } @@ -636,3 +900,12 @@ func firstNonEmpty(vals ...string) string { } return "" } + +func keysOf(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +}