Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion spec/System/TestTradeQueryRequests_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,72 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]]
requests.FetchResultBlock = orig_fetchBlock
end)
end)
end)
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)
30 changes: 26 additions & 4 deletions src/Classes/TradeQueryRequests.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 = {}
Expand All @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions src/Modules/TradeQueryDecode.lua
Original file line number Diff line number Diff line change
@@ -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