diff --git a/backend/main.go b/backend/main.go index d69cf68..cccbfa1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -115,18 +115,20 @@ var ( linkCacheMu sync.RWMutex ) -// --- Negative cache (60s — prevents thundering herd during rate-limit events) --- +// --- Negative cache (60s normal / 90min Sentinel lockdown) --- type negCacheEntry struct { - Message string - HTTPCode int - ExpiresAt time.Time + Message string + HTTPCode int + ExpiresAt time.Time + IsSentinel bool // Sentinel WAF block: use lockdownTTL and suppress background refresh } var ( negCache = make(map[string]negCacheEntry) negCacheMu sync.RWMutex negCacheTTL = 60 * time.Second + lockdownTTL = 90 * time.Minute ) // --- Singleflight (one in-flight Microsoft fetch per unique key) --- @@ -950,7 +952,7 @@ func enableCORS(next http.Handler) http.Handler { } w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - w.Header().Set("Access-Control-Expose-Headers", "X-MSDL-Link-Status, X-MSDL-Link-Expires") + w.Header().Set("Access-Control-Expose-Headers", "X-MSDL-Link-Status, X-MSDL-Link-Expires, X-MSDL-Lockdown") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return @@ -1075,6 +1077,38 @@ func handleProxy(w http.ResponseWriter, r *http.Request) { negKey := "link:" + cacheKey forceRefresh := r.URL.Query().Get("force") == "true" + // 0. Sentinel lockdown gate — checked before everything, including force refresh. + // When a Sentinel block is active, zero Microsoft API calls are made regardless + // of force=true, to avoid extending the block timer. + negCacheMu.RLock() + lockdownEntry, hasLockdown := negCache[negKey] + negCacheMu.RUnlock() + if hasLockdown && time.Now().Before(lockdownEntry.ExpiresAt) && lockdownEntry.IsSentinel { + atomic.AddInt64(&mLinkNegHits, 1) + w.Header().Set("X-MSDL-Lockdown", "1") + linkCacheMu.RLock() + staleEntry, hasStale := linkCache[cacheKey] + linkCacheMu.RUnlock() + if hasStale { + atomic.AddInt64(&mLinkStale, 1) + log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, serving stale (expires %s)\n", + productID, skuID, lockdownEntry.ExpiresAt.Format(time.RFC3339)) + w.Header().Set("X-MSDL-Link-Status", "stale") + if exp := extractRawExpiry(staleEntry.RawJSON); exp != "" { + w.Header().Set("X-MSDL-Link-Expires", exp) + } + w.Write(staleEntry.RawJSON) + return + } + log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, no stale available\n", productID, skuID) + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": lockdownEntry.Message, + "lockdown": true, + }) + return + } + // 1. Check link cache (dynamic TTL, stale-on-failure) — skipped when force=true linkCacheMu.RLock() cached, hasCached := linkCache[cacheKey] @@ -1137,10 +1171,33 @@ func handleProxy(w http.ResponseWriter, r *http.Request) { }) 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. + isSentinel := strings.Contains(err.Error(), "Sentinel") + + // Stale-on-failure: serve expired cache entry rather than erroring out. if hasCached { atomic.AddInt64(&mLinkStale, 1) + + if isSentinel { + // Sentinel lockdown: set 90-min neg cache, add lockdown header, skip + // background refresh — retrying extends the Sentinel block timer. + negCacheMu.Lock() + negCache[negKey] = negCacheEntry{ + Message: err.Error(), + HTTPCode: http.StatusTooManyRequests, + ExpiresAt: time.Now().Add(lockdownTTL), + IsSentinel: true, + } + negCacheMu.Unlock() + log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), serving stale\n", productID, skuID) + w.Header().Set("X-MSDL-Lockdown", "1") + w.Header().Set("X-MSDL-Link-Status", "stale") + if exp := extractRawExpiry(cached.RawJSON); exp != "" { + w.Header().Set("X-MSDL-Link-Expires", exp) + } + w.Write(cached.RawJSON) + return + } + 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 @@ -1168,17 +1225,32 @@ func handleProxy(w http.ResponseWriter, r *http.Request) { return } + // No stale data available. code := http.StatusBadGateway - // 4. Negative cache for rate-limit errors (only when no stale available) if _, isRL := err.(*rateLimitError); isRL { code = http.StatusTooManyRequests + ttl := negCacheTTL + if isSentinel { + ttl = lockdownTTL + } negCacheMu.Lock() negCache[negKey] = negCacheEntry{ - Message: err.Error(), - HTTPCode: code, - ExpiresAt: time.Now().Add(negCacheTTL), + Message: err.Error(), + HTTPCode: code, + ExpiresAt: time.Now().Add(ttl), + IsSentinel: isSentinel, } negCacheMu.Unlock() + if isSentinel { + log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), no stale\n", productID, skuID) + w.Header().Set("X-MSDL-Lockdown", "1") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": err.Error(), + "lockdown": true, + }) + return + } } respondJSONError(w, code, err.Error()) return diff --git a/frontend/src/components/CliHandoff.tsx b/frontend/src/components/CliHandoff.tsx index 1b21873..b0be8a6 100644 --- a/frontend/src/components/CliHandoff.tsx +++ b/frontend/src/components/CliHandoff.tsx @@ -72,8 +72,8 @@ export default function CliHandoff({ productId, langName, langDisplay, defaultOp const [copied, setCopied] = useState(false) const command = langName - ? `msdl --id ${productId} --lang "${langName}"` - : `msdl --id ${productId}` + ? `msdl.exe --id ${productId} --lang "${langName}"` + : `msdl.exe --id ${productId}` function handleCopy() { navigator.clipboard.writeText(command) diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index aaf060d..01d4df0 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -64,6 +64,7 @@ export default function ProductDetailPage() { const [expiryRemaining, setExpiryRemaining] = useState(null) const [isRefreshing, setIsRefreshing] = useState(false) const [linkStatus, setLinkStatus] = useState<'fresh' | 'cached' | 'stale' | null>(null) + const [isLockdown, setIsLockdown] = useState(false) const expiryTimerRef = useRef | null>(null) const [meta, setMeta] = useState<{ badge: string; archs: string[]; active: boolean }>({ badge: '', archs: [], active: false }) @@ -199,17 +200,23 @@ export default function ProductDetailPage() { setLinkExpiry(null) setExpiryRemaining(null) setLinkStatus(null) + setIsLockdown(false) if (expiryTimerRef.current) clearInterval(expiryTimerRef.current) try { const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}`) const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null const expiresHeader = r.headers.get('X-MSDL-Link-Expires') + const lockdownHeader = r.headers.get('X-MSDL-Lockdown') const data = await r.json() - if (data.error) throw new Error(data.error) + if (data.error) { + if (data.lockdown) setIsLockdown(true) + throw new Error(data.error) + } if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.') setDownloadLinks(data.ProductDownloadOptions) setLinkStatus(status) + if (lockdownHeader === '1') setIsLockdown(true) let expiry: Date | null = null if (expiresHeader) { const d = new Date(expiresHeader) @@ -271,11 +278,17 @@ export default function ProductDetailPage() { const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}&force=true`) const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null const expiresHeader = r.headers.get('X-MSDL-Link-Expires') + const lockdownHeader = r.headers.get('X-MSDL-Lockdown') const data = await r.json() - if (data.error) throw new Error(data.error) + if (data.error) { + if (data.lockdown) setIsLockdown(true) + throw new Error(data.error) + } if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.') setDownloadLinks(data.ProductDownloadOptions) setLinkStatus(status) + if (lockdownHeader === '1') setIsLockdown(true) + else setIsLockdown(false) let expiry: Date | null = null if (expiresHeader) { const d = new Date(expiresHeader) @@ -556,91 +569,141 @@ export default function ProductDetailPage() { transition={{ type: 'spring', stiffness: 300, damping: 28 }} >
- {/* Stale warning */} - {linkStatus === 'stale' && ( -
- -

- This link is cached and may have expired — Microsoft is currently limiting automated requests. Use the options below for a guaranteed fresh download. -

-
- )} - - {/* Expiry row */} -
-
- - {expiryRemaining !== null - ? expiryRemaining <= 0 - ? 'Links expired — refresh to get new ones' - : `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN` - : 'Links expire in 24 hours · Direct from Microsoft CDN' - } -
- {showRefresh && ( - - )} -
- - {/* Links */} - {downloadLinks.map(link => { - const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso' - const arch = link.Architecture || archFromUri(link.Uri) - const isCopied = copiedUri === link.Uri - return ( -
-
-

{filename}

-

{arch}

-
-
+ {linkStatus === 'stale' ? ( + /* Stale state: hide links, show banner + CLI recommendation */ + <> + {isLockdown ? ( + /* Sentinel lockdown — no Refresh, no links, CLI only */ + <> +
+ +

+ Our server has been temporarily blocked by Microsoft and is in a 90-minute cooldown. Use the CLI to get your link instantly — it bypasses our server entirely. +

+
+ {selectedSku && ( + + )} + + ) : ( + /* Normal stale — show warning, CLI, and Refresh */ + <> +
+ +

+ These links are cached and have expired — Microsoft is currently limiting automated requests. +

+
+ {selectedSku && ( + + )} - - - {arch} - - + + )} + + ) : ( + /* Fresh / cached state: show expiry row + links */ + <> +
+
+ + {expiryRemaining !== null + ? expiryRemaining <= 0 + ? 'Links expired — refresh to get new ones' + : `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN` + : 'Links expire in 24 hours · Direct from Microsoft CDN' + }
+ {showRefresh && ( + + )}
- ) - })} + + {downloadLinks.map(link => { + const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso' + const arch = link.Architecture || archFromUri(link.Uri) + const isCopied = copiedUri === link.Uri + return ( +
+
+

{filename}

+

{arch}

+
+
+ + + + {arch} + + +
+
+ ) + })} + + )}
)} @@ -656,8 +719,8 @@ export default function ProductDetailPage() { productId={productId!} languageName={selectedSku?.LocalizedLanguage ?? ''} /> - {/* Collapsed CLI only when highlighted card isn't already shown inside the card */} - {!(error !== null && languages.length > 0) && ( + {/* Skip outer CLI card when already shown inside the main card (any stale or error) */} + {linkStatus !== 'stale' && !(error !== null && languages.length > 0) && ( selectedSku ? (