mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-07 02:46:50 +00:00
Uses therootcompany/golib/http/middleware/v2 to add requestLogger to all routes except /api/health (too noisy). Logs method, path, status, duration.
879 lines
22 KiB
Go
879 lines
22 KiB
Go
// Command webid is the webi HTTP API server. It reads cached release
|
|
// data from the filesystem and serves release metadata, installer
|
|
// scripts, and bootstrap dispatches.
|
|
//
|
|
// It never fetches from upstream APIs — that's webicached's job.
|
|
// This server is stateless and fast: load from cache, resolve, render.
|
|
//
|
|
// Usage:
|
|
//
|
|
// go run ./cmd/webid
|
|
// go run ./cmd/webid -addr :3001 -cache ./_cache
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/webinstall/webi-installers/internal/buildmeta"
|
|
"github.com/webinstall/webi-installers/internal/lexver"
|
|
"github.com/webinstall/webi-installers/internal/render"
|
|
"github.com/webinstall/webi-installers/internal/resolve"
|
|
"github.com/webinstall/webi-installers/internal/resolver"
|
|
middleware "github.com/therootcompany/golib/http/middleware/v2"
|
|
|
|
"github.com/webinstall/webi-installers/internal/storage"
|
|
"github.com/webinstall/webi-installers/internal/storage/fsstore"
|
|
"github.com/webinstall/webi-installers/internal/storage/pgstore"
|
|
"github.com/webinstall/webi-installers/internal/uadetect"
|
|
)
|
|
|
|
func main() {
|
|
addr := flag.String("addr", ":3001", "listen address")
|
|
cacheDir := flag.String("cache", "./_cache", "cache directory root")
|
|
pgDSN := flag.String("pg", "", "PostgreSQL DSN (enables pgstore; mutually exclusive with -cache)")
|
|
installersDir := flag.String("installers", ".", "installers repo root (for install.sh/ps1)")
|
|
flag.Parse()
|
|
|
|
var store storage.Store
|
|
if *pgDSN != "" {
|
|
pg, err := pgstore.New(context.Background(), *pgDSN)
|
|
if err != nil {
|
|
log.Fatalf("pgstore: %v", err)
|
|
}
|
|
store = pg
|
|
} else {
|
|
fs, err := fsstore.New(*cacheDir)
|
|
if err != nil {
|
|
log.Fatalf("fsstore: %v", err)
|
|
}
|
|
store = fs
|
|
}
|
|
|
|
srv := &server{
|
|
store: store,
|
|
installersDir: *installersDir,
|
|
packages: make(map[string]*packageCache),
|
|
}
|
|
|
|
// Pre-load all cached packages.
|
|
srv.loadAll()
|
|
|
|
mux := http.NewServeMux()
|
|
mmux := middleware.WithMux(mux, requestLogger)
|
|
|
|
// Legacy API routes (Node.js compat).
|
|
mmux.HandleFunc("GET /api/releases/{rest...}", srv.handleReleasesAPI)
|
|
|
|
// New API routes (v1).
|
|
mmux.HandleFunc("GET /v1/releases/{rest...}", srv.handleV1Releases)
|
|
mmux.HandleFunc("GET /v1/resolve/{rest...}", srv.handleV1Resolve)
|
|
|
|
// Full installer script (package-install.tpl.sh + install.sh).
|
|
mmux.HandleFunc("GET /api/installers/{rest...}", srv.handleInstaller)
|
|
|
|
// Debug endpoint.
|
|
mmux.HandleFunc("GET /api/debug", srv.handleDebug)
|
|
|
|
// Health check (no logging — too noisy).
|
|
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintln(w, "ok")
|
|
})
|
|
|
|
// Bootstrap route: /{package} and /{package}@{version}
|
|
// Detects UA and returns rendered installer script.
|
|
mmux.HandleFunc("GET /{pkgSpec}", srv.handleBootstrap)
|
|
|
|
httpSrv := &http.Server{
|
|
Addr: *addr,
|
|
Handler: mux,
|
|
ReadTimeout: 5 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
// Graceful shutdown.
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
defer stop()
|
|
|
|
go func() {
|
|
log.Printf("webid listening on %s", *addr)
|
|
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("listen: %v", err)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
log.Println("shutting down...")
|
|
|
|
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
httpSrv.Shutdown(shutCtx)
|
|
}
|
|
|
|
// requestLogger is a middleware that logs each request with status and duration.
|
|
func requestLogger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
|
|
next.ServeHTTP(rw, r)
|
|
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.code, time.Since(start))
|
|
})
|
|
}
|
|
|
|
// statusWriter wraps ResponseWriter to capture the HTTP status code.
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
code int
|
|
}
|
|
|
|
func (sw *statusWriter) WriteHeader(code int) {
|
|
sw.code = code
|
|
sw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// server holds the shared state for all HTTP handlers.
|
|
type server struct {
|
|
store storage.Store
|
|
installersDir string
|
|
|
|
mu sync.RWMutex
|
|
packages map[string]*packageCache
|
|
webiCksum string // cached sha1[:8] of webi.sh
|
|
}
|
|
|
|
// packageCache holds a loaded package's assets and catalog.
|
|
type packageCache struct {
|
|
assets []storage.Asset
|
|
dists []resolve.Dist
|
|
catalog resolve.Catalog
|
|
}
|
|
|
|
// loadAll pre-loads all packages from the store.
|
|
func (s *server) loadAll() {
|
|
ctx := context.Background()
|
|
|
|
pkgs, err := s.store.ListPackages(ctx)
|
|
if err != nil {
|
|
log.Printf("warn: list packages: %v", err)
|
|
return
|
|
}
|
|
|
|
count := 0
|
|
for _, pkg := range pkgs {
|
|
pd, err := s.store.Load(ctx, pkg)
|
|
if err != nil {
|
|
log.Printf("warn: load %s: %v", pkg, err)
|
|
continue
|
|
}
|
|
if pd == nil || len(pd.Assets) == 0 {
|
|
continue
|
|
}
|
|
|
|
pc := &packageCache{
|
|
assets: pd.Assets,
|
|
dists: assetsToDists(pd.Assets),
|
|
}
|
|
pc.catalog = resolve.Survey(pc.dists)
|
|
|
|
s.mu.Lock()
|
|
s.packages[pkg] = pc
|
|
s.mu.Unlock()
|
|
count++
|
|
}
|
|
log.Printf("loaded %d packages from store", count)
|
|
}
|
|
|
|
// getPackage returns the cached package data, or nil if not found.
|
|
func (s *server) getPackage(pkg string) *packageCache {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.packages[pkg]
|
|
}
|
|
|
|
// assetsToDists converts storage.Asset slice to resolve.Dist slice.
|
|
func assetsToDists(assets []storage.Asset) []resolve.Dist {
|
|
dists := make([]resolve.Dist, len(assets))
|
|
for i, a := range assets {
|
|
dists[i] = resolve.Dist{
|
|
Filename: a.Filename,
|
|
Version: a.Version,
|
|
LTS: a.LTS,
|
|
Channel: a.Channel,
|
|
Date: a.Date,
|
|
OS: a.OS,
|
|
Arch: a.Arch,
|
|
Libc: a.Libc,
|
|
Format: a.Format,
|
|
Download: a.Download,
|
|
Extra: a.Extra,
|
|
Variants: a.Variants,
|
|
}
|
|
}
|
|
return dists
|
|
}
|
|
|
|
// handleReleasesAPI serves /api/releases/{package}@{version}.{format}
|
|
func (s *server) handleReleasesAPI(w http.ResponseWriter, r *http.Request) {
|
|
rest := r.PathValue("rest")
|
|
|
|
// Parse: {package}@{version}.{json|tab} or {package}.{json|tab}
|
|
pkg, version, format, err := parseReleasePath(rest)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
pc := s.getPackage(pkg)
|
|
if pc == nil {
|
|
// Check if it's a selfhosted package.
|
|
if s.isSelfHosted(pkg) {
|
|
s.serveEmptyReleases(w, format)
|
|
return
|
|
}
|
|
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Parse query parameters.
|
|
q := r.URL.Query()
|
|
osStr := q.Get("os")
|
|
archStr := q.Get("arch")
|
|
libcStr := q.Get("libc")
|
|
ltsStr := q.Get("lts")
|
|
channelStr := q.Get("channel")
|
|
formatsStr := q.Get("formats")
|
|
limitStr := q.Get("limit")
|
|
|
|
// Normalize wildcard "-" to empty (means "any").
|
|
if osStr == "-" {
|
|
osStr = ""
|
|
}
|
|
if archStr == "-" {
|
|
archStr = ""
|
|
}
|
|
if libcStr == "-" {
|
|
libcStr = ""
|
|
}
|
|
|
|
// Map Node.js OS/arch names to our canonical names.
|
|
osStr = normalizeQueryOS(osStr)
|
|
archStr = normalizeQueryArch(archStr)
|
|
|
|
// Parse LTS.
|
|
lts := ltsStr == "true" || ltsStr == "1"
|
|
|
|
// Handle channel selectors in the version field: @stable, @lts, @beta, etc.
|
|
switch strings.ToLower(version) {
|
|
case "stable", "latest":
|
|
version = ""
|
|
if channelStr == "" {
|
|
channelStr = "stable"
|
|
}
|
|
case "lts":
|
|
version = ""
|
|
lts = true
|
|
case "beta", "pre", "preview":
|
|
version = ""
|
|
if channelStr == "" {
|
|
channelStr = "beta"
|
|
}
|
|
case "rc":
|
|
version = ""
|
|
if channelStr == "" {
|
|
channelStr = "rc"
|
|
}
|
|
case "alpha", "dev":
|
|
version = ""
|
|
if channelStr == "" {
|
|
channelStr = "alpha"
|
|
}
|
|
}
|
|
|
|
// Parse formats list.
|
|
var formats []string
|
|
if formatsStr != "" {
|
|
formats = strings.Split(formatsStr, ",")
|
|
}
|
|
|
|
// Parse limit.
|
|
limit := 1000
|
|
if limitStr != "" {
|
|
fmt.Sscanf(limitStr, "%d", &limit)
|
|
}
|
|
|
|
// Filter matching releases.
|
|
filtered := filterDists(pc.dists, osStr, archStr, libcStr, channelStr, version, formats, lts, limit)
|
|
|
|
// Sort newest-first (descending by version).
|
|
sortDistsDescending(filtered)
|
|
|
|
switch format {
|
|
case "json":
|
|
s.serveJSON(w, r, pc, filtered)
|
|
case "tab":
|
|
s.serveTab(w, r, filtered)
|
|
default:
|
|
http.Error(w, "unsupported format: "+format, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// normalizeQueryOS maps Node.js OS names to our canonical names.
|
|
func normalizeQueryOS(s string) string {
|
|
switch strings.ToLower(s) {
|
|
case "macos", "mac":
|
|
return "darwin"
|
|
case "win":
|
|
return "windows"
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
// normalizeQueryArch maps Node.js arch names to our canonical names.
|
|
func normalizeQueryArch(s string) string {
|
|
switch strings.ToLower(s) {
|
|
case "amd64":
|
|
return string(buildmeta.ArchAMD64) // "x86_64"
|
|
case "arm64":
|
|
return string(buildmeta.ArchARM64) // "aarch64"
|
|
case "armv7l":
|
|
return string(buildmeta.ArchARMv7)
|
|
case "armv6l":
|
|
return string(buildmeta.ArchARMv6)
|
|
case "x86", "i386", "i686":
|
|
return string(buildmeta.ArchX86)
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
// parseReleasePath parses "{pkg}@{version}.{format}" or "{pkg}.{format}".
|
|
func parseReleasePath(rest string) (pkg, version, format string, err error) {
|
|
if strings.HasSuffix(rest, ".json") {
|
|
format = "json"
|
|
rest = strings.TrimSuffix(rest, ".json")
|
|
} else if strings.HasSuffix(rest, ".tab") {
|
|
format = "tab"
|
|
rest = strings.TrimSuffix(rest, ".tab")
|
|
} else {
|
|
return "", "", "", fmt.Errorf("unsupported format (use .json or .tab)")
|
|
}
|
|
|
|
if idx := strings.IndexByte(rest, '@'); idx >= 0 {
|
|
pkg = rest[:idx]
|
|
version = rest[idx+1:]
|
|
} else {
|
|
pkg = rest
|
|
}
|
|
|
|
if pkg == "" {
|
|
return "", "", "", fmt.Errorf("package name required")
|
|
}
|
|
|
|
return pkg, version, format, nil
|
|
}
|
|
|
|
// filterDists filters dists by query parameters, returning all matches
|
|
// up to limit. This is for the API listing, not single-best resolution.
|
|
func filterDists(dists []resolve.Dist, osStr, archStr, libcStr, channel, version string, formats []string, lts bool, limit int) []resolve.Dist {
|
|
var result []resolve.Dist
|
|
|
|
for _, d := range dists {
|
|
if osStr != "" && d.OS != osStr && d.OS != "*" && d.OS != "ANYOS" {
|
|
continue
|
|
}
|
|
|
|
if archStr != "" && d.Arch != archStr && d.Arch != "*" && d.Arch != "ANYARCH" {
|
|
continue
|
|
}
|
|
|
|
if libcStr != "" && d.Libc != "none" && d.Libc != "" && d.Libc != libcStr {
|
|
continue
|
|
}
|
|
|
|
if lts && !d.LTS {
|
|
continue
|
|
}
|
|
|
|
if channel != "" && d.Channel != channel {
|
|
continue
|
|
}
|
|
|
|
if version != "" {
|
|
// Match with or without "v" prefix:
|
|
// query "0.25" should match version "v0.25.0".
|
|
v := strings.TrimPrefix(d.Version, "v")
|
|
vq := strings.TrimPrefix(version, "v")
|
|
if !strings.HasPrefix(v, vq) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if len(formats) > 0 {
|
|
matched := false
|
|
for _, f := range formats {
|
|
if strings.Contains(d.Format, f) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
}
|
|
|
|
result = append(result, d)
|
|
if len(result) >= limit {
|
|
break
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// legacyRelease matches the Node.js JSON response format.
|
|
// Production returns a bare JSON array of these objects.
|
|
type legacyRelease struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
LTS bool `json:"lts"`
|
|
Channel string `json:"channel"`
|
|
Date string `json:"date"`
|
|
OS string `json:"os"`
|
|
Arch string `json:"arch"`
|
|
Ext string `json:"ext"`
|
|
Download string `json:"download"`
|
|
Libc string `json:"libc"`
|
|
}
|
|
|
|
// legacyOS maps Go canonical OS names to Node.js legacy names.
|
|
func legacyOS(s string) string {
|
|
switch s {
|
|
case "darwin":
|
|
return "macos"
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
// legacyArch maps Go canonical arch names to Node.js legacy names.
|
|
func legacyArch(s string) string {
|
|
switch s {
|
|
case "x86_64":
|
|
return "amd64"
|
|
case "aarch64":
|
|
return "arm64"
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
// legacyExt strips the leading "." from format strings.
|
|
func legacyExt(s string) string {
|
|
return strings.TrimPrefix(s, ".")
|
|
}
|
|
|
|
// legacyVersion strips the leading "v" from version strings.
|
|
func legacyVersion(s string) string {
|
|
return strings.TrimPrefix(s, "v")
|
|
}
|
|
|
|
// legacyLibc returns "none" for empty libc values.
|
|
func legacyLibc(s string) string {
|
|
if s == "" {
|
|
return "none"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func distsToLegacy(dists []resolve.Dist) []legacyRelease {
|
|
releases := make([]legacyRelease, len(dists))
|
|
for i, d := range dists {
|
|
releases[i] = legacyRelease{
|
|
Name: d.Filename,
|
|
Version: legacyVersion(d.Version),
|
|
LTS: d.LTS,
|
|
Channel: d.Channel,
|
|
Date: d.Date,
|
|
OS: legacyOS(d.OS),
|
|
Arch: legacyArch(d.Arch),
|
|
Ext: legacyExt(d.Format),
|
|
Download: d.Download,
|
|
Libc: legacyLibc(d.Libc),
|
|
}
|
|
}
|
|
return releases
|
|
}
|
|
|
|
func (s *server) serveJSON(w http.ResponseWriter, r *http.Request, pc *packageCache, filtered []resolve.Dist) {
|
|
// Production returns a bare JSON array, not wrapped in an object.
|
|
releases := distsToLegacy(filtered)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
pretty := r.URL.Query().Get("pretty")
|
|
if pretty == "true" || pretty == "1" {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetIndent("", " ")
|
|
enc.Encode(releases)
|
|
} else {
|
|
json.NewEncoder(w).Encode(releases)
|
|
}
|
|
}
|
|
|
|
func (s *server) serveTab(w http.ResponseWriter, r *http.Request, filtered []resolve.Dist) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
|
|
// Production only shows header row with ?pretty=true.
|
|
pretty := r.URL.Query().Get("pretty")
|
|
if pretty != "" && pretty != "false" {
|
|
fmt.Fprintln(w, "VERSION\tLTS\tCHANNEL\tRELEASE_DATE\tOS\tARCH\tEXT\tHASH\tURL\t_\tLIBC")
|
|
}
|
|
|
|
// Tab format matches Node.js production:
|
|
// version \t lts \t channel \t date \t os \t arch \t ext \t hash \t download \t comment \t libc
|
|
for _, d := range filtered {
|
|
lts := "-"
|
|
if d.LTS {
|
|
lts = "lts"
|
|
}
|
|
channel := d.Channel
|
|
if channel == "" {
|
|
channel = "-"
|
|
}
|
|
date := d.Date
|
|
if date == "" {
|
|
date = "-"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t-\t%s\t\t%s\n",
|
|
legacyVersion(d.Version),
|
|
lts,
|
|
channel,
|
|
date,
|
|
legacyOS(d.OS),
|
|
legacyArch(d.Arch),
|
|
legacyExt(d.Format),
|
|
d.Download,
|
|
legacyLibc(d.Libc),
|
|
)
|
|
}
|
|
}
|
|
|
|
// sortDistsDescending sorts dists newest-first by version.
|
|
func sortDistsDescending(dists []resolve.Dist) {
|
|
slices.SortStableFunc(dists, func(a, b resolve.Dist) int {
|
|
va := lexver.Parse(strings.TrimPrefix(a.Version, "v"))
|
|
vb := lexver.Parse(strings.TrimPrefix(b.Version, "v"))
|
|
// Descending: reverse the comparison.
|
|
return lexver.Compare(vb, va)
|
|
})
|
|
}
|
|
|
|
// serveEmptyReleases returns an empty release list for selfhosted packages.
|
|
func (s *server) serveEmptyReleases(w http.ResponseWriter, format string) {
|
|
switch format {
|
|
case "json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Production returns an empty array.
|
|
json.NewEncoder(w).Encode([]legacyRelease{})
|
|
case "tab":
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
}
|
|
}
|
|
|
|
// isSelfHosted checks if a package has install.sh but no releases.conf.
|
|
func (s *server) isSelfHosted(pkg string) bool {
|
|
installPath := filepath.Join(s.installersDir, pkg, "install.sh")
|
|
if _, err := os.Stat(installPath); err != nil {
|
|
return false
|
|
}
|
|
confPath := filepath.Join(s.installersDir, pkg, "releases.conf")
|
|
if _, err := os.Stat(confPath); err == nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// handleDebug returns UA detection info for the requesting client.
|
|
func (s *server) handleDebug(w http.ResponseWriter, r *http.Request) {
|
|
result := uadetect.FromRequest(r)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"user_agent": r.Header.Get("User-Agent"),
|
|
"os": string(result.OS),
|
|
"arch": string(result.Arch),
|
|
"libc": string(result.Libc),
|
|
})
|
|
}
|
|
|
|
// handleBootstrap serves /{package} and /{package}@{version}.
|
|
// This is the curl-pipe bootstrap: a minimal script that sets
|
|
// WEBI_PKG/WEBI_HOST/WEBI_CHECKSUM and downloads+runs webi.
|
|
func (s *server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|
pkgSpec := r.PathValue("pkgSpec")
|
|
|
|
// Parse package@version.
|
|
pkg, tag := pkgSpec, ""
|
|
if idx := strings.IndexByte(pkgSpec, '@'); idx >= 0 {
|
|
pkg = pkgSpec[:idx]
|
|
tag = pkgSpec[idx+1:]
|
|
}
|
|
|
|
if pkg == "" {
|
|
http.Error(w, "package name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify package exists.
|
|
if s.getPackage(pkg) == nil && !s.isSelfHosted(pkg) {
|
|
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
baseURL := baseURLFromRequest(r)
|
|
webiPkg := pkg
|
|
if tag != "" {
|
|
webiPkg = pkg + "@" + tag
|
|
}
|
|
|
|
// Read and inject the curl-pipe bootstrap template.
|
|
tplPath := filepath.Join(s.installersDir, "_webi", "curl-pipe-bootstrap.tpl.sh")
|
|
tpl, err := os.ReadFile(tplPath)
|
|
if err != nil {
|
|
log.Printf("bootstrap: read template: %v", err)
|
|
http.Error(w, "bootstrap template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
script := string(tpl)
|
|
script = render.InjectVar(script, "WEBI_PKG", webiPkg)
|
|
script = render.InjectVar(script, "WEBI_HOST", baseURL)
|
|
script = render.InjectVar(script, "WEBI_CHECKSUM", s.webiChecksum())
|
|
|
|
// text/html so browsers see the meta redirect to cheat sheet.
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, script)
|
|
}
|
|
|
|
// handleInstaller serves /api/installers/{pkg}@{version}.sh
|
|
// This is the full installer script with release resolution and
|
|
// embedded install.sh.
|
|
func (s *server) handleInstaller(w http.ResponseWriter, r *http.Request) {
|
|
rest := r.PathValue("rest")
|
|
|
|
// Parse: {pkg}@{version}.sh or {pkg}.sh
|
|
ext := ""
|
|
if strings.HasSuffix(rest, ".sh") {
|
|
ext = "sh"
|
|
rest = strings.TrimSuffix(rest, ".sh")
|
|
} else if strings.HasSuffix(rest, ".ps1") {
|
|
ext = "ps1"
|
|
rest = strings.TrimSuffix(rest, ".ps1")
|
|
} else {
|
|
http.Error(w, "unsupported format (use .sh or .ps1)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
pkg, tag := rest, ""
|
|
if idx := strings.IndexByte(rest, '@'); idx >= 0 {
|
|
pkg = rest[:idx]
|
|
tag = rest[idx+1:]
|
|
}
|
|
|
|
if pkg == "" {
|
|
http.Error(w, "package name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Detect platform from User-Agent.
|
|
ua := uadetect.FromRequest(r)
|
|
if ua.OS == "" {
|
|
http.Error(w, "could not detect OS from User-Agent", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isSelfHosted := s.isSelfHosted(pkg)
|
|
pc := s.getPackage(pkg)
|
|
|
|
if pc == nil && !isSelfHosted {
|
|
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
baseURL := baseURLFromRequest(r)
|
|
|
|
p := render.Params{
|
|
Host: baseURL,
|
|
PkgName: pkg,
|
|
Tag: tag,
|
|
OS: string(ua.OS),
|
|
Arch: string(ua.Arch),
|
|
Libc: string(ua.Libc),
|
|
}
|
|
|
|
// Resolve the best release (if not selfhosted).
|
|
if pc != nil {
|
|
req := resolver.Request{
|
|
OS: string(ua.OS),
|
|
Arch: string(ua.Arch),
|
|
Libc: string(ua.Libc),
|
|
}
|
|
|
|
switch strings.ToLower(tag) {
|
|
case "stable", "latest", "":
|
|
// Default.
|
|
case "lts":
|
|
req.LTS = true
|
|
case "beta", "pre", "preview":
|
|
req.Channel = "beta"
|
|
case "rc":
|
|
req.Channel = "rc"
|
|
case "alpha", "dev":
|
|
req.Channel = "alpha"
|
|
default:
|
|
req.Version = tag
|
|
}
|
|
|
|
res, err := resolver.Resolve(pc.assets, req)
|
|
if err != nil {
|
|
p.Version = "0.0.0"
|
|
p.Channel = "error"
|
|
p.Ext = "err"
|
|
p.PkgURL = "https://example.com/doesntexist.ext"
|
|
p.PkgFile = "doesntexist.ext"
|
|
p.CSV = buildCSV(p)
|
|
} else {
|
|
v := strings.TrimPrefix(res.Version, "v")
|
|
parts := splitVersion(v)
|
|
p.Version = v
|
|
p.Major = parts[0]
|
|
p.Minor = parts[1]
|
|
p.Patch = parts[2]
|
|
p.Build = parts[3]
|
|
p.GitTag = "v" + v
|
|
p.GitBranch = "v" + v
|
|
p.LTS = fmt.Sprintf("%v", res.Asset.LTS)
|
|
p.Channel = res.Asset.Channel
|
|
if p.Channel == "" {
|
|
p.Channel = "stable"
|
|
}
|
|
p.Ext = strings.TrimPrefix(res.Asset.Format, ".")
|
|
p.PkgURL = res.Asset.Download
|
|
p.PkgFile = res.Asset.Filename
|
|
p.CSV = buildCSV(p)
|
|
}
|
|
|
|
p.PkgStable = pc.catalog.Stable
|
|
p.PkgLatest = pc.catalog.Latest
|
|
p.PkgOSes = strings.Join(pc.catalog.OSes, " ")
|
|
p.PkgArches = strings.Join(pc.catalog.Arches, " ")
|
|
p.PkgLibcs = strings.Join(pc.catalog.Libcs, " ")
|
|
p.PkgFormats = strings.Join(pc.catalog.Formats, " ")
|
|
}
|
|
|
|
p.ReleasesURL = fmt.Sprintf("%s/api/releases/%s@%s.tab?os=%s&arch=%s&libc=%s&formats=tar&pretty=true",
|
|
baseURL, pkg, tag, p.OS, p.Arch, p.Libc)
|
|
|
|
var script string
|
|
var renderErr error
|
|
if ext == "ps1" {
|
|
tplPath := filepath.Join(s.installersDir, "_webi", "package-install.tpl.ps1")
|
|
script, renderErr = render.PowerShell(tplPath, s.installersDir, pkg, p)
|
|
} else {
|
|
tplPath := filepath.Join(s.installersDir, "_webi", "package-install.tpl.sh")
|
|
script, renderErr = render.Bash(tplPath, s.installersDir, pkg, p)
|
|
}
|
|
if renderErr != nil {
|
|
log.Printf("render %s: %v", pkg, renderErr)
|
|
http.Error(w, fmt.Sprintf("failed to render installer for %q: %v", pkg, renderErr), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
fmt.Fprint(w, script)
|
|
}
|
|
|
|
// baseURLFromRequest builds the base URL from the request.
|
|
func baseURLFromRequest(r *http.Request) string {
|
|
if r.TLS != nil || strings.Contains(r.Host, "webinstall") || strings.Contains(r.Host, "webi.") {
|
|
return "https://" + r.Host
|
|
}
|
|
return "http://" + r.Host
|
|
}
|
|
|
|
// webiChecksum returns the checksum of the webi.sh bootstrap script.
|
|
func (s *server) webiChecksum() string {
|
|
s.mu.RLock()
|
|
cksum := s.webiCksum
|
|
s.mu.RUnlock()
|
|
if cksum != "" {
|
|
return cksum
|
|
}
|
|
|
|
// Calculate checksum from webi.sh file.
|
|
webiPath := filepath.Join(s.installersDir, "webi", "webi.sh")
|
|
data, err := os.ReadFile(webiPath)
|
|
if err != nil {
|
|
return "00000000"
|
|
}
|
|
|
|
h := sha1.New()
|
|
h.Write(data)
|
|
cksum = fmt.Sprintf("%x", h.Sum(nil))[:8]
|
|
|
|
s.mu.Lock()
|
|
s.webiCksum = cksum
|
|
s.mu.Unlock()
|
|
return cksum
|
|
}
|
|
|
|
// buildCSV creates the WEBI_CSV line in the Node.js format.
|
|
func buildCSV(p render.Params) string {
|
|
return strings.Join([]string{
|
|
p.Version,
|
|
p.LTS,
|
|
p.Channel,
|
|
"", // date
|
|
p.OS,
|
|
p.Arch,
|
|
p.Ext,
|
|
"-",
|
|
p.PkgURL,
|
|
p.PkgFile,
|
|
"",
|
|
}, ",")
|
|
}
|
|
|
|
// splitVersion splits a version string into [major, minor, patch, build].
|
|
func splitVersion(v string) [4]string {
|
|
// Strip pre-release suffix for splitting.
|
|
base := v
|
|
build := ""
|
|
if idx := strings.IndexByte(v, '-'); idx >= 0 {
|
|
base = v[:idx]
|
|
build = v[idx+1:]
|
|
}
|
|
|
|
parts := strings.SplitN(base, ".", 4)
|
|
var result [4]string
|
|
for i := 0; i < len(parts) && i < 3; i++ {
|
|
result[i] = parts[i]
|
|
}
|
|
result[3] = build
|
|
return result
|
|
}
|