The repo is public and deployed now, so two things needed tightening: - index.html: DEFAULT_PROFILE is empty instead of a Steam vanity name. The page no longer tries to sync somebody else's account on first load, and no personal handle ships in the repo. Anyone running it locally keeps their profile in localStorage, so existing local use is unaffected. - steam-proxy-worker.js: ALLOW_ORIGIN pinned to https://rain.pages.melonbread.xyz rather than '*', so only the deployed page can call the Worker if it is ever deployed. - README: record the deployment URL and use a neutral example vanity name.
138 lines
5.5 KiB
JavaScript
138 lines
5.5 KiB
JavaScript
/**
|
|
* steam-proxy-worker.js — Cloudflare Worker that gives a *hosted* copy of
|
|
* la-li-lu-le-lo (the MGS2 Master Collection 100% companion) working Steam sync.
|
|
*
|
|
* WHY THIS EXISTS
|
|
* ---------------
|
|
* The companion reads a public Steam Community page to find out which achievements an
|
|
* account already has. That page is served as XML and sends no CORS headers, so a browser
|
|
* cannot fetch it from another origin. Running steam-sync.py locally solves this on your own
|
|
* machine; this Worker solves it once the page is hosted on GitHub Pages / Netlify / Vercel /
|
|
* Cloudflare Pages.
|
|
*
|
|
* DEPLOY (free tier is plenty — this is a handful of requests per page load)
|
|
* -------------------------------------------------------------------------
|
|
* 1. https://dash.cloudflare.com -> Workers & Pages -> Create -> Worker
|
|
* 2. Paste this whole file in, replacing the starter code. Deploy.
|
|
* 3. Copy the worker URL (e.g. https://mgs2-steam-proxy.YOURNAME.workers.dev)
|
|
* 4. In index.html, set: const STEAM_PROXY = 'https://mgs2-steam-proxy.YOURNAME.workers.dev';
|
|
* 5. Done — the page will use it automatically when it is not running on localhost.
|
|
*
|
|
* ALLOW_ORIGIN below is already pinned to the deploying page's origin, so nobody else
|
|
* can point their own page at this Worker. Add more origins to the list if you need to.
|
|
*
|
|
* The Worker makes exactly one outbound request, to the public Steam Community page for the
|
|
* profile it is asked about. It stores nothing and logs nothing.
|
|
*/
|
|
|
|
const APPID = '2131640';
|
|
const ALLOW_ORIGIN = 'https://rain.pages.melonbread.xyz'; // only this page may call the Worker
|
|
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
|
|
|
function cors(res) {
|
|
res.headers.set('Access-Control-Allow-Origin', ALLOW_ORIGIN);
|
|
res.headers.set('Access-Control-Allow-Headers', '*');
|
|
res.headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
res.headers.set('Cache-Control', 'no-store');
|
|
return res;
|
|
}
|
|
function json(obj) {
|
|
return new Response(JSON.stringify(obj), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
});
|
|
}
|
|
function cdata(block, tag) {
|
|
const m = block.match(new RegExp('<' + tag + '><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></' + tag + '>'));
|
|
return m ? m[1].trim() : '';
|
|
}
|
|
|
|
export default {
|
|
async fetch(request) {
|
|
const url = new URL(request.url);
|
|
|
|
if (request.method === 'OPTIONS') return cors(new Response(null, { status: 204 }));
|
|
if (url.pathname.replace(/\/$/, '') !== '/api/steam') {
|
|
return cors(new Response('Not found — this Worker only serves /api/steam', { status: 404 }));
|
|
}
|
|
|
|
const profile = (url.searchParams.get('profile') || '').trim();
|
|
if (!profile) {
|
|
return cors(json({ ok: false, error: 'No profile given.', hint: 'Enter a vanity name or SteamID64.' }));
|
|
}
|
|
|
|
const target = /^\d{17}$/.test(profile)
|
|
? 'https://steamcommunity.com/profiles/' + profile + '/stats/' + APPID + '/?tab=achievements&xml=1'
|
|
: 'https://steamcommunity.com/id/' + encodeURIComponent(profile) + '/stats/' + APPID + '/?tab=achievements&xml=1';
|
|
|
|
let xml;
|
|
try {
|
|
const r = await fetch(target, { headers: { 'User-Agent': UA, 'Accept-Language': 'en' } });
|
|
xml = await r.text();
|
|
} catch (e) {
|
|
return cors(json({ ok: false, error: 'Could not reach Steam.', hint: 'Try again in a moment.' }));
|
|
}
|
|
|
|
const err = xml.match(/<error><!\[CDATA\[([\s\S]*?)\]\]><\/error>/);
|
|
if (err) {
|
|
return cors(json({
|
|
ok: false,
|
|
error: err[1].trim(),
|
|
hint: 'Check the spelling, or paste the whole profile URL.',
|
|
}));
|
|
}
|
|
const privacy = (xml.match(/<privacyState>(.*?)<\/privacyState>/) || [, ''])[1].trim();
|
|
if (privacy && privacy !== 'public') {
|
|
return cors(json({
|
|
ok: false,
|
|
error: 'That profile is ' + privacy + '.',
|
|
hint: "Steam > Edit Profile > Privacy Settings > set 'Game details' to Public, then sync again.",
|
|
}));
|
|
}
|
|
|
|
const achievements = [];
|
|
const re = /<achievement closed="(\d)">([\s\S]*?)<\/achievement>/g;
|
|
let m;
|
|
while ((m = re.exec(xml)) !== null) {
|
|
const body = m[2];
|
|
achievements.push({
|
|
api: cdata(body, 'apiname'),
|
|
name: cdata(body, 'name'),
|
|
desc: cdata(body, 'description'),
|
|
unlocked: m[1] === '1',
|
|
icon: cdata(body, 'iconOpen'),
|
|
iconLocked: cdata(body, 'iconClosed'),
|
|
});
|
|
}
|
|
|
|
if (!achievements.length) {
|
|
return cors(json({
|
|
ok: false,
|
|
error: 'No MGS2 Master Collection stats on that account.',
|
|
hint: 'The account must own the game (appid ' + APPID + ') and have launched it at least once.',
|
|
}));
|
|
}
|
|
|
|
const id64 = (xml.match(/<steamID64>(\d+)<\/steamID64>/) || [, ''])[1];
|
|
// This XML carries no persona name, only steamID64 and customURL, so fall back sensibly.
|
|
let persona = '';
|
|
const pm = xml.match(/<steamID>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/steamID>/);
|
|
if (pm && pm[1].trim()) persona = pm[1].trim();
|
|
if (!persona) {
|
|
const cm = xml.match(/<customURL><!\[CDATA\[(.*?)\]\]><\/customURL>/);
|
|
if (cm && cm[1].trim()) persona = cm[1].trim();
|
|
}
|
|
if (!persona) persona = profile;
|
|
|
|
return cors(json({
|
|
ok: true,
|
|
game: 'METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version',
|
|
appid: APPID,
|
|
steamid64: id64,
|
|
persona: persona,
|
|
unlockedCount: achievements.filter((a) => a.unlocked).length,
|
|
total: achievements.length,
|
|
achievements: achievements,
|
|
}));
|
|
},
|
|
};
|