la-li-lu-le-lo/build/steam-proxy-worker.js
rain a63606b5c8 Sync works on the deployed site; add reproducible build and deploy script
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.
2026-09-15 17:40:06 -04:00

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,
}));
},
};