import { createServer } from 'node:http'; import { createReadStream } from 'node:fs'; import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; import { extname, join, normalize, resolve } from 'node:path'; import { timingSafeEqual } from 'node:crypto'; const HOST = process.env.HOST || '0.0.0.0'; const PORT = Number.parseInt(process.env.PORT || '8080', 10); const TOKEN = process.env.TOKEN || ''; const AMAP_KEY = process.env.AMAP_KEY || ''; const DATA_DIR = resolve(process.env.DATA_DIR || './data'); const DATA_FILE = join(DATA_DIR, 'spoofer.json'); const PUBLIC_DIR = resolve('./public'); const MAX_BODY = 64 * 1024; const DEFAULT_LOC = { latitude: 39.90872, longitude: 116.39748, altitude: 44, horizontalAccuracy: 39, verticalAccuracy: 1000 }; const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json; charset=utf-8', '.sgmodule': 'text/plain; charset=utf-8' }; let store = { loc: { ...DEFAULT_LOC }, favorites: [] }; let saveQueue = Promise.resolve(); function safeEqual(left, right) { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b); } function authorized(url) { return TOKEN && safeEqual(url.searchParams.get('token') || '', TOKEN); } function commonHeaders(extra = {}) { return { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', ...extra }; } function sendJson(res, data, status = 200) { res.writeHead(status, commonHeaders({ 'Content-Type': 'application/json; charset=utf-8' })); res.end(JSON.stringify(data)); } function rejectUnauthorized(res) { sendJson(res, { error: 'unauthorized' }, 401); } async function readJson(req) { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > MAX_BODY) throw new Error('body too large'); chunks.push(chunk); } return JSON.parse(Buffer.concat(chunks).toString('utf8')); } async function persist() { const snapshot = JSON.stringify(store, null, 2); saveQueue = saveQueue.then(async () => { const temporary = `${DATA_FILE}.tmp`; await writeFile(temporary, snapshot, { mode: 0o600 }); await rename(temporary, DATA_FILE); }); return saveQueue; } async function initializeStore() { await mkdir(DATA_DIR, { recursive: true }); try { const saved = JSON.parse(await readFile(DATA_FILE, 'utf8')); if (saved?.loc && Array.isArray(saved?.favorites)) { store = saved; return; } } catch (error) { if (error.code !== 'ENOENT') console.warn(`Ignoring invalid data file: ${error.message}`); } await persist(); } function externalOrigin(req) { const forwardedProto = req.headers['x-forwarded-proto']?.split(',')[0].trim(); const forwardedHost = req.headers['x-forwarded-host']?.split(',')[0].trim(); const protocol = forwardedProto || (req.socket.encrypted ? 'https' : 'http'); const host = forwardedHost || req.headers.host || `localhost:${PORT}`; return `${protocol}://${host}`; } async function serveIndex(res) { let html = await readFile(join(PUBLIC_DIR, 'index.html'), 'utf8'); const config = JSON.stringify({ hasToken: true, amapKey: AMAP_KEY }).replaceAll('<', '\\u003c'); html = html.replace('', ``); res.writeHead(200, { 'Content-Type': MIME['.html'], 'Cache-Control': 'no-store' }); res.end(html); } async function serveModule(req, res, url) { let content = await readFile(join(PUBLIC_DIR, 'ios-location-spoofer.sgmodule'), 'utf8'); const origin = externalOrigin(req); const token = url.searchParams.get('token') || ''; content = content .replaceAll('https://你的域名', origin) .replaceAll('http://你的域名', origin) .replaceAll('你的域名', new URL(origin).host) .replaceAll('你的Token', token); res.writeHead(200, commonHeaders({ 'Content-Type': MIME['.sgmodule'] })); res.end(content); } async function serveStatic(pathname, res) { const relative = normalize(decodeURIComponent(pathname)).replace(/^[/\\]+/, ''); const file = resolve(PUBLIC_DIR, relative); if (file !== PUBLIC_DIR && !file.startsWith(`${PUBLIC_DIR}${process.platform === 'win32' ? '\\' : '/'}`)) return false; try { const info = await stat(file); if (!info.isFile()) return false; res.writeHead(200, { 'Content-Type': MIME[extname(file).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'public, max-age=3600' }); createReadStream(file).pipe(res); return true; } catch { return false; } } function validCoordinate(value, min, max) { return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max; } async function handleApi(req, res, url) { if (!authorized(url)) { rejectUnauthorized(res); return true; } if (url.pathname === '/verify' && req.method === 'GET') { sendJson(res, { ok: true }); return true; } if (url.pathname === '/loc.json' && req.method === 'GET') { sendJson(res, store.loc); return true; } if (url.pathname === '/set' && req.method === 'POST') { try { const data = await readJson(req); const updated = { ...store.loc }; if ('latitude' in data && !validCoordinate(data.latitude, -90, 90)) throw new Error('bad latitude'); if ('longitude' in data && !validCoordinate(data.longitude, -180, 180)) throw new Error('bad longitude'); for (const field of ['latitude', 'longitude', 'altitude', 'horizontalAccuracy', 'verticalAccuracy']) { if (typeof data[field] === 'number' && Number.isFinite(data[field])) updated[field] = data[field]; } store.loc = updated; await persist(); sendJson(res, updated); } catch (error) { sendJson(res, { error: error.message || 'bad json' }, 400); } return true; } if (url.pathname === '/favorites' && req.method === 'GET') { sendJson(res, store.favorites); return true; } if (url.pathname === '/favorites' && req.method === 'POST') { try { const data = await readJson(req); if (!validCoordinate(data.latitude, -90, 90) || !validCoordinate(data.longitude, -180, 180)) { throw new Error('bad coordinates'); } const favorite = { id: Date.now().toString(36), name: String(data.name || '未命名').slice(0, 30), latitude: data.latitude, longitude: data.longitude, altitude: Number.isFinite(data.altitude) ? data.altitude : null, horizontalAccuracy: Number.isFinite(data.horizontalAccuracy) ? data.horizontalAccuracy : null, verticalAccuracy: Number.isFinite(data.verticalAccuracy) ? data.verticalAccuracy : null, createdAt: new Date().toISOString() }; store.favorites.unshift(favorite); store.favorites = store.favorites.slice(0, 100); await persist(); sendJson(res, favorite); } catch (error) { sendJson(res, { error: error.message || 'bad json' }, 400); } return true; } const favoriteMatch = url.pathname.match(/^\/favorites\/([^/]+)$/); if (favoriteMatch && req.method === 'DELETE') { const id = decodeURIComponent(favoriteMatch[1]); store.favorites = store.favorites.filter((favorite) => favorite.id !== id); await persist(); sendJson(res, { ok: true }); return true; } return false; } await initializeStore(); if (!TOKEN) { console.error('TOKEN is required. Refusing to start an unauthenticated location service.'); process.exit(1); } const server = createServer(async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); if (req.method === 'OPTIONS') { res.writeHead(204, commonHeaders({ 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' })); res.end(); return; } if (url.pathname === '/healthz' && req.method === 'GET') { sendJson(res, { ok: true }); return; } if (url.pathname === '/' && req.method === 'GET') { await serveIndex(res); return; } if (url.pathname === '/ios-location-spoofer.sgmodule' && req.method === 'GET') { if (!authorized(url)) return rejectUnauthorized(res); await serveModule(req, res, url); return; } if (await handleApi(req, res, url)) return; if (req.method === 'GET' && await serveStatic(url.pathname, res)) return; sendJson(res, { error: 'not found' }, 404); } catch (error) { console.error(error); if (!res.headersSent) sendJson(res, { error: 'internal server error' }, 500); else res.end(); } }); server.listen(PORT, HOST, () => { console.log(`iOS Location Spoofer listening on http://${HOST}:${PORT}`); });