first commit
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
||||
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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user