Files
release-hub/main.go
jbergner 651bd375e2
Some checks failed
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
release-tag / release-image (push) Failing after 40s
init
2025-10-25 12:30:26 +02:00

1190 lines
33 KiB
Go

// Release Hub — zentraler Manifest-Aggregator für Patch-DB Agents
//
// Ziele
// - Zentrale Verwaltung mehrerer Agent-Endpunkte (Manifest-URLs)
// - Regelmäßiges Pullen/Parsen der Manifeste
// - Vereinheitlichte Release-Liste mit Filter-/Such-UI (Server-seitig gefiltert)
// - Reines Go (Standardbibliothek), ohne externe Cloud/CDN
// - Persistenz in lokalen JSON-Dateien (Agents + Cache)
// - REST-JSON-API (/api/releases, /api/agents)
//
// Starten
// go run .
//
// Hinweis zum erwarteten Manifest-Format
// - Bevorzugt:
// { "releases": [ { "version": "1.2.3", "released_at": "2025-10-01T10:00:00Z", ... } ] }
// - Alternativ: ein reines Array [ { ... }, { ... } ]
// - Außerdem unterstützt: verschachtelte JSON-Strukturen. Insbesondere das Schema
// releases → branch → channel → arch → bitness → os → { release-obj }
// wird erkannt und in flache Releases umgewandelt. Zusätzlich akzeptiert der Hub
// gängige Alias-Felder (z. B. releasedAt/release_date/published_at → released_at,
// size → size_bytes, sha256sum → sha256, ...).
// - Datumsformat: RFC3339, YYYY-MM-DD, oder Sekunden seit Epoch.
// - Bekannte Felder: version, build, released_at, branch, channel, os, arch, notes, assets[]
// - assets: { url, sha256, size_bytes, signature_url, content_type }
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode"
)
// ---- Datenmodell ------------------------------------------------------------
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"`
}
type FlexTime struct{ time.Time }
func (ft *FlexTime) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
if s == "" || s == "null" {
return nil
}
// Versuche RFC3339, dann Datum-only
if t, err := time.Parse(time.RFC3339, s); err == nil {
ft.Time = t
return nil
}
if t, err := time.Parse("2006-01-02", s); err == nil {
ft.Time = t
return nil
}
// Fallback: versuche seconds since epoch
if sec, err := strconv.ParseInt(s, 10, 64); err == nil {
ft.Time = time.Unix(sec, 0).UTC()
return nil
}
return fmt.Errorf("unsupported time format: %q", s)
}
// Release ist die vereinheitlichte Sicht auf Agent-Releases.
// Felder sind konservativ gewählt, weitere können bei Bedarf ergänzt werden.
type Release struct {
Version string `json:"version"`
Build string `json:"build,omitempty"`
ReleasedAt FlexTime `json:"released_at"`
Branch string `json:"branch,omitempty"`
Channel string `json:"channel,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Notes string `json:"notes,omitempty"`
Vendor string `json:"vendor,omitempty"`
Product string `json:"product,omitempty"`
Assets []Asset `json:"assets,omitempty"`
// Quelle (wird vom Hub gesetzt)
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
}
// ManifestEnvelope erlaubt sowohl {"releases": [...] } als auch reines Array
// zu dekodieren.
type ManifestEnvelope struct {
Releases []Release `json:"releases"`
}
// Agent beschreibt einen registrierten Manifest-Endpunkt.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
EndpointURL string `json:"endpoint_url"`
Enabled bool `json:"enabled"`
LastOK time.Time `json:"last_ok,omitempty"`
LastError string `json:"last_error,omitempty"`
ETag string `json:"etag,omitempty"`
LastModified string `json:"last_modified,omitempty"`
}
// ---- Persistence ------------------------------------------------------------
type Store struct {
mu sync.RWMutex
agents map[string]*Agent
releases []Release // zusammengeführte, letzte erfolgreiche Pulls
dataDir string
}
func NewStore(dataDir string) *Store {
return &Store{
agents: make(map[string]*Agent),
dataDir: dataDir,
}
}
func (s *Store) load() error {
if err := os.MkdirAll(s.dataDir, 0o755); err != nil {
return err
}
// Agents
b, err := os.ReadFile(filepath.Join(s.dataDir, "agents.json"))
if err == nil {
var list []*Agent
if err := json.Unmarshal(b, &list); err != nil {
return err
}
for _, a := range list {
s.agents[a.ID] = a
}
}
// Releases Cache
b, err = os.ReadFile(filepath.Join(s.dataDir, "releases-cache.json"))
if err == nil {
var rr []Release
if err := json.Unmarshal(b, &rr); err != nil {
return err
}
s.releases = rr
}
return nil
}
func (s *Store) saveAgents() error {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]*Agent, 0, len(s.agents))
for _, a := range s.agents {
list = append(list, a)
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
b, _ := json.MarshalIndent(list, "", " ")
return os.WriteFile(filepath.Join(s.dataDir, "agents.json"), b, 0o644)
}
func (s *Store) saveReleases() error {
s.mu.RLock()
defer s.mu.RUnlock()
b, _ := json.MarshalIndent(s.releases, "", " ")
return os.WriteFile(filepath.Join(s.dataDir, "releases-cache.json"), b, 0o644)
}
// ---- Fetching ---------------------------------------------------------------
type Fetcher struct {
client *http.Client
store *Store
interval time.Duration
stopCh chan struct{}
}
func NewFetcher(store *Store, interval time.Duration) *Fetcher {
return &Fetcher{
client: &http.Client{Timeout: 20 * time.Second},
store: store,
interval: interval,
stopCh: make(chan struct{}),
}
}
func (f *Fetcher) Start() {
go func() {
// Initial sofortiger Lauf
f.FetchOnce()
ticker := time.NewTicker(f.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
f.FetchOnce()
case <-f.stopCh:
return
}
}
}()
}
func (f *Fetcher) Stop() { close(f.stopCh) }
func (f *Fetcher) FetchOnce() {
f.store.mu.RLock()
agents := make([]*Agent, 0, len(f.store.agents))
for _, a := range f.store.agents {
if a.Enabled {
agents = append(agents, a)
}
}
f.store.mu.RUnlock()
var all []Release
var mu sync.Mutex
var wg sync.WaitGroup
for _, a := range agents {
a := a
wg.Add(1)
go func() {
defer wg.Done()
rels, err := f.fetchAgent(a)
if err != nil {
log.Printf("fetch agent %s: %v", a.Name, err)
f.store.mu.Lock()
a.LastError = err.Error()
f.store.mu.Unlock()
return
}
for i := range rels {
rels[i].AgentID = a.ID
rels[i].AgentName = a.Name
}
mu.Lock()
all = append(all, rels...)
mu.Unlock()
f.store.mu.Lock()
a.LastError = ""
a.LastOK = time.Now().UTC()
f.store.mu.Unlock()
}()
}
wg.Wait()
// Vereinheitlichte Sortierung: neueste zuerst
sort.Slice(all, func(i, j int) bool {
return all[i].ReleasedAt.Time.After(all[j].ReleasedAt.Time)
})
f.store.mu.Lock()
f.store.releases = all
f.store.mu.Unlock()
_ = f.store.saveReleases()
}
func (f *Fetcher) fetchAgent(a *Agent) ([]Release, error) {
req, err := http.NewRequest(http.MethodGet, a.EndpointURL, nil)
if err != nil {
return nil, err
}
if a.ETag != "" {
req.Header.Set("If-None-Match", a.ETag)
}
if a.LastModified != "" {
req.Header.Set("If-Modified-Since", a.LastModified)
}
resp, err := f.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
f.store.mu.RLock()
var cached []Release
for _, r := range f.store.releases {
if r.AgentID == a.ID {
cached = append(cached, r)
}
}
f.store.mu.RUnlock()
return cached, nil
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("unexpected status %s: %s", resp.Status, string(b))
}
if et := resp.Header.Get("ETag"); et != "" {
a.ETag = et
}
if lm := resp.Header.Get("Last-Modified"); lm != "" {
a.LastModified = lm
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// 1) {"releases": [...]}
var env ManifestEnvelope
if err := json.Unmarshal(raw, &env); err == nil && len(env.Releases) > 0 {
return env.Releases, nil
}
// 2) Direktes Array
var list []Release
if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 {
return list, nil
}
// 3) Generisch durchsuchen (beliebig verschachtelt, Aliasse normalisieren)
if rr, err := parseAnyReleases(raw); err == nil && len(rr) > 0 {
return cleanReleases(rr), nil
}
return nil, errors.New("unrecognized manifest format: expected 'releases' or array")
}
// ---- Filtering & Query ------------------------------------------------------
type ReleaseFilter struct {
Q string
Channel string
Branch string
OS string
Arch string
Vendor string
Product string
From time.Time
To time.Time
}
func (f ReleaseFilter) match(r Release) bool {
if f.Q != "" {
q := strings.ToLower(f.Q)
hay := strings.ToLower(strings.Join([]string{
r.Version, r.Build, r.Branch, r.Channel, r.OS, r.Arch, r.Notes, r.Vendor, r.Product, r.AgentName,
}, ""))
if !strings.Contains(hay, q) {
return false
}
}
if f.Channel != "" && !equalFoldOrDash(r.Channel, f.Channel) {
return false
}
if f.Branch != "" && !equalFoldOrDash(r.Branch, f.Branch) {
return false
}
if f.OS != "" && !equalFoldOrDash(r.OS, f.OS) {
return false
}
if f.Arch != "" && !equalFoldOrDash(r.Arch, f.Arch) {
return false
}
if f.Vendor != "" && !equalFoldOrDash(r.Vendor, f.Vendor) {
return false
}
if f.Product != "" && !equalFoldOrDash(r.Product, f.Product) {
return false
}
if !f.From.IsZero() && r.ReleasedAt.Time.Before(f.From) {
return false
}
if !f.To.IsZero() && r.ReleasedAt.Time.After(f.To) {
return false
}
return true
}
func equalFoldOrDash(a, b string) bool {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
if a == "" && (b == "-" || b == "") {
return true
}
if b == "" && (a == "-" || a == "") {
return true
}
return strings.EqualFold(a, b)
}
// ---- HTTP/UI ---------------------------------------------------------------
type Server struct {
store *Store
fetcher *Fetcher
tz *time.Location
}
func NewServer(store *Store, fetcher *Fetcher) *Server {
tz, _ := time.LoadLocation("Europe/Berlin")
return &Server{store: store, fetcher: fetcher, tz: tz}
}
func (s *Server) routesAdmin(mux *http.ServeMux) {
mux.HandleFunc("/", s.handlerList(false))
mux.HandleFunc("/agents", s.handleAgents)
mux.HandleFunc("/agents/add", s.handleAgentAdd)
mux.HandleFunc("/agents/toggle", s.handleAgentToggle)
mux.HandleFunc("/agents/delete", s.handleAgentDelete)
mux.HandleFunc("/refresh", s.handleRefresh)
mux.HandleFunc("/api/releases", s.handleAPIReleases)
mux.HandleFunc("/api/agents", s.handleAPIAgents)
mux.HandleFunc("/healthz", s.handleHealth)
}
func (s *Server) routesPublic(mux *http.ServeMux) {
// Read-only Oberfläche, keine Admin- oder API-Endpunkte
mux.HandleFunc("/", s.handlerList(true))
mux.HandleFunc("/healthz", s.handleHealth)
}
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
go s.fetcher.FetchOnce()
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleAPIReleases(w http.ResponseWriter, r *http.Request) {
s.store.mu.RLock()
rels := append([]Release(nil), s.store.releases...)
s.store.mu.RUnlock()
// Optional Filter über Query-Params
rf := parseFilter(r)
out := make([]Release, 0, len(rels))
for _, v := range rels {
if rf.match(v) {
out = append(out, v)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
func (s *Server) handleAPIAgents(w http.ResponseWriter, r *http.Request) {
s.store.mu.RLock()
list := make([]*Agent, 0, len(s.store.agents))
for _, a := range s.store.agents {
list = append(list, a)
}
s.store.mu.RUnlock()
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(list)
}
func parseFilter(r *http.Request) ReleaseFilter {
q := strings.TrimSpace(r.URL.Query().Get("q"))
ch := strings.TrimSpace(r.URL.Query().Get("channel"))
br := strings.TrimSpace(r.URL.Query().Get("branch"))
osv := strings.TrimSpace(r.URL.Query().Get("os"))
ar := strings.TrimSpace(r.URL.Query().Get("arch"))
vndr := strings.TrimSpace(r.URL.Query().Get("vendor"))
prd := strings.TrimSpace(r.URL.Query().Get("product"))
var from, to time.Time
if v := strings.TrimSpace(r.URL.Query().Get("from")); v != "" {
from, _ = time.Parse("2006-01-02", v)
}
if v := strings.TrimSpace(r.URL.Query().Get("to")); v != "" {
to, _ = time.Parse("2006-01-02", v)
if !to.IsZero() {
to = to.Add(24 * time.Hour).Add(-time.Nanosecond)
}
}
return ReleaseFilter{Q: q, Channel: ch, Branch: br, OS: osv, Arch: ar, Vendor: vndr, Product: prd, From: from, To: to}
}
func (s *Server) handlerList(readOnly bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rf := parseFilter(r)
s.store.mu.RLock()
rels := append([]Release(nil), s.store.releases...)
s.store.mu.RUnlock()
filtered := make([]Release, 0, len(rels))
for _, v := range rels {
if rf.match(v) {
filtered = append(filtered, v)
}
}
data := struct {
Releases []Release
CountTotal int
CountShown int
Q, Channel, Branch, OS, Arch, Vendor, Product string
From, To string
Now string
BaseStyle string
ReadOnly bool
}{
Releases: filtered,
CountTotal: len(rels),
CountShown: len(filtered),
Q: rf.Q, Channel: rf.Channel, Branch: rf.Branch, OS: rf.OS, Arch: rf.Arch, Vendor: rf.Vendor, Product: rf.Product,
From: dateStr(rf.From), To: dateStr(rf.To),
Now: time.Now().In(s.tz).Format("02.01.2006 15:04 MST"),
BaseStyle: baseStyle,
ReadOnly: readOnly,
}
var buf bytes.Buffer
if err := pageTmpl.Execute(&buf, data); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(buf.Bytes())
}
}
func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) {
s.store.mu.RLock()
list := make([]*Agent, 0, len(s.store.agents))
for _, a := range s.store.agents {
list = append(list, a)
}
s.store.mu.RUnlock()
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
var buf bytes.Buffer
if err := agentsTmpl.Execute(&buf, struct{ Agents []*Agent }{list}); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(buf.Bytes())
}
func (s *Server) handleAgentAdd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
url := strings.TrimSpace(r.FormValue("url"))
if name == "" || url == "" {
http.Error(w, "name and url required", 400)
return
}
id := genID()
a := &Agent{ID: id, Name: name, EndpointURL: url, Enabled: true}
s.store.mu.Lock()
s.store.agents[a.ID] = a
s.store.mu.Unlock()
_ = s.store.saveAgents()
go s.fetcher.FetchOnce()
http.Redirect(w, r, "/agents", http.StatusSeeOther)
}
func (s *Server) handleAgentToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
s.store.mu.Lock()
a, ok := s.store.agents[id]
if !ok {
s.store.mu.Unlock()
http.Error(w, "not found", 404)
return
}
a.Enabled = !a.Enabled
s.store.mu.Unlock() // ⚠️ Vor saveAgents entsperren, sonst Deadlock mit RWMutex
_ = s.store.saveAgents()
http.Redirect(w, r, "/agents", http.StatusSeeOther)
}
func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
s.store.mu.Lock()
delete(s.store.agents, id)
s.store.mu.Unlock()
_ = s.store.saveAgents()
// Entferne Releases dieses Agents aus Cache
s.store.mu.Lock()
filtered := s.store.releases[:0]
for _, r := range s.store.releases {
if r.AgentID != id {
filtered = append(filtered, r)
}
}
s.store.releases = filtered
s.store.mu.Unlock()
_ = s.store.saveReleases()
http.Redirect(w, r, "/agents", http.StatusSeeOther)
}
// ---- Utils -----------------------------------------------------------------
// parseAnyReleases durchsucht beliebig verschachtelte JSON-Strukturen nach Arrays von
// Release-ähnlichen Objekten. Gefundene Objekte werden hinsichtlich bekannter Alias-Felder
// normalisiert und dann in []Release dekodiert.
func parseAnyReleases(raw []byte) ([]Release, error) {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
// 3a) Spezielles hierarchisches SEND.NRW-Schema (releases→branch→channel→arch→bit→os→obj)
if arr := parseHierarchicalFromRoot(v); len(arr) > 0 {
b, _ := json.Marshal(arr)
var rr []Release
if err := json.Unmarshal(b, &rr); err == nil && len(rr) > 0 {
return rr, nil
}
}
// 3b) Generisch: beliebige Arrays von Objekt-Maps einsammeln und normalisieren
var buckets [][]map[string]any
collectObjectArrays(v, &buckets)
var all []Release
for _, arr := range buckets {
norm := normalizeReleaseSlice(arr)
b, _ := json.Marshal(norm)
var rr []Release
if err := json.Unmarshal(b, &rr); err == nil {
keep := 0
for _, r := range rr {
if r.Version != "" || len(r.Assets) > 0 {
keep++
}
}
if keep > 0 {
all = append(all, rr...)
}
}
}
return all, nil
}
// parseHierarchicalFromRoot erkennt Strukturen vom Typ
//
// releases → branch → channel → arch → bitness → os → { release-obj }
//
// und liefert normalisierte Release-Maps zurück.
func parseHierarchicalFromRoot(root any) []map[string]any {
m, ok := root.(map[string]any)
if !ok {
return nil
}
var defBranch, defChannel, defVendor, defProduct string
if s, ok := m["default_branch"].(string); ok {
defBranch = s
}
if s, ok := m["default_channel"].(string); ok {
defChannel = s
}
if s, ok := m["vendor"].(string); ok {
defVendor = s
}
if s, ok := m["product"].(string); ok {
defProduct = s
}
relRoot, ok := m["releases"].(map[string]any)
if !ok {
return nil
}
out := make([]map[string]any, 0)
for branchKey, channelsV := range relRoot {
channels, ok := channelsV.(map[string]any)
if !ok {
continue
}
bk := branchKey
if bk == "" {
bk = defBranch
}
for channelKey, archsV := range channels {
archs, ok := archsV.(map[string]any)
if !ok {
continue
}
ck := channelKey
if ck == "" {
ck = defChannel
}
for archKey, bitsV := range archs {
bits, ok := bitsV.(map[string]any)
if !ok {
continue
}
ak := archKey
for _, osesV := range bits {
oses, ok := osesV.(map[string]any)
if !ok {
continue
}
for osKey, leafV := range oses {
leaf, ok := leafV.(map[string]any)
if !ok {
continue
}
nm := normalizeReleaseMap(leaf)
if _, ok := nm["branch"]; !ok && bk != "" {
nm["branch"] = bk
}
if _, ok := nm["channel"]; !ok && ck != "" {
nm["channel"] = ck
}
if _, ok := nm["arch"]; !ok && ak != "" {
nm["arch"] = ak
}
if _, ok := nm["os"]; !ok && osKey != "" {
nm["os"] = osKey
}
if _, ok := nm["vendor"]; !ok && defVendor != "" {
nm["vendor"] = defVendor
}
if _, ok := nm["product"]; !ok && defProduct != "" {
nm["product"] = defProduct
}
out = append(out, nm)
}
}
}
}
}
return out
}
func collectObjectArrays(v any, out *[][]map[string]any) {
switch x := v.(type) {
case []any:
// Prüfe, ob es ein Array von Objekten ist
var objArr []map[string]any
ok := true
for _, e := range x {
m, isObj := e.(map[string]any)
if !isObj {
ok = false
break
}
objArr = append(objArr, m)
}
if ok && len(objArr) > 0 {
*out = append(*out, objArr)
}
// Rekursiv weitersuchen
for _, e := range x {
collectObjectArrays(e, out)
}
case map[string]any:
for _, e := range x {
collectObjectArrays(e, out)
}
}
}
func normalizeReleaseSlice(arr []map[string]any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, m := range arr {
out = append(out, normalizeReleaseMap(m))
}
return out
}
func normalizeReleaseMap(m map[string]any) map[string]any {
// Keys in snake_case bringen & Aliasse mappen
out := make(map[string]any, len(m))
for k, v := range m {
out[camelToSnake(k)] = v
}
alias := func(dst string, alts ...string) {
if _, ok := out[dst]; ok {
return
}
for _, a := range alts {
if val, ok := out[a]; ok {
out[dst] = val
return
}
}
}
// Release-Felder
alias("version", "ver", "v")
alias("build", "build_id")
alias("released_at", "release_date", "releasedAt", "published_at", "publish_date", "date", "time", "timestamp")
alias("branch", "br")
alias("channel", "chan")
alias("os", "platform")
alias("arch", "architecture")
alias("notes", "changelog", "summary")
// Assets normalisieren (falls vorhanden)
if raw, ok := out["assets"].([]any); ok {
var aset []map[string]any
for _, e := range raw {
if am, ok := e.(map[string]any); ok {
aset = append(aset, normalizeAssetMap(am))
}
}
out["assets"] = aset
}
return out
}
func cleanReleases(in []Release) []Release {
for i := range in {
for j := range in[i].Assets {
if in[i].Assets[j].SHA256 == "-" {
in[i].Assets[j].SHA256 = ""
}
if in[i].Assets[j].SignatureURL == "-" {
in[i].Assets[j].SignatureURL = ""
}
}
}
return in
}
func normalizeAssetMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[camelToSnake(k)] = v
}
alias := func(dst string, alts ...string) {
if _, ok := out[dst]; ok {
return
}
for _, a := range alts {
if val, ok := out[a]; ok {
out[dst] = val
return
}
}
}
alias("url", "href", "link")
alias("sha256", "sha256sum", "checksum", "hash")
alias("size_bytes", "size", "bytes")
alias("signature_url", "sig", "signature", "signatureUrl")
alias("content_type", "mime", "mimetype", "type")
return out
}
func camelToSnake(s string) string {
// sehr einfacher CamelCase→snake_case Konverter
var b strings.Builder
for i, r := range s {
if unicode.IsUpper(r) {
if i > 0 {
b.WriteByte('_')
}
b.WriteRune(unicode.ToLower(r))
} else {
b.WriteRune(r)
}
}
return b.String()
}
func genID() string {
// simple time-based id, ausreichend für lokale Tooling-Zwecke
return fmt.Sprintf("a_%d", time.Now().UnixNano())
}
func dateStr(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02")
}
// ---- Templates & Styles (inline, keine externen CDNs) ----------------------
var baseStyle = `
:root{--bg:#0b1324;--card:#111a30;--muted:#93a0b0;--txt:#e9eef7;--acc:#4cc9f0;--ok:#22c55e;--warn:#f59e0b;--err:#ef4444}
*{box-sizing:border-box} body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue";background:var(--bg);color:var(--txt)}
.header{position:sticky;top:0;z-index:10;background:linear-gradient(90deg,#0b1324,#0e1a33);border-bottom:1px solid #1c2947;padding:16px}
.container{max-width:90%;margin:0 auto;padding:16px}
.card{background:var(--card);border:1px solid #1c2947;border-radius:16px;box-shadow:0 6px 18px rgba(0,0,0,.25)}
.row{display:flex;gap:12px;flex-wrap:wrap}
.btn{display:inline-block;padding:10px 14px;border-radius:12px;border:1px solid #2a3a63;background:#152448;color:var(--txt);text-decoration:none;cursor:pointer}
.btn:hover{background:#19305a}
.btn-acc{border-color:#2b9bc2}
.badge{padding:2px 8px;border-radius:999px;border:1px solid #2a3a63;color:var(--muted);font-size:12px}
.input, select{background:#0b1429;border:1px solid #2a3a63;border-radius:12px;padding:10px;color:var(--txt);width:100%}
label{font-size:12px;color:var(--muted)}
.table{width:100%;border-collapse:separate;border-spacing:0}
.table th, .table td{padding:10px 12px;border-bottom:1px solid #1c2947}
.table th{position:sticky;top:0;background:#122042;text-align:left}
.kv{display:grid;grid-template-columns:120px 1fr;gap:6px 12px}
.small{font-size:12px;color:var(--muted)}
.code{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:12px}
hr{border:0;border-top:1px solid #1c2947;margin:12px 0}
`
var pageTmpl = template.Must(template.New("page").Funcs(template.FuncMap{
"fmtTime": func(t FlexTime) string {
if t.IsZero() {
return ""
}
return t.Time.Format("02.01.2006 15:04")
},
}).Parse(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Release Hub</title>
<style>` + baseStyle + `</style>
</head>
<body>
<div class="header">
<div class="container row" style="justify-content:space-between;align-items:center">
<div>
<div style="font-weight:700;font-size:18px">Release Hub</div>
<div class="small">Unified Release · Version from: {{.Now}}</div>
</div>
{{if .ReadOnly}}
<div></div>
{{else}}
<form class="row" action="/refresh" method="post">
<button class="btn btn-acc" title="Reload now!">↻ Refresh Data</button>
<a class="btn" href="/agents">⚙ Agents</a>
</form>
{{end}}
</div>
</div>
<div class="container">
<div class="card" style="padding:12px">
<form class="row" method="get" action="/">
<div style="flex:2;min-width:220px">
<label>Search</label>
<input class="input" name="q" value="{{.Q}}" placeholder="Version, Branch, Channel, Agent, Notizen…">
</div>
<div style="flex:1;min-width:140px"><label>Channel</label><input class="input" name="channel" value="{{.Channel}}" placeholder="stable/beta…"></div>
<div style="flex:1;min-width:140px"><label>Branch</label><input class="input" name="branch" value="{{.Branch}}" placeholder="main/12.x…"></div>
<div style="flex:1;min-width:160px"><label>Vendor</label><input class="input" name="vendor" value="{{.Vendor}}" placeholder="Mozilla"></div>
<div style="flex:1;min-width:160px"><label>Product</label><input class="input" name="product" value="{{.Product}}" placeholder="Firefox"></div>
<div style="flex:1;min-width:120px"><label>OS</label><input class="input" name="os" value="{{.OS}}" placeholder="windows/linux/macos"></div>
<div style="flex:1;min-width:120px"><label>Arch</label><input class="input" name="arch" value="{{.Arch}}" placeholder="amd64/arm64…"></div>
<div style="flex:1;min-width:130px"><label>From</label><input type="date" class="input" name="from" value="{{.From}}"></div>
<div style="flex:1;min-width:130px"><label>To</label><input type="date" class="input" name="to" value="{{.To}}"></div>
<div style="align-self:end"><button class="btn btn-acc">Filter</button></div>
</form>
</div>
<div style="margin-top:12px" class="small">Show {{.CountShown}} from {{.CountTotal}} Releases</div>
<div class="card" style="margin-top:8px; overflow:auto; max-height:70vh">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Version</th>
<th>Branch</th>
<th>Channel</th>
<th>OS</th>
<th>Arch</th>
<th>Vendor</th>
<th>Product</th>
<th>Agent</th>
<th>Assets</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{{range .Releases}}
<tr>
<td>{{fmtTime .ReleasedAt}}</td>
<td><span class="badge">{{.Version}}</span> {{if .Build}}<span class="small">({{.Build}})</span>{{end}}</td>
<td>{{.Branch}}</td>
<td>{{.Channel}}</td>
<td>{{.OS}}</td>
<td>{{.Arch}}</td>
<td class="small">{{.Vendor}}</td>
<td class="small">{{.Product}}</td>
<td class="small">{{.AgentName}}</td>
<td>
{{if .Assets}}
<div class="kv small">
{{range .Assets}}
<div>Asset</div>
<div><a class="btn" href="{{.URL}}">Download</a> <span class="code">{{.ContentType}}</span> {{if .Size}}· {{.Size}} B{{end}} {{if .SHA256}}· sha256:{{.SHA256}}{{end}}</div>
{{end}}
</div>
{{else}}
<span class="small">-</span>
{{end}}
</td>
<td class="small">{{.Notes}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</body>
</html>
`))
var agentsTmpl = template.Must(template.New("agents").Parse(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agents · Release Hub</title>
<style>` + baseStyle + `</style>
</head>
<body>
<div class="header">
<div class="container row" style="justify-content:space-between;align-items:center">
<div>
<div style="font-weight:700;font-size:18px">Agenten</div>
<div class="small">Manage Manifest-Endpoint</div>
</div>
<div>
<a class="btn" href="/">← Overview</a>
</div>
</div>
</div>
<div class="container">
<div class="card" style="padding:12px">
<form class="row" method="post" action="/agents/add">
<div style="flex:1;min-width:220px"><label>Name</label><input class="input" name="name" placeholder="eg. Mozilla Firefox"></div>
<div style="flex:3;min-width:360px"><label>Manifest-URL</label><input class="input" name="url" placeholder="https://agent.local/v1/manifest"></div>
<div style="align-self:end"><button class="btn btn-acc">Add</button></div>
</form>
</div>
<div class="card" style="margin-top:12px">
<table class="table">
<thead><tr><th>Name</th><th>URL</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{{range .Agents}}
<tr>
<td>{{.Name}}</td>
<td class="small">{{.EndpointURL}}</td>
<td class="small">
{{if .Enabled}}<span class="badge" style="border-color:#2c6">active{{else}}<span class="badge" style="border-color:#666">inactive{{end}}</span>
{{if .LastOK}}· OK: {{.LastOK}}{{end}} {{if .LastError}}· Error: {{.LastError}}{{end}}
</td>
<td>
<form style="display:inline" method="post" action="/agents/toggle">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn">{{if .Enabled}}Deactivate{{else}}Activate{{end}}</button>
</form>
<form style="display:inline" method="post" action="/agents/delete" onsubmit="return confirm('Confirm Agent delete?')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn" style="border-color:#a33">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</body>
</html>
`))
// ---- main ------------------------------------------------------------------
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func getduration(k string, d time.Duration) time.Duration {
v, ok := os.LookupEnv(k)
if !ok {
return d
}
v = strings.TrimSpace(v)
if v == "" {
return d
}
if dur, err := time.ParseDuration(v); err == nil {
return dur
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return time.Duration(n) * time.Second
}
return d
}
func enabled(k string, def bool) bool {
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
if err != nil {
return def
}
return b
}
func main() {
//flag.Parse()
envAddr := getenv("HTTP_ADMIN", ":9090")
envPublicAddr := getenv("HTTP_PUBLIC", ":8080")
envDataDir := getenv("APP_DATADIR", "/data")
envRefresh := getduration("APP_REFRESH", 10*time.Minute)
store := NewStore(envDataDir)
if err := store.load(); err != nil {
log.Fatalf("load store: %v", err)
}
fetcher := NewFetcher(store, envRefresh)
fetcher.Start()
defer fetcher.Stop()
srv := NewServer(store, fetcher)
// Admin-Server
muxAdmin := http.NewServeMux()
srv.routesAdmin(muxAdmin)
errCh := make(chan error, 2)
go func() {
log.Printf("Admin (voll) listening on %s", envAddr)
errCh <- http.ListenAndServe(envAddr, muxAdmin)
}()
// Öffentlicher Read-only-Server (optional)
if envPublicAddr != "" {
muxPublic := http.NewServeMux()
srv.routesPublic(muxPublic)
go func() {
log.Printf("Public (read-only) listening on %s", envPublicAddr)
errCh <- http.ListenAndServe(envPublicAddr, muxPublic)
}()
}
log.Fatal(<-errCh)
}
// Healthcheck
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}