Files
app/dashboard/server.js
renato97 cc4696dec2 Update v9.4.0: Advanced Telegram Integration & Custom UI
- Incremented version to 9.4.0 (versionCode: 94000)
- Added custom blocked dialog layout with improved UX
- Implemented Telegram commands: /allow, /deny, /pending
- Enhanced Telegram polling for real-time device management
- Custom token display with click-to-copy functionality
- Improved device verification workflow via Telegram
- Better error handling and user feedback
- Enhanced documentation for Telegram integration
- Improved token validation and management system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 23:15:37 +01:00

465 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const morgan = require('morgan');
const crypto = require('crypto');
const axios = require('axios');
const requestIp = require('request-ip');
const geoip = require('geoip-lite');
const dotenv = require('dotenv');
const envPath = path.resolve(__dirname, '..', '.env');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
} else {
dotenv.config();
}
const app = express();
const PORT = process.env.PORT || 4000;
const DATA_PATH = path.join(__dirname, 'data', 'devices.json');
const CONFIG_PATH = path.join(__dirname, 'config.json');
const config = loadConfig();
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || config.telegramBotToken || '';
const TELEGRAM_CHAT_ID = (process.env.TELEGRAM_CHAT_ID || config.telegramChatId || '').toString();
let telegramOffset = 0;
let telegramPollingStarted = false;
app.use(cors());
app.use(express.json());
app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public'), {
extensions: ['html']
}));
app.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
function loadConfig() {
try {
if (fs.existsSync(CONFIG_PATH)) {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
return JSON.parse(raw);
}
} catch (error) {
console.warn('Could not parse config.json', error);
}
return {};
}
function ensureDataFile() {
if (!fs.existsSync(DATA_PATH)) {
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true });
fs.writeFileSync(DATA_PATH, JSON.stringify([], null, 2));
}
}
function readDevices() {
ensureDataFile();
try {
const raw = fs.readFileSync(DATA_PATH, 'utf-8');
const devices = JSON.parse(raw);
return Array.isArray(devices) ? devices : [];
} catch (err) {
console.error('Error parsing devices.json', err);
return [];
}
}
function writeDevices(devices) {
ensureDataFile();
fs.writeFileSync(DATA_PATH, JSON.stringify(devices, null, 2));
}
function sanitizeId(input) {
return String(input || '').trim();
}
function sanitizeIp(ip) {
if (!ip) return '';
if (ip.startsWith('::ffff:')) {
return ip.replace('::ffff:', '');
}
return ip;
}
function lookupLocation(ip) {
if (!ip) {
return { country: 'N/A', region: '', city: '' };
}
const geo = geoip.lookup(ip);
if (!geo) {
return { country: 'N/A', region: '', city: '' };
}
return {
country: geo.country || 'N/A',
region: geo.region || '',
city: geo.city || ''
};
}
function generateTokenParts() {
const fullToken = crypto.randomBytes(16).toString('hex');
const mid = Math.floor(fullToken.length / 2);
return {
clientPart: fullToken.slice(0, mid),
adminPart: fullToken.slice(mid)
};
}
async function sendTelegramMessage(message, options = {}) {
if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) {
return;
}
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`;
try {
const payload = {
chat_id: TELEGRAM_CHAT_ID,
text: message,
disable_web_page_preview: true
};
if (options.parseMode) {
payload.parse_mode = options.parseMode;
}
await axios.post(url, payload);
} catch (error) {
console.warn('Failed to send Telegram message', error.response ? error.response.data : error.message);
}
}
async function sendTelegramNotification(message) {
await sendTelegramMessage(message, { parseMode: 'Markdown' });
}
function formatTelegramMessage(device, verificationRequired) {
const lines = [
'*Nuevo registro de dispositivo*',
`ID: ${device.deviceId}`,
`Alias: ${device.alias || '-'}`,
`Modelo: ${device.manufacturer} ${device.model}`,
`Versión app: ${device.appVersionName} (${device.appVersionCode})`,
`IP: ${device.ip || '-'} / País: ${device.country || '-'}`,
`Última vez visto: ${device.lastSeen}`
];
if (verificationRequired && device.verification) {
lines.push('`Token Admin` (guárdalo): `' + device.verification.adminPart + '`');
lines.push('Autorizar: `/allow ' + device.deviceId + ' TOKEN_CLIENTE`');
lines.push('Rechazar: `/deny ' + device.deviceId + ' TOKEN_CLIENTE [motivo]`');
}
return lines.join('\n');
}
async function initializeTelegramOffset() {
if (!TELEGRAM_BOT_TOKEN) {
return;
}
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates`;
try {
const { data } = await axios.get(url, { params: { timeout: 1 } });
if (data.ok && Array.isArray(data.result) && data.result.length > 0) {
telegramOffset = data.result[data.result.length - 1].update_id + 1;
}
} catch (error) {
console.warn('Failed to initialize Telegram offset', error.message);
}
}
async function pollTelegramUpdates() {
if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) {
return;
}
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates`;
const params = {
timeout: 25
};
if (telegramOffset) {
params.offset = telegramOffset;
}
try {
const { data } = await axios.get(url, { params });
if (data.ok && Array.isArray(data.result)) {
for (const update of data.result) {
telegramOffset = update.update_id + 1;
if (update.message) {
processTelegramMessage(update.message);
}
}
}
} catch (error) {
console.warn('Telegram polling error', error.message);
} finally {
setTimeout(pollTelegramUpdates, 3000);
}
}
async function startTelegramPolling() {
if (telegramPollingStarted || !TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) {
return;
}
telegramPollingStarted = true;
await initializeTelegramOffset();
pollTelegramUpdates();
}
function processTelegramMessage(message) {
const chatId = message.chat && message.chat.id ? message.chat.id.toString() : '';
if (chatId && TELEGRAM_CHAT_ID && chatId !== TELEGRAM_CHAT_ID.toString()) {
return;
}
const text = (message.text || '').trim();
if (!text.startsWith('/')) {
return;
}
const parts = text.split(/\s+/);
const command = parts[0].toLowerCase();
if (command === '/allow' && parts.length >= 3) {
handleTelegramAllow(parts[1], parts[2]);
} else if (command === '/deny' && parts.length >= 3) {
const reason = parts.slice(3).join(' ') || 'Bloqueado desde Telegram';
handleTelegramDeny(parts[1], parts[2], reason);
} else if (command === '/pending') {
handleTelegramPending();
} else {
sendTelegramMessage('Comandos disponibles:\n/allow <deviceId> <token_cliente>\n/deny <deviceId> <token_cliente> [motivo]\n/pending');
}
}
async function handleTelegramAllow(deviceId, clientToken) {
const devices = readDevices();
const device = devices.find(d => d.deviceId === deviceId);
if (!device || !device.verification) {
await sendTelegramMessage(`❌ Dispositivo ${deviceId} no encontrado.`);
return;
}
if (device.verification.status === 'verified') {
await sendTelegramMessage(` El dispositivo ${deviceId} ya está verificado.`);
return;
}
if (device.verification.clientPart !== clientToken.trim()) {
await sendTelegramMessage('❌ Token del cliente inválido.');
return;
}
device.verification.status = 'verified';
device.verification.verifiedAt = new Date().toISOString();
device.blocked = false;
device.notes = '';
writeDevices(devices);
await sendTelegramMessage(`✅ Dispositivo ${deviceId} autorizado correctamente.`);
}
async function handleTelegramDeny(deviceId, clientToken, reason) {
const devices = readDevices();
const device = devices.find(d => d.deviceId === deviceId);
if (!device || !device.verification) {
await sendTelegramMessage(`❌ Dispositivo ${deviceId} no encontrado.`);
return;
}
if (device.verification.clientPart !== clientToken.trim()) {
await sendTelegramMessage('❌ Token del cliente inválido.');
return;
}
device.verification.status = 'denied';
device.verification.deniedAt = new Date().toISOString();
device.blocked = true;
device.notes = reason;
writeDevices(devices);
await sendTelegramMessage(`🚫 Dispositivo ${deviceId} bloqueado. Motivo: ${reason}`);
}
async function handleTelegramPending() {
const devices = readDevices();
const pending = devices.filter(d => !d.verification || d.verification.status !== 'verified');
if (!pending.length) {
await sendTelegramMessage('No hay dispositivos pendientes.');
return;
}
const lines = pending.slice(0, 10).map(device => {
const token = device.verification ? device.verification.clientPart : 'N/A';
return `${device.deviceId} - Token cliente: ${token}`;
});
await sendTelegramMessage('Pendientes:\n' + lines.join('\n'));
}
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/api/devices', (req, res) => {
const devices = readDevices();
res.json({ devices });
});
app.post('/api/devices/register', (req, res) => {
const {
deviceId,
deviceName,
model,
manufacturer,
osVersion,
appVersionName,
appVersionCode
} = req.body || {};
const trimmedId = sanitizeId(deviceId);
if (!trimmedId) {
return res.status(400).json({ error: 'deviceId is required' });
}
const devices = readDevices();
const now = new Date().toISOString();
const clientIp = sanitizeIp(requestIp.getClientIp(req) || req.ip || '');
const location = lookupLocation(clientIp);
let existing = devices.find(d => d.deviceId === trimmedId);
let isNew = false;
if (!existing) {
const tokenParts = generateTokenParts();
existing = {
deviceId: trimmedId,
alias: '',
deviceName: deviceName || '',
model: model || '',
manufacturer: manufacturer || '',
osVersion: osVersion || '',
appVersionName: appVersionName || '',
appVersionCode: appVersionCode || 0,
firstSeen: now,
lastSeen: now,
blocked: false,
notes: '',
installs: 1,
ip: clientIp,
country: location.country,
verification: {
clientPart: tokenParts.clientPart,
adminPart: tokenParts.adminPart,
status: 'pending',
createdAt: now
}
};
devices.push(existing);
isNew = true;
} else {
existing.deviceName = deviceName || existing.deviceName;
existing.model = model || existing.model;
existing.manufacturer = manufacturer || existing.manufacturer;
existing.osVersion = osVersion || existing.osVersion;
existing.appVersionName = appVersionName || existing.appVersionName;
existing.appVersionCode = appVersionCode || existing.appVersionCode;
existing.lastSeen = now;
existing.installs = (existing.installs || 1) + 1;
existing.ip = clientIp || existing.ip;
const hasValidCountry = location.country && location.country !== 'N/A';
existing.country = hasValidCountry ? location.country : (existing.country || 'N/A');
existing.verification = existing.verification || {
...generateTokenParts(),
status: 'pending',
createdAt: now
};
}
const verificationRequired = !existing.verification || existing.verification.status !== 'verified';
const blocked = existing.blocked || verificationRequired;
writeDevices(devices);
if (isNew || verificationRequired) {
sendTelegramNotification(formatTelegramMessage(existing, verificationRequired));
}
res.json({
blocked,
device: existing,
message: verificationRequired ? 'Instalación pendiente de verificación.' : (existing.notes || ''),
verification: {
required: verificationRequired,
clientTokenPart: verificationRequired ? existing.verification.clientPart : ''
}
});
});
app.post('/api/devices/:deviceId/block', (req, res) => {
const { deviceId } = req.params;
const { reason } = req.body || {};
const devices = readDevices();
const existing = devices.find(d => d.deviceId === deviceId);
if (!existing) {
return res.status(404).json({ error: 'Device not found' });
}
existing.blocked = true;
existing.notes = reason || existing.notes;
existing.blockedAt = new Date().toISOString();
writeDevices(devices);
res.json({ blocked: true, device: existing });
});
app.post('/api/devices/:deviceId/unblock', (req, res) => {
const { deviceId } = req.params;
const devices = readDevices();
const existing = devices.find(d => d.deviceId === deviceId);
if (!existing) {
return res.status(404).json({ error: 'Device not found' });
}
existing.blocked = false;
writeDevices(devices);
res.json({ blocked: false, device: existing });
});
app.post('/api/devices/:deviceId/verify', (req, res) => {
const { deviceId } = req.params;
const { clientTokenPart, adminTokenPart } = req.body || {};
const devices = readDevices();
const existing = devices.find(d => d.deviceId === deviceId);
if (!existing || !existing.verification) {
return res.status(404).json({ error: 'Device not found' });
}
if (!clientTokenPart || !adminTokenPart) {
return res.status(400).json({ error: 'Both token parts are required' });
}
if (
existing.verification.clientPart !== clientTokenPart.trim() ||
existing.verification.adminPart !== adminTokenPart.trim()
) {
return res.status(400).json({ error: 'Invalid token parts' });
}
existing.verification.status = 'verified';
existing.verification.verifiedAt = new Date().toISOString();
existing.blocked = false;
writeDevices(devices);
res.json({ verified: true, device: existing });
});
app.put('/api/devices/:deviceId/alias', (req, res) => {
const { deviceId } = req.params;
const { alias } = req.body || {};
const devices = readDevices();
const existing = devices.find(d => d.deviceId === deviceId);
if (!existing) {
return res.status(404).json({ error: 'Device not found' });
}
existing.alias = alias || '';
writeDevices(devices);
res.json({ device: existing });
});
app.delete('/api/devices/:deviceId', (req, res) => {
const { deviceId } = req.params;
const devices = readDevices();
const filtered = devices.filter(d => d.deviceId !== deviceId);
if (filtered.length === devices.length) {
return res.status(404).json({ error: 'Device not found' });
}
writeDevices(filtered);
res.json({ removed: true });
});
app.listen(PORT, () => {
ensureDataFile();
console.log(`StreamPlayer dashboard server listening on port ${PORT}`);
startTelegramPolling();
});