UNPKG

steam-user

Version:

Steam client for Individual and AnonUser Steam account types

684 lines (581 loc) 20.2 kB
const FS = require('fs'); const StdLib = require('@doctormckay/stdlib'); const SteamCrypto = require('@doctormckay/steam-crypto'); const Helpers = require('./helpers.js'); const ContentManifest = require('./content_manifest.js'); const CdnCompression = require('./cdn_compression.js'); const EDepotFileFlag = require('../enums/EDepotFileFlag.js'); const EMsg = require('../enums/EMsg.js'); const EResult = require('../enums/EResult.js'); const SteamUserApps = require('./apps.js'); class SteamUserCDN extends SteamUserApps { /** * Get the list of currently-available content servers. * @param {int} [appid] - If you know the appid you want to download, pass it here as some content servers only provide content for specific games * @param {function} [callback] * @return Promise */ getContentServers(appid, callback) { if (typeof appid == 'function') { callback = appid; appid = null; } return StdLib.Promises.timeoutCallbackPromise(10000, ['servers'], callback, async (resolve, reject) => { let res; if (this._contentServerCache && this._contentServerCache.timestamp && Date.now() - this._contentServerCache.timestamp < (1000 * 60 * 60)) { // Cache for 1 hour res = this._contentServerCache.response; } else { res = await this._apiRequest('GET', 'IContentServerDirectoryService', 'GetServersForSteamPipe', 1, {cell_id: this.cellID || 0}); } if (!res || !res.response || !res.response.servers) { return reject(new Error('Malformed response')); } this._contentServerCache = { timestamp: Date.now(), response: res }; let servers = []; for (let serverKey in res.response.servers) { let server = res.response.servers[serverKey]; if (server.allowed_app_ids && appid && !server.allowed_app_ids.includes(appid)) { continue; } if (server.type == 'CDN' || server.type == 'SteamCache') { servers.push(server); } } if (servers.length == 0) { return reject(new Error('No content servers available')); } servers = servers.map((srv) => { let processedSrv = { type: srv.type, sourceid: srv.source_id, cell: srv.cell_id, load: srv.load, preferred_server: srv.preferred_server, weightedload: srv.weighted_load, NumEntriesInClientList: srv.num_entries_in_client_list, Host: srv.host, vhost: srv.vhost, https_support: srv.https_support, //usetokenauth: '1' }; if (srv.allowed_app_ids) { processedSrv.allowed_app_ids = srv.allowed_app_ids; } return processedSrv; }); if (servers.length == 0) { delete this._contentServerCache; return reject(new Error('No servers found')); } // Return a copy of the array, not the original return resolve({servers: JSON.parse(JSON.stringify(servers))}); }); } /** * Request the decryption key for a particular depot. * @param {int} appID * @param {int} depotID * @param {function} [callback] * @return Promise */ getDepotDecryptionKey(appID, depotID, callback) { appID = parseInt(appID, 10); depotID = parseInt(depotID, 10); return StdLib.Promises.timeoutCallbackPromise(10000, ['key'], callback, async (resolve, reject) => { // Check if it's cached locally let filename = `depot_key_${appID}_${depotID}.bin`; let file = await this._readFile(filename); if (file && file.length > 4 && Math.floor(Date.now() / 1000) - file.readUInt32LE(0) < (60 * 60 * 24 * 14)) { return resolve({key: file.slice(4)}); } this._send(EMsg.ClientGetDepotDecryptionKey, { depot_id: depotID, app_id: appID }, async (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } if (body.depot_id != depotID) { return reject(new Error('Did not receive decryption key for correct depot')); } let key = body.depot_encryption_key; file = Buffer.concat([Buffer.alloc(4), key]); file.writeUInt32LE(Math.floor(Date.now() / 1000), 0); await this._saveFile(filename, file); return resolve({key}); }); }); } /** * Get an auth token for a particular CDN server. * @param {int} appID * @param {int} depotID * @param {string} hostname - The hostname of the CDN server for which we want a token * @param {function} [callback] * @return Promise */ getCDNAuthToken(appID, depotID, hostname, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['token', 'expires'], callback, (resolve, reject) => { if (this._contentServerTokens[depotID + '_' + hostname] && this._contentServerTokens[depotID + '_' + hostname].expires - Date.now() > (1000 * 60 * 60)) { return resolve(this._contentServerTokens[depotID + '_' + hostname]); } this._send(EMsg.ClientGetCDNAuthToken, { app_id: appID, depot_id: depotID, host_name: hostname }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } return resolve(this._contentServerTokens[depotID + '_' + hostname] = { token: body.token, expires: new Date(body.expiration_time * 1000) }); }); }); } /** * Download a depot content manifest. * @param {int} appID * @param {int} depotID * @param {string} manifestID * @param {string} branchName - Now mandatory. Use 'public' for a the public build (i.e. not a beta) * @param {string} [branchPassword] * @param {function} [callback] * @return Promise */ getManifest(appID, depotID, manifestID, branchName, branchPassword, callback) { if (typeof manifestID == 'object' && typeof manifestID.gid == 'string') { // At some point, Valve changed the format of appinfo. // Previously, appinfo.depots[depotId].manifests.public would get you the public manifest ID. // Now, you need to access it with appinfo.depots[depotId].manifests.public.gid. // Here's a shim to keep consumers working properly if they expect the old format. manifestID = manifestID.gid; this._warn(`appinfo format has changed: you now need to use appinfo.depots[${depotID}].manifests.${branchName || 'public'}.gid to access the manifest ID. steam-user is fixing up your input, but you should update your code to retrieve the manifest ID from its new location in the appinfo structure.`); } if (typeof branchName == 'function') { callback = branchName; branchName = null; } if (typeof branchPassword == 'function') { callback = branchPassword; branchPassword = null; } return StdLib.Promises.timeoutCallbackPromise(10000, ['manifest'], callback, async (resolve, reject) => { let manifest = ContentManifest.parse((await this.getRawManifest(appID, depotID, manifestID, branchName, branchPassword)).manifest); if (!manifest.filenames_encrypted) { return resolve({manifest}); } ContentManifest.decryptFilenames(manifest, (await this.getDepotDecryptionKey(appID, depotID)).key); return resolve({manifest}); }); } /** * Download and decompress a manifest, but don't parse it into a JavaScript object. * @param {int} appID * @param {int} depotID * @param {string} manifestID * @param {string} branchName - Now mandatory. Use 'public' for a the public build (i.e. not a beta) * @param {string} [branchPassword] * @param {function} [callback] */ getRawManifest(appID, depotID, manifestID, branchName, branchPassword, callback) { if (typeof branchName == 'function') { callback = branchName; branchName = null; } if (typeof branchPassword == 'function') { callback = branchPassword; branchPassword = null; } return StdLib.Promises.callbackPromise(['manifest'], callback, async (resolve, reject) => { let {servers} = await this.getContentServers(appID); let server = servers[Math.floor(Math.random() * servers.length)]; let urlBase = (server.https_support == 'mandatory' ? 'https://' : 'http://') + server.Host; let vhost = server.vhost || server.Host; let token = ''; if (server.usetokenauth == 1) { // Only request a CDN auth token if this server wants one. // I'm not sure that any servers use token auth anymore, but in case there's one out there that does, // we should still try. token = (await this.getCDNAuthToken(appID, depotID, vhost)).token; } if (!branchName) { this._warn(`No branch name was specified for app ${appID}, depot ${depotID}. Assuming "public".`); branchName = 'public'; } let {requestCode} = await this.getManifestRequestCode(appID, depotID, manifestID, branchName, branchPassword); let manifestRequestCode = `/${requestCode}`; let manifestUrl = `${urlBase}/depot/${depotID}/manifest/${manifestID}/5${manifestRequestCode}${token}`; this.emit('debug', `Downloading manifest from ${manifestUrl} (${vhost})`); download(manifestUrl, vhost, async (err, res) => { if (err) { return reject(err); } if (res.type != 'complete') { return; } try { let manifest = await CdnCompression.unzip(res.data); return resolve({manifest}); } catch (ex) { return reject(ex); } }); }); } /** * Gets a manifest request code * @param {int} appID * @param {int} depotID * @param {string} manifestID * @param {string} branchName * @param {string} [branchPassword] * @param {function} [callback] */ getManifestRequestCode(appID, depotID, manifestID, branchName, branchPassword, callback) { if (typeof branchPassword == 'function') { callback = branchPassword; branchPassword = null; } return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this._sendUnified('ContentServerDirectory.GetManifestRequestCode#1', { app_id: appID, depot_id: depotID, manifest_id: manifestID, app_branch: branchName, branch_password_hash: branchPassword ? StdLib.Hashing.sha1(branchPassword) : undefined }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve({requestCode: body.manifest_request_code}); }); }); } /** * Download a chunk from a content server. * @param {int} appID - The AppID to which this chunk belongs * @param {int} depotID - The depot ID to which this chunk belongs * @param {string} chunkSha1 - This chunk's SHA1 hash (aka its ID) * @param {object} [contentServer] - If not provided, one will be chosen randomly. Should be an object identical to those output by getContentServers * @param {function} [callback] - First argument is Error/null, second is a Buffer containing the chunk's data * @return Promise */ downloadChunk(appID, depotID, chunkSha1, contentServer, callback) { if (typeof contentServer === 'function') { callback = contentServer; contentServer = null; } chunkSha1 = chunkSha1.toLowerCase(); return StdLib.Promises.callbackPromise(['chunk'], callback, async (resolve, reject) => { if (!contentServer) { let {servers} = await this.getContentServers(appID); contentServer = servers[Math.floor(Math.random() * servers.length)]; } let urlBase = (contentServer.https_support == 'mandatory' ? 'https://' : 'http://') + contentServer.Host; let vhost = contentServer.vhost || contentServer.Host; let {key} = await this.getDepotDecryptionKey(appID, depotID); let token = ''; if (contentServer.usetokenauth == 1) { token = (await this.getCDNAuthToken(appID, depotID, vhost)).token; } download(`${urlBase}/depot/${depotID}/chunk/${chunkSha1}${token}`, vhost, async (err, res) => { if (err) { return reject(err); } if (res.type != 'complete') { return; } try { let result = await CdnCompression.unzip(SteamCrypto.symmetricDecrypt(res.data, key)); if (StdLib.Hashing.sha1(result) != chunkSha1) { return reject(new Error('Checksum mismatch')); } return resolve({chunk: result}); } catch (ex) { return reject(ex); } }); }); } /** * Download a specific file from a depot. * @param {int} appID - The AppID which owns the file you want * @param {int} depotID - The depot ID which contains the file you want * @param {object} fileManifest - An object from the "files" array of a downloaded and parsed manifest * @param {string} [outputFilePath] - If provided, downloads the file to this location on the disk. If not, downloads the file into memory and sends it back in the callback. * @param {function} [callback] * @returns {Promise} */ downloadFile(appID, depotID, fileManifest, outputFilePath, callback) { if (typeof outputFilePath === 'function') { callback = outputFilePath; outputFilePath = null; } return StdLib.Promises.callbackPromise(null, callback, async (resolve, reject) => { if (fileManifest.flags & EDepotFileFlag.Directory) { return reject(new Error(`Attempted to download a directory ${fileManifest.filename}`)); } let numWorkers = 4; let fileSize = parseInt(fileManifest.size, 10); let bytesDownloaded = 0; let {servers: availableServers} = await this.getContentServers(appID); let servers = []; let serversInUse = []; let currentServerIdx = 0; let downloadBuffer; let outputFd; let killed = false; // Choose some content servers for (let i = 0; i < numWorkers; i++) { assignServer(i); serversInUse.push(false); } if (outputFilePath) { await new Promise((resolve, reject) => { FS.open(outputFilePath, 'w', (err, fd) => { if (err) { return reject(err); } outputFd = fd; FS.ftruncate(outputFd, fileSize, (err) => { if (err) { FS.closeSync(outputFd); return reject(err); } return resolve(); }); }); }); } else { downloadBuffer = Buffer.alloc(fileSize); } let self = this; let queue = new StdLib.DataStructures.AsyncQueue(function dlChunk(chunk, cb) { let serverIdx; // eslint-disable-next-line while (true) { // Find the next available download slot if (serversInUse[currentServerIdx]) { incrementCurrentServerIdx(); } else { serverIdx = currentServerIdx; serversInUse[serverIdx] = true; break; } } self.downloadChunk(appID, depotID, chunk.sha, servers[serverIdx], (err, data) => { serversInUse[serverIdx] = false; if (killed) { return; } if (err) { // Error downloading chunk if ((chunk.retries && chunk.retries >= 5) || availableServers.length == 0) { // This chunk hasn't been retired the max times yet, and we have servers left. reject(err); queue.kill(); killed = true; } else { chunk.retries = chunk.retries || 0; chunk.retries++; assignServer(serverIdx); dlChunk(chunk, cb); } return; } bytesDownloaded += data.length; if (typeof callback === 'function') { callback(null, { type: 'progress', bytesDownloaded, totalSizeBytes: fileSize }); } // Chunk downloaded successfully if (outputFilePath) { FS.write(outputFd, data, 0, data.length, parseInt(chunk.offset, 10), (err) => { if (err) { reject(err); queue.kill(); killed = true; } else { cb(); } }); } else { data.copy(downloadBuffer, parseInt(chunk.offset, 10)); cb(); } }); }, numWorkers); fileManifest.chunks.forEach((chunk) => { queue.push(JSON.parse(JSON.stringify(chunk))); }); queue.drain = () => { // Verify hash let hash; if (outputFilePath) { FS.close(outputFd, (err) => { if (err) { return reject(err); } if (fileSize === 0) { // Steam uses a hash of all 0s if the file is empty, which won't validate properly return resolve({type: 'complete'}); } // File closed. Now re-open it so we can hash it! hash = require('crypto').createHash('sha1'); FS.createReadStream(outputFilePath).pipe(hash); hash.on('readable', () => { if (!hash.read) { return; // already done } hash = hash.read(); if (hash.toString('hex') != fileManifest.sha_content) { return reject(new Error('File checksum mismatch')); } else { resolve({ type: 'complete' }); } }); }); } else { if (fileSize > 0 && StdLib.Hashing.sha1(downloadBuffer) != fileManifest.sha_content) { return reject(new Error('File checksum mismatch')); } return resolve({ type: 'complete', file: downloadBuffer }); } }; if (fileSize === 0) { // nothing to download, so manually trigger the queue drain method queue.drain(); } function assignServer(idx) { servers[idx] = availableServers.splice(Math.floor(Math.random() * availableServers.length), 1)[0]; } function incrementCurrentServerIdx() { if (++currentServerIdx >= servers.length) { currentServerIdx = 0; } } }); } /** * Request decryption keys for a beta branch of an app from its beta password. * @param {int} appID * @param {string} password * @param {function} [callback] - First arg is Error|null, second is an object mapping branch names to their decryption keys * @return Promise */ getAppBetaDecryptionKeys(appID, password, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['keys'], callback, (resolve, reject) => { this._send(EMsg.ClientCheckAppBetaPassword, { app_id: appID, betapassword: password }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } let branches = {}; (body.betapasswords || []).forEach((beta) => { branches[beta.betaname] = Buffer.from(beta.betapassword, 'hex'); }); return resolve({keys: branches}); }); }); } } // Private functions function download(url, hostHeader, destinationFilename, callback) { if (typeof destinationFilename === 'function') { callback = destinationFilename; destinationFilename = null; } let timeout = null; let ended = false; let resetTimeout = () => { clearTimeout(timeout); timeout = setTimeout(() => { if (ended) { return; } ended = true; callback(new Error('Request timed out')); }, 10000); }; let options = require('url').parse(url); options.method = 'GET'; options.headers = { Host: hostHeader, Accept: 'text/html,*/*;q=0.9', 'Accept-Encoding': 'gzip,identity,*;q=0', 'Accept-Charset': 'ISO-8859-1,utf-8,*;q=0.7', 'User-Agent': 'Valve/Steam HTTP Client 1.0' }; let module = options.protocol.replace(':', ''); let req = require(module).request(options, (res) => { if (ended) { return; } if (res.statusCode != 200) { ended = true; callback(new Error('HTTP error ' + res.statusCode)); return; } res.setEncoding('binary'); // apparently using null just doesn't work... thanks node let stream = res; if (res.headers['content-encoding'] && res.headers['content-encoding'] == 'gzip') { stream = require('zlib').createGunzip(); stream.setEncoding('binary'); res.pipe(stream); } let totalSizeBytes = parseInt(res.headers['content-length'] || 0, 10); let receivedBytes = 0; let dataBuffer = Buffer.alloc(0); if (destinationFilename) { stream.pipe(require('fs').createWriteStream(destinationFilename)); } stream.on('data', (chunk) => { if (ended) { return; } resetTimeout(); if (typeof chunk === 'string') { chunk = Buffer.from(chunk, 'binary'); } receivedBytes += chunk.length; if (!destinationFilename) { dataBuffer = Buffer.concat([dataBuffer, chunk]); } callback(null, {type: 'progress', receivedBytes: receivedBytes, totalSizeBytes: totalSizeBytes}); }); stream.on('end', () => { if (ended) { return; } ended = true; callback(null, {type: 'complete', data: dataBuffer}); }); }); req.on('error', (err) => { if (ended) { return; } ended = true; callback(err); }); req.end(); resetTimeout(); } module.exports = SteamUserCDN;