The Pages deployment is now the primary copy, so two things had to change: - Hosted pages fall back to steam-sync.py on 127.0.0.1:8765 instead of refusing to sync. steam-sync.py now answers Chrome's Private Network Access preflight (Access-Control-Allow-Private-Network), which is what a public https page needs before it may call into localhost. - The build sources lived in /tmp and would have been lost. They now live in build/, and build.py outputs to the repo root. deploy.sh rebuilds, commits and pushes in one step. Editing index.html directly is no longer the workflow - edit build/ and run ./deploy.sh.
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,
|
|
}));
|
|
},
|
|
};
|