v2.3.1: CDN redirect gateway, fix token username bug

- Migrate patch downloads to auth server redirect gateway (302 -> CDN)
  Allows instant CDN switching via admin panel without launcher update
- Fix identity token "Player" username mismatch on fresh install
  Add token username verification with retry in fetchAuthTokens
- Refactor versionManager to use mirror manifest via auth.sanasol.ws/patches
- Add optimal patch routing (BFS) for differential updates
- Add PATCH_CDN_INFRASTRUCTURE.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanasol
2026-02-20 14:36:09 +01:00
parent 66112f15b2
commit 0dafb17c7b
6 changed files with 685 additions and 367 deletions

View File

@@ -2,186 +2,240 @@ const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const { getOS, getArch } = require('../utils/platformUtils');
const { smartRequest } = require('../utils/proxyClient');
const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches';
const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
const NEW_API_URL = 'https://thecute.cloud/ShipOfYarn/api.php';
// Patches CDN via auth server redirect gateway (allows instant CDN switching)
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`;
const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`;
let apiCache = null;
let apiCacheTime = 0;
const API_CACHE_DURATION = 60000; // 1 minute
// Fallback: latest known build number if manifest is unreachable
const FALLBACK_LATEST_BUILD = 11;
async function fetchNewAPI() {
let manifestCache = null;
let manifestCacheTime = 0;
const MANIFEST_CACHE_DURATION = 60000; // 1 minute
/**
* Fetch the mirror manifest from MEGA S4
*/
async function fetchMirrorManifest() {
const now = Date.now();
if (apiCache && (now - apiCacheTime) < API_CACHE_DURATION) {
console.log('[NewAPI] Using cached API data');
return apiCache;
if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) {
console.log('[Mirror] Using cached manifest');
return manifestCache;
}
try {
console.log('[NewAPI] Fetching from:', NEW_API_URL);
const response = await axios.get(NEW_API_URL, {
console.log('[Mirror] Fetching manifest from:', MIRROR_MANIFEST_URL);
const response = await axios.get(MIRROR_MANIFEST_URL, {
timeout: 15000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
});
if (response.data && response.data.hytale) {
apiCache = response.data;
apiCacheTime = now;
console.log('[NewAPI] API data fetched and cached successfully');
if (response.data && response.data.files) {
manifestCache = response.data;
manifestCacheTime = now;
console.log('[Mirror] Manifest fetched successfully');
return response.data;
} else {
throw new Error('Invalid API response structure');
}
throw new Error('Invalid manifest structure');
} catch (error) {
console.error('[NewAPI] Error fetching API:', error.message);
if (apiCache) {
console.log('[NewAPI] Using expired cache due to error');
return apiCache;
console.error('[Mirror] Error fetching manifest:', error.message);
if (manifestCache) {
console.log('[Mirror] Using expired cache');
return manifestCache;
}
throw error;
}
}
async function getLatestVersionFromNewAPI(branch = 'release') {
try {
const apiData = await fetchNewAPI();
const osName = getOS();
const arch = getArch();
let osKey = osName;
if (osName === 'darwin') {
osKey = 'mac';
/**
* Parse manifest to get available patches for current platform
* Returns array of { from, to, key, size }
*/
function getPlatformPatches(manifest, branch = 'release') {
const os = getOS();
const arch = getArch();
const prefix = `${os}/${arch}/${branch}/`;
const patches = [];
for (const [key, info] of Object.entries(manifest.files)) {
if (key.startsWith(prefix) && key.endsWith('.pwr')) {
const filename = key.slice(prefix.length, -4); // e.g., "0_to_11"
const match = filename.match(/^(\d+)_to_(\d+)$/);
if (match) {
patches.push({
from: parseInt(match[1]),
to: parseInt(match[2]),
key,
size: info.size
});
}
}
const branchData = apiData.hytale[branch];
if (!branchData || !branchData[osKey]) {
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`);
}
const osData = branchData[osKey];
const versions = Object.keys(osData).filter(key => key.endsWith('.pwr'));
if (versions.length === 0) {
throw new Error(`No .pwr files found for ${osKey}`);
}
const versionNumbers = versions.map(v => {
const match = v.match(/v(\d+)/);
return match ? parseInt(match[1]) : 0;
});
const latestVersionNumber = Math.max(...versionNumbers);
console.log(`[NewAPI] Latest version number: ${latestVersionNumber} for branch ${branch}`);
return `v${latestVersionNumber}`;
} catch (error) {
console.error('[NewAPI] Error getting latest version:', error.message);
throw error;
}
return patches;
}
async function getPWRUrlFromNewAPI(branch = 'release', version = 'v8') {
try {
const apiData = await fetchNewAPI();
const osName = getOS();
const arch = getArch();
let osKey = osName;
if (osName === 'darwin') {
osKey = 'mac';
}
let fileName;
if (osName === 'windows') {
fileName = `${version}-windows-amd64.pwr`;
} else if (osName === 'linux') {
fileName = `${version}-linux-amd64.pwr`;
} else if (osName === 'darwin') {
fileName = `${version}-darwin-arm64.pwr`;
}
const branchData = apiData.hytale[branch];
if (!branchData || !branchData[osKey]) {
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`);
}
const osData = branchData[osKey];
const url = osData[fileName];
if (!url) {
throw new Error(`No URL found for ${fileName}`);
}
console.log(`[NewAPI] URL for ${fileName}: ${url}`);
return url;
} catch (error) {
console.error('[NewAPI] Error getting PWR URL:', error.message);
throw error;
/**
* Find optimal patch path using BFS with download size minimization
* Returns array of { from, to, url, size, key } steps, or null if no path found
*/
function findOptimalPatchPath(currentBuild, targetBuild, patches) {
if (currentBuild >= targetBuild) return [];
const edges = {};
for (const patch of patches) {
if (!edges[patch.from]) edges[patch.from] = [];
edges[patch.from].push(patch);
}
const queue = [{ build: currentBuild, path: [], totalSize: 0 }];
let bestPath = null;
let bestSize = Infinity;
while (queue.length > 0) {
const { build, path, totalSize } = queue.shift();
if (build === targetBuild) {
if (totalSize < bestSize) {
bestPath = path;
bestSize = totalSize;
}
continue;
}
if (totalSize >= bestSize) continue;
const nextEdges = edges[build] || [];
for (const edge of nextEdges) {
if (edge.to <= build || edge.to > targetBuild) continue;
if (path.some(p => p.to === edge.to)) continue;
queue.push({
build: edge.to,
path: [...path, {
from: edge.from,
to: edge.to,
url: `${MIRROR_BASE_URL}/${edge.key}`,
size: edge.size,
key: edge.key
}],
totalSize: totalSize + edge.size
});
}
}
return bestPath;
}
/**
* Get the optimal update plan from currentBuild to targetBuild
* Returns { steps: [{from, to, url, size}], totalSize, isFullInstall }
*/
async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') {
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
// Try optimal path
const steps = findOptimalPatchPath(currentBuild, targetBuild, patches);
if (steps && steps.length > 0) {
const totalSize = steps.reduce((sum, s) => sum + s.size, 0);
console.log(`[Mirror] Update plan: ${steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${(totalSize / 1024 / 1024).toFixed(0)} MB)`);
return { steps, totalSize, isFullInstall: steps.length === 1 && steps[0].from === 0 };
}
// Fallback: full install 0 -> target
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
if (fullPatch) {
const step = {
from: 0,
to: targetBuild,
url: `${MIRROR_BASE_URL}/${fullPatch.key}`,
size: fullPatch.size,
key: fullPatch.key
};
console.log(`[Mirror] Full install: 0\u2192${targetBuild} (${(fullPatch.size / 1024 / 1024).toFixed(0)} MB)`);
return { steps: [step], totalSize: fullPatch.size, isFullInstall: true };
}
throw new Error(`No patch path found from build ${currentBuild} to ${targetBuild} for ${getOS()}/${getArch()}`);
}
async function getLatestClientVersion(branch = 'release') {
try {
console.log(`[NewAPI] Fetching latest client version from new API (branch: ${branch})...`);
// Utiliser la nouvelle API
const latestVersion = await getLatestVersionFromNewAPI(branch);
console.log(`[NewAPI] Latest client version for ${branch}: ${latestVersion}`);
return latestVersion;
} catch (error) {
console.error('[NewAPI] Error fetching client version from new API:', error.message);
console.log('[NewAPI] Falling back to old API...');
// Fallback vers l'ancienne API si la nouvelle échoue
try {
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
timeout: 40000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
});
console.log(`[Mirror] Fetching latest client version (branch: ${branch})...`);
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
if (response.data && response.data.client_version) {
const version = response.data.client_version;
console.log(`Latest client version for ${branch} (old API): ${version}`);
return version;
} else {
console.log('Warning: Invalid API response, falling back to latest known version (v8)');
return 'v8';
}
} catch (fallbackError) {
console.error('Error fetching client version from old API:', fallbackError.message);
console.log('Warning: Both APIs unavailable, falling back to latest known version (v8)');
return 'v8';
if (patches.length === 0) {
console.log(`[Mirror] No patches for branch '${branch}', using fallback`);
return `v${FALLBACK_LATEST_BUILD}`;
}
const latestBuild = Math.max(...patches.map(p => p.to));
console.log(`[Mirror] Latest client version: v${latestBuild}`);
return `v${latestBuild}`;
} catch (error) {
console.error('[Mirror] Error:', error.message);
return `v${FALLBACK_LATEST_BUILD}`;
}
}
// Fonction utilitaire pour extraire le numéro de version
// Supporte les formats: "7.pwr", "v8", "v8-windows-amd64.pwr", etc.
/**
* Get PWR download URL for fresh install (0 -> target)
* Backward-compatible with old getPWRUrlFromNewAPI signature
* Checks mirror first, then constructs URL for the branch
*/
async function getPWRUrl(branch = 'release', version = 'v11') {
const targetBuild = extractVersionNumber(version);
const os = getOS();
const arch = getArch();
try {
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
if (fullPatch) {
const url = `${MIRROR_BASE_URL}/${fullPatch.key}`;
console.log(`[Mirror] PWR URL: ${url}`);
return url;
}
if (patches.length > 0) {
// Branch exists in mirror but no full patch for this target - construct URL
console.log(`[Mirror] No 0->${targetBuild} patch found, constructing URL`);
} else {
console.log(`[Mirror] Branch '${branch}' not in mirror, constructing URL`);
}
} catch (error) {
console.error('[Mirror] Error getting PWR URL:', error.message);
}
// Construct mirror URL (will work if patch was uploaded but manifest is stale)
return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`;
}
// Backward-compatible alias
const getPWRUrlFromNewAPI = getPWRUrl;
// Utility function to extract version number
// Supports: "7.pwr", "v8", "v8-windows-amd64.pwr", "5_to_10", etc.
function extractVersionNumber(version) {
if (!version) return 0;
// Nouveau format: "v8" ou "v8-xxx.pwr"
// New format: "v8" or "v8-xxx.pwr"
const vMatch = version.match(/v(\d+)/);
if (vMatch) {
return parseInt(vMatch[1]);
}
// Ancien format: "7.pwr"
if (vMatch) return parseInt(vMatch[1]);
// Old format: "7.pwr"
const pwrMatch = version.match(/(\d+)\.pwr/);
if (pwrMatch) {
return parseInt(pwrMatch[1]);
}
// Fallback: essayer de parser directement
if (pwrMatch) return parseInt(pwrMatch[1]);
// Fallback
const num = parseInt(version);
return isNaN(num) ? 0 : num;
}
@@ -189,7 +243,7 @@ function extractVersionNumber(version) {
function buildArchiveUrl(buildNumber, branch = 'release') {
const os = getOS();
const arch = getArch();
return `${BASE_PATCH_URL}/${os}/${arch}/${branch}/0/${buildNumber}.pwr`;
return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`;
}
async function checkArchiveExists(buildNumber, branch = 'release') {
@@ -197,91 +251,56 @@ async function checkArchiveExists(buildNumber, branch = 'release') {
try {
const response = await axios.head(url, { timeout: 10000 });
return response.status === 200;
} catch (error) {
} catch {
return false;
}
}
async function discoverAvailableVersions(latestKnown, branch = 'release', maxProbe = 50) {
const available = [];
const latest = extractVersionNumber(latestKnown);
for (let i = latest; i >= Math.max(1, latest - maxProbe); i--) {
const exists = await checkArchiveExists(i, branch);
if (exists) {
available.push(`${i}.pwr`);
}
}
return available;
}
async function fetchPatchManifest(branch = 'release') {
async function discoverAvailableVersions(latestKnown, branch = 'release') {
try {
const os = getOS();
const arch = getArch();
const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, {
timeout: 10000
});
return response.data.patches || {};
} catch (error) {
console.error('Failed to fetch patch manifest:', error.message);
return {};
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
const versions = [...new Set(patches.map(p => p.to))].sort((a, b) => b - a);
return versions.map(v => `${v}.pwr`);
} catch {
return [];
}
}
async function extractVersionDetails(targetVersion, branch = 'release') {
const buildNumber = extractVersionNumber(targetVersion);
const previousBuild = buildNumber - 1;
const manifest = await fetchPatchManifest(branch);
const patchInfo = manifest[buildNumber];
const fullUrl = buildArchiveUrl(buildNumber, branch);
return {
version: targetVersion,
buildNumber: buildNumber,
buildNumber,
buildName: `HYTALE-Build-${buildNumber}`,
fullUrl: patchInfo?.original_url || buildArchiveUrl(buildNumber, branch),
differentialUrl: patchInfo?.patch_url || null,
checksum: patchInfo?.patch_hash || null,
sourceVersion: patchInfo?.from ? `${patchInfo.from}.pwr` : (previousBuild > 0 ? `${previousBuild}.pwr` : null),
isDifferential: !!patchInfo?.proper_patch,
releaseNotes: patchInfo?.patch_note || null
fullUrl,
differentialUrl: null,
checksum: null,
sourceVersion: null,
isDifferential: false,
releaseNotes: null
};
}
function canUseDifferentialUpdate(currentVersion, targetDetails) {
if (!targetDetails) return false;
if (!targetDetails.differentialUrl) return false;
if (!targetDetails.isDifferential) return false;
if (!currentVersion) return false;
const currentBuild = extractVersionNumber(currentVersion);
const expectedSource = extractVersionNumber(targetDetails.sourceVersion);
return currentBuild === expectedSource;
function canUseDifferentialUpdate() {
// Differential updates are now handled via getUpdatePlan()
return false;
}
function needsIntermediatePatches(currentVersion, targetVersion) {
if (!currentVersion) return [];
const current = extractVersionNumber(currentVersion);
const target = extractVersionNumber(targetVersion);
const intermediates = [];
for (let i = current + 1; i <= target; i++) {
intermediates.push(`${i}.pwr`);
}
return intermediates;
if (current >= target) return [];
return [targetVersion];
}
async function computeFileChecksum(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
@@ -290,7 +309,6 @@ async function computeFileChecksum(filePath) {
async function validateChecksum(filePath, expectedChecksum) {
if (!expectedChecksum) return true;
const actualChecksum = await computeFileChecksum(filePath);
return actualChecksum === expectedChecksum;
}
@@ -299,7 +317,7 @@ function getInstalledClientVersion() {
try {
const { loadVersionClient } = require('../core/config');
return loadVersionClient();
} catch (err) {
} catch {
return null;
}
}
@@ -315,8 +333,12 @@ module.exports = {
computeFileChecksum,
validateChecksum,
getInstalledClientVersion,
fetchNewAPI,
getLatestVersionFromNewAPI,
fetchMirrorManifest,
getPWRUrl,
getPWRUrlFromNewAPI,
extractVersionNumber
getUpdatePlan,
extractVersionNumber,
getPlatformPatches,
findOptimalPatchPath,
MIRROR_BASE_URL
};