diff --git a/.idea/modules.xml b/.idea/modules.xml index 60ab3a1..1d21c64 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,8 +2,8 @@ - + - \ No newline at end of file + diff --git a/.idea/steamcommunity.iml b/.idea/steamcommunity.iml index 3c366a8..52b9af4 100644 --- a/.idea/steamcommunity.iml +++ b/.idea/steamcommunity.iml @@ -5,6 +5,6 @@ - + - \ No newline at end of file + diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 18cac0e..33d08e3 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index a569dc6..499aac3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # Steam Community for Node.js [![npm version](https://img.shields.io/npm/v/steamcommunity.svg)](https://npmjs.com/package/steamcommunity) [![npm downloads](https://img.shields.io/npm/dm/steamcommunity.svg)](https://npmjs.com/package/steamcommunity) -[![dependencies](https://img.shields.io/david/DoctorMcKay/node-steamcommunity.svg)](https://david-dm.org/DoctorMcKay/node-steamcommunity) [![license](https://img.shields.io/npm/l/steamcommunity.svg)](https://github.com/DoctorMcKay/node-steamcommunity/blob/master/LICENSE) [![paypal](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=N36YVAT42CZ4G&item_name=node%2dsteamcommunity¤cy_code=USD) diff --git a/classes/CConfirmation.js b/classes/CConfirmation.js index 80172c4..f23c03f 100644 --- a/classes/CConfirmation.js +++ b/classes/CConfirmation.js @@ -11,7 +11,9 @@ function CConfirmation(community, data) { this.key = data.key; this.title = data.title; this.receiving = data.receiving; + this.sending = data.sending; this.time = data.time; + this.timestamp = data.timestamp; this.icon = data.icon; this.offerID = this.type == SteamCommunity.ConfirmationType.Trade ? this.creator : null; } diff --git a/classes/CSteamSharedFile.js b/classes/CSteamSharedFile.js new file mode 100644 index 0000000..277dbb9 --- /dev/null +++ b/classes/CSteamSharedFile.js @@ -0,0 +1,221 @@ +const Cheerio = require('cheerio'); +const SteamID = require('steamid'); + +const SteamCommunity = require('../index.js'); +const Helpers = require('../components/helpers.js'); + +const ESharedFileType = require('../resources/ESharedFileType.js'); + + +/** + * Scrape a sharedfile's DOM to get all available information + * @param {string} sharedFileId - ID of the sharedfile + * @param {function} callback - First argument is null/Error, second is object containing all available information + */ +SteamCommunity.prototype.getSteamSharedFile = function(sharedFileId, callback) { + // Construct object holding all the data we can scrape + let sharedfile = { + id: sharedFileId, + type: null, + appID: null, + owner: null, + fileSize: null, + postDate: null, + resolution: null, + uniqueVisitorsCount: null, + favoritesCount: null, + upvoteCount: null, + guideNumRatings: null, + isUpvoted: null, + isDownvoted: null + }; + + // Get DOM of sharedfile + this.httpRequestGet(`https://steamcommunity.com/sharedfiles/filedetails/?id=${sharedFileId}`, (err, res, body) => { + try { + + /* --------------------- Preprocess output --------------------- */ + + // Load output into cheerio to make parsing easier + let $ = Cheerio.load(body); + + // Dynamically map detailsStatsContainerLeft to detailsStatsContainerRight in an object to make readout easier. It holds size, post date and resolution. + let detailsStatsObj = {}; + let detailsLeft = $(".detailsStatsContainerLeft").children(); + let detailsRight = $(".detailsStatsContainerRight").children(); + + Object.keys(detailsLeft).forEach((e) => { // Dynamically get all details. Don't hardcore so that this also works for guides. + if (isNaN(e)) { + return; // Ignore invalid entries + } + + detailsStatsObj[detailsLeft[e].children[0].data.trim()] = detailsRight[e].children[0].data; + }); + + // Dynamically map stats_table descriptions to values. This holds Unique Visitors and Current Favorites + let statsTableObj = {}; + let statsTable = $(".stats_table").children(); + + Object.keys(statsTable).forEach((e) => { + if (isNaN(e)) { + return; // Ignore invalid entries + } + + // Value description is at index 3, value data at index 1 + statsTableObj[statsTable[e].children[3].children[0].data] = statsTable[e].children[1].children[0].data.replace(/,/g, ""); // Remove commas from 1k+ values + }); + + + /* --------------------- Find and map values --------------------- */ + + // Find appID in share button onclick event + sharedfile.appID = Number($("#ShareItemBtn").attr()["onclick"].replace(`ShowSharePublishedFilePopup( '${sharedFileId}', '`, "").replace("' );", "")); + + + // Find fileSize if not guide + sharedfile.fileSize = detailsStatsObj["File Size"] || null; // TODO: Convert to bytes? It seems like to always be MB but no guarantee + + + // Find postDate and convert to timestamp + let posted = detailsStatsObj["Posted"].trim(); + + sharedfile.postDate = Helpers.decodeSteamTime(posted); + + + // Find resolution if artwork or screenshot + sharedfile.resolution = detailsStatsObj["Size"] || null; + + + // Find uniqueVisitorsCount. We can't use ' || null' here as Number("0") casts to false + if (statsTableObj["Unique Visitors"]) { + sharedfile.uniqueVisitorsCount = Number(statsTableObj["Unique Visitors"]); + } + + + // Find favoritesCount. We can't use ' || null' here as Number("0") casts to false + if (statsTableObj["Current Favorites"]) { + sharedfile.favoritesCount = Number(statsTableObj["Current Favorites"]); + } + + + // Find upvoteCount. We can't use ' || null' here as Number("0") casts to false + let upvoteCount = $("#VotesUpCountContainer > #VotesUpCount").text(); + + if (upvoteCount) { + sharedfile.upvoteCount = Number(upvoteCount); + } + + + // Find numRatings if this is a guide as they use a different voting system + let numRatings = $(".ratingSection > .numRatings").text().replace(" ratings", ""); + + sharedfile.guideNumRatings = Number(numRatings) || null; // Set to null if not a guide or if the guide does not have enough ratings to show a value + + + // Determine if this account has already voted on this sharedfile + sharedfile.isUpvoted = String($(".workshopItemControlCtn > #VoteUpBtn")[0].attribs["class"]).includes("toggled"); // Check if upvote btn class contains "toggled" + sharedfile.isDownvoted = String($(".workshopItemControlCtn > #VoteDownBtn")[0].attribs["class"]).includes("toggled"); // Check if downvote btn class contains "toggled" + + + // Determine type by looking at the second breadcrumb. Find the first separator as it has a unique name and go to the next element which holds our value of interest + let breadcrumb = $(".breadcrumbs > .breadcrumb_separator").next().get(0).children[0].data || ""; + + if (breadcrumb.includes("Screenshot")) { + sharedfile.type = ESharedFileType.Screenshot; + } + + if (breadcrumb.includes("Artwork")) { + sharedfile.type = ESharedFileType.Artwork; + } + + if (breadcrumb.includes("Guide")) { + sharedfile.type = ESharedFileType.Guide; + } + + + // Find owner profile link, convert to steamID64 using SteamIdResolver lib and create a SteamID object + let ownerHref = $(".friendBlockLinkOverlay").attr()["href"]; + + Helpers.resolveVanityURL(ownerHref, (err, data) => { // This request takes <1 sec + if (err) { + callback(err); + return; + } + + sharedfile.owner = new SteamID(data.steamID); + + // Make callback when ID was resolved as otherwise owner will always be null + callback(null, new CSteamSharedFile(this, sharedfile)); + }); + + } catch (err) { + callback(err, null); + } + }, "steamcommunity"); +}; + +/** + * Constructor - Creates a new SharedFile object + * @class + * @param {SteamCommunity} community + * @param {{ id: string, type: ESharedFileType, appID: number, owner: SteamID|null, fileSize: string|null, postDate: number, resolution: string|null, uniqueVisitorsCount: number, favoritesCount: number, upvoteCount: number|null, guideNumRatings: Number|null, isUpvoted: boolean, isDownvoted: boolean }} data + */ +function CSteamSharedFile(community, data) { + /** + * @type {SteamCommunity} + */ + this._community = community; + + // Clone all the data we received + Object.assign(this, data); +} + +/** + * Deletes a comment from this sharedfile's comment section + * @param {String} cid - ID of the comment to delete + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedFile.prototype.deleteComment = function(cid, callback) { + this._community.deleteSharedFileComment(this.owner, this.id, cid, callback); +}; + +/** + * Favorites this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedFile.prototype.favorite = function(callback) { + this._community.favoriteSharedFile(this.id, this.appID, callback); +}; + +/** + * Posts a comment to this sharedfile + * @param {String} message - Content of the comment to post + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedFile.prototype.comment = function(message, callback) { + this._community.postSharedFileComment(this.owner, this.id, message, callback); +}; + +/** + * Subscribes to this sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedFile.prototype.subscribe = function(callback) { + this._community.subscribeSharedFileComments(this.owner, this.id, callback); +}; + +/** + * Unfavorites this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedFile.prototype.unfavorite = function(callback) { + this._community.unfavoriteSharedFile(this.id, this.appID, callback); +}; + +/** + * Unsubscribes from this sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedFile.prototype.unsubscribe = function(callback) { + this._community.unsubscribeSharedFileComments(this.owner, this.id, callback); +}; diff --git a/components/confirmations.js b/components/confirmations.js index 93f4861..44ec968 100644 --- a/components/confirmations.js +++ b/components/confirmations.js @@ -4,61 +4,55 @@ const SteamTotp = require('steam-totp'); const SteamCommunity = require('../index.js'); const CConfirmation = require('../classes/CConfirmation.js'); +var EConfirmationType = require('../resources/EConfirmationType.js'); /** * Get a list of your account's currently outstanding confirmations. * @param {int} time - The unix timestamp with which the following key was generated - * @param {string} key - The confirmation key that was generated using the preceeding time and the tag "conf" (this key can be reused) + * @param {string} key - The confirmation key that was generated using the preceeding time and the tag 'conf' (this key can be reused) * @param {SteamCommunity~getConfirmations} callback - Called when the list of confirmations is received */ SteamCommunity.prototype.getConfirmations = function(time, key, callback) { - request(this, 'conf', key, time, 'conf', null, false, (err, body) => { - if (err) { - if (err.message == 'Invalid protocol: steammobile:') { - err.message = 'Not Logged In'; - this._notifySessionExpired(err); - } + var self = this; + // Ugly hack to maintain backward compatibility + var tag = 'conf'; + if (typeof key == 'object') { + tag = key.tag; + key = key.key; + } + + // The official Steam app uses the tag 'list', but 'conf' still works so let's use that for backward compatibility. + request(this, 'getlist', key, time, tag, null, true, function(err, body) { + if (err) { callback(err); return; } - let $ = Cheerio.load(body); - let empty = $('#mobileconf_empty'); - if (empty.length > 0) { - if (!$(empty).hasClass('mobileconf_done')) { - // An error occurred - callback(new Error(empty.find('div:nth-of-type(2)').text())); - } else { - callback(null, []); + if (!body.success) { + if (body.needauth) { + var err = new Error('Not Logged In'); + self._notifySessionExpired(err); + callback(err); + return; } + callback(new Error(body.message || body.detail || 'Failed to get confirmation list')); return; } - // We have something to confirm - let confirmations = $('#mobileconf_list'); - if (!confirmations) { - callback(new Error('Malformed response')); - return; - } - - let confs = []; - Array.prototype.forEach.call(confirmations.find('.mobileconf_list_entry'), (conf) => { - conf = $(conf); - - let img = conf.find('.mobileconf_list_entry_icon img'); - confs.push(new CConfirmation(this, { - id: conf.data('confid'), - type: conf.data('type'), - creator: conf.data('creator'), - key: conf.data('key'), - title: conf.find('.mobileconf_list_entry_description>div:nth-of-type(1)').text().trim(), - receiving: conf.find('.mobileconf_list_entry_description>div:nth-of-type(2)').text().trim(), - time: conf.find('.mobileconf_list_entry_description>div:nth-of-type(3)').text().trim(), - icon: img.length < 1 ? '' : $(img).attr('src') - })); - }); + var confs = (body.conf || []).map(conf => new CConfirmation(self, { + id: conf.id, + type: conf.type, + creator: conf.creator_id, + key: conf.nonce, + title: `${conf.type_name || 'Confirm'} - ${conf.headline || ''}`, + receiving: conf.type == EConfirmationType.Trade ? ((conf.summary || [])[1] || '') : '', + sending: (conf.summary || [])[0] || '', + time: (new Date(conf.creation_time * 1000)).toISOString(), // for backward compatibility + timestamp: new Date(conf.creation_time * 1000), + icon: conf.icon || '' + })); callback(null, confs); }); @@ -74,22 +68,23 @@ SteamCommunity.prototype.getConfirmations = function(time, key, callback) { * Get the trade offer ID associated with a particular confirmation * @param {int} confID - The ID of the confirmation in question * @param {int} time - The unix timestamp with which the following key was generated - * @param {string} key - The confirmation key that was generated using the preceeding time and the tag "details" (this key can be reused) + * @param {string} key - The confirmation key that was generated using the preceeding time and the tag "detail" (this key can be reused) * @param {SteamCommunity~getConfirmationOfferID} callback */ SteamCommunity.prototype.getConfirmationOfferID = function(confID, time, key, callback) { - request(this, 'details/' + confID, key, time, 'details', null, true, (err, body) => { + // The official Steam app uses the tag 'detail', but 'details' still works so let's use that for backward compatibility + request(this, 'detailspage/' + confID, key, time, 'details', null, false, function(err, body) { if (err) { callback(err); return; } - if (!body.success) { + if (typeof body != 'string') { callback(new Error('Cannot load confirmation details')); return; } - let $ = Cheerio.load(body.html); + let $ = Cheerio.load(body); let offer = $('.tradeoffer'); if (offer.length < 1) { callback(null, null); @@ -116,11 +111,19 @@ SteamCommunity.prototype.getConfirmationOfferID = function(confID, time, key, ca * @param {SteamCommunity~genericErrorCallback} callback - Called when the request is complete */ SteamCommunity.prototype.respondToConfirmation = function(confID, confKey, time, key, accept, callback) { - request(this, (confID instanceof Array) ? 'multiajaxop' : 'ajaxop', key, time, accept ? 'allow' : 'cancel', { + // Ugly hack to maintain backward compatibility + var tag = accept ? 'allow' : 'cancel'; + if (typeof key == 'object') { + tag = key.tag; + key = key.key; + } + + // The official app uses tags reject/accept, but cancel/allow still works so use these for backward compatibility + request(this, (confID instanceof Array) ? 'multiajaxop' : 'ajaxop', key, time, tag, { op: accept ? 'allow' : 'cancel', cid: confID, ck: confKey - }, true, (err, body) => { + }, true, function(err, body) { if (!callback) { return; } @@ -203,7 +206,39 @@ SteamCommunity.prototype.acceptConfirmationForObject = function(identitySecret, }); } + function doConfirmation() { + var offset = self._timeOffset; + var time = SteamTotp.time(offset); + var confKey = SteamTotp.getConfirmationKey(identitySecret, time, 'list'); + self.getConfirmations(time, {tag: 'list', key: confKey}, function(err, confs) { + if (err) { + callback(err); + return; + } + var conf = confs.filter(function(conf) { return conf.creator == objectID; }); + if (conf.length == 0) { + callback(new Error('Could not find confirmation for object ' + objectID)); + return; + } + + conf = conf[0]; + + // make sure we don't reuse the same time + var localOffset = 0; + do { + time = SteamTotp.time(offset) + localOffset++; + } while (self._usedConfTimes.indexOf(time) != -1); + + self._usedConfTimes.push(time); + if (self._usedConfTimes.length > 60) { + self._usedConfTimes.splice(0, self._usedConfTimes.length - 60); // we don't need to save more than 60 entries + } + + confKey = SteamTotp.getConfirmationKey(identitySecret, time, 'accept'); + conf.respond(time, {tag: 'accept', key: confKey}, true, callback); + }); + } }; /** @@ -247,7 +282,7 @@ function request(community, url, key, time, tag, params, json, callback) { params.a = community.steamID.getSteamID64(); params.k = key; params.t = time; - params.m = 'android'; + params.m = 'react'; params.tag = tag; let req = { diff --git a/components/helpers.js b/components/helpers.js index 725c9ab..e4eb5aa 100644 --- a/components/helpers.js +++ b/components/helpers.js @@ -1,4 +1,6 @@ const EResult = require('../resources/EResult.js'); +const request = require('request'); +const xml2js = require('xml2js'); /** * Make sure that a provided input is a valid SteamID object. @@ -50,3 +52,53 @@ exports.eresultError = function(eresult, message) { err.eresult = eresult; return err; }; + +exports.decodeJwt = function(jwt) { + let parts = jwt.split('.'); + if (parts.length != 3) { + throw new Error('Invalid JWT'); + } + + let standardBase64 = parts[1].replace(/-/g, '+') + .replace(/_/g, '/'); + + return JSON.parse(Buffer.from(standardBase64, 'base64').toString('utf8')); +}; + +/** + * Resolves a Steam profile URL to get steamID64 and vanityURL + * @param {String} url - Full steamcommunity profile URL or only the vanity part. + * @param {Object} callback - First argument is null/Error, second is object containing vanityURL (String) and steamID (String) + */ +exports.resolveVanityURL = function(url, callback) { + // Precede url param if only the vanity was provided + if (!url.includes("steamcommunity.com")) { + url = "https://steamcommunity.com/id/" + url; + } + + // Make request to get XML data + request(url + "/?xml=1", function(err, response, body) { + if (err) { + callback(err); + return; + } + + // Parse XML data returned from Steam into an object + new xml2js.Parser().parseString(body, (err, parsed) => { + if (err) { + callback(new Error("Couldn't parse XML response")); + return; + } + + if (parsed.response && parsed.response.error) { + callback(new Error("Couldn't find Steam ID")); + return; + } + + let steamID64 = parsed.profile.steamID64[0]; + let vanityURL = parsed.profile.customURL[0]; + + callback(null, {"vanityURL": vanityURL, "steamID": steamID64}); + }); + }); +}; \ No newline at end of file diff --git a/components/profile.js b/components/profile.js index 7f837c1..2cbe7a1 100644 --- a/components/profile.js +++ b/components/profile.js @@ -108,6 +108,15 @@ SteamCommunity.prototype.editProfile = function(settings, callback) { values.customURL = settings[i]; break; + case 'primaryGroup': + if(typeof settings[i] === 'object' && settings[i].getSteamID64) { + values.primary_group_steamid = settings[i].getSteamID64(); + } else { + values.primary_group_steamid = new SteamID(settings[i]).getSteamID64(); + } + + break; + // These don't work right now /* case 'background': @@ -119,15 +128,6 @@ SteamCommunity.prototype.editProfile = function(settings, callback) { // Currently, game badges aren't supported values.favorite_badge_badgeid = settings[i]; break; - - case 'primaryGroup': - if(typeof settings[i] === 'object' && settings[i].getSteamID64) { - values.primary_group_steamid = settings[i].getSteamID64(); - } else { - values.primary_group_steamid = new SteamID(settings[i]).getSteamID64(); - } - - break; */ // TODO: profile showcases } diff --git a/components/sharedfiles.js b/components/sharedfiles.js new file mode 100644 index 0000000..b4fa2c7 --- /dev/null +++ b/components/sharedfiles.js @@ -0,0 +1,158 @@ +var SteamID = require('steamid'); + +var SteamCommunity = require('../index.js'); + + +/** + * Deletes a comment from a sharedfile's comment section + * @param {SteamID | String} userID - ID of the user associated to this sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} cid - ID of the comment to delete + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.deleteSharedFileComment = function(userID, sharedFileId, cid, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/delete/${userID.toString()}/${sharedFileId}/`, + "form": { + "gidcomment": cid, + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(err); + }, "steamcommunity"); +}; + +/** + * Favorites a sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} appid - ID of the app associated to this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.favoriteSharedFile = function(sharedFileId, appid, callback) { + this.httpRequestPost({ + "uri": "https://steamcommunity.com/sharedfiles/favorite", + "form": { + "id": sharedFileId, + "appid": appid, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(err); + }, "steamcommunity"); +}; + +/** + * Posts a comment to a sharedfile + * @param {SteamID | String} userID - ID of the user associated to this sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} message - Content of the comment to post + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.postSharedFileComment = function(userID, sharedFileId, message, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/post/${userID.toString()}/${sharedFileId}/`, + "form": { + "comment": message, + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(err); + }, "steamcommunity"); +}; + +/** + * Subscribes to a sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {SteamID | String} userID ID of the user associated to this sharedfile + * @param {String} sharedFileId ID of the sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.subscribeSharedFileComments = function(userID, sharedFileId, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/subscribe/${userID.toString()}/${sharedFileId}/`, + "form": { + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { // eslint-disable-line + if (!callback) { + return; + } + + callback(err); + }, "steamcommunity"); +}; + +/** + * Unfavorites a sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} appid - ID of the app associated to this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.unfavoriteSharedFile = function(sharedFileId, appid, callback) { + this.httpRequestPost({ + "uri": "https://steamcommunity.com/sharedfiles/unfavorite", + "form": { + "id": sharedFileId, + "appid": appid, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(err); + }, "steamcommunity"); +}; + +/** + * Unsubscribes from a sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {SteamID | String} userID - ID of the user associated to this sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.unsubscribeSharedFileComments = function(userID, sharedFileId, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/unsubscribe/${userID.toString()}/${sharedFileId}/`, + "form": { + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { // eslint-disable-line + if (!callback) { + return; + } + + callback(err); + }, "steamcommunity"); +}; diff --git a/components/twofactor.js b/components/twofactor.js index b6d5d7d..e18478b 100644 --- a/components/twofactor.js +++ b/components/twofactor.js @@ -10,15 +10,18 @@ const ETwoFactorTokenType = { }; SteamCommunity.prototype.enableTwoFactor = function(callback) { - if (!this.oAuthToken) { - return callback(new Error('enableTwoFactor can only be used when logged on via steamcommunity\'s `login` method without the `disableMobile` option.')); + this._verifyMobileAccessToken(); + + if (!this.mobileAccessToken) { + callback(new Error('No mobile access token available. Provide one by calling setMobileAppAccessToken()')); + return; } this.httpRequestPost({ - uri: 'https://api.steampowered.com/ITwoFactorService/AddAuthenticator/v1/', + uri: "https://api.steampowered.com/ITwoFactorService/AddAuthenticator/v1/?access_token=" + this.mobileAccessToken, + // TODO: Send this as protobuf to more closely mimic official app behavior form: { steamid: this.steamID.getSteamID64(), - access_token: this.oAuthToken, authenticator_time: Math.floor(Date.now() / 1000), authenticator_type: ETwoFactorTokenType.ValveMobileApp, device_identifier: SteamTotp.getDeviceID(this.steamID), @@ -36,9 +39,11 @@ SteamCommunity.prototype.enableTwoFactor = function(callback) { return; } - let err2 = Helpers.eresultError(body.response.status); - if (err2) { - return callback(err2); + if (body.response.status != 1) { + var error = new Error('Error ' + body.response.status); + error.eresult = body.response.status; + callback(error); + return; } callback(null, body.response); @@ -46,21 +51,23 @@ SteamCommunity.prototype.enableTwoFactor = function(callback) { }; SteamCommunity.prototype.finalizeTwoFactor = function(secret, activationCode, callback) { - if (!this.oAuthToken) { - return callback(new Error('finalizeTwoFactor can only be used when logged on via steamcommunity\'s `login` method without the `disableMobile` option.')); + this._verifyMobileAccessToken(); + + if (!this.mobileAccessToken) { + callback(new Error('No mobile access token available. Provide one by calling setMobileAppAccessToken()')); + return; } let attemptsLeft = 30; let diff = 0; - const finalize = () => { + let finalize = () => { let code = SteamTotp.generateAuthCode(secret, diff); this.httpRequestPost({ - uri: 'https://api.steampowered.com/ITwoFactorService/FinalizeAddAuthenticator/v1/', + uri: 'https://api.steampowered.com/ITwoFactorService/FinalizeAddAuthenticator/v1/?access_token=' + this.mobileAccessToken, form: { steamid: this.steamID.getSteamID64(), - access_token: this.oAuthToken, authenticator_code: code, authenticator_time: Math.floor(Date.now() / 1000), activation_code: activationCode @@ -90,19 +97,18 @@ SteamCommunity.prototype.finalizeTwoFactor = function(secret, activationCode, ca // We made more than 30 attempts, something must be wrong return callback(Helpers.eresultError(SteamCommunity.EResult.Fail)); } - diff += 30; finalize(); - } else if (!body.success) { - callback(Helpers.eresultError(body.status)); + } else if(!body.success) { + callback(new Error('Error ' + body.status)); } else { callback(null); } }, 'steamcommunity'); - }; + } - SteamTotp.getTimeOffset((err, offset, latency) => { + SteamTotp.getTimeOffset(function(err, offset, latency) { if (err) { callback(err); return; @@ -114,20 +120,22 @@ SteamCommunity.prototype.finalizeTwoFactor = function(secret, activationCode, ca }; SteamCommunity.prototype.disableTwoFactor = function(revocationCode, callback) { - if (!this.oAuthToken) { - return callback(new Error('disableTwoFactor can only be used when logged on via steamcommunity\'s `login` method without the `disableMobile` option.')); + this._verifyMobileAccessToken(); + + if (!this.mobileAccessToken) { + callback(new Error('No mobile access token available. Provide one by calling setMobileAppAccessToken()')); + return; } this.httpRequestPost({ - uri: 'https://api.steampowered.com/ITwoFactorService/RemoveAuthenticator/v1/', + uri: 'https://api.steampowered.com/ITwoFactorService/RemoveAuthenticator/v1/?access_token=' + this.mobileAccessToken, form: { steamid: this.steamID.getSteamID64(), - access_token: this.oAuthToken, revocation_code: revocationCode, steamguard_scheme: 1 }, json: true - }, (err, response, body) => { + }, function(err, response, body) { if (err) { callback(err); return; diff --git a/components/users.js b/components/users.js index e5a1d62..35b70cd 100644 --- a/components/users.js +++ b/components/users.js @@ -376,18 +376,7 @@ SteamCommunity.prototype.getUserInventoryContexts = function(userID, callback) { let match = body.match(/var g_rgAppContextData = ([^\n]+);\r?\n/); if (!match) { - let errorMessage = 'Malformed response'; - - if (body.includes('0 items in their inventory.')) { - callback(null, {}); - return; - } else if (body.includes('inventory is currently private.')) { - errorMessage = 'Private inventory'; - } else if (body.includes('profile_private_info')) { - errorMessage = 'Private profile'; - } - - callback(new Error(errorMessage)); + callback(new Error('Malformed response')); return; } @@ -399,6 +388,21 @@ SteamCommunity.prototype.getUserInventoryContexts = function(userID, callback) { return; } + if (Object.keys(data).length == 0) { + if (body.match(/inventory is currently private\./)) { + callback(new Error('Private inventory')); + return; + } + + if (body.match(/profile_private_info/)) { + callback(new Error('Private profile')); + return; + } + + // If they truly have no items in their inventory, Steam will send g_rgAppContextData as [] instead of {}. + data = {}; + } + callback(null, data); }, 'steamcommunity'); }; @@ -449,7 +453,7 @@ SteamCommunity.prototype.getUserInventory = function(userID, appID, contextID, t } for (let i in body.rgCurrency) { - currency.push(new CEconItem(body.rgInventory[i], body.rgDescriptions, contextID)); + currency.push(new CEconItem(body.rgCurrency[i], body.rgDescriptions, contextID)); } if (body.more) { diff --git a/components/webapi.js b/components/webapi.js index 1cefcf9..0040856 100644 --- a/components/webapi.js +++ b/components/webapi.js @@ -1,5 +1,7 @@ const SteamCommunity = require('../index.js'); +const Helpers = require('./helpers.js'); + SteamCommunity.prototype.getWebApiKey = function(domain, callback) { this.httpRequest({ uri: 'https://steamcommunity.com/dev/apikey?l=english', @@ -40,5 +42,76 @@ SteamCommunity.prototype.getWebApiKey = function(domain, callback) { this.getWebApiKey(domain, callback); }, 'steamcommunity'); } - }, 'steamcommunity'); + }, "steamcommunity"); +}; + +/** + * @deprecated No longer works. Will be removed in a future release. + * @param {function} callback + */ +SteamCommunity.prototype.getWebApiOauthToken = function(callback) { + if (this.oAuthToken) { + return callback(null, this.oAuthToken); + } + + callback(new Error('This operation requires an OAuth token, which is no longer issued by Steam.')); +}; + +/** + * Sets an access_token generated by steam-session using EAuthTokenPlatformType.MobileApp. + * Required for some operations such as 2FA enabling and disabling. + * This will throw an Error if the provided token is not valid, was not generated for the MobileApp platform, is expired, + * or does not belong to the logged-in user account. + * + * @param {string} token + */ +SteamCommunity.prototype.setMobileAppAccessToken = function(token) { + if (!this.steamID) { + throw new Error('Log on to steamcommunity before setting a mobile app access token'); + } + + let decodedToken = Helpers.decodeJwt(token); + + if (!decodedToken.iss || !decodedToken.sub || !decodedToken.aud || !decodedToken.exp) { + throw new Error('Provided value is not a valid Steam access token'); + } + + if (decodedToken.iss == 'steam') { + throw new Error('Provided token is a refresh token, not an access token'); + } + + if (decodedToken.sub != this.steamID.getSteamID64()) { + throw new Error(`Provided token belongs to account ${decodedToken.sub}, but we are logged into ${this.steamID.getSteamID64()}`); + } + + if (decodedToken.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Provided token is expired'); + } + + if ((decodedToken.aud || []).indexOf('mobile') == -1) { + throw new Error('Provided token is not valid for MobileApp platform type'); + } + + this.mobileAccessToken = token; +}; + +/** + * Verifies that the mobile access token we already have set is still valid for current login. + * + * @private + */ +SteamCommunity.prototype._verifyMobileAccessToken = function() { + if (!this.mobileAccessToken) { + // No access token, so nothing to do here. + return; + } + + let decodedToken = Helpers.decodeJwt(this.mobileAccessToken); + + let isTokenInvalid = decodedToken.sub != this.steamID.getSteamID64() // SteamID doesn't match + || decodedToken.exp < Math.floor(Date.now() / 1000); // Token is expired + + if (isTokenInvalid) { + delete this.mobileAccessToken; + } }; diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5e301a2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,35 @@ +# node-steamcommunity examples + +The files in this directory are example scripts that you can use as a getting-started point for using node-steamcommunity. + +## Enable or Disable Two-Factor Authentication + +If you need to enable or disable 2FA on your bot account, you can use enable_twofactor.js and disable_twofactor.js to do so. +The way that you're intended to use these scripts is by cloning the repository locally, and then running them directly +from this examples directory. + +For example: + +```shell +git clone https://github.com/DoctorMcKay/node-steamcommunity node-steamcommunity +cd node-steamcommunity +npm install +cd examples +node enable_twofactor.js +``` + +## Accept All Confirmations + +If you need to accept trade or market confirmations on your bot account for which you have your identity secret, you can +use accept_all_confirmations.js to do so. The way that you're intended to use this script is by cloning the repository +locally, and then running it directly from this examples directory. + +For example: + +```shell +git clone https://github.com/DoctorMcKay/node-steamcommunity node-steamcommunity +cd node-steamcommunity +npm install +cd examples +node accept_all_confirmations.js +``` diff --git a/examples/accept_all_confirmations.js b/examples/accept_all_confirmations.js new file mode 100644 index 0000000..e9518a2 --- /dev/null +++ b/examples/accept_all_confirmations.js @@ -0,0 +1,173 @@ +// If you aren't running this script inside of the repository, replace the following line with: +// const SteamCommunity = require('steamcommunity'); +const SteamCommunity = require('../index.js'); +const SteamSession = require('steam-session'); +const SteamTotp = require('steam-totp'); +const ReadLine = require('readline'); + +const EConfirmationType = SteamCommunity.ConfirmationType; + +let g_AbortPromptFunc = null; + +let community = new SteamCommunity(); + +main(); +async function main() { + let accountName = await promptAsync('Username: '); + let password = await promptAsync('Password (hidden): ', true); + + // Create a LoginSession for us to use to attempt to log into steam + let session = new SteamSession.LoginSession(SteamSession.EAuthTokenPlatformType.MobileApp); + + // Go ahead and attach our event handlers before we do anything else. + session.on('authenticated', async () => { + abortPrompt(); + + let cookies = await session.getWebCookies(); + community.setCookies(cookies); + + doConfirmations(); + }); + + session.on('timeout', () => { + abortPrompt(); + console.log('This login attempt has timed out.'); + }); + + session.on('error', (err) => { + abortPrompt(); + + // This should ordinarily not happen. This only happens in case there's some kind of unexpected error while + // polling, e.g. the network connection goes down or Steam chokes on something. + + console.log(`ERROR: This login attempt has failed! ${err.message}`); + }); + + // Start our login attempt + let startResult = await session.startWithCredentials({accountName, password}); + if (startResult.actionRequired) { + // Some Steam Guard action is required. We only care about email and device codes; in theory an + // EmailConfirmation and/or DeviceConfirmation action could be possible, but we're just going to ignore those. + // If the user does receive a confirmation and accepts it, LoginSession will detect and handle that automatically. + // The only consequence of ignoring it here is that we don't print a message to the user indicating that they + // could accept an email or device confirmation. + + let codeActionTypes = [SteamSession.EAuthSessionGuardType.EmailCode, SteamSession.EAuthSessionGuardType.DeviceCode]; + let codeAction = startResult.validActions.find(action => codeActionTypes.includes(action.type)); + if (codeAction) { + if (codeAction.type == SteamSession.EAuthSessionGuardType.EmailCode) { + // We wouldn't expect this to happen since mobile confirmations are only possible with 2FA enabled, but just in case... + console.log(`A code has been sent to your email address at ${codeAction.detail}.`); + } else { + console.log('You need to provide a Steam Guard Mobile Authenticator code.'); + } + + let code = await promptAsync('Code or Shared Secret: '); + if (code) { + // The code might've been a shared secret + if (code.length > 10) { + code = SteamTotp.getAuthCode(code); + } + await session.submitSteamGuardCode(code); + } + + // If we fall through here without submitting a Steam Guard code, that means one of two things: + // 1. The user pressed enter without providing a code, in which case the script will simply exit + // 2. The user approved a device/email confirmation, in which case 'authenticated' was emitted and the prompt was canceled + } + } +} + +async function doConfirmations() { + let identitySecret = await promptAsync('Identity Secret: '); + + let confs = await new Promise((resolve, reject) => { + let time = SteamTotp.time(); + let key = SteamTotp.getConfirmationKey(identitySecret, time, 'conf'); + community.getConfirmations(time, key, (err, confs) => { + if (err) { + return reject(err); + } + + resolve(confs); + }); + }); + + console.log(`Found ${confs.length} outstanding confirmations.`); + + // We need to track the previous timestamp we used, as we cannot reuse timestamps. + let previousTime = 0; + + for (let i = 0; i < confs.length; i++) { + let conf = confs[i]; + + process.stdout.write(`Accepting confirmation for ${EConfirmationType[conf.type]} - ${conf.title}... `); + + try { + await new Promise((resolve, reject) => { + let time = SteamTotp.time(); + if (time == previousTime) { + time++; + } + + previousTime = time; + let key = SteamTotp.getConfirmationKey(identitySecret, time, 'allow'); + conf.respond(time, key, true, (err) => { + err ? reject(err) : resolve(); + }); + }); + + console.log('success'); + } catch (ex) { + console.log(`error: ${ex.message}`); + } + + // sleep 500ms so we don't run too far away from the current timestamp + await new Promise(resolve => setTimeout(resolve, 500)); + } + + console.log('Finished processing confirmations'); + process.exit(0); +} + +// Nothing interesting below here, just code for prompting for input from the console. + +function promptAsync(question, sensitiveInput = false) { + return new Promise((resolve) => { + let rl = ReadLine.createInterface({ + input: process.stdin, + output: sensitiveInput ? null : process.stdout, + terminal: true + }); + + g_AbortPromptFunc = () => { + rl.close(); + resolve(''); + }; + + if (sensitiveInput) { + // We have to write the question manually if we didn't give readline an output stream + process.stdout.write(question); + } + + rl.question(question, (result) => { + if (sensitiveInput) { + // We have to manually print a newline + process.stdout.write('\n'); + } + + g_AbortPromptFunc = null; + rl.close(); + resolve(result); + }); + }); +} + +function abortPrompt() { + if (!g_AbortPromptFunc) { + return; + } + + g_AbortPromptFunc(); + process.stdout.write('\n'); +} diff --git a/examples/disable_twofactor.js b/examples/disable_twofactor.js index 1c2c617..98f9c90 100644 --- a/examples/disable_twofactor.js +++ b/examples/disable_twofactor.js @@ -1,62 +1,135 @@ // If you aren't running this script inside of the repository, replace the following line with: // const SteamCommunity = require('steamcommunity'); const SteamCommunity = require('../index.js'); +const SteamSession = require('steam-session'); const ReadLine = require('readline'); +let g_AbortPromptFunc = null; + let community = new SteamCommunity(); -let rl = ReadLine.createInterface({ - input: process.stdin, - output: process.stdout -}); -rl.question('Username: ', (accountName) => { - rl.question('Password: ', (password) => { - rl.question('Two-Factor Auth Code: ', (authCode) =>{ - rl.question('Revocation Code: R', (rCode) => { - doLogin(accountName, password, authCode, '', rCode); - }); - }); +main(); +async function main() { + let accountName = await promptAsync('Username: '); + let password = await promptAsync('Password (hidden): ', true); + + // Create a LoginSession for us to use to attempt to log into steam + let session = new SteamSession.LoginSession(SteamSession.EAuthTokenPlatformType.MobileApp); + + // Go ahead and attach our event handlers before we do anything else. + session.on('authenticated', async () => { + abortPrompt(); + + let accessToken = session.accessToken; + let cookies = await session.getWebCookies(); + + community.setCookies(cookies); + community.setMobileAppAccessToken(accessToken); + + // Enabling or disabling 2FA is presently the only action in node-steamcommunity which requires an access token. + // In all other cases, using `community.setCookies(cookies)` is all you need to do in order to be logged in, + // although there's never any harm in setting a mobile app access token. + + doRevoke(); }); -}); -function doLogin(accountName, password, authCode, captcha, rCode) { - community.login({ - accountName: accountName, - password: password, - twoFactorCode: authCode, - captcha: captcha - }, (err, sessionID, cookies, steamguard) => { + session.on('timeout', () => { + abortPrompt(); + console.log('This login attempt has timed out.'); + }); + + session.on('error', (err) => { + abortPrompt(); + + // This should ordinarily not happen. This only happens in case there's some kind of unexpected error while + // polling, e.g. the network connection goes down or Steam chokes on something. + + console.log(`ERROR: This login attempt has failed! ${err.message}`); + }); + + // Start our login attempt + let startResult = await session.startWithCredentials({accountName, password}); + if (startResult.actionRequired) { + // Some Steam Guard action is required. We only care about email and device codes; in theory an + // EmailConfirmation and/or DeviceConfirmation action could be possible, but we're just going to ignore those. + // If the user does receive a confirmation and accepts it, LoginSession will detect and handle that automatically. + // The only consequence of ignoring it here is that we don't print a message to the user indicating that they + // could accept an email or device confirmation. + + let codeActionTypes = [SteamSession.EAuthSessionGuardType.EmailCode, SteamSession.EAuthSessionGuardType.DeviceCode]; + let codeAction = startResult.validActions.find(action => codeActionTypes.includes(action.type)); + if (codeAction) { + if (codeAction.type == SteamSession.EAuthSessionGuardType.EmailCode) { + // We wouldn't expect this to happen since we're trying to disable 2FA, but just in case... + console.log(`A code has been sent to your email address at ${codeAction.detail}.`); + } else { + console.log('You need to provide a Steam Guard Mobile Authenticator code.'); + } + + let code = await promptAsync('Code: '); + if (code) { + await session.submitSteamGuardCode(code); + } + + // If we fall through here without submitting a Steam Guard code, that means one of two things: + // 1. The user pressed enter without providing a code, in which case the script will simply exit + // 2. The user approved a device/email confirmation, in which case 'authenticated' was emitted and the prompt was canceled + } + } +} + +async function doRevoke() { + let rCode = await promptAsync('Revocation Code: R'); + community.disableTwoFactor('R' + rCode, (err) => { if (err) { - if (err.message == 'SteamGuard') { - console.log('This account does not have two-factor authentication enabled.'); - process.exit(); - return; - } - - if (err.message == 'CAPTCHA') { - console.log(err.captchaurl); - rl.question('CAPTCHA: ', (captchaInput) => { - doLogin(accountName, password, authCode, captchaInput); - }); - - return; - } - console.log(err); process.exit(); return; } - console.log('Logged on!'); - community.disableTwoFactor('R' + rCode, (err) => { - if (err) { - console.log(err); - process.exit(); - return; + console.log('Two-factor authentication disabled!'); + process.exit(); + }); +} + +// Nothing interesting below here, just code for prompting for input from the console. + +function promptAsync(question, sensitiveInput = false) { + return new Promise((resolve) => { + let rl = ReadLine.createInterface({ + input: process.stdin, + output: sensitiveInput ? null : process.stdout, + terminal: true + }); + + g_AbortPromptFunc = () => { + rl.close(); + resolve(''); + }; + + if (sensitiveInput) { + // We have to write the question manually if we didn't give readline an output stream + process.stdout.write(question); + } + + rl.question(question, (result) => { + if (sensitiveInput) { + // We have to manually print a newline + process.stdout.write('\n'); } - console.log('Two-factor authentication disabled!'); - process.exit(); + g_AbortPromptFunc = null; + rl.close(); + resolve(result); }); }); } + +function abortPrompt() { + if (!g_AbortPromptFunc) { + return; + } + + g_AbortPromptFunc(); + process.stdout.write('\n'); +} diff --git a/examples/enable_twofactor.js b/examples/enable_twofactor.js index 60495a8..69adfda 100644 --- a/examples/enable_twofactor.js +++ b/examples/enable_twofactor.js @@ -1,52 +1,98 @@ // If you aren't running this script inside of the repository, replace the following line with: // const SteamCommunity = require('steamcommunity'); const SteamCommunity = require('../index.js'); +const SteamSession = require('steam-session'); const ReadLine = require('readline'); const FS = require('fs'); const EResult = SteamCommunity.EResult; +let g_AbortPromptFunc = null; + let community = new SteamCommunity(); -let rl = ReadLine.createInterface({ - input: process.stdin, - output: process.stdout -}); -rl.question('Username: ', (accountName) => { - rl.question('Password: ', (password) => { - doLogin(accountName, password); +main(); +async function main() { + let accountName = await promptAsync('Username: '); + let password = await promptAsync('Password (hidden): ', true); + + // Create a LoginSession for us to use to attempt to log into steam + let session = new SteamSession.LoginSession(SteamSession.EAuthTokenPlatformType.MobileApp); + + // Go ahead and attach our event handlers before we do anything else. + session.on('authenticated', async () => { + abortPrompt(); + + let accessToken = session.accessToken; + let cookies = await session.getWebCookies(); + + community.setCookies(cookies); + community.setMobileAppAccessToken(accessToken); + + // Enabling or disabling 2FA is presently the only action in node-steamcommunity which requires an access token. + // In all other cases, using `community.setCookies(cookies)` is all you need to do in order to be logged in, + // although there's never any harm in setting a mobile app access token. + + doSetup(); }); -}); -function doLogin(accountName, password, authCode, captcha) { - community.login({ - accountName: accountName, - password: password, - authCode: authCode, - captcha: captcha - }, (err, sessionID, cookies, steamguard) => { + session.on('timeout', () => { + abortPrompt(); + console.log('This login attempt has timed out.'); + }); + + session.on('error', (err) => { + abortPrompt(); + + // This should ordinarily not happen. This only happens in case there's some kind of unexpected error while + // polling, e.g. the network connection goes down or Steam chokes on something. + + console.log(`ERROR: This login attempt has failed! ${err.message}`); + }); + + // Start our login attempt + let startResult = await session.startWithCredentials({accountName, password}); + if (startResult.actionRequired) { + // Some Steam Guard action is required. We only care about email and device codes; in theory an + // EmailConfirmation and/or DeviceConfirmation action could be possible, but we're just going to ignore those. + // If the user does receive a confirmation and accepts it, LoginSession will detect and handle that automatically. + // The only consequence of ignoring it here is that we don't print a message to the user indicating that they + // could accept an email or device confirmation. + + let codeActionTypes = [SteamSession.EAuthSessionGuardType.EmailCode, SteamSession.EAuthSessionGuardType.DeviceCode]; + let codeAction = startResult.validActions.find(action => codeActionTypes.includes(action.type)); + if (codeAction) { + if (codeAction.type == SteamSession.EAuthSessionGuardType.EmailCode) { + console.log(`A code has been sent to your email address at ${codeAction.detail}.`); + } else { + // We wouldn't expect this to happen since we're trying to enable 2FA, but just in case... + console.log('You need to provide a Steam Guard Mobile Authenticator code.'); + } + + let code = await promptAsync('Code: '); + if (code) { + await session.submitSteamGuardCode(code); + } + + // If we fall through here without submitting a Steam Guard code, that means one of two things: + // 1. The user pressed enter without providing a code, in which case the script will simply exit + // 2. The user approved a device/email confirmation, in which case 'authenticated' was emitted and the prompt was canceled + } + } +} + +function doSetup() { + community.enableTwoFactor((err, response) => { if (err) { - if (err.message == 'SteamGuardMobile') { - console.log('This account already has two-factor authentication enabled.'); + if (err.eresult == EResult.Fail) { + console.log('Error: Failed to enable two-factor authentication. Do you have a phone number attached to your account?'); process.exit(); return; } - if (err.message == 'SteamGuard') { - console.log(`An email has been sent to your address at ${err.emaildomain}`); - rl.question('Steam Guard Code: ', (code) => { - doLogin(accountName, password, code); - }); - - return; - } - - if (err.message == 'CAPTCHA') { - console.log(err.captchaurl); - rl.question('CAPTCHA: ', (captchaInput) => { - doLogin(accountName, password, authCode, captchaInput); - }); - + if (err.eresult == EResult.RateLimitExceeded) { + console.log('Error: RateLimitExceeded. Try again later.'); + process.exit(); return; } @@ -55,58 +101,82 @@ function doLogin(accountName, password, authCode, captcha) { return; } - console.log('Logged on!'); - community.enableTwoFactor((err, response) => { - if (err) { - if (err.eresult == EResult.Fail) { - console.log('Error: Failed to enable two-factor authentication. Do you have a phone number attached to your account?'); - process.exit(); - return; - } - - if (err.eresult == EResult.RateLimitExceeded) { - console.log('Error: RateLimitExceeded. Try again later.'); - process.exit(); - return; - } - - console.log(err); - process.exit(); - return; - } - - if (response.status != EResult.OK) { - console.log(`Error: Status ${response.status}`); - process.exit(); - return; - } - - let filename = `twofactor_${community.steamID.getSteamID64()}.json`; - console.log(`Writing secrets to ${filename}`); - console.log(`Revocation code: ${response.revocation_code}`); - FS.writeFileSync(filename, JSON.stringify(response, null, '\t')); - - promptActivationCode(response); - }); - }); -} - -function promptActivationCode(response) { - rl.question('SMS Code: ', (smsCode) => { - community.finalizeTwoFactor(response.shared_secret, smsCode, (err) => { - if (err) { - if (err.message == 'Invalid activation code') { - console.log(err); - promptActivationCode(response); - return; - } - - console.log(err); - } else { - console.log('Two-factor authentication enabled!'); - } - + if (response.status != EResult.OK) { + console.log(`Error: Status ${response.status}`); process.exit(); + return; + } + + let filename = `twofactor_${community.steamID.getSteamID64()}.json`; + console.log(`Writing secrets to ${filename}`); + console.log(`Revocation code: ${response.revocation_code}`); + FS.writeFileSync(filename, JSON.stringify(response, null, '\t')); + + promptActivationCode(response); + }); +} + +async function promptActivationCode(response) { + if (response.phone_number_hint) { + console.log(`A code has been sent to your phone ending in ${response.phone_number_hint}.`); + } + + let smsCode = await promptAsync('SMS Code: '); + community.finalizeTwoFactor(response.shared_secret, smsCode, (err) => { + if (err) { + if (err.message == 'Invalid activation code') { + console.log(err); + promptActivationCode(response); + return; + } + + console.log(err); + } else { + console.log('Two-factor authentication enabled!'); + } + + process.exit(); + }); +} + +// Nothing interesting below here, just code for prompting for input from the console. + +function promptAsync(question, sensitiveInput = false) { + return new Promise((resolve) => { + let rl = ReadLine.createInterface({ + input: process.stdin, + output: sensitiveInput ? null : process.stdout, + terminal: true + }); + + g_AbortPromptFunc = () => { + rl.close(); + resolve(''); + }; + + if (sensitiveInput) { + // We have to write the question manually if we didn't give readline an output stream + process.stdout.write(question); + } + + rl.question(question, (result) => { + if (sensitiveInput) { + // We have to manually print a newline + process.stdout.write('\n'); + } + + g_AbortPromptFunc = null; + rl.close(); + resolve(result); }); }); } + +function abortPrompt() { + if (!g_AbortPromptFunc) { + return; + } + + g_AbortPromptFunc(); + process.stdout.write('\n'); +} diff --git a/index.js b/index.js index e72f089..7c1d608 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,7 @@ module.exports = SteamCommunity; SteamCommunity.SteamID = SteamID; SteamCommunity.EConfirmationType = require('./resources/EConfirmationType.js'); SteamCommunity.EResult = require('./resources/EResult.js'); +SteamCommunity.ESharedFileType = require('./resources/ESharedFileType.js'); SteamCommunity.EFriendRelationship = require('./resources/EFriendRelationship.js'); @@ -66,7 +67,7 @@ SteamCommunity.prototype.login = function(details, callback) { this._setCookie(`steamMachineAuth${parts[0]}=${encodeURIComponent(parts[1])}`, true); } - let disableMobile = details.disableMobile; + let disableMobile = typeof details.disableMobile == 'undefined' ? true : details.disableMobile; // Delete the cache delete this._profileURL; @@ -159,18 +160,17 @@ SteamCommunity.prototype.login = function(details, callback) { this._captchaGid = body.captcha_gid; - return reject(error); - } else if (!body.success) { - return reject(new Error(body.message || 'Unknown error')); - } else if (!disableMobile && !body.oauth) { - return reject(new Error('Malformed response')); - } else { - let sessionID = this.getSessionID(); - let oAuth; + callback(error); + } else if (!body.success) { + callback(new Error(body.message || 'Unknown error')); + } else { + var sessionID = generateSessionID(); + var oAuth = {}; + self._setCookie(Request.cookie('sessionid=' + sessionID)); let cookies = this._jar.getCookieStringSync('https://steamcommunity.com').split(';').map(cookie => cookie.trim()); - if (!disableMobile) { + if (!disableMobile && body.oauth) { oAuth = JSON.parse(body.oauth); this.steamID = new SteamID(oAuth.steamid); this.oAuthToken = oAuth.oauth_token; @@ -211,6 +211,12 @@ SteamCommunity.prototype.login = function(details, callback) { }); }; +/** + * @deprecated + * @param {string} steamguard + * @param {string} token + * @param {function} callback + */ SteamCommunity.prototype.oAuthLogin = function(steamguard, token, callback) { steamguard = steamguard.split('||'); let steamID = new SteamID(steamguard[0]); @@ -307,6 +313,10 @@ SteamCommunity.prototype.setCookies = function(cookies) { this._setCookie(cookie, !!(cookieName.match(/^steamMachineAuth/) || cookieName.match(/Secure$/))); }); + + // The account we're logged in as might have changed, so verify that our mobile access token (if any) is still valid + // for this account. + this._verifyMobileAccessToken(); }; SteamCommunity.prototype.getSessionID = function() { @@ -566,6 +576,8 @@ require('./components/profile.js'); require('./components/market.js'); require('./components/groups.js'); require('./components/users.js'); +require('./components/sharedfiles.js'); +require('./components/inventoryhistory.js'); require('./components/webapi.js'); require('./components/twofactor.js'); require('./components/confirmations.js'); @@ -573,6 +585,7 @@ require('./components/help.js'); require('./classes/CMarketItem.js'); require('./classes/CMarketSearchResult.js'); require('./classes/CSteamGroup.js'); +require('./classes/CSteamSharedFile.js'); require('./classes/CSteamUser.js'); /** diff --git a/package.json b/package.json index aabee36..061d1d4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "tough-cookie": "^4.0.0", "xml2js": "^0.4.22" }, + "devDependencies": { + "steam-session": "^1.2.3" + }, "engines": { "node": ">=14.0.0" }, diff --git a/resources/ESharedFileType.js b/resources/ESharedFileType.js new file mode 100644 index 0000000..21290c8 --- /dev/null +++ b/resources/ESharedFileType.js @@ -0,0 +1,13 @@ +/** + * @enum ESharedFileType + */ +module.exports = { + "Screenshot": 0, + "Artwork": 1, + "Guide": 2, + + // Value-to-name mapping for convenience + "0": "Screenshot", + "1": "Artwork", + "2": "Guide" +};