277 lines
8.8 KiB
JavaScript
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}`);
|
|
});
|
|
|