Replaced strconv.Atoi on sku_id with a permissive allowlist regex (alphanumeric, hyphens, underscores). Older products like Windows 8.1 (#48, #52) return non-integer SKU IDs from Microsoft API, causing the Get Download Links button to fail with 'sku_id must be a numeric value'. Closes #9 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
472 lines
13 KiB
Go
472 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
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" // Different port to run alongside Node
|
|
)
|
|
|
|
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_\-]+$`)
|
|
)
|
|
|
|
func setWorkerSecret(req *http.Request) {
|
|
if workerSecret != "" {
|
|
req.Header.Set("X-Worker-Secret", workerSecret)
|
|
}
|
|
}
|
|
|
|
// proxyURL rewrites a Microsoft URL to go through the CF Worker when CF_WORKER_URL is set.
|
|
// The Worker receives the original host via ?host= and forwards the request from Cloudflare's edge.
|
|
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()
|
|
}
|
|
|
|
// Map product ID to referer URL
|
|
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"
|
|
}
|
|
|
|
// Replicate Fido session tracking
|
|
func setupSession() (string, error) {
|
|
sessionID := uuid.New().String()
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
// Step 1: Register session
|
|
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) // Ignore errors intentionally
|
|
|
|
// Step 2: Fetch tracking JS
|
|
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 // Proceed anyway
|
|
}
|
|
defer resp2.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp2.Body)
|
|
mdtText := string(bodyBytes)
|
|
|
|
// Regex to extract w and rticks
|
|
reW := regexp.MustCompile(`[&?]w=([^&"'\s]+)`)
|
|
reRt := regexp.MustCompile(`rticks[="]+\+?\s*(\d{10,})`)
|
|
|
|
wMatch := reW.FindStringSubmatch(mdtText)
|
|
rtMatch := reRt.FindStringSubmatch(mdtText)
|
|
|
|
// Step 3: Send fingerprint response
|
|
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
|
|
}
|
|
|
|
// Map DownloadType code to string
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Background cleanup for stale sessions
|
|
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()
|
|
}
|
|
}
|
|
|
|
// Middleware for CORS
|
|
func enableCORS(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
// Allowed origins
|
|
if origin == "https://msdl.tech-latest.com" || origin == "http://localhost:5173" || origin == "http://localhost:3000" {
|
|
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
|
|
}
|
|
|
|
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{}
|
|
|
|
// Retry logic
|
|
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 {
|
|
respondJSONError(w, http.StatusBadGateway, "Request failed: "+err.Error())
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
if attempt == 2 {
|
|
respondJSONError(w, http.StatusBadGateway, fmt.Sprintf("Microsoft API returned HTTP %d: %s", resp.StatusCode, string(bodyBytes)))
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Double encoded decode logic
|
|
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)
|
|
|
|
// Check for specific MS errors
|
|
if errs, ok := data["Errors"].([]interface{}); ok && len(errs) > 0 {
|
|
if attempt == 2 {
|
|
msg := "Microsoft API error"
|
|
if errMap, ok := errs[0].(map[string]interface{}); ok {
|
|
if val, exists := errMap["Value"]; exists {
|
|
msg = val.(string)
|
|
}
|
|
}
|
|
respondJSONError(w, http.StatusBadGateway, msg)
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
finalData = data
|
|
break
|
|
}
|
|
|
|
skus, ok := finalData["Skus"].([]interface{})
|
|
if !ok || len(skus) == 0 {
|
|
respondJSONError(w, http.StatusNotFound, "No languages found for this product ID.")
|
|
return
|
|
}
|
|
|
|
log.Printf("/skuinfo: product_id=%s -> %d languages (session=%s)\n", productID, len(skus), sessionID[:8])
|
|
json.NewEncoder(w).Encode(finalData)
|
|
}
|
|
|
|
// --- /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
|
|
}
|
|
|
|
var sessionID string
|
|
cacheMutex.RLock()
|
|
cached, exists := sessionCache[productID]
|
|
cacheMutex.RUnlock()
|
|
|
|
if exists && time.Since(cached.CreatedAt) < SESSION_TTL {
|
|
sessionID = cached.SessionID
|
|
log.Printf("/proxy: Reusing session %s for product_id=%s\n", sessionID[:8], productID)
|
|
} else {
|
|
log.Printf("/proxy: No valid session for product_id=%s, creating new one...\n", productID)
|
|
sessionID, _ = setupSession()
|
|
|
|
// Warmup with SKU info request
|
|
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 {
|
|
respondJSONError(w, http.StatusBadGateway, "Request failed: "+err.Error())
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
respondJSONError(w, http.StatusBadGateway, fmt.Sprintf("Microsoft API returned HTTP %d: %s", resp.StatusCode, string(bodyBytes)))
|
|
return
|
|
}
|
|
|
|
// Double encoded decode
|
|
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)
|
|
|
|
// Check for Microsoft errors
|
|
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 && typeNum == 9 {
|
|
respondJSONError(w, http.StatusTooManyRequests, "Your IP has been temporarily blocked by Microsoft. Please try again later. (Code 715-123130)")
|
|
return
|
|
}
|
|
if val, exists := errMap["Value"].(string); exists {
|
|
respondJSONError(w, http.StatusBadGateway, val)
|
|
return
|
|
}
|
|
}
|
|
respondJSONError(w, http.StatusBadGateway, "Microsoft API error")
|
|
return
|
|
}
|
|
|
|
optsRaw, exists := data["ProductDownloadOptions"]
|
|
if !exists {
|
|
respondJSONError(w, http.StatusNotFound, "No download links found for this SKU.")
|
|
return
|
|
}
|
|
|
|
opts, ok := optsRaw.([]interface{})
|
|
if !ok || len(opts) == 0 {
|
|
respondJSONError(w, http.StatusNotFound, "No download links found for this SKU.")
|
|
return
|
|
}
|
|
|
|
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("/proxy: product_id=%s, sku_id=%s -> %d links\n", productID, skuID, len(opts))
|
|
|
|
// Write pure JSON
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false) // Don't encode & to & in URLs!
|
|
enc.Encode(data)
|
|
w.Write(buf.Bytes())
|
|
}
|
|
|
|
func main() {
|
|
go cleanupSessions()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// API Routing
|
|
mux.HandleFunc("/skuinfo", handleSkuInfo)
|
|
mux.HandleFunc("/proxy", handleProxy)
|
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
// Plain root response
|
|
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)
|
|
}
|
|
}
|