diff --git a/backend/main.go b/backend/main.go index a3e2185..9b1354c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,13 +4,16 @@ import ( "bytes" "encoding/json" "fmt" + "html" "io" "log" "net/http" "net/url" "os" "regexp" + "sort" "strconv" + "strings" "sync" "time" @@ -39,6 +42,146 @@ var ( validSkuID = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`) ) +// --- 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$`) +) + +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} + + // Step 1: Fetch the evalcenter page to extract fwlinks + 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) + + // Step 2: Extract all fwlink URLs (unescape HTML entities like & → &) + rawMatches := fwlinkRe.FindAllString(string(bodyBytes), -1) + + // Deduplicate fwlinks + 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) + } + } + + // Step 3: Follow all redirects in parallel + 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: en-us first, then alphabetically by lang + 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 +} + func setWorkerSecret(req *http.Request) { if workerSecret != "" { req.Header.Set("X-Worker-Secret", workerSecret) @@ -178,7 +321,7 @@ 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" { + if origin == "https://msdl.tech-latest.com" || strings.HasPrefix(origin, "http://localhost:") { w.Header().Set("Access-Control-Allow-Origin", origin) } @@ -440,14 +583,77 @@ func handleProxy(w http.ResponseWriter, r *http.Request) { w.Write(buf.Bytes()) } +// --- /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 + } + + // Check cache + evalCacheMu.RLock() + cached, exists := evalCache[product] + evalCacheMu.RUnlock() + + if exists && time.Since(cached.CachedAt) < evalCacheTTL { + 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 { + respondJSONError(w, http.StatusBadGateway, "Failed to fetch eval links: "+err.Error()) + return + } + if len(links) == 0 { + 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\n", product, len(links)) + json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: links}) +} + +// warmEvalCache pre-fetches all eval product links in the background at startup. +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 warmEvalCache() mux := http.NewServeMux() // API Routing mux.HandleFunc("/skuinfo", handleSkuInfo) mux.HandleFunc("/proxy", handleProxy) + mux.HandleFunc("/evallinks", handleEvalLinks) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index 1ad0753..c7a6ecb 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -32,6 +32,19 @@ yearly + + + https://msdl.tech-latest.com/eval + 2026-05-18 + 0.8 + monthly + + https://msdl.tech-latest.com/product/server-20252026-05-180.8monthly + https://msdl.tech-latest.com/product/server-20222026-05-180.9monthly + https://msdl.tech-latest.com/product/server-20192026-05-180.8monthly + https://msdl.tech-latest.com/product/server-20162026-05-180.7monthly + https://msdl.tech-latest.com/product/win11-ent2026-05-180.8monthly + https://msdl.tech-latest.com/product/482026-05-120.8monthly https://msdl.tech-latest.com/product/522026-05-120.8monthly diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b701fa1..e217b8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,17 @@ import ProductDetailPage from './pages/ProductDetailPage' import AboutPage from './pages/AboutPage' import PrivacyPolicyPage from './pages/PrivacyPolicyPage' import DisclaimerPage from './pages/DisclaimerPage' +import EvalPage from './pages/EvalPage' +import EvalDetailPage from './pages/EvalDetailPage' import NotFoundPage from './pages/NotFoundPage' +import { evalSlugSet } from './data/evalProducts' +import { useParams } from 'react-router-dom' + +function ProductRouter() { + const { productId } = useParams<{ productId: string }>() + if (productId && evalSlugSet.has(productId)) return + return +} import ScrollToTop from './components/ScrollToTop' export default function App() { @@ -19,7 +29,8 @@ export default function App() { } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Dock.tsx b/frontend/src/components/Dock.tsx index 3c3c48b..f8a2d81 100644 --- a/frontend/src/components/Dock.tsx +++ b/frontend/src/components/Dock.tsx @@ -24,7 +24,12 @@ export default function Dock() { const [activeIndex, setActiveIndex] = useState(0) useEffect(() => { - const idx = items.findIndex(item => item.path && item.path === location.pathname) + // Exact match first + let idx = items.findIndex(item => item.path && item.path === location.pathname) + // /product/* and /eval* → highlight Products tab + if (idx === -1 && (location.pathname.startsWith('/product/') || location.pathname.startsWith('/eval'))) { + idx = items.findIndex(item => item.path === '/products') + } if (idx !== -1) setActiveIndex(idx) }, [location.pathname]) diff --git a/frontend/src/components/SiteFooter.tsx b/frontend/src/components/SiteFooter.tsx index a231272..0fc38d4 100644 --- a/frontend/src/components/SiteFooter.tsx +++ b/frontend/src/components/SiteFooter.tsx @@ -9,6 +9,8 @@ export default function SiteFooter() { All ISO files are hosted on Microsoft's official CDN.

+ Enterprise & Server + · Privacy Policy · Disclaimer diff --git a/frontend/src/data/evalProducts.ts b/frontend/src/data/evalProducts.ts new file mode 100644 index 0000000..9190611 --- /dev/null +++ b/frontend/src/data/evalProducts.ts @@ -0,0 +1,96 @@ +export interface EvalProductConfig { + slug: string + name: string + version: string + build: string + description: string + archs: string[] + type: 'server' | 'enterprise' + requirements: { + cpu: string + ram: string + disk: string + note?: string + } +} + +export const evalProducts: EvalProductConfig[] = [ + { + slug: 'server-2025', + name: 'Windows Server 2025', + version: 'Standard / Datacenter', + build: 'Latest evaluation build', + description: + 'The latest Windows Server release featuring enhanced security, hybrid cloud capabilities, and improved performance. Includes SMB over QUIC, credential guard improvements, and delegated managed service accounts.', + archs: ['x64'], + type: 'server', + requirements: { + cpu: '1.4 GHz 64-bit', + ram: '512 MB / 2 GB GUI', + disk: '32 GB', + }, + }, + { + slug: 'server-2022', + name: 'Windows Server 2022', + version: 'Standard / Datacenter', + build: 'Latest evaluation build', + description: + 'Long-term servicing channel release with Secured-core server support, TLS 1.3 by default, and DNS-over-HTTPS. The recommended choice for new server infrastructure evaluation.', + archs: ['x64'], + type: 'server', + requirements: { + cpu: '1.4 GHz 64-bit', + ram: '512 MB / 2 GB GUI', + disk: '32 GB', + }, + }, + { + slug: 'server-2019', + name: 'Windows Server 2019', + version: 'Standard / Datacenter', + build: '17763', + description: + 'Stable and widely deployed. Includes Windows Defender Advanced Threat Protection, Storage Migration Service, and System Insights. Ideal for existing infrastructure evaluation.', + archs: ['x64'], + type: 'server', + requirements: { + cpu: '1.4 GHz 64-bit', + ram: '512 MB / 2 GB GUI', + disk: '32 GB', + }, + }, + { + slug: 'server-2016', + name: 'Windows Server 2016', + version: 'Standard / Datacenter', + build: '14393', + description: + 'Legacy server release with Nano Server, Storage Spaces Direct, and Windows Containers support. Suitable for older environment compatibility testing.', + archs: ['x64'], + type: 'server', + requirements: { + cpu: '1.4 GHz 64-bit', + ram: '512 MB / 2 GB GUI', + disk: '32 GB', + }, + }, + { + slug: 'win11-ent', + name: 'Windows 11 Enterprise', + version: 'Evaluation', + build: 'Latest evaluation build', + description: + 'Full enterprise feature set including Windows Hello for Business, BitLocker, Microsoft Defender for Endpoint integration, and advanced management via Intune and Group Policy.', + archs: ['x64'], + type: 'enterprise', + requirements: { + cpu: '1 GHz, 2+ cores 64-bit', + ram: '4 GB', + disk: '64 GB', + note: 'TPM 2.0 required', + }, + }, +] + +export const evalSlugSet = new Set(evalProducts.map(p => p.slug)) diff --git a/frontend/src/pages/EvalDetailPage.tsx b/frontend/src/pages/EvalDetailPage.tsx new file mode 100644 index 0000000..d7ed824 --- /dev/null +++ b/frontend/src/pages/EvalDetailPage.tsx @@ -0,0 +1,325 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { motion, AnimatePresence } from 'motion/react' +import { ArrowLeft, Download, ExternalLink, AlertTriangle, Copy, Check, Server, ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { evalProducts } from '../data/evalProducts' +import Aria2Tip from '../components/Aria2Tip' +import RelatedReleases from '../components/RelatedReleases' +import NotFoundPage from './NotFoundPage' + +const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3002' +const SITE_URL = 'https://msdl.tech-latest.com' + +interface EvalLink { + arch: string + lang: string + url: string +} + +const langNames: Record = { + 'en-us': 'English', + 'zh-cn': 'Chinese (Simplified)', + 'zh-tw': 'Chinese (Traditional)', + 'fr-fr': 'French', + 'de-de': 'German', + 'it-it': 'Italian', + 'ja-jp': 'Japanese', + 'ko-kr': 'Korean', + 'pt-br': 'Portuguese (Brazil)', + 'ru-ru': 'Russian', + 'es-es': 'Spanish', + 'pl-pl': 'Polish', + 'nl-nl': 'Dutch', + 'sv-se': 'Swedish', + 'tr-tr': 'Turkish', +} + +function WinLogo({ className }: { className?: string }) { + return ( + + ) +} + +export default function EvalDetailPage() { + const { productId } = useParams<{ productId: string }>() + const navigate = useNavigate() + + const product = evalProducts.find(p => p.slug === productId) + + const [links, setLinks] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [copiedUrl, setCopiedUrl] = useState(null) + const [moreOpen, setMoreOpen] = useState(false) + + useEffect(() => { + if (!product) return + setIsLoading(true) + setError(null) + fetch(`${API_BASE}/evallinks?product=${product.slug}`) + .then(r => r.json()) + .then(data => { + if (data.error) throw new Error(data.error) + setLinks(data.links ?? []) + }) + .catch(err => setError(err.message)) + .finally(() => setIsLoading(false)) + }, [product?.slug]) + + async function handleCopy(url: string) { + try { + await navigator.clipboard.writeText(url) + setCopiedUrl(url) + toast.success('Link copied to clipboard') + setTimeout(() => setCopiedUrl(null), 2000) + } catch { + toast.error('Failed to copy link') + } + } + + if (!product) return + + const primaryLink = links[0] ?? null + const otherLinks = links.slice(1) + + const pageTitle = `${product.name} ISO Download | Windows ISO Downloader` + const pageDesc = `Download ${product.name} evaluation ISO directly from Microsoft. ${product.description}` + const canonical = `${SITE_URL}/product/${product.slug}` + + const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL }, + { '@type': 'ListItem', position: 2, name: 'Enterprise & Server', item: `${SITE_URL}/eval` }, + { '@type': 'ListItem', position: 3, name: product.name, item: canonical }, + ], + } + + return ( + <> + {pageTitle} + + + + + +