164 lines
6.7 KiB
TypeScript
164 lines
6.7 KiB
TypeScript
|
|
import { Socket } from 'socket.io';
|
||
|
|
import { spawn, ChildProcess } from 'child_process';
|
||
|
|
import fs from 'fs';
|
||
|
|
import path from 'path';
|
||
|
|
|
||
|
|
// Critical paths in the backend
|
||
|
|
const SCRIPTS_DIR = '/opt/iiab/maps/tile-extract/';
|
||
|
|
const EXTRACT_SCRIPT = path.join(SCRIPTS_DIR, 'tile-extract.py');
|
||
|
|
const CATALOG_JSON = '/library/www/osm/maps/extracts.json';
|
||
|
|
|
||
|
|
// --- FUNCTION: Read existing regions from JSON ---
|
||
|
|
function getMapsCatalog() {
|
||
|
|
try {
|
||
|
|
if (!fs.existsSync(CATALOG_JSON)) return [];
|
||
|
|
|
||
|
|
console.log('[Maps] Reading extracts.json catalog...');
|
||
|
|
const fileContent = fs.readFileSync(CATALOG_JSON, 'utf8');
|
||
|
|
const data = JSON.parse(fileContent);
|
||
|
|
|
||
|
|
// Transform JSON format {"regions": {"name": [bbox...]}}
|
||
|
|
// to an array manageable by the web: [{name, bbox}]
|
||
|
|
const regions = [];
|
||
|
|
if (data && data.regions) {
|
||
|
|
for (const name in data.regions) {
|
||
|
|
// Format coordinates to 4 decimals so they look clean
|
||
|
|
const bbox = data.regions[name].map((coord: number) => coord.toFixed(4));
|
||
|
|
regions.push({
|
||
|
|
name: name,
|
||
|
|
bbox: bbox.join(', ') // "min_lon, min_lat, max_lon, max_lat"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return regions;
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[Maps] Error reading maps catalog:', error);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- FUNCTION: The Security Regex Shield (Stricter) ---
|
||
|
|
// The region name MUST be lowercase letters, numbers, or underscore ONLY.
|
||
|
|
const regionNameRegex = /^[a-z0-9_]+$/;
|
||
|
|
|
||
|
|
function validateSecureCommand(rawCommand: string): { type: string, region: string, bbox?: string, error?: string } {
|
||
|
|
const tokens = rawCommand.trim().split(/\s+/);
|
||
|
|
|
||
|
|
// 1. Must start with sudo and point to the correct script
|
||
|
|
if (tokens[0] !== 'sudo' || tokens[1] !== EXTRACT_SCRIPT) {
|
||
|
|
return { type: '', region: '', error: 'The command does not start with sudo /opt/iiab/maps/tile-extract/tile-extract.py' };
|
||
|
|
}
|
||
|
|
|
||
|
|
const action = tokens[2]; // 'extract' or 'delete'
|
||
|
|
const regionName = tokens[3];
|
||
|
|
|
||
|
|
// 2. Validate the format of the region name
|
||
|
|
if (!regionName || !regionNameRegex.test(regionName)) {
|
||
|
|
return { type: '', region: '', error: 'SECURITY ERROR: The region name MUST contain ONLY lowercase letters, numbers, and underscores (_).' };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (action === 'delete' && tokens.length === 4) {
|
||
|
|
// Variant 1: DELETE (sudo tile-extract.py delete desert1)
|
||
|
|
return { type: 'delete', region: regionName };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (action === 'extract' && tokens.length === 5) {
|
||
|
|
// Variant 2: DOWNLOAD (sudo tile-extract.py extract desert1 bbox)
|
||
|
|
const bbox = tokens[4];
|
||
|
|
if (!/^[-0-9.,]+$/.test(bbox)) {
|
||
|
|
return { type: '', region: '', error: 'ERROR: The coordinates (Bounding Box) have an invalid format.' };
|
||
|
|
}
|
||
|
|
return { type: 'extract', region: regionName, bbox: bbox };
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Anything else is an error
|
||
|
|
return { type: '', region: '', error: 'Invalid command format. Only sudo [script] extract {region} {bbox} OR sudo [script] delete {region} are allowed.' };
|
||
|
|
}
|
||
|
|
|
||
|
|
export const handleMapsEvents = (socket: Socket) => {
|
||
|
|
let scriptProcess: ChildProcess | null = null;
|
||
|
|
|
||
|
|
// A. Send catalog to the web
|
||
|
|
socket.on('request_maps_catalog', () => {
|
||
|
|
const catalog = getMapsCatalog();
|
||
|
|
socket.emit('maps_catalog_ready', catalog);
|
||
|
|
});
|
||
|
|
|
||
|
|
// B. Process raw order (copy-paste) with strict validation
|
||
|
|
socket.on('start_command', (config: { rawCommand: string }) => {
|
||
|
|
if (scriptProcess) {
|
||
|
|
socket.emit('terminal_output', '\n[System] A process is already running.\n');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const validation = validateSecureCommand(config.rawCommand);
|
||
|
|
|
||
|
|
if (validation.error) {
|
||
|
|
socket.emit('terminal_output', `\n[System] SECURITY ERROR: ${validation.error}\n`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let args = [EXTRACT_SCRIPT, validation.type, validation.region];
|
||
|
|
if (validation.type === 'extract' && validation.bbox) args.push(validation.bbox);
|
||
|
|
|
||
|
|
socket.emit('terminal_output', `[System] Valid command. Starting action [${validation.type}] for region: ${validation.region}...\n`);
|
||
|
|
|
||
|
|
scriptProcess = spawn('sudo', args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
||
|
|
|
||
|
|
socket.emit('process_status', { isRunning: true });
|
||
|
|
|
||
|
|
scriptProcess.stdout?.on('data', (data) => socket.emit('terminal_output', data.toString()));
|
||
|
|
scriptProcess.stderr?.on('data', (data) => socket.emit('terminal_output', data.toString()));
|
||
|
|
|
||
|
|
scriptProcess.on('exit', (code) => {
|
||
|
|
scriptProcess = null;
|
||
|
|
socket.emit('terminal_output', `\n[System] Process [${validation.type}] finished with code ${code}\n`);
|
||
|
|
socket.emit('process_status', { isRunning: false });
|
||
|
|
// Reload catalog if successful
|
||
|
|
if (code === 0) {
|
||
|
|
const catalog = getMapsCatalog();
|
||
|
|
socket.emit('maps_catalog_ready', catalog);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// C. Process direct card deletion (Intelligent UI)
|
||
|
|
socket.on('delete_map_region', (regionName: string) => {
|
||
|
|
if (scriptProcess) return;
|
||
|
|
|
||
|
|
// Double security check just in case
|
||
|
|
if (!regionNameRegex.test(regionName)) {
|
||
|
|
socket.emit('terminal_output', `\n[System] ERROR: Invalid region name for deletion: ${regionName}\n`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
socket.emit('terminal_output', `[System] Starting direct deletion for region: ${regionName}...\n`);
|
||
|
|
|
||
|
|
scriptProcess = spawn('sudo', [EXTRACT_SCRIPT, 'delete', regionName], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
||
|
|
socket.emit('process_status', { isRunning: true });
|
||
|
|
|
||
|
|
scriptProcess.stdout?.on('data', (data) => socket.emit('terminal_output', data.toString()));
|
||
|
|
scriptProcess.on('exit', (code) => {
|
||
|
|
scriptProcess = null;
|
||
|
|
if (code === 0) {
|
||
|
|
socket.emit('terminal_output', `\n[System] 🗑️ Region ${regionName} successfully deleted from JSON.\n`);
|
||
|
|
// Send updated catalog
|
||
|
|
const catalog = getMapsCatalog();
|
||
|
|
socket.emit('maps_catalog_ready', catalog);
|
||
|
|
} else {
|
||
|
|
socket.emit('terminal_output', `\n[System] Error deleting region ${regionName}. Code ${code}\n`);
|
||
|
|
}
|
||
|
|
socket.emit('process_status', { isRunning: false });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
socket.on('send_input', (input: string) => {
|
||
|
|
if (scriptProcess && scriptProcess.stdin) {
|
||
|
|
scriptProcess.stdin.write(`${input}\n`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
socket.on('disconnect', () => {
|
||
|
|
if (scriptProcess) scriptProcess.kill();
|
||
|
|
});
|
||
|
|
};
|