first commit

This commit is contained in:
2025-11-24 16:30:37 +00:00
commit 843e93a274
114 changed files with 25585 additions and 0 deletions

92
lib/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
},
];