Files
app/dashboard/server.js
renato97 73a4f81341 Update v9.3.1: Enhanced UX and Environment Configuration
- Incremented version to 9.3.1 (versionCode: 93100)
- Added copy token button in blocked dialog for better UX
- Fixed environment variable configuration for Telegram integration
- Improved clipboard functionality for token sharing
- Enhanced dashboard environment handling with dotenv
- Corrected variable names for Telegram configuration
- Improved error handling for token copy operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:18:31 +01:00

322 lines
9.4 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 || '';
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 sendTelegramNotification(message) {
if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) {
return;
}
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`;
try {
await axios.post(url, {
chat_id: TELEGRAM_CHAT_ID,
text: message,
parse_mode: 'Markdown'
});
} catch (error) {
console.warn('Failed to send Telegram notification', error.response ? error.response.data : error.message);
}
}
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('Comparte el token del cliente y este admin para autorizar.');
}
return 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}`);
});