All checks were successful
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
build-binaries / publish-agent (push) Has been skipped
release-tag / release-image (push) Successful in 2m7s
912 lines
27 KiB
Go
912 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ---- Data model -------------------------------------------------------------
|
|
|
|
// Asset describes a downloadable artifact for a release.
|
|
// Keep it minimal and verifiable.
|
|
// All fields are JSON-tagged for a stable API.
|
|
type Asset struct {
|
|
URL string `json:"url"`
|
|
SHA256 string `json:"sha256"`
|
|
Size int64 `json:"size_bytes,omitempty"`
|
|
SignatureURL string `json:"signature_url,omitempty"`
|
|
ContentType string `json:"content_type,omitempty"`
|
|
}
|
|
|
|
// Release holds the metadata for a specific Branch→Channel→Arch→Bit→OS combo.
|
|
// Channel is duplicated in the payload for clarity in responses.
|
|
type Release struct {
|
|
Version string `json:"version"` // e.g. 12.3.1
|
|
Build string `json:"build,omitempty"` // optional build id
|
|
ReleasedAt time.Time `json:"released_at"` // RFC3339
|
|
NotesURL string `json:"notes_url,omitempty"`
|
|
Assets []Asset `json:"assets"`
|
|
Meta map[string]string `json:"meta,omitempty"` // optional free-form
|
|
ChannelHint string `json:"channel,omitempty"` // echoed by server
|
|
}
|
|
|
|
// ProductManifest nests by Branch→Channel→Arch→Bit→OS.
|
|
// Example path: Releases["12.x"]["stable"]["amd64"]["64"]["windows"]
|
|
type ProductManifest struct {
|
|
Product string `json:"product"`
|
|
DefaultBranch string `json:"default_branch,omitempty"`
|
|
DefaultChannel string `json:"default_channel,omitempty"`
|
|
Releases map[string]map[string]map[string]map[string]map[string]Release `json:"releases"`
|
|
}
|
|
|
|
// 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"`
|
|
Bit string `json:"bit"` // "32" or "64"
|
|
OS string `json:"os"`
|
|
Release Release `json:"release"`
|
|
}
|
|
|
|
// 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"`
|
|
Bit string `json:"bit"`
|
|
OS string `json:"os"`
|
|
Release Release `json:"release"`
|
|
}
|
|
|
|
// ---- Store & persistence ----------------------------------------------------
|
|
|
|
type store struct {
|
|
mu sync.RWMutex
|
|
manifest MultiManifest
|
|
path string
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
// 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"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
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 {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
// 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 = multi
|
|
s.mu.Unlock()
|
|
return 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)
|
|
}
|
|
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 := releases[br]
|
|
if _, ok := ch["stable"]; !ok {
|
|
ch["stable"] = make(map[string]map[string]map[string]Release)
|
|
}
|
|
for arch, bits := range archs {
|
|
if _, ok := ch["stable"][arch]; !ok {
|
|
ch["stable"][arch] = make(map[string]map[string]Release)
|
|
}
|
|
for bit, osmap := range bits {
|
|
if _, ok := ch["stable"][arch][bit]; !ok {
|
|
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 = mm
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *store) persistLocked() error {
|
|
// Caller must hold s.mu (write)
|
|
s.manifest.UpdatedAt = time.Now().UTC()
|
|
b, err := json.MarshalIndent(s.manifest, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
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 := pm.Releases[pr.Branch]
|
|
if !ok {
|
|
lvl1 = make(map[string]map[string]map[string]map[string]Release)
|
|
pm.Releases[pr.Branch] = lvl1
|
|
}
|
|
lvlCh, ok := lvl1[pr.Channel]
|
|
if !ok {
|
|
lvlCh = make(map[string]map[string]map[string]Release)
|
|
lvl1[pr.Channel] = lvlCh
|
|
}
|
|
lvl2, ok := lvlCh[pr.Arch]
|
|
if !ok {
|
|
lvl2 = make(map[string]map[string]Release)
|
|
lvlCh[pr.Arch] = lvl2
|
|
}
|
|
lvl3, ok := lvl2[pr.Bit]
|
|
if !ok {
|
|
lvl3 = make(map[string]Release)
|
|
lvl2[pr.Bit] = lvl3
|
|
}
|
|
rel := pr.Release
|
|
rel.ChannelHint = pr.Channel
|
|
lvl3[pr.OS] = rel
|
|
return s.persistLocked()
|
|
}
|
|
|
|
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()
|
|
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
|
|
}
|
|
lvlCh, ok := lvl1[channel]
|
|
if !ok {
|
|
return Release{}, false
|
|
}
|
|
lvl2, ok := lvlCh[arch]
|
|
if !ok {
|
|
return Release{}, false
|
|
}
|
|
lvl3, ok := lvl2[bit]
|
|
if !ok {
|
|
return Release{}, false
|
|
}
|
|
rel, ok := lvl3[osname]
|
|
return rel, ok
|
|
}
|
|
|
|
func (s *store) branches(product string) []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
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 (
|
|
allowedOS = map[string]struct{}{"windows": {}, "linux": {}, "macos": {}, "freebsd": {}}
|
|
allowedArch = map[string]struct{}{"amd64": {}, "386": {}, "arm64": {}, "armv7": {}, "ppc64le": {}}
|
|
allowedBit = map[string]struct{}{"64": {}, "32": {}}
|
|
allowedChannels = map[string]struct{}{"stable": {}, "beta": {}, "rc": {}, "nightly": {}}
|
|
)
|
|
|
|
func validateTuple(branch, channel, arch, bit, osname string) error {
|
|
if strings.TrimSpace(branch) == "" {
|
|
return errors.New("branch required, e.g. '12.x'")
|
|
}
|
|
if _, ok := allowedChannels[channel]; !ok {
|
|
return fmt.Errorf("invalid channel: %s", channel)
|
|
}
|
|
if _, ok := allowedArch[arch]; !ok {
|
|
return fmt.Errorf("invalid arch: %s", arch)
|
|
}
|
|
if _, ok := allowedBit[bit]; !ok {
|
|
return fmt.Errorf("invalid bit: %s (use '32' or '64')", bit)
|
|
}
|
|
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
|
|
}
|
|
|
|
func validateRelease(r Release) error {
|
|
if strings.TrimSpace(r.Version) == "" {
|
|
return errors.New("release.version required")
|
|
}
|
|
if r.ReleasedAt.IsZero() {
|
|
return errors.New("release.released_at required (RFC3339)")
|
|
}
|
|
if len(r.Assets) == 0 {
|
|
return errors.New("release.assets must not be empty")
|
|
}
|
|
for i, a := range r.Assets {
|
|
if a.URL == "" {
|
|
return fmt.Errorf("assets[%d].url required", i)
|
|
}
|
|
if a.SHA256 == "" {
|
|
return fmt.Errorf("assets[%d].sha256 required", i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- HTTP helpers -----------------------------------------------------------
|
|
|
|
func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
http.Error(w, "json marshal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
etag := sha256.Sum256(b)
|
|
etagStr := "\"" + hex.EncodeToString(etag[:]) + "\""
|
|
w.Header().Set("ETag", etagStr)
|
|
if etagMatches(r, etagStr) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
w.WriteHeader(status)
|
|
_, _ = 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)
|
|
dec := json.NewDecoder(lr)
|
|
dec.DisallowUnknownFields()
|
|
return dec.Decode(dst)
|
|
}
|
|
|
|
func cors(w http.ResponseWriter, r *http.Request) bool {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Vary", "Origin")
|
|
if r.Method == http.MethodOptions {
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---- Handlers ---------------------------------------------------------------
|
|
|
|
type server struct {
|
|
st *store
|
|
apiToken string // optional; if set, required for POST endpoints
|
|
}
|
|
|
|
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
if cors(w, r) {
|
|
return
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
// 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
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
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) {
|
|
if cors(w, r) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
keys := keysOfMap(allowedChannels)
|
|
s.st.mu.RLock()
|
|
defCh := "stable"
|
|
if pm := s.getProductUnsafe(""); pm != nil {
|
|
defCh = pm.DefaultChannel
|
|
}
|
|
s.st.mu.RUnlock()
|
|
writeJSON(w, r, http.StatusOK, map[string]any{"channels": keys, "default": defCh})
|
|
}
|
|
|
|
func keysOfMap(m map[string]struct{}) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (s *server) handleLatest(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
|
|
}
|
|
q := r.URL.Query()
|
|
product := q.Get("product")
|
|
branch := q.Get("branch")
|
|
channel := q.Get("channel")
|
|
arch := q.Get("arch")
|
|
bit := q.Get("bit")
|
|
osname := q.Get("os")
|
|
|
|
// 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(product, branch, channel, arch, bit, osname)
|
|
if !ok {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
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/{product}/{branch}/{channel}/{arch}/{bit}/{os}
|
|
if cors(w, r) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/latest/"), "/")
|
|
if len(parts) != 6 {
|
|
http.Error(w, "expected /v1/latest/{product}/{branch}/{channel}/{arch}/{bit}/{os}", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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{Product: product, Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
|
|
}
|
|
|
|
func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodOptions {
|
|
cors(w, r)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
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 pr publishRequest
|
|
if err := parseJSON(r, &pr); err != nil {
|
|
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
|
|
}
|
|
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// 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)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
s.st.mu.Lock()
|
|
if strings.TrimSpace(req.Vendor) != "" {
|
|
s.st.manifest.Vendor = req.Vendor
|
|
}
|
|
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()
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.st.mu.Unlock()
|
|
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
|
|
var adminHTML embed.FS
|
|
|
|
func (s *server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
b, err := adminHTML.ReadFile("admin.html")
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
// ---- Main ------------------------------------------------------------------
|
|
|
|
func main() {
|
|
addr := envOr("HTTP_PUBLIC", ":8080")
|
|
manifestPath := envOr("MANIFEST_PATH", "/data/manifest.json")
|
|
vendor := envOr("APP_VENDOR", "YourVendor")
|
|
defaultProduct := envOr("APP_PRODUCT", "YourProduct")
|
|
token := envOr("API_TOKEN", "") // optional; if set, required for POST
|
|
|
|
st := newStore(manifestPath, vendor, defaultProduct)
|
|
if err := st.loadIfExists(); err != nil {
|
|
log.Fatalf("load manifest: %v", err)
|
|
}
|
|
|
|
srv := &server{st: st, apiToken: token}
|
|
|
|
http.HandleFunc("/healthz", srv.handleHealth)
|
|
http.HandleFunc("/admin", srv.handleAdmin)
|
|
|
|
// 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)
|
|
// 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=
|
|
|
|
log.Printf("agent listening on %s (admin UI at /admin)", addr)
|
|
log.Fatal(http.ListenAndServe(addr, nil))
|
|
}
|
|
|
|
// ---- Utils -----------------------------------------------------------------
|
|
|
|
func envOr(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
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
|
|
}
|