diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index 8fb665e871..07b89c5238 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -228,4 +228,72 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] requests.FetchResultBlock = orig_fetchBlock end) end) -end) \ No newline at end of file +end) + +describe("TradeQueryRequests URL searches", function() + local requests + before_each(function() + requests = new("TradeQueryRequests"):TradeQueryRequests() + end) + + it("encodes the league once for both query lookup and search", function() + requests:SearchWithURL("https://www.pathofexile.com/trade2/search/poe2/Forbidden%20Rites/example", function() end) + local lookup = table.remove(requests.requestQueue.search, 1) + assert.are.equal("https://www.pathofexile.com/api/trade2/search/poe2/Forbidden%20Rites/example", lookup.url) + lookup.callback('{"query":{"stats":[]}}') + local search = requests.requestQueue.search[1] + assert.are.equal("https://www.pathofexile.com/api/trade2/search/poe2/Forbidden%20Rites", search.url) + assert.are.same({ price = "asc" }, require("dkjson").decode(search.body).sort) + end) + + it("preserves escaped percent signs and slashes in league names", function() + requests:SearchWithURL("https://www.pathofexile.com/trade2/search/poe2/Test%2520%2FLeague/example", function() end) + assert.are.equal("https://www.pathofexile.com/api/trade2/search/poe2/Test%2520%2FLeague/example", requests.requestQueue.search[1].url) + end) + + it("still accepts legacy URLs without a realm", function() + requests:SearchWithURL("https://www.pathofexile.com/trade2/search/Standard/example", function() end) + assert.are.equal("https://www.pathofexile.com/api/trade2/search/Standard/example", requests.requestQueue.search[1].url) + end) + + it("rejects unrelated URLs without throwing or queueing a request", function() + local errorMessage + requests:SearchWithURL("https://example.com/trade2/search/Standard/example", function(_, err) + errorMessage = err + end) + assert.are.equal("Invalid URL", errorMessage) + assert.are.equal(0, #requests.requestQueue.search) + end) + + it("reports malformed query responses without starting a search", function() + local errorMessage + requests:SearchWithURL("https://www.pathofexile.com/trade2/search/Standard/example", function(_, err) + errorMessage = err + end) + local lookup = table.remove(requests.requestQueue.search, 1) + lookup.callback('{}') + assert.are.equal("Failed to parse search query JSON", errorMessage) + assert.are.equal(0, #requests.requestQueue.search) + end) + it("submits compressed browser queries directly, including repeated searches", function() + local payload = "H4sIAAAAAAAAA42U62rkMAyF38W_hyDLF0nzKksp3ozbGtJMmksvlHn3VaalTSGm-yPEIcdfjk4kv5u70s15nMzx3Yz56XbzeCpT-tvlkznepW7Kh620e-7W22N6NUcPl8vlYOYxnfJ2_2Y5jKXNXxvsh_5tqMnbNOf78_i2rs_DXM69OZqXnIZz30xtHuYxGyWMaSzzT1F_7pe-PC36_vqRaU6zIv9s8LouWpPJr0NX2jI3q-YWbUQQ8gHNwTynbrnafcnl_mE2R-eoYYgOA3FAUvIuxJLwKmG_BwkNB6AYmCJa5BoEOXgKVmAXYmMj6MSFCBY81iDsRZg8xhqDick6dQNVIzZoHALRyx4EoWFxguQ4sPPVapwncDHybq6rE1IMgY1kN4y-fUj9f6Ya0YtHlUSplqJpSNB6az_GRyC9nLJczcavDLKktYoI1o2gtoBFsrAbRwMOmLTbPKLEGsRB0MDQQ9iDQENiI8P6KUe2SvHA4C1Hv1sPNFGs0w5jr6Ggr4TyqxVmG5i13x2QduvNx9xfx_mq2ex6LDrBsSEQzxyJBD5b4nt0v7en_mRW2upimX6cAVNul3E9ufQM-AfqJq6d4AQAAA" + for i = 1, 2 do + requests:SearchWithURL("https://www.pathofexile.com/trade2/search/poe2/Forbidden%20Rites/" .. payload, function() end) + local search = table.remove(requests.requestQueue.search, 1) + assert.are.equal("https://www.pathofexile.com/api/trade2/search/poe2/Forbidden%20Rites", search.url) + local query = require("dkjson").decode(search.body) + assert.are.equal("weight", query.query.stats[1].type) + assert.are.equal("desc", query.sort["statgroup.0"]) + end + end) + + it("rejects damaged compressed queries without making a request", function() + local errorMessage + requests:SearchWithURL("https://www.pathofexile.com/trade2/search/poe2/Standard/H4sIAAAA", function(_, err) + errorMessage = err + end) + assert.are.equal("Failed to decode compressed search query", errorMessage) + assert.are.equal(0, #requests.requestQueue.search) + end) + +end) diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 56e3720b71..5322ee0c5d 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -455,7 +455,11 @@ end ---@param callback fun(items:table, errMsg:string, query: string?) function TradeQueryRequestsClass:SearchWithURL(url, callback) - local subpath = url:match(self.hostName .. "trade2/search/(.+)$") + local prefix = self.hostName .. "trade2/search/" + if url:sub(1, #prefix) ~= prefix then + return callback(nil, "Invalid URL", nil) + end + local subpath = url:sub(#prefix + 1) local paths = {} for path in subpath:gmatch("[^/]+") do table.insert(paths, path) @@ -467,7 +471,10 @@ function TradeQueryRequestsClass:SearchWithURL(url, callback) if #paths == 3 then realm = paths[1] end - league = paths[#paths-1] + -- URL path segments are already escaped; buildUrl encodes the league again. + league = paths[#paths-1]:gsub("%%(%x%x)", function(hex) + return string.char(tonumber(hex, 16)) + end) queryId = paths[#paths] self:FetchSearchQuery(realm, league, queryId, function(query, errMsg) if errMsg then @@ -476,8 +483,8 @@ function TradeQueryRequestsClass:SearchWithURL(url, callback) -- update sorting on provided url to sort by weights. local json_data = dkjson.decode(query) - if not json_data or json_data.error then - errMsg = json_data and json_data.error or "Failed to parse search query JSON" + if type(json_data) ~= "table" or json_data.error or type(json_data.query) ~= "table" then + return callback(nil, type(json_data) == "table" and json_data.error or "Failed to parse search query JSON", nil) end if json_data.query.stats and json_data.query.stats[1] and json_data.query.stats[1].type == "weight" then json_data.sort = {} @@ -498,6 +505,21 @@ end ---@param league string ---@param callback fun(query:string, errMsg:string) function TradeQueryRequestsClass:FetchSearchQuery(realm, league, queryId, callback) + -- Browser share links can contain a gzip-compressed query instead of a saved ID. + if queryId:sub(1, 4) == "H4sI" then + local ok, query = pcall(function() + local compressed = require("base64").decode(queryId:gsub("-", "+"):gsub("_", "/")) + return LoadModule("Modules/TradeQueryDecode")(compressed) + end) + if not ok or not query then + return callback(nil, "Failed to decode compressed search query") + end + local data = dkjson.decode(query) + if type(data) ~= "table" then + return callback(nil, "Failed to parse compressed search query") + end + return callback(dkjson.encode({ query = data })) + end local url = self:buildUrl(self.hostName .. "api/trade2/search", realm, league, queryId) table.insert(self.requestQueue["search"], { url = url, diff --git a/src/Modules/TradeQueryDecode.lua b/src/Modules/TradeQueryDecode.lua new file mode 100644 index 0000000000..d6ccc5b2cc --- /dev/null +++ b/src/Modules/TradeQueryDecode.lua @@ -0,0 +1,50 @@ +-- Decode gzip-compressed trade website share queries. +-- SimpleGraphic's Inflate only supports zlib streams, so use zlib's gzip mode. +local ffi = require("ffi") +if not pcall(ffi.typeof, "pob_trade_z_stream") then +ffi.cdef[[ +typedef struct { + const unsigned char *next_in; + unsigned int avail_in; + unsigned long total_in; + unsigned char *next_out; + unsigned int avail_out; + unsigned long total_out; + const char *msg; + void *state; + void *(*zalloc)(void *, unsigned int, unsigned int); + void (*zfree)(void *, void *); + void *opaque; + int data_type; + unsigned long adler; + unsigned long reserved; +} pob_trade_z_stream; +const char *zlibVersion(void); +int inflateInit2_(pob_trade_z_stream *, int, const char *, int); +int inflate(pob_trade_z_stream *, int); +int inflateEnd(pob_trade_z_stream *); +]] +end +local zlib = ffi.load(ffi.os == "Windows" and "zlib1" or "z") + +return function(compressed) + -- Trade queries are small; cap expansion to avoid unbounded allocations. + local capacity = 1024 * 1024 + local output = ffi.new("unsigned char[?]", capacity) + local stream = ffi.new("pob_trade_z_stream[1]") + stream[0].next_in = compressed + stream[0].avail_in = #compressed + stream[0].next_out = output + stream[0].avail_out = capacity + if zlib.inflateInit2_(stream, 31, zlib.zlibVersion(), ffi.sizeof(stream[0])) ~= 0 then + return nil + end + local status = zlib.inflate(stream, 4) -- Z_FINISH + local length = tonumber(stream[0].total_out) + local remaining = stream[0].avail_in + zlib.inflateEnd(stream) + if status ~= 1 or remaining ~= 0 then -- Z_STREAM_END + return nil + end + return ffi.string(output, length) +end