Files
2026-09-09 18:10:20 +08:00

277 lines
8.8 KiB
JavaScript

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('</head>', `<script>window.__CFG__=${config};</script></head>`);
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}`);
});