initial commit

This commit is contained in:
Will Bradley
2025-10-11 17:03:31 -07:00
commit 4767b67460
25 changed files with 5098 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
const bcrypt = require('bcryptjs');
const { userQueries, activityLogQueries } = require('../database/queries');
const logger = require('../utils/logger');
// Hash password
async function hashPassword(password) {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
// Verify password
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
// Create user
async function createUser(username, password) {
try {
const hashedPassword = await hashPassword(password);
const result = userQueries.createUser.run(username, hashedPassword);
logger.info(`User created: ${username}`);
return { id: result.lastInsertRowid, username };
} catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error('Username already exists');
}
throw error;
}
}
// Authenticate user
async function authenticateUser(username, password) {
try {
const user = userQueries.getUserByUsername.get(username);
if (!user) {
logger.warn(`Failed login attempt for username: ${username}`);
return null;
}
const isValid = await verifyPassword(password, user.password_hash);
if (!isValid) {
logger.warn(`Invalid password for username: ${username}`);
return null;
}
// Update last login
userQueries.updateLastLogin.run(user.id);
logger.info(`User logged in: ${username}`);
// Return user without password hash
const { password_hash, ...userWithoutPassword } = user;
return userWithoutPassword;
} catch (error) {
logger.error('Error authenticating user:', error);
throw error;
}
}
// Middleware to check if user is authenticated
function requireAuth(req, res, next) {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ error: 'Authentication required' });
}
}
// Log activity
function logActivity(userId, action, details = null, ipAddress = null) {
try {
activityLogQueries.logActivity.run(userId, action, details, ipAddress);
} catch (error) {
logger.error('Error logging activity:', error);
}
}
module.exports = {
hashPassword,
verifyPassword,
createUser,
authenticateUser,
requireAuth,
logActivity
};
+53
View File
@@ -0,0 +1,53 @@
require('dotenv').config();
module.exports = {
// Server configuration
server: {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development'
},
// Session configuration
session: {
secret: process.env.SESSION_SECRET || 'change-this-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
},
// MQTT configuration
mqtt: {
broker: process.env.MQTT_BROKER || 'mqtt://mqtt.meshtastic.org',
port: parseInt(process.env.MQTT_PORT) || 1883,
username: process.env.MQTT_USERNAME || 'meshdev',
password: process.env.MQTT_PASSWORD || 'large4cats',
topic: process.env.MQTT_TOPIC || 'msh/US/#',
options: {
clientId: `meshtastic-dashboard-${Math.random().toString(16).substr(2, 8)}`,
clean: true,
reconnectPeriod: 1000,
connectTimeout: 30 * 1000
}
},
// Data retention configuration
dataRetention: {
days: parseInt(process.env.DATA_RETENTION_DAYS) || 30,
purgeCronSchedule: process.env.PURGE_CRON_SCHEDULE || '0 2 * * *'
},
// Rate limiting configuration
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100
},
// Logging configuration
logging: {
level: process.env.LOG_LEVEL || 'info'
}
};
+150
View File
@@ -0,0 +1,150 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
// Ensure data directory exists
const dataDir = path.join(__dirname, '..', '..', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'meshtastic.db');
const db = new Database(dbPath);
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
// Initialize database schema
function initializeDatabase() {
// Users table
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
)
`);
// Nodes table - stores information about Meshtastic nodes
db.exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT UNIQUE NOT NULL,
short_name TEXT,
long_name TEXT,
hardware_model TEXT,
role TEXT,
firmware_version TEXT,
last_heard DATETIME,
battery_level INTEGER,
voltage REAL,
channel_utilization REAL,
air_util_tx REAL,
uptime_seconds INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index on node_id
db.exec(`
CREATE INDEX IF NOT EXISTS idx_nodes_node_id ON nodes(node_id)
`);
// Positions table - stores GPS position data
db.exec(`
CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude INTEGER,
precision_bits INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (node_id) REFERENCES nodes(node_id)
)
`);
// Create indexes for positions
db.exec(`
CREATE INDEX IF NOT EXISTS idx_positions_node_id ON positions(node_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_positions_timestamp ON positions(timestamp)
`);
// Messages table - stores text messages
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
from_node TEXT NOT NULL,
to_node TEXT,
channel INTEGER,
text TEXT,
rx_time DATETIME,
rx_snr REAL,
rx_rssi INTEGER,
hop_limit INTEGER,
want_ack BOOLEAN,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (from_node) REFERENCES nodes(node_id)
)
`);
// Create indexes for messages
db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_from_node ON messages(from_node)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at)
`);
// Telemetry table - stores device telemetry data
db.exec(`
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
battery_level INTEGER,
voltage REAL,
channel_utilization REAL,
air_util_tx REAL,
uptime_seconds INTEGER,
temperature REAL,
relative_humidity REAL,
barometric_pressure REAL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (node_id) REFERENCES nodes(node_id)
)
`);
// Create indexes for telemetry
db.exec(`
CREATE INDEX IF NOT EXISTS idx_telemetry_node_id ON telemetry(node_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON telemetry(timestamp)
`);
// Activity log table
db.exec(`
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
console.log('Database initialized successfully');
}
// Initialize the database
initializeDatabase();
module.exports = db;
+244
View File
@@ -0,0 +1,244 @@
const db = require('./db');
// User queries
const userQueries = {
createUser: db.prepare(`
INSERT INTO users (username, password_hash)
VALUES (?, ?)
`),
getUserByUsername: db.prepare(`
SELECT * FROM users WHERE username = ?
`),
updateLastLogin: db.prepare(`
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?
`),
getAllUsers: db.prepare(`
SELECT id, username, created_at, last_login FROM users
`)
};
// Node queries
const nodeQueries = {
upsertNode: db.prepare(`
INSERT INTO nodes (
node_id, short_name, long_name, hardware_model, role,
firmware_version, last_heard, battery_level, voltage,
channel_utilization, air_util_tx, uptime_seconds, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(node_id) DO UPDATE SET
short_name = COALESCE(excluded.short_name, short_name),
long_name = COALESCE(excluded.long_name, long_name),
hardware_model = COALESCE(excluded.hardware_model, hardware_model),
role = COALESCE(excluded.role, role),
firmware_version = COALESCE(excluded.firmware_version, firmware_version),
last_heard = COALESCE(excluded.last_heard, last_heard),
battery_level = COALESCE(excluded.battery_level, battery_level),
voltage = COALESCE(excluded.voltage, voltage),
channel_utilization = COALESCE(excluded.channel_utilization, channel_utilization),
air_util_tx = COALESCE(excluded.air_util_tx, air_util_tx),
uptime_seconds = COALESCE(excluded.uptime_seconds, uptime_seconds),
updated_at = CURRENT_TIMESTAMP
`),
getNodeById: db.prepare(`
SELECT
n.*,
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude,
(SELECT longitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as longitude,
(SELECT altitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as altitude,
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp
FROM nodes n
WHERE n.node_id = ?
`),
getAllNodes: db.prepare(`
SELECT
n.*,
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude,
(SELECT longitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as longitude,
(SELECT altitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as altitude,
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp
FROM nodes n
ORDER BY n.last_heard DESC
`),
updateNodeLastHeard: db.prepare(`
UPDATE nodes SET last_heard = CURRENT_TIMESTAMP WHERE node_id = ?
`)
};
// Position queries
const positionQueries = {
insertPosition: db.prepare(`
INSERT INTO positions (node_id, latitude, longitude, altitude, precision_bits)
VALUES (?, ?, ?, ?, ?)
`),
getLatestPositions: db.prepare(`
SELECT p.*, n.short_name, n.long_name, n.last_heard
FROM positions p
LEFT JOIN nodes n ON p.node_id = n.node_id
WHERE p.id IN (
SELECT MAX(id) FROM positions GROUP BY node_id
)
ORDER BY p.timestamp DESC
`),
getPositionsByNode: db.prepare(`
SELECT * FROM positions
WHERE node_id = ?
ORDER BY timestamp DESC
LIMIT ?
`),
deleteOldPositions: db.prepare(`
DELETE FROM positions WHERE timestamp < datetime('now', '-' || ? || ' days')
`),
getPositionTrails: db.prepare(`
SELECT
node_id,
latitude,
longitude,
altitude,
timestamp,
id
FROM (
SELECT
node_id,
latitude,
longitude,
altitude,
timestamp,
id,
ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY timestamp DESC) as rn
FROM positions
) AS ranked
WHERE rn <= ?
ORDER BY node_id, timestamp DESC
`)
};
// Message queries
const messageQueries = {
insertMessage: db.prepare(`
INSERT INTO messages (
message_id, from_node, to_node, channel, text,
rx_time, rx_snr, rx_rssi, hop_limit, want_ack
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
getRecentMessages: db.prepare(`
SELECT m.*,
n1.short_name as from_short_name,
n1.long_name as from_long_name,
n2.short_name as to_short_name,
n2.long_name as to_long_name
FROM messages m
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
LEFT JOIN nodes n2 ON m.to_node = n2.node_id
ORDER BY m.created_at DESC
LIMIT ?
`),
getRecentMessagesWithTimeLimit: db.prepare(`
SELECT m.*,
n1.short_name as from_short_name,
n1.long_name as from_long_name,
n2.short_name as to_short_name,
n2.long_name as to_long_name
FROM messages m
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
LEFT JOIN nodes n2 ON m.to_node = n2.node_id
WHERE m.created_at >= datetime('now', '-24 hours')
ORDER BY m.created_at DESC
LIMIT ?
`),
getMessagesByNode: db.prepare(`
SELECT m.*,
n1.short_name as from_short_name,
n1.long_name as from_long_name
FROM messages m
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
WHERE m.from_node = ? OR m.to_node = ?
ORDER BY m.created_at DESC
LIMIT ?
`),
deleteOldMessages: db.prepare(`
DELETE FROM messages WHERE created_at < datetime('now', '-' || ? || ' days')
`)
};
// Telemetry queries
const telemetryQueries = {
insertTelemetry: db.prepare(`
INSERT INTO telemetry (
node_id, battery_level, voltage, channel_utilization,
air_util_tx, uptime_seconds, temperature, relative_humidity,
barometric_pressure
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
getLatestTelemetryByNode: db.prepare(`
SELECT * FROM telemetry
WHERE node_id = ?
ORDER BY timestamp DESC
LIMIT 1
`),
getTelemetryHistory: db.prepare(`
SELECT * FROM telemetry
WHERE node_id = ?
ORDER BY timestamp DESC
LIMIT ?
`),
deleteOldTelemetry: db.prepare(`
DELETE FROM telemetry WHERE timestamp < datetime('now', '-' || ? || ' days')
`)
};
// Activity log queries
const activityLogQueries = {
logActivity: db.prepare(`
INSERT INTO activity_log (user_id, action, details, ip_address)
VALUES (?, ?, ?, ?)
`),
getRecentActivity: db.prepare(`
SELECT a.*, u.username
FROM activity_log a
LEFT JOIN users u ON a.user_id = u.id
ORDER BY a.timestamp DESC
LIMIT ?
`)
};
// Statistics queries
const statsQueries = {
getMessageCount: db.prepare(`SELECT COUNT(*) as count FROM messages`),
getNodeCount: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
getPositionCount: db.prepare(`SELECT COUNT(*) as count FROM positions`),
getDbSize: () => {
const result = db.prepare(`
SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()
`).get();
return result.size;
}
};
module.exports = {
userQueries,
nodeQueries,
positionQueries,
messageQueries,
telemetryQueries,
activityLogQueries,
statsQueries
};
+401
View File
@@ -0,0 +1,401 @@
const mqtt = require('mqtt');
const config = require('../config/config');
const logger = require('../utils/logger');
const { nodeQueries, positionQueries, messageQueries, telemetryQueries } = require('../database/queries');
class MeshtasticMQTTClient {
constructor() {
this.client = null;
this.connected = false;
this.messageCallbacks = [];
}
connect() {
const { broker, username, password, topic, options } = config.mqtt;
logger.info(`Connecting to MQTT broker: ${broker}`);
// Create MQTT client
this.client = mqtt.connect(broker, {
...options,
username,
password
});
// Connection event handlers
this.client.on('connect', () => {
this.connected = true;
logger.info('Connected to MQTT broker');
// Subscribe to Meshtastic topics
this.client.subscribe(topic, (err) => {
if (err) {
logger.error('Failed to subscribe to topic:', err);
} else {
logger.info(`Subscribed to topic: ${topic}`);
}
});
});
this.client.on('error', (error) => {
logger.error('MQTT connection error:', error);
this.connected = false;
});
this.client.on('close', () => {
this.connected = false;
logger.warn('MQTT connection closed');
});
this.client.on('reconnect', () => {
logger.info('Reconnecting to MQTT broker...');
});
// Message handler
this.client.on('message', (topic, message) => {
this.handleMessage(topic, message);
});
return this;
}
handleMessage(topic, message) {
try {
// Parse the topic to extract information
const topicParts = topic.split('/');
// Log raw message for debugging (changed to info for testing)
logger.info(`Received MQTT message on topic: ${topic} (${message.length} bytes)`);
// Try to parse as JSON first (some messages might be JSON)
let payload;
try {
payload = JSON.parse(message.toString());
this.processJsonMessage(topic, payload);
} catch (e) {
// If not JSON, treat as protobuf or binary data
this.processRawMessage(topic, message);
}
// Notify subscribers
this.notifyCallbacks(topic, message);
} catch (error) {
logger.error('Error handling MQTT message:', error);
}
}
processJsonMessage(topic, payload) {
try {
const { from, to, channel, id, sender } = payload;
// Extract node ID (convert to hex string if it's a number)
const fromNode = sender || (from ? `!${from.toString(16).padStart(8, '0')}` : null);
const toNode = to ? `!${to.toString(16).padStart(8, '0')}` : null;
// Handle different message types based on actual JSON structure
if (payload.type === 'sendtext' && payload.payload) {
this.handleTextMessage(fromNode, toNode, {
...payload,
text: typeof payload.payload === 'string' ? payload.payload : payload.payload.text,
id: payload.id,
channel: payload.channel,
rxSnr: payload.snr,
rxRssi: payload.rssi
});
}
if (payload.type === 'position' && payload.payload) {
const pos = payload.payload;
this.handlePositionUpdate(fromNode, {
latitude: pos.latitude_i / 10000000,
longitude: pos.longitude_i / 10000000,
altitude: pos.altitude,
precisionBits: pos.precision_bits
});
}
if (payload.type === 'nodeinfo' && payload.payload) {
this.handleNodeInfo(fromNode, {
shortName: payload.payload.shortname,
longName: payload.payload.longname,
hardwareModel: payload.payload.hardware,
role: payload.payload.role
});
}
if (payload.type === 'telemetry' && payload.payload) {
this.handleTelemetry(fromNode, {
deviceMetrics: {
batteryLevel: payload.payload.battery_level,
voltage: payload.payload.voltage,
channelUtilization: payload.payload.channel_utilization,
airUtilTx: payload.payload.air_util_tx,
uptimeSeconds: payload.payload.uptime_seconds
}
});
}
if (payload.type === 'neighborinfo' && payload.payload) {
// Just update last_seen for the node
nodeQueries.upsertNode.run(
fromNode,
null, null, null, null, null,
new Date().toISOString(),
null, null, null, null, null
);
}
} catch (error) {
logger.error('Error processing JSON message:', error);
}
}
processRawMessage(topic, message) {
// For protobuf messages, we'll need to decode them
// This is a simplified version - real implementation would use protobuf definitions
try {
// Extract information from topic
const topicParts = topic.split('/');
// Basic message logging for debugging
logger.debug(`Raw message length: ${message.length} bytes`);
} catch (error) {
logger.error('Error processing raw message:', error);
}
}
handleTextMessage(fromNode, toNode, data) {
try {
if (!fromNode) return;
logger.info(`Text message from ${fromNode}: ${data.text || '(empty)'}`);
// Ensure node exists first (upsert)
nodeQueries.upsertNode.run(
fromNode,
null, null, null, null, null,
new Date().toISOString(),
null, null, null, null, null
);
// Insert message - ensure all values are proper types
const messageId = data.id ? String(data.id) : null;
const channel = typeof data.channel === 'number' ? data.channel : 0;
const text = data.text || '';
const rxSnr = typeof data.rxSnr === 'number' ? data.rxSnr : null;
const rxRssi = typeof data.rxRssi === 'number' ? data.rxRssi : null;
const hopLimit = typeof data.hopLimit === 'number' ? data.hopLimit : null;
const wantAck = data.wantAck === true ? 1 : 0;
messageQueries.insertMessage.run(
messageId,
fromNode,
toNode,
channel,
text,
new Date().toISOString(),
rxSnr,
rxRssi,
hopLimit,
wantAck
);
} catch (error) {
logger.error('Error handling text message:', error);
}
}
handlePositionUpdate(nodeId, data) {
try {
if (!nodeId || !data.latitude || !data.longitude) return;
logger.info(`Position update from ${nodeId}: ${data.latitude}, ${data.longitude}`);
// Update node
nodeQueries.upsertNode.run(
nodeId,
null, // short_name
null, // long_name
null, // hardware_model
null, // role
null, // firmware_version
new Date().toISOString(),
null, // battery_level
null, // voltage
null, // channel_utilization
null, // air_util_tx
null // uptime_seconds
);
// Insert position
positionQueries.insertPosition.run(
nodeId,
data.latitude,
data.longitude,
data.altitude || null,
data.precisionBits || null
);
} catch (error) {
logger.error('Error handling position update:', error);
}
}
handleNodeInfo(nodeId, data) {
try {
if (!nodeId) return;
logger.info(`Node info update for ${nodeId}`);
// Update node information
nodeQueries.upsertNode.run(
nodeId,
data.shortName || data.user?.shortName || null,
data.longName || data.user?.longName || null,
data.hardwareModel || data.user?.hwModel || null,
data.role || null,
data.firmwareVersion || null,
new Date().toISOString(),
null, // battery_level
null, // voltage
null, // channel_utilization
null, // air_util_tx
null // uptime_seconds
);
} catch (error) {
logger.error('Error handling node info:', error);
}
}
handleTelemetry(nodeId, data) {
try {
if (!nodeId) return;
logger.info(`Telemetry update from ${nodeId}`);
// Update node with telemetry data
if (data.deviceMetrics) {
const metrics = data.deviceMetrics;
nodeQueries.upsertNode.run(
nodeId,
null, // short_name
null, // long_name
null, // hardware_model
null, // role
null, // firmware_version
new Date().toISOString(),
metrics.batteryLevel || null,
metrics.voltage || null,
metrics.channelUtilization || null,
metrics.airUtilTx || null,
metrics.uptimeSeconds || null
);
// Insert telemetry record
telemetryQueries.insertTelemetry.run(
nodeId,
metrics.batteryLevel || null,
metrics.voltage || null,
metrics.channelUtilization || null,
metrics.airUtilTx || null,
metrics.uptimeSeconds || null,
null, // temperature
null, // relative_humidity
null // barometric_pressure
);
}
if (data.environmentMetrics) {
const env = data.environmentMetrics;
telemetryQueries.insertTelemetry.run(
nodeId,
null, // battery_level
null, // voltage
null, // channel_utilization
null, // air_util_tx
null, // uptime_seconds
env.temperature || null,
env.relativeHumidity || null,
env.barometricPressure || null
);
}
} catch (error) {
logger.error('Error handling telemetry:', error);
}
}
// Publish a message to MQTT
publish(topic, message) {
return new Promise((resolve, reject) => {
if (!this.connected) {
reject(new Error('MQTT client not connected'));
return;
}
this.client.publish(topic, message, (error) => {
if (error) {
logger.error('Error publishing message:', error);
reject(error);
} else {
logger.info(`Published message to topic: ${topic}`);
resolve();
}
});
});
}
// Send a text message
async sendTextMessage(text, channel = 0) {
try {
const message = JSON.stringify({
type: 'text',
text,
channel,
timestamp: Date.now()
});
// Publish to the appropriate topic
const baseTopic = config.mqtt.topic.replace('/#', '');
await this.publish(`${baseTopic}/2/json/mqtt`, message);
return true;
} catch (error) {
logger.error('Error sending text message:', error);
throw error;
}
}
// Register a callback for messages
onMessage(callback) {
this.messageCallbacks.push(callback);
}
// Notify all callbacks
notifyCallbacks(topic, message) {
this.messageCallbacks.forEach(callback => {
try {
callback(topic, message);
} catch (error) {
logger.error('Error in message callback:', error);
}
});
}
// Get connection status
isConnected() {
return this.connected;
}
// Disconnect
disconnect() {
if (this.client) {
this.client.end();
this.connected = false;
logger.info('Disconnected from MQTT broker');
}
}
}
// Create singleton instance
const mqttClient = new MeshtasticMQTTClient();
module.exports = mqttClient;
+265
View File
@@ -0,0 +1,265 @@
const express = require('express');
const router = express.Router();
const { authenticateUser, requireAuth, logActivity } = require('../auth/auth');
const {
nodeQueries,
positionQueries,
messageQueries,
telemetryQueries,
statsQueries
} = require('../database/queries');
const mqttClient = require('../mqtt/client');
const logger = require('../utils/logger');
// Login endpoint
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
const user = await authenticateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Set session
req.session.userId = user.id;
req.session.username = user.username;
// Log activity
logActivity(user.id, 'login', null, req.ip);
res.json({
success: true,
user: {
id: user.id,
username: user.username
}
});
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout endpoint
router.post('/logout', requireAuth, (req, res) => {
const userId = req.session.userId;
req.session.destroy((err) => {
if (err) {
logger.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
logActivity(userId, 'logout', null, req.ip);
res.json({ success: true });
});
});
// Check authentication status
router.get('/auth/status', (req, res) => {
if (req.session && req.session.userId) {
res.json({
authenticated: true,
user: {
id: req.session.userId,
username: req.session.username
}
});
} else {
res.json({ authenticated: false });
}
});
// Get all nodes
router.get('/nodes', requireAuth, (req, res) => {
try {
const nodes = nodeQueries.getAllNodes.all();
res.json(nodes);
} catch (error) {
logger.error('Error fetching nodes:', error);
res.status(500).json({ error: 'Failed to fetch nodes' });
}
});
// Get specific node
router.get('/nodes/:nodeId', requireAuth, (req, res) => {
try {
const { nodeId } = req.params;
const node = nodeQueries.getNodeById.get(nodeId);
if (!node) {
return res.status(404).json({ error: 'Node not found' });
}
res.json(node);
} catch (error) {
logger.error('Error fetching node:', error);
res.status(500).json({ error: 'Failed to fetch node' });
}
});
// Get latest positions for all nodes
router.get('/positions', requireAuth, (req, res) => {
try {
const positions = positionQueries.getLatestPositions.all();
res.json(positions);
} catch (error) {
logger.error('Error fetching positions:', error);
res.status(500).json({ error: 'Failed to fetch positions' });
}
});
// Get position trails for all nodes (last 10 positions per node)
// MUST come before /positions/:nodeId to avoid matching "trails" as a nodeId
router.get('/positions/trails/all', requireAuth, (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const trails = positionQueries.getPositionTrails.all(limit);
res.json(trails);
} catch (error) {
logger.error('Error fetching position trails:', error);
res.status(500).json({ error: 'Failed to fetch position trails' });
}
});
// Get position history for a specific node
router.get('/positions/:nodeId', requireAuth, (req, res) => {
try {
const { nodeId } = req.params;
const limit = parseInt(req.query.limit) || 100;
const positions = positionQueries.getPositionsByNode.all(nodeId, limit);
res.json(positions);
} catch (error) {
logger.error('Error fetching position history:', error);
res.status(500).json({ error: 'Failed to fetch position history' });
}
});
// Get recent messages (24 hours or up to 1000 messages, whichever is less)
router.get('/messages', requireAuth, (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 1000, 1000);
const messages = messageQueries.getRecentMessagesWithTimeLimit.all(limit);
res.json(messages);
} catch (error) {
logger.error('Error fetching messages:', error);
res.status(500).json({ error: 'Failed to fetch messages' });
}
});
// Get messages for a specific node
router.get('/messages/node/:nodeId', requireAuth, (req, res) => {
try {
const { nodeId } = req.params;
const limit = parseInt(req.query.limit) || 100;
const messages = messageQueries.getMessagesByNode.all(nodeId, nodeId, limit);
res.json(messages);
} catch (error) {
logger.error('Error fetching node messages:', error);
res.status(500).json({ error: 'Failed to fetch node messages' });
}
});
// Send a message
router.post('/messages/send', requireAuth, async (req, res) => {
try {
const { text, channel } = req.body;
if (!text) {
return res.status(400).json({ error: 'Message text required' });
}
await mqttClient.sendTextMessage(text, channel || 0);
// Log activity
logActivity(req.session.userId, 'send_message', text, req.ip);
res.json({ success: true });
} catch (error) {
logger.error('Error sending message:', error);
res.status(500).json({ error: 'Failed to send message' });
}
});
// Get telemetry for a specific node
router.get('/telemetry/:nodeId', requireAuth, (req, res) => {
try {
const { nodeId } = req.params;
const limit = parseInt(req.query.limit) || 100;
const telemetry = telemetryQueries.getTelemetryHistory.all(nodeId, limit);
res.json(telemetry);
} catch (error) {
logger.error('Error fetching telemetry:', error);
res.status(500).json({ error: 'Failed to fetch telemetry' });
}
});
// Get dashboard statistics
router.get('/stats', requireAuth, (req, res) => {
try {
const messageCount = statsQueries.getMessageCount.get();
const nodeCount = statsQueries.getNodeCount.get();
const positionCount = statsQueries.getPositionCount.get();
const dbSize = statsQueries.getDbSize();
res.json({
messages: messageCount.count,
nodes: nodeCount.count,
positions: positionCount.count,
databaseSize: dbSize,
mqttConnected: mqttClient.isConnected()
});
} catch (error) {
logger.error('Error fetching stats:', error);
res.status(500).json({ error: 'Failed to fetch stats' });
}
});
// Purge old data
router.post('/purge', requireAuth, (req, res) => {
try {
const { days } = req.body;
const daysToKeep = days || 30;
const messagesDeleted = messageQueries.deleteOldMessages.run(daysToKeep);
const positionsDeleted = positionQueries.deleteOldPositions.run(daysToKeep);
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(daysToKeep);
logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records`);
// Log activity
logActivity(
req.session.userId,
'purge_data',
`Purged data older than ${daysToKeep} days`,
req.ip
);
res.json({
success: true,
deleted: {
messages: messagesDeleted.changes,
positions: positionsDeleted.changes,
telemetry: telemetryDeleted.changes
}
});
} catch (error) {
logger.error('Error purging data:', error);
res.status(500).json({ error: 'Failed to purge data' });
}
});
// MQTT status
router.get('/mqtt/status', requireAuth, (req, res) => {
res.json({
connected: mqttClient.isConnected()
});
});
module.exports = router;
+51
View File
@@ -0,0 +1,51 @@
const readline = require('readline');
const { createUser } = require('../auth/auth');
const logger = require('../utils/logger');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
function question(query) {
return new Promise(resolve => rl.question(query, resolve));
}
async function main() {
console.log('\n=== Create New User ===\n');
try {
const username = await question('Enter username: ');
if (!username || username.length < 3) {
console.error('Username must be at least 3 characters long');
process.exit(1);
}
const password = await question('Enter password: ');
if (!password || password.length < 6) {
console.error('Password must be at least 6 characters long');
process.exit(1);
}
const confirmPassword = await question('Confirm password: ');
if (password !== confirmPassword) {
console.error('Passwords do not match');
process.exit(1);
}
await createUser(username, password);
console.log(`\nUser '${username}' created successfully!\n`);
process.exit(0);
} catch (error) {
console.error('Error creating user:', error.message);
process.exit(1);
} finally {
rl.close();
}
}
main();
+121
View File
@@ -0,0 +1,121 @@
const express = require('express');
const session = require('express-session');
const helmet = require('helmet');
const cors = require('cors');
const path = require('path');
const rateLimit = require('express-rate-limit');
const config = require('./config/config');
const logger = require('./utils/logger');
const apiRoutes = require('./routes/api');
const mqttClient = require('./mqtt/client');
const cronService = require('./services/cron');
// Initialize Express app
const app = express();
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "https:", "http:"],
connectSrc: ["'self'"]
}
}
}));
// CORS configuration
app.use(cors({
origin: config.server.nodeEnv === 'production' ? false : true,
credentials: true
}));
// Body parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session configuration
app.use(session(config.session));
// Rate limiting
const limiter = rateLimit({
windowMs: config.rateLimit.windowMs,
max: config.rateLimit.maxRequests,
message: 'Too many requests from this IP, please try again later.'
});
app.use('/api/', limiter);
// Serve static files
app.use(express.static(path.join(__dirname, '..', 'public')));
// API routes
app.use('/api', apiRoutes);
// Serve index.html for all other routes (SPA)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
// Error handler
app.use((err, req, res, next) => {
logger.error('Express error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// Start server
function start() {
const PORT = config.server.port;
// Connect to MQTT broker
logger.info('Starting MQTT client...');
mqttClient.connect();
// Start cron service
logger.info('Starting cron service...');
cronService.start();
// Start Express server
app.listen(PORT, () => {
logger.info(`Server running on http://localhost:${PORT}`);
logger.info(`Environment: ${config.server.nodeEnv}`);
});
}
// Graceful shutdown
process.on('SIGINT', () => {
logger.info('Shutting down gracefully...');
mqttClient.disconnect();
cronService.stop();
process.exit(0);
});
process.on('SIGTERM', () => {
logger.info('Shutting down gracefully...');
mqttClient.disconnect();
cronService.stop();
process.exit(0);
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught exception:', error);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection at:', promise, 'reason:', reason);
process.exit(1);
});
// Start the application
start();
module.exports = app;
+53
View File
@@ -0,0 +1,53 @@
const cron = require('node-cron');
const config = require('../config/config');
const { messageQueries, positionQueries, telemetryQueries } = require('../database/queries');
const logger = require('../utils/logger');
class CronService {
constructor() {
this.tasks = [];
}
start() {
// Schedule data purging
const purgeTask = cron.schedule(
config.dataRetention.purgeCronSchedule,
() => {
this.purgeOldData();
},
{
scheduled: true,
timezone: 'UTC'
}
);
this.tasks.push(purgeTask);
logger.info(`Cron job scheduled: Data purging at ${config.dataRetention.purgeCronSchedule}`);
}
purgeOldData() {
try {
const days = config.dataRetention.days;
logger.info(`Starting automatic data purge (keeping last ${days} days)`);
const messagesDeleted = messageQueries.deleteOldMessages.run(days);
const positionsDeleted = positionQueries.deleteOldPositions.run(days);
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(days);
logger.info(
`Data purge completed: ${messagesDeleted.changes} messages, ` +
`${positionsDeleted.changes} positions, ` +
`${telemetryDeleted.changes} telemetry records deleted`
);
} catch (error) {
logger.error('Error during automatic data purge:', error);
}
}
stop() {
this.tasks.forEach(task => task.stop());
logger.info('Cron service stopped');
}
}
module.exports = new CronService();
+58
View File
@@ -0,0 +1,58 @@
const winston = require('winston');
const path = require('path');
const fs = require('fs');
// Ensure logs directory exists
const logsDir = path.join(__dirname, '..', '..', 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Define log format
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.printf(({ timestamp, level, message, stack }) => {
if (stack) {
return `${timestamp} [${level.toUpperCase()}]: ${message}\n${stack}`;
}
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
})
);
// Create logger instance
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
transports: [
// Write all logs to console
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
logFormat
)
}),
// Write all logs to combined.log
new winston.transports.File({
filename: path.join(logsDir, 'combined.log'),
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// Write error logs to error.log
new winston.transports.File({
filename: path.join(logsDir, 'error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
});
// Create a stream object for Morgan HTTP logger
logger.stream = {
write: (message) => {
logger.info(message.trim());
}
};
module.exports = logger;