Skip to content

Commit 35e9de5

Browse files
committed
PlayPurchase - Add service to fetch published items with ownership enforcement
1 parent c13ac9c commit 35e9de5

1 file changed

Lines changed: 104 additions & 0 deletions

File tree

src/services/download.service.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {env} from "../config/env.js";
2+
import {createHttp} from "../utils/http.js";
3+
import {badRequest, forbidden, HttpError, internal} from "../utils/httpError.js";
4+
import {getInventory} from "./minecraft.service.js";
5+
import {getEntityTokenForPlayer} from "./playfab.service.js";
6+
7+
const http = createHttp(env.HTTP_TIMEOUT_MS);
8+
9+
function norm(value) {
10+
return String(value || "").trim().toLowerCase();
11+
}
12+
13+
function matchString(value, needle) {
14+
const left = norm(value);
15+
const right = norm(needle);
16+
if (!left || !right) return false;
17+
if (left === right) return true;
18+
return left.includes(right);
19+
}
20+
21+
function containsNeedle(root, needle) {
22+
const seen = new Set();
23+
const stack = [root];
24+
let steps = 0;
25+
26+
while (stack.length) {
27+
const current = stack.pop();
28+
steps++;
29+
if (steps > 6000) return false;
30+
31+
if (current == null) continue;
32+
33+
const type = typeof current;
34+
if (type === "string") {
35+
if (matchString(current, needle)) return true;
36+
continue;
37+
}
38+
if (type === "number" || type === "boolean") continue;
39+
40+
if (type === "object") {
41+
if (seen.has(current)) continue;
42+
seen.add(current);
43+
44+
if (Array.isArray(current)) {
45+
for (let i = 0; i < current.length; i++) stack.push(current[i]);
46+
} else {
47+
const values = Object.values(current);
48+
for (let i = 0; i < values.length; i++) stack.push(values[i]);
49+
}
50+
}
51+
}
52+
53+
return false;
54+
}
55+
56+
async function assertOwned(mcToken, itemId) {
57+
if (!mcToken) throw badRequest("mcToken is required");
58+
if (!itemId) throw badRequest("itemId is required");
59+
const entitlements = await getInventory(mcToken, true);
60+
const ok = Array.isArray(entitlements) && containsNeedle(entitlements, itemId);
61+
if (!ok) throw forbidden("Item not owned");
62+
return {ok: true};
63+
}
64+
65+
async function fetchPublishedItem(entityToken, itemId, eTag) {
66+
const url = `https://${env.PLAYFAB_TITLE_ID}.playfabapi.com/Catalog/GetPublishedItem`;
67+
const headers = {
68+
Accept: "application/json",
69+
"Content-Type": "application/json",
70+
"accept-language": env.ACCEPT_LANGUAGE,
71+
"X-EntityToken": entityToken
72+
};
73+
const body = {ETag: eTag || "", ItemId: itemId};
74+
75+
try {
76+
const {data} = await http.post(url, body, {headers});
77+
if (data && typeof data.code === "number" && data.code !== 200) {
78+
throw internal("Failed to get published item", data);
79+
}
80+
return data ?? {};
81+
} catch (err) {
82+
if (err instanceof HttpError) throw err;
83+
throw internal("Failed to get published item", err.response?.data || err.message);
84+
}
85+
}
86+
87+
export async function getPublishedItemDownload({
88+
mcToken,
89+
entityToken,
90+
sessionTicket,
91+
playfabId,
92+
itemId,
93+
eTag = "",
94+
enforceOwnership = false
95+
}) {
96+
if (!itemId) throw badRequest("itemId is required");
97+
if (typeof eTag !== "string") throw badRequest("eTag must be a string");
98+
99+
if (enforceOwnership) await assertOwned(mcToken, itemId);
100+
101+
let token = String(entityToken || "").trim();
102+
if (!token) token = await getEntityTokenForPlayer(sessionTicket, playfabId);
103+
return await fetchPublishedItem(token, itemId, eTag);
104+
}

0 commit comments

Comments
 (0)