feat: route Microsoft API calls through Cloudflare Worker to avoid IP blocks
- Add cloudflare-worker/worker.js: edge proxy forwarding requests to allowed Microsoft hosts from Cloudflare distributed IPs. Set CF_WORKER_URL env var to activate; omit to keep direct-to-Microsoft behaviour (self-hosters unaffected). - Wrap all outbound Microsoft URLs in proxyURL() in main.go so the Worker sits transparently in front of every session and download-link request. - Fix Back button navigating to external referrer when user lands directly on a product URL (#5) — use window.history.state?.idx to detect whether there is prior in-app history before calling navigate(-1). Closes #5, Closes #6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a8f9ba1da1
commit
8b036e94ba
4 changed files with 97 additions and 7 deletions
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -34,8 +35,37 @@ var (
|
||||||
sessionCache = make(map[string]SessionEntry)
|
sessionCache = make(map[string]SessionEntry)
|
||||||
cacheMutex sync.RWMutex
|
cacheMutex sync.RWMutex
|
||||||
SESSION_TTL = 15 * time.Minute
|
SESSION_TTL = 15 * time.Minute
|
||||||
|
workerSecret = os.Getenv("CF_WORKER_SECRET")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
// Map product ID to referer URL
|
||||||
func getReferer(productId string) string {
|
func getReferer(productId string) string {
|
||||||
id, err := strconv.Atoi(productId)
|
id, err := strconv.Atoi(productId)
|
||||||
|
|
@ -60,8 +90,9 @@ func setupSession() (string, error) {
|
||||||
q1 := url.Values{}
|
q1 := url.Values{}
|
||||||
q1.Set("org_id", ORG_ID)
|
q1.Set("org_id", ORG_ID)
|
||||||
q1.Set("session_id", sessionID)
|
q1.Set("session_id", sessionID)
|
||||||
req1, _ := http.NewRequest("GET", "https://vlscppe.microsoft.com/tags?"+q1.Encode(), nil)
|
req1, _ := http.NewRequest("GET", proxyURL("https://vlscppe.microsoft.com/tags?"+q1.Encode()), nil)
|
||||||
req1.Header.Set("User-Agent", UA)
|
req1.Header.Set("User-Agent", UA)
|
||||||
|
setWorkerSecret(req1)
|
||||||
client.Do(req1) // Ignore errors intentionally
|
client.Do(req1) // Ignore errors intentionally
|
||||||
|
|
||||||
// Step 2: Fetch tracking JS
|
// Step 2: Fetch tracking JS
|
||||||
|
|
@ -69,8 +100,9 @@ func setupSession() (string, error) {
|
||||||
q2.Set("instanceId", CUSTOMER_ID)
|
q2.Set("instanceId", CUSTOMER_ID)
|
||||||
q2.Set("PageId", "si")
|
q2.Set("PageId", "si")
|
||||||
q2.Set("session_id", sessionID)
|
q2.Set("session_id", sessionID)
|
||||||
req2, _ := http.NewRequest("GET", "https://ov-df.microsoft.com/mdt.js?"+q2.Encode(), nil)
|
req2, _ := http.NewRequest("GET", proxyURL("https://ov-df.microsoft.com/mdt.js?"+q2.Encode()), nil)
|
||||||
req2.Header.Set("User-Agent", UA)
|
req2.Header.Set("User-Agent", UA)
|
||||||
|
setWorkerSecret(req2)
|
||||||
resp2, err := client.Do(req2)
|
resp2, err := client.Do(req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sessionID, nil // Proceed anyway
|
return sessionID, nil // Proceed anyway
|
||||||
|
|
@ -100,10 +132,11 @@ func setupSession() (string, error) {
|
||||||
q3.Set("w", wVal)
|
q3.Set("w", wVal)
|
||||||
q3.Set("mdt", fmt.Sprintf("%d", mdt))
|
q3.Set("mdt", fmt.Sprintf("%d", mdt))
|
||||||
q3.Set("rticks", rtVal)
|
q3.Set("rticks", rtVal)
|
||||||
fpURL := "https://ov-df.microsoft.com/?" + q3.Encode()
|
fpURL := proxyURL("https://ov-df.microsoft.com/?" + q3.Encode())
|
||||||
|
|
||||||
req3, _ := http.NewRequest("GET", fpURL, nil)
|
req3, _ := http.NewRequest("GET", fpURL, nil)
|
||||||
req3.Header.Set("User-Agent", UA)
|
req3.Header.Set("User-Agent", UA)
|
||||||
|
setWorkerSecret(req3)
|
||||||
client.Do(req3)
|
client.Do(req3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +223,7 @@ func handleSkuInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
skuQ.Set("friendlyFileName", "undefined")
|
skuQ.Set("friendlyFileName", "undefined")
|
||||||
skuQ.Set("Locale", LOCALE)
|
skuQ.Set("Locale", LOCALE)
|
||||||
skuQ.Set("sessionID", sessionID)
|
skuQ.Set("sessionID", sessionID)
|
||||||
reqURL := "https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + skuQ.Encode()
|
reqURL := proxyURL("https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + skuQ.Encode())
|
||||||
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
var finalData map[string]interface{}
|
var finalData map[string]interface{}
|
||||||
|
|
@ -204,6 +237,7 @@ func handleSkuInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
req.Header.Set("User-Agent", UA)
|
req.Header.Set("User-Agent", UA)
|
||||||
req.Header.Set("Referer", getReferer(productID))
|
req.Header.Set("Referer", getReferer(productID))
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
setWorkerSecret(req)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -303,13 +337,14 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
warmupQ.Set("friendlyFileName", "undefined")
|
warmupQ.Set("friendlyFileName", "undefined")
|
||||||
warmupQ.Set("Locale", LOCALE)
|
warmupQ.Set("Locale", LOCALE)
|
||||||
warmupQ.Set("sessionID", sessionID)
|
warmupQ.Set("sessionID", sessionID)
|
||||||
warmupURL := "https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + warmupQ.Encode()
|
warmupURL := proxyURL("https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + warmupQ.Encode())
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
req, _ := http.NewRequest("GET", warmupURL, nil)
|
req, _ := http.NewRequest("GET", warmupURL, nil)
|
||||||
req.Header.Set("User-Agent", UA)
|
req.Header.Set("User-Agent", UA)
|
||||||
req.Header.Set("Referer", getReferer(productID))
|
req.Header.Set("Referer", getReferer(productID))
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
setWorkerSecret(req)
|
||||||
client.Do(req)
|
client.Do(req)
|
||||||
|
|
||||||
cacheMutex.Lock()
|
cacheMutex.Lock()
|
||||||
|
|
@ -324,13 +359,14 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
proxyQ.Set("friendlyFileName", "undefined")
|
proxyQ.Set("friendlyFileName", "undefined")
|
||||||
proxyQ.Set("Locale", LOCALE)
|
proxyQ.Set("Locale", LOCALE)
|
||||||
proxyQ.Set("sessionID", sessionID)
|
proxyQ.Set("sessionID", sessionID)
|
||||||
reqURL := "https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?" + proxyQ.Encode()
|
reqURL := proxyURL("https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?" + proxyQ.Encode())
|
||||||
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
req, _ := http.NewRequest("GET", reqURL, nil)
|
req, _ := http.NewRequest("GET", reqURL, nil)
|
||||||
req.Header.Set("User-Agent", UA)
|
req.Header.Set("User-Agent", UA)
|
||||||
req.Header.Set("Referer", getReferer(productID))
|
req.Header.Set("Referer", getReferer(productID))
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
setWorkerSecret(req)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
54
cloudflare-worker/worker.js
Normal file
54
cloudflare-worker/worker.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
const ALLOWED_HOSTS = [
|
||||||
|
"www.microsoft.com",
|
||||||
|
"vlscppe.microsoft.com",
|
||||||
|
"ov-df.microsoft.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (env.CF_WORKER_SECRET) {
|
||||||
|
const secret = request.headers.get("X-Worker-Secret");
|
||||||
|
if (secret !== env.CF_WORKER_SECRET) {
|
||||||
|
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target host comes from the ?host= param; path+search are forwarded as-is
|
||||||
|
const targetHost = url.searchParams.get("host");
|
||||||
|
if (!targetHost || !ALLOWED_HOSTS.includes(targetHost)) {
|
||||||
|
return new Response(JSON.stringify({ error: "Invalid or missing host parameter" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
url.searchParams.delete("host");
|
||||||
|
url.hostname = targetHost;
|
||||||
|
url.protocol = "https:";
|
||||||
|
url.port = "";
|
||||||
|
|
||||||
|
const upstreamRequest = new Request(url.toString(), {
|
||||||
|
method: request.method,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": request.headers.get("User-Agent") || "Mozilla/5.0",
|
||||||
|
"Referer": request.headers.get("Referer") || "",
|
||||||
|
"Accept": request.headers.get("Accept") || "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upstream = await fetch(upstreamRequest);
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": upstream.headers.get("Content-Type") || "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
|
|
@ -213,7 +213,7 @@ export default function ProductDetailPage() {
|
||||||
<div className="max-w-xl mx-auto px-5 pt-12 pb-10">
|
<div className="max-w-xl mx-auto px-5 pt-12 pb-10">
|
||||||
{/* Back */}
|
{/* Back */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(window.history.state?.idx ? -1 : '/')}
|
||||||
className="flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-300 mb-8 transition-colors"
|
className="flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-300 mb-8 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={15} />
|
<ArrowLeft size={15} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue