first commit
This commit is contained in:
92
lib/auth.ts
Normal file
92
lib/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
// SQLite database connection
|
||||
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
|
||||
|
||||
function getDb() {
|
||||
return new Database(dbPath, { readonly: true });
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
secret: process.env.AUTH_SECRET || "fallback-secret-for-development",
|
||||
trustHost: true, // Allow any host (development & production)
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
authorize: async (credentials) => {
|
||||
if (!credentials?.username || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Query user from database
|
||||
const user = db.prepare('SELECT * FROM User WHERE username = ?')
|
||||
.get(credentials.username as string) as any;
|
||||
|
||||
db.close();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.passwordHash
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last login
|
||||
const updateDb = new Database(dbPath);
|
||||
updateDb.prepare('UPDATE User SET lastLoginAt = datetime(\'now\') WHERE id = ?')
|
||||
.run(user.id);
|
||||
updateDb.close();
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
email: user.email || undefined,
|
||||
role: user.role,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
authorized: async ({ auth }) => {
|
||||
return !!auth;
|
||||
},
|
||||
jwt: async ({ token, user }) => {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as any).role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session: async ({ session, token }) => {
|
||||
if (session.user) {
|
||||
(session.user as any).id = token.id;
|
||||
(session.user as any).role = token.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
83
lib/crypto-utils.ts
Normal file
83
lib/crypto-utils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Encryption utilities for sensitive data
|
||||
* Uses AES-256-GCM for encryption
|
||||
*/
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Get encryption key from environment
|
||||
*/
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.ENCRYPTION_KEY;
|
||||
if (!key || key.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be a 32-byte hex string (64 characters)');
|
||||
}
|
||||
return Buffer.from(key, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt text using AES-256-GCM
|
||||
* Returns base64 encoded string with format: iv:authTag:encrypted
|
||||
*/
|
||||
export function encrypt(text: string): string {
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Text to encrypt cannot be empty or null');
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine iv, authTag, and encrypted data
|
||||
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
|
||||
} catch (error) {
|
||||
console.error('[Crypto] Encryption failed:', error);
|
||||
throw new Error('Failed to encrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt text encrypted with encrypt()
|
||||
* Expects base64 string with format: iv:authTag:encrypted
|
||||
*/
|
||||
export function decrypt(encryptedText: string): string {
|
||||
try {
|
||||
const key = getEncryptionKey();
|
||||
const parts = encryptedText.split(':');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted text format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
const authTag = Buffer.from(parts[1], 'base64');
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('[Crypto] Decryption failed:', error);
|
||||
throw new Error('Failed to decrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random encryption key (32 bytes as hex string)
|
||||
*/
|
||||
export function generateEncryptionKey(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
532
lib/db.ts
Normal file
532
lib/db.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
// Database helper for SQLite operations
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
|
||||
const locationsDbPath = path.join(process.cwd(), 'data', 'locations.sqlite');
|
||||
|
||||
export function getDb() {
|
||||
const db = new Database(dbPath);
|
||||
// Enable WAL mode for better concurrency
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getLocationsDb() {
|
||||
const db = new Database(locationsDbPath);
|
||||
// Enable WAL mode for better concurrency
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
}
|
||||
|
||||
// Device operations
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
ownerId: string | null;
|
||||
isActive: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export const deviceDb = {
|
||||
findAll: (options?: { userId?: string }): Device[] => {
|
||||
const db = getDb();
|
||||
let query = 'SELECT * FROM Device WHERE isActive = 1';
|
||||
const params: any[] = [];
|
||||
|
||||
if (options?.userId) {
|
||||
query += ' AND ownerId = ?';
|
||||
params.push(options.userId);
|
||||
}
|
||||
|
||||
const devices = db.prepare(query).all(...params) as Device[];
|
||||
db.close();
|
||||
return devices;
|
||||
},
|
||||
|
||||
findById: (id: string): Device | null => {
|
||||
const db = getDb();
|
||||
const device = db.prepare('SELECT * FROM Device WHERE id = ?').get(id) as Device | undefined;
|
||||
db.close();
|
||||
return device || null;
|
||||
},
|
||||
|
||||
create: (device: { id: string; name: string; color: string; ownerId: string | null; description?: string; icon?: string }): Device => {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO Device (id, name, color, ownerId, isActive, description, icon, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?, datetime('now'), datetime('now'))
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
device.id,
|
||||
device.name,
|
||||
device.color,
|
||||
device.ownerId,
|
||||
device.description || null,
|
||||
device.icon || null
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM Device WHERE id = ?').get(device.id) as Device;
|
||||
db.close();
|
||||
return created;
|
||||
},
|
||||
|
||||
update: (id: string, data: { name?: string; color?: string; description?: string; icon?: string }): Device | null => {
|
||||
const db = getDb();
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push('name = ?');
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.color !== undefined) {
|
||||
updates.push('color = ?');
|
||||
values.push(data.color);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
values.push(data.description);
|
||||
}
|
||||
if (data.icon !== undefined) {
|
||||
updates.push('icon = ?');
|
||||
values.push(data.icon);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
db.close();
|
||||
return deviceDb.findById(id);
|
||||
}
|
||||
|
||||
updates.push('updatedAt = datetime(\'now\')');
|
||||
values.push(id);
|
||||
|
||||
const sql = `UPDATE Device SET ${updates.join(', ')} WHERE id = ?`;
|
||||
db.prepare(sql).run(...values);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM Device WHERE id = ?').get(id) as Device | undefined;
|
||||
db.close();
|
||||
return updated || null;
|
||||
},
|
||||
|
||||
delete: (id: string): boolean => {
|
||||
const db = getDb();
|
||||
const result = db.prepare('UPDATE Device SET isActive = 0, updatedAt = datetime(\'now\') WHERE id = ?').run(id);
|
||||
db.close();
|
||||
return result.changes > 0;
|
||||
},
|
||||
};
|
||||
|
||||
// User operations
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
passwordHash: string;
|
||||
role: string;
|
||||
parent_user_id: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export const userDb = {
|
||||
findAll: (options?: { excludeUsername?: string; parentUserId?: string }): User[] => {
|
||||
const db = getDb();
|
||||
let query = 'SELECT * FROM User';
|
||||
const params: any[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (options?.excludeUsername) {
|
||||
conditions.push('username != ?');
|
||||
params.push(options.excludeUsername);
|
||||
}
|
||||
|
||||
if (options?.parentUserId) {
|
||||
conditions.push('parent_user_id = ?');
|
||||
params.push(options.parentUserId);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
const users = params.length > 0
|
||||
? db.prepare(query).all(...params) as User[]
|
||||
: db.prepare(query).all() as User[];
|
||||
db.close();
|
||||
return users;
|
||||
},
|
||||
|
||||
findById: (id: string): User | null => {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM User WHERE id = ?').get(id) as User | undefined;
|
||||
db.close();
|
||||
return user || null;
|
||||
},
|
||||
|
||||
findByUsername: (username: string): User | null => {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM User WHERE username = ?').get(username) as User | undefined;
|
||||
db.close();
|
||||
return user || null;
|
||||
},
|
||||
|
||||
create: (user: { id: string; username: string; email: string | null; passwordHash: string; role: string; parent_user_id?: string | null }): User => {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO User (id, username, email, passwordHash, role, parent_user_id, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
user.id,
|
||||
user.username,
|
||||
user.email,
|
||||
user.passwordHash,
|
||||
user.role,
|
||||
user.parent_user_id || null
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM User WHERE id = ?').get(user.id) as User;
|
||||
db.close();
|
||||
return created;
|
||||
},
|
||||
|
||||
update: (id: string, data: { username?: string; email?: string | null; passwordHash?: string; role?: string }): User | null => {
|
||||
const db = getDb();
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.username !== undefined) {
|
||||
updates.push('username = ?');
|
||||
values.push(data.username);
|
||||
}
|
||||
if (data.email !== undefined) {
|
||||
updates.push('email = ?');
|
||||
values.push(data.email);
|
||||
}
|
||||
if (data.passwordHash !== undefined) {
|
||||
updates.push('passwordHash = ?');
|
||||
values.push(data.passwordHash);
|
||||
}
|
||||
if (data.role !== undefined) {
|
||||
updates.push('role = ?');
|
||||
values.push(data.role);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
db.close();
|
||||
return userDb.findById(id);
|
||||
}
|
||||
|
||||
updates.push('updatedAt = datetime(\'now\')');
|
||||
values.push(id);
|
||||
|
||||
const sql = `UPDATE User SET ${updates.join(', ')} WHERE id = ?`;
|
||||
db.prepare(sql).run(...values);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM User WHERE id = ?').get(id) as User | undefined;
|
||||
db.close();
|
||||
return updated || null;
|
||||
},
|
||||
|
||||
delete: (id: string): boolean => {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM User WHERE id = ?').run(id);
|
||||
db.close();
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of device IDs that a user is allowed to access
|
||||
* @param userId - The user's ID
|
||||
* @param role - The user's role (ADMIN, VIEWER)
|
||||
* @param username - The user's username (for super admin check)
|
||||
* @returns Array of device IDs the user can access
|
||||
*/
|
||||
getAllowedDeviceIds: (userId: string, role: string, username: string): string[] => {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
// Super admin (username === "admin") can see ALL devices
|
||||
if (username === 'admin') {
|
||||
const allDevices = db.prepare('SELECT id FROM Device WHERE isActive = 1').all() as { id: string }[];
|
||||
return allDevices.map(d => d.id);
|
||||
}
|
||||
|
||||
// VIEWER users see their parent user's devices
|
||||
if (role === 'VIEWER') {
|
||||
const user = db.prepare('SELECT parent_user_id FROM User WHERE id = ?').get(userId) as { parent_user_id: string | null } | undefined;
|
||||
if (user?.parent_user_id) {
|
||||
const devices = db.prepare('SELECT id FROM Device WHERE ownerId = ? AND isActive = 1').all(user.parent_user_id) as { id: string }[];
|
||||
return devices.map(d => d.id);
|
||||
}
|
||||
// If VIEWER has no parent, return empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
// Regular ADMIN users see only their own devices
|
||||
if (role === 'ADMIN') {
|
||||
const devices = db.prepare('SELECT id FROM Device WHERE ownerId = ? AND isActive = 1').all(userId) as { id: string }[];
|
||||
return devices.map(d => d.id);
|
||||
}
|
||||
|
||||
// Default: no access
|
||||
return [];
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Location operations (separate database for tracking data)
|
||||
export interface Location {
|
||||
id?: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timestamp: string;
|
||||
user_id: number;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
username: string | null;
|
||||
marker_label: string | null;
|
||||
display_time: string | null;
|
||||
chat_id: number;
|
||||
battery: number | null;
|
||||
speed: number | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface LocationFilters {
|
||||
username?: string;
|
||||
user_id?: number;
|
||||
timeRangeHours?: number;
|
||||
startTime?: string; // ISO string for custom range start
|
||||
endTime?: string; // ISO string for custom range end
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const locationDb = {
|
||||
/**
|
||||
* Insert a new location record (ignores duplicates)
|
||||
*/
|
||||
create: (location: Location): Location | null => {
|
||||
const db = getLocationsDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO Location (
|
||||
latitude, longitude, timestamp, user_id,
|
||||
first_name, last_name, username, marker_label,
|
||||
display_time, chat_id, battery, speed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
location.timestamp,
|
||||
location.user_id || 0,
|
||||
location.first_name || null,
|
||||
location.last_name || null,
|
||||
location.username || null,
|
||||
location.marker_label || null,
|
||||
location.display_time || null,
|
||||
location.chat_id || 0,
|
||||
location.battery !== undefined && location.battery !== null ? Number(location.battery) : null,
|
||||
location.speed !== undefined && location.speed !== null ? Number(location.speed) : null
|
||||
);
|
||||
|
||||
// If changes is 0, it was a duplicate and ignored
|
||||
if (result.changes === 0) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const created = db.prepare('SELECT * FROM Location WHERE id = ?').get(result.lastInsertRowid) as Location;
|
||||
db.close();
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk insert multiple locations (ignores duplicates, returns count of actually inserted)
|
||||
*/
|
||||
createMany: (locations: Location[]): number => {
|
||||
if (locations.length === 0) return 0;
|
||||
|
||||
const db = getLocationsDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO Location (
|
||||
latitude, longitude, timestamp, user_id,
|
||||
first_name, last_name, username, marker_label,
|
||||
display_time, chat_id, battery, speed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
let insertedCount = 0;
|
||||
const insertMany = db.transaction((locations: Location[]) => {
|
||||
for (const loc of locations) {
|
||||
const batteryValue = loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null;
|
||||
const speedValue = loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null;
|
||||
|
||||
// Debug log
|
||||
console.log('[DB Insert Debug]', {
|
||||
username: loc.username,
|
||||
speed_in: loc.speed,
|
||||
speed_out: speedValue,
|
||||
battery_in: loc.battery,
|
||||
battery_out: batteryValue
|
||||
});
|
||||
|
||||
const result = stmt.run(
|
||||
loc.latitude,
|
||||
loc.longitude,
|
||||
loc.timestamp,
|
||||
loc.user_id || 0,
|
||||
loc.first_name || null,
|
||||
loc.last_name || null,
|
||||
loc.username || null,
|
||||
loc.marker_label || null,
|
||||
loc.display_time || null,
|
||||
loc.chat_id || 0,
|
||||
batteryValue,
|
||||
speedValue
|
||||
);
|
||||
insertedCount += result.changes;
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(locations);
|
||||
db.close();
|
||||
return insertedCount;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find locations with filters
|
||||
*/
|
||||
findMany: (filters: LocationFilters = {}): Location[] => {
|
||||
const db = getLocationsDb();
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
// Filter by user_id (typically 0 for MQTT devices)
|
||||
if (filters.user_id !== undefined) {
|
||||
conditions.push('user_id = ?');
|
||||
params.push(filters.user_id);
|
||||
}
|
||||
|
||||
// Filter by username (device tracker ID)
|
||||
if (filters.username) {
|
||||
conditions.push('username = ?');
|
||||
params.push(filters.username);
|
||||
}
|
||||
|
||||
// Filter by time range - either custom range or quick filter
|
||||
if (filters.startTime && filters.endTime) {
|
||||
// Custom range: between startTime and endTime
|
||||
conditions.push('timestamp BETWEEN ? AND ?');
|
||||
params.push(filters.startTime, filters.endTime);
|
||||
} else if (filters.timeRangeHours) {
|
||||
// Quick filter: calculate cutoff in JavaScript for accuracy
|
||||
const cutoffTime = new Date(Date.now() - filters.timeRangeHours * 60 * 60 * 1000).toISOString();
|
||||
conditions.push('timestamp >= ?');
|
||||
params.push(cutoffTime);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const limit = filters.limit || 1000;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
const sql = `
|
||||
SELECT * FROM Location
|
||||
${whereClause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
const locations = db.prepare(sql).all(...params) as Location[];
|
||||
db.close();
|
||||
return locations;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of locations matching filters
|
||||
*/
|
||||
count: (filters: LocationFilters = {}): number => {
|
||||
const db = getLocationsDb();
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.user_id !== undefined) {
|
||||
conditions.push('user_id = ?');
|
||||
params.push(filters.user_id);
|
||||
}
|
||||
|
||||
if (filters.username) {
|
||||
conditions.push('username = ?');
|
||||
params.push(filters.username);
|
||||
}
|
||||
|
||||
// Filter by time range - either custom range or quick filter
|
||||
if (filters.startTime && filters.endTime) {
|
||||
// Custom range: between startTime and endTime
|
||||
conditions.push('timestamp BETWEEN ? AND ?');
|
||||
params.push(filters.startTime, filters.endTime);
|
||||
} else if (filters.timeRangeHours) {
|
||||
// Quick filter: calculate cutoff in JavaScript for accuracy
|
||||
const cutoffTime = new Date(Date.now() - filters.timeRangeHours * 60 * 60 * 1000).toISOString();
|
||||
conditions.push('timestamp >= ?');
|
||||
params.push(cutoffTime);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const sql = `SELECT COUNT(*) as count FROM Location ${whereClause}`;
|
||||
|
||||
const result = db.prepare(sql).get(...params) as { count: number };
|
||||
db.close();
|
||||
return result.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete locations older than specified hours
|
||||
* Returns number of deleted records
|
||||
*/
|
||||
deleteOlderThan: (hours: number): number => {
|
||||
const db = getLocationsDb();
|
||||
const result = db.prepare(`
|
||||
DELETE FROM Location
|
||||
WHERE timestamp < datetime('now', '-' || ? || ' hours')
|
||||
`).run(hours);
|
||||
db.close();
|
||||
return result.changes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get database stats
|
||||
*/
|
||||
getStats: (): { total: number; oldest: string | null; newest: string | null; sizeKB: number } => {
|
||||
const db = getLocationsDb();
|
||||
|
||||
const countResult = db.prepare('SELECT COUNT(*) as total FROM Location').get() as { total: number };
|
||||
const timeResult = db.prepare('SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM Location').get() as { oldest: string | null; newest: string | null };
|
||||
const sizeResult = db.prepare("SELECT page_count * page_size / 1024 as sizeKB FROM pragma_page_count(), pragma_page_size()").get() as { sizeKB: number };
|
||||
|
||||
db.close();
|
||||
|
||||
return {
|
||||
total: countResult.total,
|
||||
oldest: timeResult.oldest,
|
||||
newest: timeResult.newest,
|
||||
sizeKB: Math.round(sizeResult.sizeKB)
|
||||
};
|
||||
},
|
||||
};
|
||||
96
lib/demo-data.ts
Normal file
96
lib/demo-data.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Demo GPS data for landing page showcase
|
||||
* 3 devices moving through Munich with realistic routes
|
||||
*/
|
||||
|
||||
export interface DemoLocation {
|
||||
lat: number;
|
||||
lng: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DemoDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
route: DemoLocation[];
|
||||
}
|
||||
|
||||
// Device 1: Route through Munich city center (Marienplatz → Odeonsplatz → Englischer Garten)
|
||||
const route1: [number, number][] = [
|
||||
[48.1374, 11.5755], // Marienplatz
|
||||
[48.1388, 11.5764], // Kaufingerstraße
|
||||
[48.1402, 11.5775], // Stachus
|
||||
[48.1425, 11.5788], // Lenbachplatz
|
||||
[48.1448, 11.5798], // Odeonsplatz
|
||||
[48.1472, 11.5810], // Ludwigstraße
|
||||
[48.1495, 11.5823], // Siegestor
|
||||
[48.1520, 11.5840], // Englischer Garten Süd
|
||||
[48.1545, 11.5858], // Eisbach
|
||||
[48.1570, 11.5880], // Kleinhesseloher See
|
||||
];
|
||||
|
||||
// Device 2: Route to Olympiapark (Hauptbahnhof → Olympiapark)
|
||||
const route2: [number, number][] = [
|
||||
[48.1408, 11.5581], // Hauptbahnhof
|
||||
[48.1435, 11.5545], // Karlstraße
|
||||
[48.1465, 11.5510], // Brienner Straße
|
||||
[48.1495, 11.5475], // Königsplatz
|
||||
[48.1530, 11.5445], // Josephsplatz
|
||||
[48.1565, 11.5420], // Nymphenburg
|
||||
[48.1600, 11.5450], // Olympiapark Süd
|
||||
[48.1635, 11.5480], // Olympiaturm
|
||||
[48.1665, 11.5510], // Olympiastadion
|
||||
[48.1690, 11.5540], // BMW Welt
|
||||
];
|
||||
|
||||
// Device 3: Route along Isar river (Deutsches Museum → Flaucher)
|
||||
const route3: [number, number][] = [
|
||||
[48.1300, 11.5835], // Deutsches Museum
|
||||
[48.1275, 11.5850], // Ludwigsbrücke
|
||||
[48.1250, 11.5865], // Muffathalle
|
||||
[48.1225, 11.5880], // Wittelsbacherbrücke
|
||||
[48.1200, 11.5895], // Gärtnerplatz
|
||||
[48.1175, 11.5910], // Reichenbachbrücke
|
||||
[48.1150, 11.5925], // Isartor
|
||||
[48.1125, 11.5940], // Flaucher Nord
|
||||
[48.1100, 11.5955], // Flaucher
|
||||
[48.1075, 11.5970], // Tierpark Hellabrunn
|
||||
];
|
||||
|
||||
export const DEMO_DEVICES: DemoDevice[] = [
|
||||
{
|
||||
id: 'demo-1',
|
||||
name: 'City Tour',
|
||||
color: '#3b82f6', // Blue
|
||||
route: route1.map((coords, idx) => ({
|
||||
lat: coords[0],
|
||||
lng: coords[1],
|
||||
timestamp: new Date(Date.now() + idx * 1000).toISOString(),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'demo-2',
|
||||
name: 'Olympiapark Route',
|
||||
color: '#10b981', // Green
|
||||
route: route2.map((coords, idx) => ({
|
||||
lat: coords[0],
|
||||
lng: coords[1],
|
||||
timestamp: new Date(Date.now() + idx * 1000).toISOString(),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'demo-3',
|
||||
name: 'Isar Tour',
|
||||
color: '#f59e0b', // Orange
|
||||
route: route3.map((coords, idx) => ({
|
||||
lat: coords[0],
|
||||
lng: coords[1],
|
||||
timestamp: new Date(Date.now() + idx * 1000).toISOString(),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
// Calculate center of all routes for initial map view
|
||||
export const DEMO_MAP_CENTER: [number, number] = [48.1485, 11.5680]; // Munich center
|
||||
export const DEMO_MAP_ZOOM = 12;
|
||||
16
lib/devices.ts
Normal file
16
lib/devices.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Device } from '@/types/location';
|
||||
|
||||
export const DEVICES: Record<string, Device> = {
|
||||
'10': { id: '10', name: 'Device A', color: '#e74c3c' },
|
||||
'11': { id: '11', name: 'Device B', color: '#3498db' },
|
||||
};
|
||||
|
||||
export const DEFAULT_DEVICE: Device = {
|
||||
id: 'unknown',
|
||||
name: 'Unknown Device',
|
||||
color: '#95a5a6',
|
||||
};
|
||||
|
||||
export function getDevice(id: string): Device {
|
||||
return DEVICES[id] || DEFAULT_DEVICE;
|
||||
}
|
||||
57
lib/email-renderer.ts
Normal file
57
lib/email-renderer.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Renders React Email templates to HTML
|
||||
*/
|
||||
import { render } from '@react-email/components';
|
||||
import WelcomeEmail from '@/emails/welcome';
|
||||
import PasswordResetEmail from '@/emails/password-reset';
|
||||
import MqttCredentialsEmail from '@/emails/mqtt-credentials';
|
||||
|
||||
export interface WelcomeEmailData {
|
||||
username: string;
|
||||
loginUrl: string;
|
||||
temporaryPassword?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetEmailData {
|
||||
username: string;
|
||||
resetUrl: string;
|
||||
expiresIn?: string;
|
||||
}
|
||||
|
||||
export interface MqttCredentialsEmailData {
|
||||
deviceName: string;
|
||||
deviceId: string;
|
||||
mqttUsername: string;
|
||||
mqttPassword: string;
|
||||
brokerUrl: string;
|
||||
brokerHost?: string;
|
||||
brokerPort?: string;
|
||||
}
|
||||
|
||||
export async function renderWelcomeEmail(data: WelcomeEmailData): Promise<string> {
|
||||
return render(WelcomeEmail(data));
|
||||
}
|
||||
|
||||
export async function renderPasswordResetEmail(data: PasswordResetEmailData): Promise<string> {
|
||||
return render(PasswordResetEmail(data));
|
||||
}
|
||||
|
||||
export async function renderMqttCredentialsEmail(data: MqttCredentialsEmailData): Promise<string> {
|
||||
return render(MqttCredentialsEmail(data));
|
||||
}
|
||||
|
||||
export async function renderEmailTemplate(
|
||||
template: string,
|
||||
data: any
|
||||
): Promise<string> {
|
||||
switch (template) {
|
||||
case 'welcome':
|
||||
return renderWelcomeEmail(data);
|
||||
case 'password-reset':
|
||||
return renderPasswordResetEmail(data);
|
||||
case 'mqtt-credentials':
|
||||
return renderMqttCredentialsEmail(data);
|
||||
default:
|
||||
throw new Error(`Unknown email template: ${template}`);
|
||||
}
|
||||
}
|
||||
227
lib/email-service.ts
Normal file
227
lib/email-service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Email service for sending emails via SMTP
|
||||
* Supports hybrid configuration (DB + .env fallback)
|
||||
*/
|
||||
import nodemailer, { Transporter } from 'nodemailer';
|
||||
import { SMTPConfig } from './types/smtp';
|
||||
import { settingsDb } from './settings-db';
|
||||
import {
|
||||
renderWelcomeEmail,
|
||||
renderPasswordResetEmail,
|
||||
renderMqttCredentialsEmail,
|
||||
WelcomeEmailData,
|
||||
PasswordResetEmailData,
|
||||
MqttCredentialsEmailData,
|
||||
} from './email-renderer';
|
||||
|
||||
export class EmailService {
|
||||
/**
|
||||
* Cached SMTP transporter instance.
|
||||
* Set to null initially and reused for subsequent emails to avoid reconnecting.
|
||||
* Call resetTransporter() when SMTP configuration changes to invalidate cache.
|
||||
*/
|
||||
private transporter: Transporter | null = null;
|
||||
|
||||
/**
|
||||
* Get SMTP configuration (DB first, then .env fallback)
|
||||
*/
|
||||
private async getConfig(): Promise<SMTPConfig> {
|
||||
// Try database first
|
||||
const dbConfig = settingsDb.getSMTPConfig();
|
||||
if (dbConfig) {
|
||||
console.log('[EmailService] Using SMTP config from database');
|
||||
return dbConfig;
|
||||
}
|
||||
|
||||
// Fallback to environment variables
|
||||
console.log('[EmailService] Using SMTP config from environment');
|
||||
const envConfig: SMTPConfig = {
|
||||
host: process.env.SMTP_HOST || '',
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL || '',
|
||||
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
|
||||
},
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
// Validate env config
|
||||
if (!envConfig.host || !envConfig.auth.user || !envConfig.auth.pass) {
|
||||
throw new Error('SMTP configuration is incomplete. Please configure SMTP settings in admin panel or .env file.');
|
||||
}
|
||||
|
||||
return envConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure nodemailer transporter
|
||||
*/
|
||||
private async getTransporter(): Promise<Transporter> {
|
||||
if (this.transporter) {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
const config = await this.getConfig();
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: {
|
||||
user: config.auth.user,
|
||||
pass: config.auth.pass,
|
||||
},
|
||||
connectionTimeout: config.timeout || 10000,
|
||||
});
|
||||
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email
|
||||
*/
|
||||
private async sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
const transporter = await this.getTransporter();
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${config.from.name}" <${config.from.email}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
replyTo: config.replyTo,
|
||||
});
|
||||
|
||||
console.log('[EmailService] Email sent:', {
|
||||
messageId: info.messageId,
|
||||
to,
|
||||
subject,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[EmailService] Failed to send email:', error);
|
||||
throw new Error(`Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome email to new user
|
||||
*/
|
||||
async sendWelcomeEmail(data: WelcomeEmailData & { email: string }): Promise<void> {
|
||||
const html = await renderWelcomeEmail({
|
||||
username: data.username,
|
||||
loginUrl: data.loginUrl,
|
||||
temporaryPassword: data.temporaryPassword,
|
||||
});
|
||||
|
||||
await this.sendEmail(
|
||||
data.email,
|
||||
'Welcome to Location Tracker',
|
||||
html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async sendPasswordResetEmail(data: PasswordResetEmailData & { email: string }): Promise<void> {
|
||||
const html = await renderPasswordResetEmail({
|
||||
username: data.username,
|
||||
resetUrl: data.resetUrl,
|
||||
expiresIn: data.expiresIn || '1 hour',
|
||||
});
|
||||
|
||||
await this.sendEmail(
|
||||
data.email,
|
||||
'Password Reset Request - Location Tracker',
|
||||
html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send MQTT credentials email
|
||||
*/
|
||||
async sendMqttCredentialsEmail(data: MqttCredentialsEmailData & { email: string }): Promise<void> {
|
||||
const html = await renderMqttCredentialsEmail({
|
||||
deviceName: data.deviceName,
|
||||
deviceId: data.deviceId,
|
||||
mqttUsername: data.mqttUsername,
|
||||
mqttPassword: data.mqttPassword,
|
||||
brokerUrl: data.brokerUrl,
|
||||
brokerHost: data.brokerHost,
|
||||
brokerPort: data.brokerPort,
|
||||
});
|
||||
|
||||
await this.sendEmail(
|
||||
data.email,
|
||||
`MQTT Credentials - ${data.deviceName}`,
|
||||
html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SMTP connection
|
||||
* @throws Error with detailed message if connection fails
|
||||
*/
|
||||
async testConnection(config?: SMTPConfig): Promise<boolean> {
|
||||
try {
|
||||
let transporter: Transporter;
|
||||
|
||||
if (config) {
|
||||
// Test provided config
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: {
|
||||
user: config.auth.user,
|
||||
pass: config.auth.pass,
|
||||
},
|
||||
connectionTimeout: config.timeout || 10000,
|
||||
});
|
||||
} else {
|
||||
// Test current config
|
||||
transporter = await this.getTransporter();
|
||||
}
|
||||
|
||||
await transporter.verify();
|
||||
console.log('[EmailService] SMTP connection test successful');
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('[EmailService] SMTP connection test failed:', error);
|
||||
|
||||
// Provide more helpful error messages
|
||||
if (error.code === 'EAUTH') {
|
||||
throw new Error(
|
||||
'Authentication failed. For Gmail, use an App Password (not your regular password). ' +
|
||||
'Enable 2FA and generate an App Password at: https://myaccount.google.com/apppasswords'
|
||||
);
|
||||
} else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNECTION') {
|
||||
throw new Error('Connection timeout. Check your host, port, and firewall settings.');
|
||||
} else if (error.code === 'ESOCKET') {
|
||||
throw new Error('Connection failed. Verify your SMTP host and port are correct.');
|
||||
} else {
|
||||
throw new Error(error.message || 'SMTP connection test failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached transporter (call when SMTP config changes)
|
||||
*/
|
||||
resetTransporter(): void {
|
||||
this.transporter = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const emailService = new EmailService();
|
||||
239
lib/mosquitto-sync.ts
Normal file
239
lib/mosquitto-sync.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// Mosquitto configuration synchronization service
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { mqttCredentialDb, mqttAclRuleDb, mqttSyncStatusDb } from './mqtt-db';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
// Konfiguration aus Environment Variablen
|
||||
const PASSWORD_FILE = process.env.MOSQUITTO_PASSWORD_FILE || '/mosquitto/config/password.txt';
|
||||
const ACL_FILE = process.env.MOSQUITTO_ACL_FILE || '/mosquitto/config/acl.txt';
|
||||
const MOSQUITTO_CONTAINER = process.env.MOSQUITTO_CONTAINER_NAME || 'mosquitto';
|
||||
const ADMIN_USERNAME = process.env.MOSQUITTO_ADMIN_USERNAME || 'admin';
|
||||
const ADMIN_PASSWORD = process.env.MOSQUITTO_ADMIN_PASSWORD || 'admin';
|
||||
|
||||
/**
|
||||
* Hash ein Passwort im Mosquitto-kompatiblen Format (PBKDF2-SHA512)
|
||||
* Format: $7$<iterations>$<base64_salt>$<base64_hash>
|
||||
*
|
||||
* Mosquitto verwendet PBKDF2 mit SHA-512, 101 Iterationen (Standard),
|
||||
* 12-Byte Salt und 64-Byte Hash
|
||||
*/
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
try {
|
||||
// Mosquitto Standard-Parameter
|
||||
const iterations = 101;
|
||||
const saltLength = 12;
|
||||
const hashLength = 64;
|
||||
|
||||
// Generiere zufälligen Salt
|
||||
const salt = crypto.randomBytes(saltLength);
|
||||
|
||||
// PBKDF2 mit SHA-512
|
||||
const hash = await new Promise<Buffer>((resolve, reject) => {
|
||||
crypto.pbkdf2(password, salt, iterations, hashLength, 'sha512', (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
|
||||
// Base64-Kodierung (Standard Base64, nicht URL-safe)
|
||||
const saltBase64 = salt.toString('base64');
|
||||
const hashBase64 = hash.toString('base64');
|
||||
|
||||
// Mosquitto Format: $7$iterations$salt$hash
|
||||
// $7$ = PBKDF2-SHA512 Identifier
|
||||
const mosquittoHash = `$7$${iterations}$${saltBase64}$${hashBase64}`;
|
||||
|
||||
return mosquittoHash;
|
||||
} catch (error) {
|
||||
console.error('Failed to hash password:', error);
|
||||
throw new Error('Password hashing failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiere Mosquitto Password File Entry
|
||||
*/
|
||||
async function generatePasswordEntry(username: string, password: string): Promise<string> {
|
||||
const hash = await hashPassword(password);
|
||||
return `${username}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiere die Mosquitto Password Datei
|
||||
*/
|
||||
async function generatePasswordFile(): Promise<string> {
|
||||
let content = '';
|
||||
|
||||
// Füge Admin User hinzu
|
||||
const adminEntry = await generatePasswordEntry(ADMIN_USERNAME, ADMIN_PASSWORD);
|
||||
content += `# Admin user\n${adminEntry}\n\n`;
|
||||
|
||||
// Füge Device Credentials hinzu
|
||||
const credentials = mqttCredentialDb.findAllActive();
|
||||
if (credentials.length > 0) {
|
||||
content += '# Provisioned devices\n';
|
||||
for (const cred of credentials) {
|
||||
content += `${cred.mqtt_username}:${cred.mqtt_password_hash}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiere die Mosquitto ACL Datei
|
||||
*/
|
||||
function generateACLFile(): string {
|
||||
let content = '';
|
||||
|
||||
// Füge Admin ACL hinzu
|
||||
content += `# Admin user - full access\n`;
|
||||
content += `user ${ADMIN_USERNAME}\n`;
|
||||
content += `topic readwrite #\n\n`;
|
||||
|
||||
// Füge Device ACLs hinzu
|
||||
const rules = mqttAclRuleDb.findAll();
|
||||
|
||||
if (rules.length > 0) {
|
||||
content += '# Device permissions\n';
|
||||
|
||||
// Gruppiere Regeln nach device_id
|
||||
const rulesByDevice = rules.reduce((acc, rule) => {
|
||||
if (!acc[rule.device_id]) {
|
||||
acc[rule.device_id] = [];
|
||||
}
|
||||
acc[rule.device_id].push(rule);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof rules>);
|
||||
|
||||
// Schreibe ACL Regeln pro Device
|
||||
for (const [deviceId, deviceRules] of Object.entries(rulesByDevice)) {
|
||||
const credential = mqttCredentialDb.findByDeviceId(deviceId);
|
||||
if (!credential) continue;
|
||||
|
||||
content += `# Device: ${deviceId}\n`;
|
||||
content += `user ${credential.mqtt_username}\n`;
|
||||
|
||||
for (const rule of deviceRules) {
|
||||
content += `topic ${rule.permission} ${rule.topic_pattern}\n`;
|
||||
}
|
||||
|
||||
content += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibe Password File
|
||||
*/
|
||||
async function writePasswordFile(content: string): Promise<void> {
|
||||
const configDir = path.dirname(PASSWORD_FILE);
|
||||
|
||||
// Stelle sicher dass das Config-Verzeichnis existiert
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Schreibe Datei mit sicheren Permissions (nur owner kann lesen/schreiben)
|
||||
await fs.writeFile(PASSWORD_FILE, content, { mode: 0o600 });
|
||||
|
||||
console.log(`✓ Password file written: ${PASSWORD_FILE}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibe ACL File
|
||||
*/
|
||||
async function writeACLFile(content: string): Promise<void> {
|
||||
const configDir = path.dirname(ACL_FILE);
|
||||
|
||||
// Stelle sicher dass das Config-Verzeichnis existiert
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Schreibe Datei mit sicheren Permissions
|
||||
await fs.writeFile(ACL_FILE, content, { mode: 0o600 });
|
||||
|
||||
console.log(`✓ ACL file written: ${ACL_FILE}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload Mosquitto Konfiguration
|
||||
* Sendet SIGHUP an Mosquitto Container
|
||||
*/
|
||||
async function reloadMosquitto(): Promise<boolean> {
|
||||
try {
|
||||
// Sende SIGHUP an mosquitto container um config zu reloaden
|
||||
await execPromise(`docker exec ${MOSQUITTO_CONTAINER} kill -HUP 1`);
|
||||
console.log('✓ Mosquitto configuration reloaded');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('⚠ Could not reload Mosquitto automatically (requires docker socket permissions)');
|
||||
console.log('→ Changes saved to config files - restart Mosquitto to apply: docker-compose restart mosquitto');
|
||||
// Werfe keinen Fehler - Config-Dateien sind aktualisiert, werden beim nächsten Restart geladen
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync alle MQTT Konfigurationen nach Mosquitto
|
||||
*/
|
||||
export async function syncMosquittoConfig(): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
reloaded: boolean;
|
||||
}> {
|
||||
try {
|
||||
console.log('Starting Mosquitto sync...');
|
||||
|
||||
// Generiere Password File
|
||||
const passwordContent = await generatePasswordFile();
|
||||
await writePasswordFile(passwordContent);
|
||||
|
||||
// Generiere ACL File
|
||||
const aclContent = generateACLFile();
|
||||
await writeACLFile(aclContent);
|
||||
|
||||
// Versuche Mosquitto zu reloaden
|
||||
const reloaded = await reloadMosquitto();
|
||||
|
||||
// Markiere als synced
|
||||
mqttSyncStatusDb.markSynced();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: reloaded
|
||||
? 'Mosquitto configuration synced and reloaded successfully'
|
||||
: 'Mosquitto configuration synced. Restart Mosquitto to apply changes.',
|
||||
reloaded
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Failed to sync Mosquitto config:', error);
|
||||
|
||||
// Markiere Sync als fehlgeschlagen
|
||||
mqttSyncStatusDb.markSyncFailed(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync Mosquitto configuration: ${errorMessage}`,
|
||||
reloaded: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole Mosquitto Sync Status
|
||||
*/
|
||||
export function getMosquittoSyncStatus() {
|
||||
return mqttSyncStatusDb.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash ein Passwort für MQTT Credentials
|
||||
* Exportiere dies damit es in API Routes verwendet werden kann
|
||||
*/
|
||||
export { hashPassword };
|
||||
361
lib/mqtt-db.ts
Normal file
361
lib/mqtt-db.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
// MQTT credentials and ACL database operations
|
||||
import { getDb } from './db';
|
||||
|
||||
export interface MqttCredential {
|
||||
id: number;
|
||||
device_id: string;
|
||||
mqtt_username: string;
|
||||
mqtt_password_hash: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MqttAclRule {
|
||||
id: number;
|
||||
device_id: string;
|
||||
topic_pattern: string;
|
||||
permission: 'read' | 'write' | 'readwrite';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MqttSyncStatus {
|
||||
id: number;
|
||||
pending_changes: number;
|
||||
last_sync_at: string | null;
|
||||
last_sync_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const mqttCredentialDb = {
|
||||
/**
|
||||
* Finde alle MQTT Credentials
|
||||
*/
|
||||
findAll: (): MqttCredential[] => {
|
||||
const db = getDb();
|
||||
const credentials = db.prepare('SELECT * FROM mqtt_credentials').all() as MqttCredential[];
|
||||
db.close();
|
||||
return credentials;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finde MQTT Credential für ein Device
|
||||
*/
|
||||
findByDeviceId: (deviceId: string): MqttCredential | null => {
|
||||
const db = getDb();
|
||||
const credential = db.prepare('SELECT * FROM mqtt_credentials WHERE device_id = ?')
|
||||
.get(deviceId) as MqttCredential | undefined;
|
||||
db.close();
|
||||
return credential || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finde MQTT Credential nach Username
|
||||
*/
|
||||
findByUsername: (username: string): MqttCredential | null => {
|
||||
const db = getDb();
|
||||
const credential = db.prepare('SELECT * FROM mqtt_credentials WHERE mqtt_username = ?')
|
||||
.get(username) as MqttCredential | undefined;
|
||||
db.close();
|
||||
return credential || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Erstelle neue MQTT Credentials für ein Device
|
||||
*/
|
||||
create: (data: {
|
||||
device_id: string;
|
||||
mqtt_username: string;
|
||||
mqtt_password_hash: string;
|
||||
enabled?: number;
|
||||
}): MqttCredential => {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO mqtt_credentials (device_id, mqtt_username, mqtt_password_hash, enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
data.device_id,
|
||||
data.mqtt_username,
|
||||
data.mqtt_password_hash,
|
||||
data.enabled ?? 1
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM mqtt_credentials WHERE id = ?')
|
||||
.get(result.lastInsertRowid) as MqttCredential;
|
||||
|
||||
// Mark pending changes
|
||||
mqttSyncStatusDb.markPendingChanges();
|
||||
|
||||
db.close();
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Aktualisiere MQTT Credentials
|
||||
*/
|
||||
update: (deviceId: string, data: {
|
||||
mqtt_password_hash?: string;
|
||||
enabled?: number;
|
||||
}): MqttCredential | null => {
|
||||
const db = getDb();
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.mqtt_password_hash !== undefined) {
|
||||
updates.push('mqtt_password_hash = ?');
|
||||
values.push(data.mqtt_password_hash);
|
||||
}
|
||||
if (data.enabled !== undefined) {
|
||||
updates.push('enabled = ?');
|
||||
values.push(data.enabled);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
db.close();
|
||||
return mqttCredentialDb.findByDeviceId(deviceId);
|
||||
}
|
||||
|
||||
updates.push('updated_at = datetime(\'now\')');
|
||||
values.push(deviceId);
|
||||
|
||||
const sql = `UPDATE mqtt_credentials SET ${updates.join(', ')} WHERE device_id = ?`;
|
||||
db.prepare(sql).run(...values);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM mqtt_credentials WHERE device_id = ?')
|
||||
.get(deviceId) as MqttCredential | undefined;
|
||||
|
||||
// Mark pending changes
|
||||
mqttSyncStatusDb.markPendingChanges();
|
||||
|
||||
db.close();
|
||||
return updated || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lösche MQTT Credentials für ein Device
|
||||
*/
|
||||
delete: (deviceId: string): boolean => {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM mqtt_credentials WHERE device_id = ?').run(deviceId);
|
||||
|
||||
// Mark pending changes
|
||||
mqttSyncStatusDb.markPendingChanges();
|
||||
|
||||
db.close();
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finde alle aktiven MQTT Credentials (für Mosquitto Sync)
|
||||
*/
|
||||
findAllActive: (): MqttCredential[] => {
|
||||
const db = getDb();
|
||||
const credentials = db.prepare('SELECT * FROM mqtt_credentials WHERE enabled = 1')
|
||||
.all() as MqttCredential[];
|
||||
db.close();
|
||||
return credentials;
|
||||
},
|
||||
};
|
||||
|
||||
export const mqttAclRuleDb = {
|
||||
/**
|
||||
* Finde alle ACL Regeln für ein Device
|
||||
*/
|
||||
findByDeviceId: (deviceId: string): MqttAclRule[] => {
|
||||
const db = getDb();
|
||||
const rules = db.prepare('SELECT * FROM mqtt_acl_rules WHERE device_id = ?')
|
||||
.all(deviceId) as MqttAclRule[];
|
||||
db.close();
|
||||
return rules;
|
||||
},
|
||||
|
||||
/**
|
||||
* Erstelle eine neue ACL Regel
|
||||
*/
|
||||
create: (data: {
|
||||
device_id: string;
|
||||
topic_pattern: string;
|
||||
permission: 'read' | 'write' | 'readwrite';
|
||||
}): MqttAclRule => {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO mqtt_acl_rules (device_id, topic_pattern, permission)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
data.device_id,
|
||||
data.topic_pattern,
|
||||
data.permission
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
|
||||
.get(result.lastInsertRowid) as MqttAclRule;
|
||||
|
||||
// Mark pending changes
|
||||
mqttSyncStatusDb.markPendingChanges();
|
||||
|
||||
db.close();
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Erstelle Default ACL Regel für ein Device (owntracks/owntrack/[device-id]/#)
|
||||
*/
|
||||
createDefaultRule: (deviceId: string): MqttAclRule => {
|
||||
return mqttAclRuleDb.create({
|
||||
device_id: deviceId,
|
||||
topic_pattern: `owntracks/owntrack/${deviceId}/#`,
|
||||
permission: 'readwrite'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Aktualisiere eine ACL Regel
|
||||
*/
|
||||
update: (id: number, data: {
|
||||
topic_pattern?: string;
|
||||
permission?: 'read' | 'write' | 'readwrite';
|
||||
}): MqttAclRule | null => {
|
||||
const db = getDb();
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.topic_pattern !== undefined) {
|
||||
updates.push('topic_pattern = ?');
|
||||
values.push(data.topic_pattern);
|
||||
}
|
||||
if (data.permission !== undefined) {
|
||||
updates.push('permission = ?');
|
||||
values.push(data.permission);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
db.close();
|
||||
const existing = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
|
||||
.get(id) as MqttAclRule | undefined;
|
||||
return existing || null;
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
const sql = `UPDATE mqtt_acl_rules SET ${updates.join(', ')} WHERE id = ?`;
|
||||
db.prepare(sql).run(...values);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
|
||||
.get(id) as MqttAclRule | undefined;
|
||||
|
||||
// Mark pending changes
|
||||
mqttSyncStatusDb.markPendingChanges();
|
||||
|
||||
db.close();
|
||||
return updated || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lösche eine ACL Regel
|
||||
*/
|
||||
delete: (id: number): boolean => {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM mqtt_acl_rules WHERE id = ?').run(id);
|
||||
|
||||
// Mark pending changes
|
||||
mqttSyncStatusDb.markPendingChanges();
|
||||
|
||||
db.close();
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lösche alle ACL Regeln für ein Device
|
||||
*/
|
||||
deleteByDeviceId: (deviceId: string): number => {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM mqtt_acl_rules WHERE device_id = ?').run(deviceId);
|
||||
|
||||
// Mark pending changes
|
||||
if (result.changes > 0) {
|
||||
mqttSyncStatusDb.markPendingChanges();
|
||||
}
|
||||
|
||||
db.close();
|
||||
return result.changes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finde alle ACL Regeln (für Mosquitto Sync)
|
||||
*/
|
||||
findAll: (): MqttAclRule[] => {
|
||||
const db = getDb();
|
||||
const rules = db.prepare(`
|
||||
SELECT acl.* FROM mqtt_acl_rules acl
|
||||
INNER JOIN mqtt_credentials cred ON acl.device_id = cred.device_id
|
||||
WHERE cred.enabled = 1
|
||||
`).all() as MqttAclRule[];
|
||||
db.close();
|
||||
return rules;
|
||||
},
|
||||
};
|
||||
|
||||
export const mqttSyncStatusDb = {
|
||||
/**
|
||||
* Hole den aktuellen Sync Status
|
||||
*/
|
||||
get: (): MqttSyncStatus | null => {
|
||||
const db = getDb();
|
||||
const status = db.prepare('SELECT * FROM mqtt_sync_status WHERE id = 1')
|
||||
.get() as MqttSyncStatus | undefined;
|
||||
db.close();
|
||||
return status || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Markiere dass es ausstehende Änderungen gibt
|
||||
*/
|
||||
markPendingChanges: (): void => {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
UPDATE mqtt_sync_status
|
||||
SET pending_changes = pending_changes + 1,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
`).run();
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Markiere erfolgreichen Sync
|
||||
*/
|
||||
markSynced: (): void => {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
UPDATE mqtt_sync_status
|
||||
SET pending_changes = 0,
|
||||
last_sync_at = datetime('now'),
|
||||
last_sync_status = 'success',
|
||||
updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
`).run();
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Markiere fehlgeschlagenen Sync
|
||||
*/
|
||||
markSyncFailed: (error: string): void => {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
UPDATE mqtt_sync_status
|
||||
SET last_sync_at = datetime('now'),
|
||||
last_sync_status = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
`).run(`error: ${error}`);
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
225
lib/mqtt-subscriber.ts
Normal file
225
lib/mqtt-subscriber.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// MQTT Subscriber Service für OwnTracks Location Updates
|
||||
import mqtt from 'mqtt';
|
||||
import { locationDb, Location } from './db';
|
||||
|
||||
// OwnTracks Message Format
|
||||
interface OwnTracksMessage {
|
||||
_type: 'location' | 'transition' | 'waypoint' | 'lwt';
|
||||
tid?: string; // Tracker ID
|
||||
lat: number;
|
||||
lon: number;
|
||||
tst: number; // Timestamp (Unix epoch)
|
||||
batt?: number; // Battery level (0-100)
|
||||
vel?: number; // Velocity/Speed in km/h
|
||||
acc?: number; // Accuracy
|
||||
alt?: number; // Altitude
|
||||
cog?: number; // Course over ground
|
||||
t?: string; // Trigger (p=ping, c=region, b=beacon, u=manual, t=timer, v=monitoring)
|
||||
}
|
||||
|
||||
class MQTTSubscriber {
|
||||
private client: mqtt.MqttClient | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private isConnecting = false;
|
||||
|
||||
constructor(
|
||||
private brokerUrl: string,
|
||||
private username?: string,
|
||||
private password?: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Verbinde zum MQTT Broker und subscribiere Topics
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.isConnecting || this.client?.connected) {
|
||||
console.log('Already connecting or connected to MQTT broker');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
console.log(`Connecting to MQTT broker: ${this.brokerUrl}`);
|
||||
|
||||
const options: mqtt.IClientOptions = {
|
||||
clean: true,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 30000,
|
||||
};
|
||||
|
||||
if (this.username && this.password) {
|
||||
options.username = this.username;
|
||||
options.password = this.password;
|
||||
}
|
||||
|
||||
this.client = mqtt.connect(this.brokerUrl, options);
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
console.log('✓ Connected to MQTT broker');
|
||||
|
||||
// Subscribiere owntracks Topic (alle Devices) mit QoS 1
|
||||
this.client?.subscribe('owntracks/+/+', { qos: 1 }, (err) => {
|
||||
if (err) {
|
||||
console.error('Failed to subscribe to owntracks topic:', err);
|
||||
} else {
|
||||
console.log('✓ Subscribed to owntracks/+/+ with QoS 1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.client.on('message', (topic, message) => {
|
||||
this.handleMessage(topic, message);
|
||||
});
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
console.error('MQTT client error:', error);
|
||||
this.isConnecting = false;
|
||||
});
|
||||
|
||||
this.client.on('reconnect', () => {
|
||||
this.reconnectAttempts++;
|
||||
console.log(`Reconnecting to MQTT broker (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnect attempts reached, giving up');
|
||||
this.client?.end();
|
||||
}
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
console.log('MQTT connection closed');
|
||||
this.isConnecting = false;
|
||||
});
|
||||
|
||||
this.client.on('offline', () => {
|
||||
console.log('MQTT client offline');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeite eingehende MQTT Nachricht
|
||||
*/
|
||||
private handleMessage(topic: string, message: Buffer): void {
|
||||
try {
|
||||
// Parse Topic: owntracks/user/device
|
||||
const parts = topic.split('/');
|
||||
if (parts.length !== 3 || parts[0] !== 'owntracks') {
|
||||
console.log(`Ignoring non-owntracks topic: ${topic}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, user, device] = parts;
|
||||
|
||||
// Parse Message Payload
|
||||
const payload = JSON.parse(message.toString()) as OwnTracksMessage;
|
||||
|
||||
// Nur location messages verarbeiten
|
||||
if (payload._type !== 'location') {
|
||||
console.log(`Ignoring non-location message type: ${payload._type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Konvertiere zu Location Format
|
||||
const location: Location = {
|
||||
latitude: payload.lat,
|
||||
longitude: payload.lon,
|
||||
timestamp: new Date(payload.tst * 1000).toISOString(),
|
||||
user_id: 0, // MQTT Devices haben user_id 0
|
||||
username: device, // Device ID als username
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
marker_label: payload.tid || device,
|
||||
display_time: null,
|
||||
chat_id: 0,
|
||||
battery: payload.batt ?? null,
|
||||
speed: payload.vel ?? null,
|
||||
};
|
||||
|
||||
// Speichere in Datenbank
|
||||
const saved = locationDb.create(location);
|
||||
|
||||
if (saved) {
|
||||
console.log(`✓ Location saved: ${device} at (${payload.lat}, ${payload.lon})`);
|
||||
} else {
|
||||
console.log(`⚠ Duplicate location ignored: ${device}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process MQTT message:', error);
|
||||
console.error('Topic:', topic);
|
||||
console.error('Message:', message.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect vom MQTT Broker
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.client) {
|
||||
console.log('Disconnecting from MQTT broker');
|
||||
this.client.end();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check ob verbunden
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.client?.connected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole Client Status Info
|
||||
*/
|
||||
getStatus(): {
|
||||
connected: boolean;
|
||||
reconnectAttempts: number;
|
||||
brokerUrl: string;
|
||||
} {
|
||||
return {
|
||||
connected: this.isConnected(),
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
brokerUrl: this.brokerUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton Instance
|
||||
let mqttSubscriber: MQTTSubscriber | null = null;
|
||||
|
||||
/**
|
||||
* Initialisiere und starte MQTT Subscriber
|
||||
*/
|
||||
export function initMQTTSubscriber(): MQTTSubscriber {
|
||||
if (mqttSubscriber) {
|
||||
return mqttSubscriber;
|
||||
}
|
||||
|
||||
const brokerUrl = process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883';
|
||||
const username = process.env.MQTT_USERNAME;
|
||||
const password = process.env.MQTT_PASSWORD;
|
||||
|
||||
mqttSubscriber = new MQTTSubscriber(brokerUrl, username, password);
|
||||
mqttSubscriber.connect();
|
||||
|
||||
return mqttSubscriber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole existierende MQTT Subscriber Instance
|
||||
*/
|
||||
export function getMQTTSubscriber(): MQTTSubscriber | null {
|
||||
return mqttSubscriber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppe MQTT Subscriber
|
||||
*/
|
||||
export function stopMQTTSubscriber(): void {
|
||||
if (mqttSubscriber) {
|
||||
mqttSubscriber.disconnect();
|
||||
mqttSubscriber = null;
|
||||
}
|
||||
}
|
||||
96
lib/password-reset-db.ts
Normal file
96
lib/password-reset-db.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Database operations for password reset tokens
|
||||
*/
|
||||
import { getDb } from './db';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface PasswordResetToken {
|
||||
token: string;
|
||||
user_id: string;
|
||||
expires_at: string;
|
||||
used: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const passwordResetDb = {
|
||||
/**
|
||||
* Create a new password reset token
|
||||
* Returns token string
|
||||
*/
|
||||
create: (userId: string, expiresInHours: number = 1): string => {
|
||||
const db = getDb();
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO password_reset_tokens (token, user_id, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(token, userId, expiresAt);
|
||||
|
||||
db.close();
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get token by token string
|
||||
*/
|
||||
findByToken: (token: string): PasswordResetToken | null => {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare('SELECT * FROM password_reset_tokens WHERE token = ?')
|
||||
.get(token) as PasswordResetToken | undefined;
|
||||
db.close();
|
||||
return result || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate token (exists, not used, not expired)
|
||||
*/
|
||||
isValid: (token: string): boolean => {
|
||||
const resetToken = passwordResetDb.findByToken(token);
|
||||
if (!resetToken) return false;
|
||||
if (resetToken.used) return false;
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(resetToken.expires_at);
|
||||
if (now > expiresAt) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark token as used
|
||||
*/
|
||||
markUsed: (token: string): boolean => {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?')
|
||||
.run(token);
|
||||
db.close();
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete expired tokens (cleanup)
|
||||
*/
|
||||
deleteExpired: (): number => {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare("DELETE FROM password_reset_tokens WHERE expires_at < datetime('now')")
|
||||
.run();
|
||||
db.close();
|
||||
return result.changes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all tokens for a user
|
||||
*/
|
||||
deleteByUserId: (userId: string): number => {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?')
|
||||
.run(userId);
|
||||
db.close();
|
||||
return result.changes;
|
||||
},
|
||||
};
|
||||
102
lib/settings-db.ts
Normal file
102
lib/settings-db.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Database operations for app settings
|
||||
*/
|
||||
import { getDb } from './db';
|
||||
import { SMTPConfig } from './types/smtp';
|
||||
import { encrypt, decrypt } from './crypto-utils';
|
||||
|
||||
export interface Setting {
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const settingsDb = {
|
||||
/**
|
||||
* Get a setting by key
|
||||
*/
|
||||
get: (key: string): Setting | null => {
|
||||
const db = getDb();
|
||||
const setting = db
|
||||
.prepare('SELECT * FROM settings WHERE key = ?')
|
||||
.get(key) as Setting | undefined;
|
||||
db.close();
|
||||
return setting || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a setting value
|
||||
*/
|
||||
set: (key: string, value: string): void => {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = datetime('now')`
|
||||
).run(key, value);
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a setting
|
||||
*/
|
||||
delete: (key: string): boolean => {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM settings WHERE key = ?').run(key);
|
||||
db.close();
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get SMTP config from database (password decrypted)
|
||||
*/
|
||||
getSMTPConfig: (): SMTPConfig | null => {
|
||||
const setting = settingsDb.get('smtp_config');
|
||||
if (!setting) return null;
|
||||
|
||||
try {
|
||||
const config = JSON.parse(setting.value) as SMTPConfig;
|
||||
|
||||
// Decrypt password if present
|
||||
if (config.auth?.pass) {
|
||||
try {
|
||||
config.auth.pass = decrypt(config.auth.pass);
|
||||
} catch (decryptError) {
|
||||
console.error('[SettingsDB] Failed to decrypt password:', decryptError);
|
||||
throw decryptError;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('[SettingsDB] Failed to parse SMTP config:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save SMTP config to database (password encrypted)
|
||||
*/
|
||||
setSMTPConfig: (config: SMTPConfig): void => {
|
||||
// Encrypt password before saving
|
||||
let encryptedPass: string;
|
||||
try {
|
||||
encryptedPass = encrypt(config.auth.pass);
|
||||
} catch (encryptError) {
|
||||
console.error('[SettingsDB] Failed to encrypt password:', encryptError);
|
||||
throw encryptError;
|
||||
}
|
||||
|
||||
const configToSave = {
|
||||
...config,
|
||||
auth: {
|
||||
...config.auth,
|
||||
pass: encryptedPass,
|
||||
},
|
||||
};
|
||||
|
||||
settingsDb.set('smtp_config', JSON.stringify(configToSave));
|
||||
},
|
||||
};
|
||||
36
lib/startup.ts
Normal file
36
lib/startup.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Startup-Script für Server-Side Services
|
||||
// Wird beim Start der Next.js App ausgeführt
|
||||
|
||||
import { initMQTTSubscriber } from './mqtt-subscriber';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialisiere alle Server-Side Services
|
||||
*/
|
||||
export function initializeServices() {
|
||||
// Verhindere mehrfache Initialisierung
|
||||
if (initialized) {
|
||||
console.log('Services already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Initializing server-side services...');
|
||||
|
||||
try {
|
||||
// Starte MQTT Subscriber nur wenn MQTT_BROKER_URL konfiguriert ist
|
||||
if (process.env.MQTT_BROKER_URL) {
|
||||
console.log('Starting MQTT subscriber...');
|
||||
initMQTTSubscriber();
|
||||
console.log('✓ MQTT subscriber started');
|
||||
} else {
|
||||
console.log('⚠ MQTT_BROKER_URL not configured, skipping MQTT subscriber');
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
console.log('✓ All services initialized');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize services:', error);
|
||||
// Werfe keinen Fehler - App sollte trotzdem starten können
|
||||
}
|
||||
}
|
||||
48
lib/types/smtp.ts
Normal file
48
lib/types/smtp.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* SMTP Configuration types
|
||||
*/
|
||||
|
||||
export interface SMTPConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: {
|
||||
user: string;
|
||||
pass: string; // Encrypted in DB
|
||||
};
|
||||
from: {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
replyTo?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface SMTPConfigResponse {
|
||||
config: SMTPConfig | null;
|
||||
source: 'database' | 'env';
|
||||
}
|
||||
|
||||
export interface SMTPTestRequest {
|
||||
config: SMTPConfig;
|
||||
testEmail: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
name: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const EMAIL_TEMPLATES: EmailTemplate[] = [
|
||||
{
|
||||
name: 'welcome',
|
||||
subject: 'Welcome to Location Tracker',
|
||||
description: 'Sent when a new user is created',
|
||||
},
|
||||
{
|
||||
name: 'password-reset',
|
||||
subject: 'Password Reset Request',
|
||||
description: 'Sent when user requests password reset',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user