windows-iso-downloader/backend/main.go
Shekhar Vaidya 1c54c36d03 feat: expiry countdown, refresh links, CLI tabs, recently viewed, file size support
- Backend: ?force=true on /proxy bypasses cache for fresh link fetch
- Frontend: parse `se` param for live expiry countdown on download links
- Frontend: show Refresh button when link expires in <6h, force-fetches fresh URL
- Frontend: CliCommand component replaces Aria2Tip — wget/curl/aria2 tabs, persists selection in localStorage
- Frontend: RecentlyViewed component — localStorage, shows active countdown or expired state
- Frontend: FileSize shown alongside arch if returned by Microsoft CDN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:06:17 +05:30

1125 lines
31 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"html"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"golang.org/x/sync/singleflight"
)
// Package-level rand source — avoids global mutex contention under concurrent load.
// Go 1.20+ auto-seeds the global rand, but a local source is faster at high QPS.
var rng = rand.New(rand.NewSource(time.Now().UnixNano()))
const (
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
PROFILE = "606624d44113"
LOCALE = "en-US"
ORG_ID = "y6jn8c31"
CUSTOMER_ID = "560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175"
PORT = ":3002"
)
// --- Session cache (short-lived, used to chain /skuinfo → /proxy) ---
type SessionEntry struct {
SessionID string
CreatedAt time.Time
}
var (
sessionCache = make(map[string]SessionEntry)
cacheMutex sync.RWMutex
SESSION_TTL = 15 * time.Minute
workerSecret = os.Getenv("CF_WORKER_SECRET")
validSkuID = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)
)
// --- SKU info cache (7-day TTL — language lists are stable) ---
type skuCacheEntry struct {
RawJSON []byte
ExpiresAt time.Time
}
var (
skuCache = make(map[string]skuCacheEntry)
skuCacheMu sync.RWMutex
skuCacheTTL = 7 * 24 * time.Hour
)
// --- Download link cache (dynamic TTL from URL expiry) ---
type linkCacheEntry struct {
RawJSON []byte
ExpiresAt time.Time // soft expiry: stop serving without stale fallback
FetchedAt time.Time
}
var (
linkCache = make(map[string]linkCacheEntry)
linkCacheMu sync.RWMutex
)
// --- Negative cache (60s — prevents thundering herd during rate-limit events) ---
type negCacheEntry struct {
Message string
HTTPCode int
ExpiresAt time.Time
}
var (
negCache = make(map[string]negCacheEntry)
negCacheMu sync.RWMutex
negCacheTTL = 60 * time.Second
)
// --- Singleflight (one in-flight Microsoft fetch per unique key) ---
var sfGroup singleflight.Group
// --- Metrics counters (atomic — no mutex needed) ---
var (
// /skuinfo
mSkuRequests int64
mSkuCacheHits int64
mSkuNegHits int64
mSkuFetches int64 // actual Microsoft calls
// /proxy
mLinkRequests int64
mLinkCacheHits int64
mLinkNegHits int64
mLinkFetches int64
mLinkStale int64 // stale-on-failure serves
// /evallinks
mEvalRequests int64
mEvalCacheHits int64
mEvalStale int64
)
// --- Evalcenter (Enterprise / Server eval ISOs) ---
type EvalProduct struct {
Name string
EvalURL string
}
type EvalLink struct {
Arch string `json:"arch"`
Lang string `json:"lang"`
URL string `json:"url"`
}
type EvalLinksResponse struct {
Product string `json:"product"`
Name string `json:"name"`
Links []EvalLink `json:"links"`
}
type evalCacheEntry struct {
Links []EvalLink
CachedAt time.Time
}
var (
evalProductMap = map[string]EvalProduct{
"server-2025": {Name: "Windows Server 2025", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2025"},
"server-2022": {Name: "Windows Server 2022", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022"},
"server-2019": {Name: "Windows Server 2019", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2019"},
"server-2016": {Name: "Windows Server 2016", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2016"},
"win11-ent": {Name: "Windows 11 Enterprise", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-11-enterprise"},
}
evalCache = make(map[string]evalCacheEntry)
evalCacheMu sync.RWMutex
evalCacheTTL = 24 * time.Hour
fwlinkRe = regexp.MustCompile(`https://go\.microsoft\.com/fwlink/[^"'\s<>]+`)
isoLangRe = regexp.MustCompile(`_([a-z]{2}-[a-z]{2})\.iso$`)
)
// --- Helpers ---
// jitter adds a random ±maxJ offset to base duration.
// Prevents synchronised mass-expiry spikes across cache entries.
func jitter(base, maxJ time.Duration) time.Duration {
offset := time.Duration(rng.Int63n(int64(maxJ*2))) - maxJ
return base + offset
}
// parseLinkExpiry reads the `se` (signed expiry) query param from the first
// download URL in a Microsoft GetProductDownloadLinksBySku response and returns
// a cache expiry time of (se - now) - 30min, with ±5min jitter.
// Falls back to 22h on any parse failure.
func parseLinkExpiry(rawJSON []byte) time.Time {
fallback := func() time.Time {
return time.Now().Add(jitter(22*time.Hour, 5*time.Minute))
}
var data map[string]interface{}
if err := json.Unmarshal(rawJSON, &data); err != nil {
return fallback()
}
opts, ok := data["ProductDownloadOptions"].([]interface{})
if !ok || len(opts) == 0 {
return fallback()
}
first, ok := opts[0].(map[string]interface{})
if !ok {
return fallback()
}
uri, ok := first["Uri"].(string)
if !ok {
return fallback()
}
u, err := url.Parse(uri)
if err != nil {
return fallback()
}
se := u.Query().Get("se")
if se == "" {
return fallback()
}
t, err := time.Parse(time.RFC3339, se)
if err != nil {
return fallback()
}
ttl := time.Until(t) - 30*time.Minute
if ttl < time.Hour {
ttl = time.Hour // safety floor — never cache for less than 1h
}
return time.Now().Add(jitter(ttl, 5*time.Minute))
}
// rateLimitError marks a Microsoft rate-limit response so callers can choose
// to serve stale data instead of propagating the error.
type rateLimitError struct{ message string }
func (e *rateLimitError) Error() string { return e.message }
// --- Evalcenter helpers ---
func detectArch(rawURL string) string {
lower := strings.ToLower(rawURL)
if strings.Contains(lower, "arm64") {
return "ARM64"
}
if strings.Contains(lower, "x64") {
return "x64"
}
if strings.Contains(lower, "x86") {
return "x86"
}
return "ISO"
}
func detectLang(rawURL string) string {
m := isoLangRe.FindStringSubmatch(strings.ToLower(rawURL))
if len(m) > 1 {
return m[1]
}
return ""
}
func fetchEvalLinks(evalURL string) ([]EvalLink, error) {
client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest("GET", evalURL, nil)
req.Header.Set("User-Agent", UA)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching evalcenter page: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
rawMatches := fwlinkRe.FindAllString(string(bodyBytes), -1)
seen := map[string]bool{}
var fwlinks []string
for _, raw := range rawMatches {
fwlink := html.UnescapeString(raw)
if !seen[fwlink] {
seen[fwlink] = true
fwlinks = append(fwlinks, fwlink)
}
}
type result struct {
link EvalLink
ok bool
}
results := make([]result, len(fwlinks))
var wg sync.WaitGroup
for i, fwlink := range fwlinks {
wg.Add(1)
go func(idx int, fw string) {
defer wg.Done()
fwReq, _ := http.NewRequest("GET", fw, nil)
fwReq.Header.Set("User-Agent", UA)
fwResp, err := client.Do(fwReq)
if err != nil {
return
}
fwResp.Body.Close()
finalURL := fwResp.Request.URL.String()
if !strings.Contains(strings.ToLower(finalURL), ".iso") {
return
}
results[idx] = result{
link: EvalLink{Arch: detectArch(finalURL), Lang: detectLang(finalURL), URL: finalURL},
ok: true,
}
}(i, fwlink)
}
wg.Wait()
var links []EvalLink
for _, r := range results {
if r.ok {
links = append(links, r.link)
}
}
sort.Slice(links, func(i, j int) bool {
if links[i].Lang == "en-us" {
return true
}
if links[j].Lang == "en-us" {
return false
}
return links[i].Lang < links[j].Lang
})
return links, nil
}
// --- CF Worker / session helpers ---
func setWorkerSecret(req *http.Request) {
if workerSecret != "" {
req.Header.Set("X-Worker-Secret", workerSecret)
}
}
func proxyURL(msURL string) string {
workerBase := os.Getenv("CF_WORKER_URL")
if workerBase == "" {
return msURL
}
parsed, err := url.Parse(msURL)
if err != nil {
return msURL
}
workerParsed, err := url.Parse(workerBase)
if err != nil {
return msURL
}
q := parsed.Query()
q.Set("host", parsed.Hostname())
workerParsed.Path = parsed.Path
workerParsed.RawQuery = q.Encode()
return workerParsed.String()
}
func getReferer(productId string) string {
id, err := strconv.Atoi(productId)
if err != nil {
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
}
if id >= 2935 {
return "https://www.microsoft.com/en-us/software-download/windows11"
}
if id >= 2618 {
return "https://www.microsoft.com/en-us/software-download/windows10ISO"
}
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
}
func setupSession() (string, error) {
sessionID := uuid.New().String()
client := &http.Client{Timeout: 10 * time.Second}
q1 := url.Values{}
q1.Set("org_id", ORG_ID)
q1.Set("session_id", sessionID)
req1, _ := http.NewRequest("GET", proxyURL("https://vlscppe.microsoft.com/tags?"+q1.Encode()), nil)
req1.Header.Set("User-Agent", UA)
setWorkerSecret(req1)
client.Do(req1)
q2 := url.Values{}
q2.Set("instanceId", CUSTOMER_ID)
q2.Set("PageId", "si")
q2.Set("session_id", sessionID)
req2, _ := http.NewRequest("GET", proxyURL("https://ov-df.microsoft.com/mdt.js?"+q2.Encode()), nil)
req2.Header.Set("User-Agent", UA)
setWorkerSecret(req2)
resp2, err := client.Do(req2)
if err != nil {
return sessionID, nil
}
defer resp2.Body.Close()
bodyBytes, _ := io.ReadAll(resp2.Body)
mdtText := string(bodyBytes)
reW := regexp.MustCompile(`[&?]w=([^&"'\s]+)`)
reRt := regexp.MustCompile(`rticks[="]+\+?\s*(\d{10,})`)
wMatch := reW.FindStringSubmatch(mdtText)
rtMatch := reRt.FindStringSubmatch(mdtText)
if len(wMatch) > 1 && len(rtMatch) > 1 {
wVal := wMatch[1]
rtVal := rtMatch[1]
mdt := time.Now().UnixMilli()
q3 := url.Values{}
q3.Set("session_id", sessionID)
q3.Set("CustomerId", CUSTOMER_ID)
q3.Set("PageId", "si")
q3.Set("w", wVal)
q3.Set("mdt", fmt.Sprintf("%d", mdt))
q3.Set("rticks", rtVal)
fpURL := proxyURL("https://ov-df.microsoft.com/?" + q3.Encode())
req3, _ := http.NewRequest("GET", fpURL, nil)
req3.Header.Set("User-Agent", UA)
setWorkerSecret(req3)
client.Do(req3)
}
return sessionID, nil
}
func mapDownloadType(typeNum int) string {
switch typeNum {
case 0:
return "x86"
case 1:
return "x64"
case 2:
return "ARM64"
default:
return fmt.Sprintf("type_%d", typeNum)
}
}
// --- Microsoft fetch functions (called via singleflight) ---
// fetchSkuInfoFromMS performs the full Microsoft session + SKU info fetch.
// Returns (rawJSON, httpErrorCode, error). httpErrorCode is non-zero only on
// rate-limit/API errors that should be stored in the negative cache.
func fetchSkuInfoFromMS(productID string) ([]byte, int, error) {
sessionID, _ := setupSession()
cacheMutex.Lock()
sessionCache[productID] = SessionEntry{SessionID: sessionID, CreatedAt: time.Now()}
cacheMutex.Unlock()
skuQ := url.Values{}
skuQ.Set("profile", PROFILE)
skuQ.Set("productEditionId", productID)
skuQ.Set("SKU", "undefined")
skuQ.Set("friendlyFileName", "undefined")
skuQ.Set("Locale", LOCALE)
skuQ.Set("sessionID", sessionID)
reqURL := proxyURL("https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + skuQ.Encode())
client := &http.Client{Timeout: 15 * time.Second}
var finalData map[string]interface{}
var finalRaw []byte
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(2 * time.Second)
}
req, _ := http.NewRequest("GET", reqURL, nil)
req.Header.Set("User-Agent", UA)
req.Header.Set("Referer", getReferer(productID))
req.Header.Set("Accept", "application/json")
setWorkerSecret(req)
resp, err := client.Do(req)
if err != nil {
if attempt == 2 {
return nil, 0, fmt.Errorf("request failed: %w", err)
}
continue
}
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, http.StatusTooManyRequests, &rateLimitError{"Microsoft is rate-limiting requests. Please try again shortly."}
}
if resp.StatusCode != http.StatusOK {
if attempt == 2 {
return nil, http.StatusBadGateway, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, string(bodyBytes))
}
continue
}
if len(bodyBytes) > 0 && bodyBytes[0] == '"' {
var unquoted string
json.Unmarshal(bodyBytes, &unquoted)
bodyBytes = []byte(unquoted)
}
var data map[string]interface{}
json.Unmarshal(bodyBytes, &data)
if errs, ok := data["Errors"].([]interface{}); ok && len(errs) > 0 {
if attempt == 2 {
msg := "Microsoft API error"
code := http.StatusBadGateway
if errMap, ok := errs[0].(map[string]interface{}); ok {
if val, exists := errMap["Value"]; exists {
msg = val.(string)
}
// 715-123130 rate-limit code
if typeNum, ok := errMap["Type"].(float64); ok && int(typeNum) == 9 {
code = http.StatusTooManyRequests
return nil, code, &rateLimitError{msg}
}
}
return nil, code, fmt.Errorf("%s", msg)
}
continue
}
finalData = data
finalRaw = bodyBytes
break
}
skus, ok := finalData["Skus"].([]interface{})
if !ok || len(skus) == 0 {
return nil, http.StatusNotFound, fmt.Errorf("no languages found for this product ID")
}
log.Printf("MS fetch /skuinfo: product_id=%s -> %d languages\n", productID, len(skus))
return finalRaw, 0, nil
}
// fetchDownloadLinksFromMS performs the Microsoft download link fetch.
// Returns (rawJSON, error). rateLimitError is returned on 429/715-123130.
func fetchDownloadLinksFromMS(productID, skuID string) ([]byte, error) {
var sessionID string
cacheMutex.RLock()
cached, exists := sessionCache[productID]
cacheMutex.RUnlock()
if exists && time.Since(cached.CreatedAt) < SESSION_TTL {
sessionID = cached.SessionID
log.Printf("MS fetch /proxy: reusing session %s for product_id=%s\n", sessionID[:8], productID)
} else {
log.Printf("MS fetch /proxy: new session for product_id=%s\n", productID)
sessionID, _ = setupSession()
// Warm the session with a SKU info call
warmupQ := url.Values{}
warmupQ.Set("profile", PROFILE)
warmupQ.Set("productEditionId", productID)
warmupQ.Set("SKU", "undefined")
warmupQ.Set("friendlyFileName", "undefined")
warmupQ.Set("Locale", LOCALE)
warmupQ.Set("sessionID", sessionID)
warmupURL := proxyURL("https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + warmupQ.Encode())
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest("GET", warmupURL, nil)
req.Header.Set("User-Agent", UA)
req.Header.Set("Referer", getReferer(productID))
req.Header.Set("Accept", "application/json")
setWorkerSecret(req)
client.Do(req)
cacheMutex.Lock()
sessionCache[productID] = SessionEntry{SessionID: sessionID, CreatedAt: time.Now()}
cacheMutex.Unlock()
}
proxyQ := url.Values{}
proxyQ.Set("profile", PROFILE)
proxyQ.Set("productEditionId", "undefined")
proxyQ.Set("SKU", skuID)
proxyQ.Set("friendlyFileName", "undefined")
proxyQ.Set("Locale", LOCALE)
proxyQ.Set("sessionID", sessionID)
reqURL := proxyURL("https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?" + proxyQ.Encode())
client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest("GET", reqURL, nil)
req.Header.Set("User-Agent", UA)
req.Header.Set("Referer", getReferer(productID))
req.Header.Set("Accept", "application/json")
setWorkerSecret(req)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, &rateLimitError{"Microsoft is rate-limiting requests. Please try again shortly."}
}
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, string(bodyBytes))
}
if len(bodyBytes) > 0 && bodyBytes[0] == '"' {
var unquoted string
json.Unmarshal(bodyBytes, &unquoted)
bodyBytes = []byte(unquoted)
}
var data map[string]interface{}
json.Unmarshal(bodyBytes, &data)
if errs, ok := data["Errors"].([]interface{}); ok && len(errs) > 0 {
if errMap, ok := errs[0].(map[string]interface{}); ok {
if typeNum, exists := errMap["Type"].(float64); exists && int(typeNum) == 9 {
msg := "Your IP has been temporarily blocked by Microsoft. Please try again later. (Code 715-123130)"
if val, ok := errMap["Value"].(string); ok {
msg = val
}
return nil, &rateLimitError{msg}
}
if val, exists := errMap["Value"].(string); exists {
return nil, fmt.Errorf("%s", val)
}
}
return nil, fmt.Errorf("Microsoft API error")
}
opts, ok := data["ProductDownloadOptions"].([]interface{})
if !ok || len(opts) == 0 {
return nil, fmt.Errorf("no download links found for this SKU")
}
for _, optRaw := range opts {
if optMap, ok := optRaw.(map[string]interface{}); ok {
if arch, hasArch := optMap["Architecture"]; !hasArch || arch == nil {
if dType, hasDType := optMap["DownloadType"].(float64); hasDType {
optMap["Architecture"] = mapDownloadType(int(dType))
}
}
}
}
log.Printf("MS fetch /proxy: product_id=%s sku_id=%s -> %d links\n", productID, skuID, len(opts))
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(data)
return buf.Bytes(), nil
}
// --- Background helpers ---
func cleanupSessions() {
for {
time.Sleep(1 * time.Minute)
cacheMutex.Lock()
now := time.Now()
for key, entry := range sessionCache {
if now.Sub(entry.CreatedAt) > SESSION_TTL {
delete(sessionCache, key)
}
}
cacheMutex.Unlock()
}
}
// --- CORS middleware ---
func enableCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "https://msdl.tech-latest.com" || strings.HasPrefix(origin, "http://localhost:") {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func respondJSONError(w http.ResponseWriter, status int, message string) {
log.Printf("ERROR [%d]: %s\n", status, message)
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
// --- /skuinfo endpoint ---
func handleSkuInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
productID := r.URL.Query().Get("product_id")
if productID == "" {
respondJSONError(w, http.StatusBadRequest, "product_id query parameter is required")
return
}
if _, err := strconv.Atoi(productID); err != nil {
respondJSONError(w, http.StatusBadRequest, "product_id must be a numeric value")
return
}
atomic.AddInt64(&mSkuRequests, 1)
negKey := "sku:" + productID
// 1. Check negative cache (short-circuit during rate-limit events)
negCacheMu.RLock()
neg, hasNeg := negCache[negKey]
negCacheMu.RUnlock()
if hasNeg && time.Now().Before(neg.ExpiresAt) {
atomic.AddInt64(&mSkuNegHits, 1)
log.Printf("/skuinfo: product_id=%s -> neg cache hit\n", productID)
respondJSONError(w, neg.HTTPCode, neg.Message)
return
}
// 2. Check SKU cache (7-day TTL)
skuCacheMu.RLock()
entry, hasSku := skuCache[productID]
skuCacheMu.RUnlock()
if hasSku && time.Now().Before(entry.ExpiresAt) {
atomic.AddInt64(&mSkuCacheHits, 1)
log.Printf("/skuinfo: product_id=%s -> cache hit\n", productID)
w.Write(entry.RawJSON)
return
}
// 3. Singleflight: collapse concurrent cache misses into one Microsoft fetch
sfKey := "sku:" + productID
type sfResult struct {
raw []byte
code int
}
v, err, _ := sfGroup.Do(sfKey, func() (interface{}, error) {
raw, code, err := fetchSkuInfoFromMS(productID)
return sfResult{raw: raw, code: code}, err
})
if err != nil {
res := v.(sfResult)
code := res.code
if code == 0 {
code = http.StatusBadGateway
}
// 4. Store in negative cache only for rate-limit / API errors
if _, isRL := err.(*rateLimitError); isRL || code == http.StatusTooManyRequests {
negCacheMu.Lock()
negCache[negKey] = negCacheEntry{
Message: err.Error(),
HTTPCode: http.StatusTooManyRequests,
ExpiresAt: time.Now().Add(negCacheTTL),
}
negCacheMu.Unlock()
}
respondJSONError(w, code, err.Error())
return
}
res := v.(sfResult)
// 5. Store in SKU cache with 7-day TTL + jitter
skuCacheMu.Lock()
skuCache[productID] = skuCacheEntry{
RawJSON: res.raw,
ExpiresAt: time.Now().Add(jitter(skuCacheTTL, 5*time.Minute)),
}
skuCacheMu.Unlock()
atomic.AddInt64(&mSkuFetches, 1)
log.Printf("/skuinfo: product_id=%s -> fetched and cached\n", productID)
w.Write(res.raw)
}
// --- /proxy endpoint ---
func handleProxy(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
productID := r.URL.Query().Get("product_id")
skuID := r.URL.Query().Get("sku_id")
if productID == "" || skuID == "" {
respondJSONError(w, http.StatusBadRequest, "product_id and sku_id query parameters are required")
return
}
if _, err := strconv.Atoi(productID); err != nil {
respondJSONError(w, http.StatusBadRequest, "product_id must be a numeric value")
return
}
if !validSkuID.MatchString(skuID) {
respondJSONError(w, http.StatusBadRequest, "sku_id contains invalid characters")
return
}
atomic.AddInt64(&mLinkRequests, 1)
cacheKey := productID + ":" + skuID
negKey := "link:" + cacheKey
forceRefresh := r.URL.Query().Get("force") == "true"
// 1. Check link cache (dynamic TTL, stale-on-failure) — skipped when force=true
linkCacheMu.RLock()
cached, hasCached := linkCache[cacheKey]
linkCacheMu.RUnlock()
if !forceRefresh && hasCached && time.Now().Before(cached.ExpiresAt) {
atomic.AddInt64(&mLinkCacheHits, 1)
log.Printf("/proxy: product_id=%s sku_id=%s -> cache hit\n", productID, skuID)
w.Write(cached.RawJSON)
return
}
// 2. Check negative cache — but only if no stale data and not a force refresh
if !forceRefresh && !hasCached {
negCacheMu.RLock()
neg, hasNeg := negCache[negKey]
negCacheMu.RUnlock()
if hasNeg && time.Now().Before(neg.ExpiresAt) {
atomic.AddInt64(&mLinkNegHits, 1)
log.Printf("/proxy: product_id=%s sku_id=%s -> neg cache hit\n", productID, skuID)
respondJSONError(w, neg.HTTPCode, neg.Message)
return
}
}
// 3. Singleflight: one fetch per (product, sku) key
sfKey := "link:" + cacheKey
if forceRefresh {
sfKey = "link:force:" + cacheKey
}
v, err, _ := sfGroup.Do(sfKey, func() (interface{}, error) {
return fetchDownloadLinksFromMS(productID, skuID)
})
if err != nil {
// 6. Stale-on-failure: serve expired cache entry rather than erroring out
// Also fire a background refresh so the next request gets fresh data.
if hasCached {
atomic.AddInt64(&mLinkStale, 1)
log.Printf("/proxy: product_id=%s sku_id=%s -> fetch failed (%v), serving stale\n", productID, skuID, err)
go func() {
// Use singleflight so concurrent stale serves don't all hit Microsoft
sfBgKey := "link:" + cacheKey
sfGroup.Do(sfBgKey, func() (interface{}, error) {
raw, bgErr := fetchDownloadLinksFromMS(productID, skuID)
if bgErr != nil {
log.Printf("/proxy: background refresh failed for %s:%s: %v\n", productID, skuID, bgErr)
return nil, bgErr
}
exp := parseLinkExpiry(raw)
linkCacheMu.Lock()
linkCache[cacheKey] = linkCacheEntry{RawJSON: raw, ExpiresAt: exp, FetchedAt: time.Now()}
linkCacheMu.Unlock()
log.Printf("/proxy: background refresh succeeded for %s:%s, cached until %s\n", productID, skuID, exp.Format(time.RFC3339))
return nil, nil
})
}()
w.Write(cached.RawJSON)
return
}
code := http.StatusBadGateway
// 4. Negative cache for rate-limit errors (only when no stale available)
if _, isRL := err.(*rateLimitError); isRL {
code = http.StatusTooManyRequests
negCacheMu.Lock()
negCache[negKey] = negCacheEntry{
Message: err.Error(),
HTTPCode: code,
ExpiresAt: time.Now().Add(negCacheTTL),
}
negCacheMu.Unlock()
}
respondJSONError(w, code, err.Error())
return
}
raw := v.([]byte)
// 5. Dynamic TTL: parse `se` from signed URL, subtract 30min buffer, add jitter
expiresAt := parseLinkExpiry(raw)
linkCacheMu.Lock()
linkCache[cacheKey] = linkCacheEntry{
RawJSON: raw,
ExpiresAt: expiresAt,
FetchedAt: time.Now(),
}
linkCacheMu.Unlock()
atomic.AddInt64(&mLinkFetches, 1)
if forceRefresh {
log.Printf("/proxy: product_id=%s sku_id=%s -> force refresh, cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
} else {
log.Printf("/proxy: product_id=%s sku_id=%s -> fetched and cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
}
w.Write(raw)
}
// --- /evallinks endpoint ---
func handleEvalLinks(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
product := r.URL.Query().Get("product")
if product == "" {
respondJSONError(w, http.StatusBadRequest, "product query parameter is required")
return
}
evalProduct, ok := evalProductMap[product]
if !ok {
respondJSONError(w, http.StatusNotFound, "Unknown eval product: "+product)
return
}
atomic.AddInt64(&mEvalRequests, 1)
evalCacheMu.RLock()
cached, exists := evalCache[product]
evalCacheMu.RUnlock()
if exists && time.Since(cached.CachedAt) < evalCacheTTL {
atomic.AddInt64(&mEvalCacheHits, 1)
log.Printf("/evallinks: product=%s -> cache hit (%d links)\n", product, len(cached.Links))
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: cached.Links})
return
}
links, err := fetchEvalLinks(evalProduct.EvalURL)
if err != nil {
// Stale-on-failure for eval links too
if exists {
atomic.AddInt64(&mEvalStale, 1)
log.Printf("/evallinks: product=%s -> fetch failed, serving stale\n", product)
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: cached.Links})
return
}
respondJSONError(w, http.StatusBadGateway, "Failed to fetch eval links: "+err.Error())
return
}
if len(links) == 0 {
if exists {
atomic.AddInt64(&mEvalStale, 1)
log.Printf("/evallinks: product=%s -> 0 links returned, serving stale\n", product)
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: cached.Links})
return
}
respondJSONError(w, http.StatusNotFound, "No download links found for this eval product")
return
}
evalCacheMu.Lock()
evalCache[product] = evalCacheEntry{Links: links, CachedAt: time.Now()}
evalCacheMu.Unlock()
log.Printf("/evallinks: product=%s -> %d links cached\n", product, len(links))
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: links})
}
// --- Cache eviction (prevents unbounded map growth) ---
func cleanupCaches() {
ticker := time.NewTicker(30 * time.Minute)
for range ticker.C {
now := time.Now()
var skuDel, linkDel, negDel int
skuCacheMu.Lock()
for k, v := range skuCache {
if now.After(v.ExpiresAt) {
delete(skuCache, k)
skuDel++
}
}
skuCacheMu.Unlock()
// Keep link entries for 4h past soft expiry — stale-on-failure window
linkCacheMu.Lock()
for k, v := range linkCache {
if now.After(v.ExpiresAt.Add(4 * time.Hour)) {
delete(linkCache, k)
linkDel++
}
}
linkCacheMu.Unlock()
negCacheMu.Lock()
for k, v := range negCache {
if now.After(v.ExpiresAt) {
delete(negCache, k)
negDel++
}
}
negCacheMu.Unlock()
if skuDel+linkDel+negDel > 0 {
log.Printf("cache cleanup: evicted sku=%d link=%d neg=%d\n", skuDel, linkDel, negDel)
}
}
}
// --- /metrics endpoint ---
func handleMetrics(w http.ResponseWriter, r *http.Request) {
secret := os.Getenv("METRICS_SECRET")
if secret == "" {
respondJSONError(w, http.StatusForbidden, "metrics endpoint not configured")
return
}
provided := r.URL.Query().Get("secret")
if provided == "" {
provided = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if provided != secret {
respondJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
skuReqs := atomic.LoadInt64(&mSkuRequests)
skuHits := atomic.LoadInt64(&mSkuCacheHits)
skuFetches := atomic.LoadInt64(&mSkuFetches)
skuNeg := atomic.LoadInt64(&mSkuNegHits)
linkReqs := atomic.LoadInt64(&mLinkRequests)
linkHits := atomic.LoadInt64(&mLinkCacheHits)
linkFetches := atomic.LoadInt64(&mLinkFetches)
linkNeg := atomic.LoadInt64(&mLinkNegHits)
linkStale := atomic.LoadInt64(&mLinkStale)
evalReqs := atomic.LoadInt64(&mEvalRequests)
evalHits := atomic.LoadInt64(&mEvalCacheHits)
evalStale := atomic.LoadInt64(&mEvalStale)
hitRate := func(hits, total int64) string {
if total == 0 {
return "N/A"
}
return fmt.Sprintf("%.1f%%", float64(hits)/float64(total)*100)
}
skuCacheMu.RLock()
skuSize := len(skuCache)
skuCacheMu.RUnlock()
linkCacheMu.RLock()
linkSize := len(linkCache)
linkCacheMu.RUnlock()
negCacheMu.RLock()
negSize := len(negCache)
negCacheMu.RUnlock()
evalCacheMu.RLock()
evalSize := len(evalCache)
evalCacheMu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sku": map[string]interface{}{
"requests": skuReqs,
"cache_hits": skuHits,
"ms_fetches": skuFetches,
"neg_hits": skuNeg,
"hit_rate": hitRate(skuHits, skuReqs),
"cache_size": skuSize,
},
"link": map[string]interface{}{
"requests": linkReqs,
"cache_hits": linkHits,
"ms_fetches": linkFetches,
"neg_hits": linkNeg,
"stale": linkStale,
"hit_rate": hitRate(linkHits, linkReqs),
"cache_size": linkSize,
},
"eval": map[string]interface{}{
"requests": evalReqs,
"cache_hits": evalHits,
"stale": evalStale,
"hit_rate": hitRate(evalHits, evalReqs),
"cache_size": evalSize,
},
"neg_cache_size": negSize,
"total_ms_fetches": skuFetches + linkFetches,
})
}
func warmEvalCache() {
for slug, product := range evalProductMap {
go func(s string, p EvalProduct) {
links, err := fetchEvalLinks(p.EvalURL)
if err != nil || len(links) == 0 {
log.Printf("eval warm: %s failed: %v\n", s, err)
return
}
evalCacheMu.Lock()
evalCache[s] = evalCacheEntry{Links: links, CachedAt: time.Now()}
evalCacheMu.Unlock()
log.Printf("eval warm: %s -> %d links cached\n", s, len(links))
}(slug, product)
}
}
func main() {
go cleanupSessions()
go cleanupCaches()
go warmEvalCache()
mux := http.NewServeMux()
mux.HandleFunc("/skuinfo", handleSkuInfo)
mux.HandleFunc("/proxy", handleProxy)
mux.HandleFunc("/evallinks", handleEvalLinks)
mux.HandleFunc("/metrics", handleMetrics)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Write([]byte("MSDL API v3 is running"))
})
handler := enableCORS(mux)
log.Printf("Go Backend running on http://localhost%s\n", PORT)
err := http.ListenAndServe(PORT, handler)
if err != nil {
log.Fatal(err)
}
}