- 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>
465 lines
14 KiB
JavaScript
465 lines
14 KiB
JavaScript
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();
|
||
});
|