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

View File

@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { renderEmailTemplate } from '@/lib/email-renderer';
/**
* GET /api/admin/emails/preview?template=welcome
* Render email template with sample data for preview
*/
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const template = searchParams.get('template');
if (!template) {
return NextResponse.json(
{ error: 'Template parameter is required' },
{ status: 400 }
);
}
// Sample data for each template
const sampleData: Record<string, any> = {
welcome: {
username: 'John Doe',
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
temporaryPassword: 'TempPass123!',
},
'password-reset': {
username: 'John Doe',
resetUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=sample-token-123`,
expiresIn: '1 hour',
},
};
if (!sampleData[template]) {
return NextResponse.json(
{ error: `Unknown template: ${template}` },
{ status: 400 }
);
}
const html = await renderEmailTemplate(template, sampleData[template]);
return new NextResponse(html, {
headers: { 'Content-Type': 'text/html' },
});
} catch (error) {
console.error('[API] Email preview failed:', error);
return NextResponse.json(
{ error: 'Failed to render email template' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
// Simple rate limiting (in-memory)
const rateLimitMap = new Map<string, number[]>();
const RATE_LIMIT = 5; // max requests
const RATE_WINDOW = 60 * 1000; // per minute
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const requests = rateLimitMap.get(ip) || [];
// Filter out old requests
const recentRequests = requests.filter(time => now - time < RATE_WINDOW);
if (recentRequests.length >= RATE_LIMIT) {
return false;
}
recentRequests.push(now);
rateLimitMap.set(ip, recentRequests);
return true;
}
/**
* POST /api/admin/emails/send-test
* Send test email with specific template
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many requests. Please wait a minute.' },
{ status: 429 }
);
}
const body = await request.json();
const { template, email } = body;
if (!template || !email) {
return NextResponse.json(
{ error: 'Template and email are required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
// Send appropriate template
switch (template) {
case 'welcome':
await emailService.sendWelcomeEmail({
email,
username: 'Test User',
loginUrl: `${baseUrl}/login`,
temporaryPassword: 'TempPass123!',
});
break;
case 'password-reset':
await emailService.sendPasswordResetEmail({
email,
username: 'Test User',
resetUrl: `${baseUrl}/reset-password?token=sample-token-123`,
expiresIn: '1 hour',
});
break;
default:
return NextResponse.json(
{ error: `Unknown template: ${template}` },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
message: `Test email sent to ${email}`,
});
} catch (error) {
console.error('[API] Send test email failed:', error);
return NextResponse.json(
{ error: `Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,149 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { settingsDb } from '@/lib/settings-db';
import { emailService } from '@/lib/email-service';
import { SMTPConfig, SMTPConfigResponse } from '@/lib/types/smtp';
/**
* GET /api/admin/settings/smtp
* Returns current SMTP configuration (password masked)
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const dbConfig = settingsDb.getSMTPConfig();
let response: SMTPConfigResponse;
if (dbConfig) {
// Mask password
const maskedConfig = {
...dbConfig,
auth: {
...dbConfig.auth,
pass: '***',
},
};
response = { config: maskedConfig, source: 'database' };
} else {
// Check if env config exists
const hasEnvConfig =
process.env.SMTP_HOST &&
process.env.SMTP_USER &&
process.env.SMTP_PASS;
if (hasEnvConfig) {
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: '***',
},
from: {
email: process.env.SMTP_FROM_EMAIL || '',
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
},
replyTo: process.env.SMTP_REPLY_TO,
timeout: 10000,
};
response = { config: envConfig, source: 'env' };
} else {
response = { config: null, source: 'env' };
}
}
return NextResponse.json(response);
} catch (error) {
console.error('[API] Failed to get SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to get SMTP configuration' },
{ status: 500 }
);
}
}
/**
* POST /api/admin/settings/smtp
* Save SMTP configuration to database
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const config = body.config as SMTPConfig;
// Trim whitespace from credentials to prevent auth errors
if (config.host) config.host = config.host.trim();
if (config.auth?.user) config.auth.user = config.auth.user.trim();
if (config.auth?.pass) config.auth.pass = config.auth.pass.trim();
if (config.from?.email) config.from.email = config.from.email.trim();
// Validation
if (!config.host || !config.port || !config.auth?.user || !config.auth?.pass) {
return NextResponse.json(
{ error: 'Missing required SMTP configuration fields' },
{ status: 400 }
);
}
if (config.port < 1 || config.port > 65535) {
return NextResponse.json(
{ error: 'Port must be between 1 and 65535' },
{ status: 400 }
);
}
// Save to database (password will be encrypted)
settingsDb.setSMTPConfig(config);
// Reset the cached transporter to use new config
emailService.resetTransporter();
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Failed to save SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to save SMTP configuration' },
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/settings/smtp
* Reset to environment config
*/
export async function DELETE() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
settingsDb.delete('smtp_config');
// Reset the cached transporter to use env config
emailService.resetTransporter();
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Failed to delete SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to reset SMTP configuration' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
import { SMTPConfig } from '@/lib/types/smtp';
/**
* POST /api/admin/settings/smtp/test
* Test SMTP configuration by sending a test email
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { config, testEmail } = body as { config?: SMTPConfig; testEmail: string };
if (!testEmail) {
return NextResponse.json(
{ error: 'Test email address is required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(testEmail)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Test connection
try {
await emailService.testConnection(config);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'SMTP connection failed. Please check your settings.'
},
{ status: 500 }
);
}
// Send test email
try {
await emailService.sendWelcomeEmail({
email: testEmail,
username: 'Test User',
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
temporaryPassword: undefined,
});
return NextResponse.json({
success: true,
message: `Test email sent successfully to ${testEmail}`,
});
} catch (sendError) {
console.error('[API] Test email send failed:', sendError);
return NextResponse.json(
{
error: `Email send failed: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
},
{ status: 500 }
);
}
} catch (error) {
console.error('[API] SMTP test failed:', error);
return NextResponse.json(
{ error: 'SMTP test failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import { passwordResetDb } from '@/lib/password-reset-db';
import { emailService } from '@/lib/email-service';
/**
* POST /api/auth/forgot-password
* Request password reset email
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Find user by email
const users = userDb.findAll();
const user = users.find(u => u.email?.toLowerCase() === email.toLowerCase());
// SECURITY: Always return success to prevent user enumeration
// Even if user doesn't exist, return success but don't send email
if (!user) {
console.log('[ForgotPassword] Email not found, but returning success (security)');
return NextResponse.json({
success: true,
message: 'If an account exists with this email, a password reset link has been sent.',
});
}
// Create password reset token
const token = passwordResetDb.create(user.id, 1); // 1 hour expiry
// Send password reset email
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
try {
await emailService.sendPasswordResetEmail({
email: user.email!,
username: user.username,
resetUrl,
expiresIn: '1 hour',
});
console.log('[ForgotPassword] Password reset email sent to:', user.email);
} catch (emailError) {
console.error('[ForgotPassword] Failed to send email:', emailError);
// Don't fail the request if email fails - log and continue
}
return NextResponse.json({
success: true,
message: 'If an account exists with this email, a password reset link has been sent.',
});
} catch (error) {
console.error('[ForgotPassword] Error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again later.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,121 @@
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { emailService } from '@/lib/email-service';
/**
* POST /api/auth/register
* Public user registration endpoint
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { username, email, password } = body;
// Validation
if (!username || !email || !password) {
return NextResponse.json(
{ error: 'Missing required fields: username, email, password' },
{ status: 400 }
);
}
// Username validation (at least 3 characters, alphanumeric + underscore)
if (username.length < 3) {
return NextResponse.json(
{ error: 'Username must be at least 3 characters long' },
{ status: 400 }
);
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return NextResponse.json(
{ error: 'Username can only contain letters, numbers, and underscores' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Password validation (at least 6 characters)
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters long' },
{ status: 400 }
);
}
// Check if username already exists
const existingUser = userDb.findByUsername(username);
if (existingUser) {
return NextResponse.json(
{ error: 'Username already taken' },
{ status: 409 }
);
}
// Check if email already exists
const allUsers = userDb.findAll();
const emailExists = allUsers.find(u => u.email?.toLowerCase() === email.toLowerCase());
if (emailExists) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user with VIEWER role (new users get viewer access by default)
const user = userDb.create({
id: randomUUID(),
username,
email,
passwordHash,
role: 'VIEWER', // New registrations get VIEWER role
});
console.log('[Register] New user registered:', username);
// Send welcome email (don't fail registration if email fails)
try {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
await emailService.sendWelcomeEmail({
email,
username,
loginUrl: `${baseUrl}/login`,
});
console.log('[Register] Welcome email sent to:', email);
} catch (emailError) {
console.error('[Register] Failed to send welcome email:', emailError);
// Don't fail registration if email fails
}
// Remove password hash from response
const { passwordHash: _, ...safeUser } = user;
return NextResponse.json(
{
success: true,
message: 'Account created successfully',
user: safeUser,
},
{ status: 201 }
);
} catch (error) {
console.error('[Register] Error:', error);
return NextResponse.json(
{ error: 'Registration failed. Please try again later.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import { passwordResetDb } from '@/lib/password-reset-db';
import bcrypt from 'bcryptjs';
/**
* POST /api/auth/reset-password
* Reset password with token
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { token, newPassword } = body;
if (!token || !newPassword) {
return NextResponse.json(
{ error: 'Token and new password are required' },
{ status: 400 }
);
}
// Password validation
if (newPassword.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Validate token
if (!passwordResetDb.isValid(token)) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Get token details
const resetToken = passwordResetDb.findByToken(token);
if (!resetToken) {
return NextResponse.json(
{ error: 'Invalid reset token' },
{ status: 400 }
);
}
// Get user
const user = userDb.findById(resetToken.user_id);
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Hash new password
const passwordHash = await bcrypt.hash(newPassword, 10);
// Update user password
userDb.update(user.id, { passwordHash });
// Mark token as used
passwordResetDb.markUsed(token);
console.log('[ResetPassword] Password reset successful for user:', user.username);
return NextResponse.json({
success: true,
message: 'Password has been reset successfully',
});
} catch (error) {
console.error('[ResetPassword] Error:', error);
return NextResponse.json(
{ error: 'Failed to reset password' },
{ status: 500 }
);
}
}
/**
* GET /api/auth/reset-password?token=xxx
* Validate reset token (for checking if link is still valid)
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.json(
{ error: 'Token is required' },
{ status: 400 }
);
}
const isValid = passwordResetDb.isValid(token);
return NextResponse.json({ valid: isValid });
} catch (error) {
console.error('[ResetPassword] Validation error:', error);
return NextResponse.json(
{ error: 'Failed to validate token' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,129 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { deviceDb } from "@/lib/db";
// GET /api/devices/[id] - Get single device
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const device = deviceDb.findById(id);
if (!device) {
return NextResponse.json({ error: "Device not found" }, { status: 404 });
}
return NextResponse.json({
device: {
id: device.id,
name: device.name,
color: device.color,
isActive: device.isActive === 1,
createdAt: device.createdAt,
updatedAt: device.updatedAt,
description: device.description,
icon: device.icon,
}
});
} catch (error) {
console.error("Error fetching device:", error);
return NextResponse.json(
{ error: "Failed to fetch device" },
{ status: 500 }
);
}
}
// PATCH /api/devices/[id] - Update device (ADMIN only)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only ADMIN can update devices
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const device = deviceDb.findById(id);
if (!device) {
return NextResponse.json({ error: "Device not found" }, { status: 404 });
}
const body = await request.json();
const { name, color, description, icon } = body;
const updated = deviceDb.update(id, {
name,
color,
description,
icon,
});
if (!updated) {
return NextResponse.json({ error: "Failed to update device" }, { status: 500 });
}
return NextResponse.json({ device: updated });
} catch (error) {
console.error("Error updating device:", error);
return NextResponse.json(
{ error: "Failed to update device" },
{ status: 500 }
);
}
}
// DELETE /api/devices/[id] - Soft delete device (ADMIN only)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only ADMIN can delete devices
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const device = deviceDb.findById(id);
if (!device) {
return NextResponse.json({ error: "Device not found" }, { status: 404 });
}
const success = deviceDb.delete(id);
if (!success) {
return NextResponse.json({ error: "Failed to delete device" }, { status: 500 });
}
return NextResponse.json({ message: "Device deleted successfully" });
} catch (error) {
console.error("Error deleting device:", error);
return NextResponse.json(
{ error: "Failed to delete device" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { deviceDb, userDb } from "@/lib/db";
// GET /api/devices/public - Authenticated endpoint for device names and colors
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as any).id;
const role = (session.user as any).role;
const username = session.user.name || '';
// Get list of device IDs the user is allowed to access
const allowedDeviceIds = userDb.getAllowedDeviceIds(userId, role, username);
// Fetch all active devices
const allDevices = deviceDb.findAll();
// Filter to only devices the user can access
const userDevices = allDevices.filter(device =>
allowedDeviceIds.includes(device.id)
);
// Return only public information (id, name, color)
const publicDevices = userDevices.map((device) => ({
id: device.id,
name: device.name,
color: device.color,
}));
return NextResponse.json({ devices: publicDevices });
} catch (error) {
console.error("Error fetching public devices:", error);
return NextResponse.json(
{ error: "Failed to fetch devices" },
{ status: 500 }
);
}
}

120
app/api/devices/route.ts Normal file
View File

@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { deviceDb, locationDb, userDb } from "@/lib/db";
// GET /api/devices - List all devices (from database)
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get devices from database (filtered by user ownership)
const userId = (session.user as any).id;
let targetUserId = userId;
// If user is a VIEWER with a parent, show parent's devices instead
const currentUser = userDb.findById(userId);
if (currentUser && currentUser.role === 'VIEWER' && currentUser.parent_user_id) {
targetUserId = currentUser.parent_user_id;
console.log(`[Devices] VIEWER ${currentUser.username} accessing parent's devices (parent_id: ${targetUserId})`);
}
const devices = deviceDb.findAll({ userId: targetUserId });
// Fetch location data from local SQLite cache (24h history)
const allLocations = locationDb.findMany({
user_id: 0, // MQTT devices only
timeRangeHours: 24, // Last 24 hours
limit: 10000,
});
// Merge devices with latest location data
const devicesWithLocation = devices.map((device) => {
// Find all locations for this device
const deviceLocations = allLocations.filter((loc) => loc.username === device.id);
// Get latest location (first one, already sorted by timestamp DESC)
const latestLocation = deviceLocations[0] || null;
return {
id: device.id,
name: device.name,
color: device.color,
isActive: device.isActive === 1,
createdAt: device.createdAt,
updatedAt: device.updatedAt,
description: device.description,
icon: device.icon,
latestLocation: latestLocation,
_count: {
locations: deviceLocations.length,
},
};
});
return NextResponse.json({ devices: devicesWithLocation });
} catch (error) {
console.error("Error fetching devices:", error);
return NextResponse.json(
{ error: "Failed to fetch devices" },
{ status: 500 }
);
}
}
// POST /api/devices - Create new device (ADMIN only)
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only ADMIN can create devices
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { id, name, color, description, icon } = body;
// Validation
if (!id || !name || !color) {
return NextResponse.json(
{ error: "Missing required fields: id, name, color" },
{ status: 400 }
);
}
// Check if device with this ID already exists
const existing = deviceDb.findById(id);
if (existing) {
return NextResponse.json(
{ error: "Device with this ID already exists" },
{ status: 409 }
);
}
// Create device
const device = deviceDb.create({
id,
name,
color,
ownerId: (session.user as any).id,
description,
icon,
});
return NextResponse.json({ device }, { status: 201 });
} catch (error) {
console.error("Error creating device:", error);
return NextResponse.json(
{ error: "Failed to create device" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { locationDb } from '@/lib/db';
/**
* POST /api/locations/cleanup (ADMIN only)
*
* Delete old location records and optimize database
*
* Body:
* {
* "retentionHours": 168 // 7 days default
* }
*/
export async function POST(request: NextRequest) {
try {
// Super admin only (username "admin")
const session = await auth();
const username = session?.user?.name || '';
if (!session?.user || username !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const retentionHours = body.retentionHours || 168; // Default: 7 days
// Validate retention period
if (retentionHours <= 0 || retentionHours > 8760) { // Max 1 year
return NextResponse.json(
{ error: 'Invalid retention period. Must be between 1 and 8760 hours (1 year)' },
{ status: 400 }
);
}
// Get stats before cleanup
const statsBefore = locationDb.getStats();
// Delete old records
const deletedCount = locationDb.deleteOlderThan(retentionHours);
// Get stats after cleanup
const statsAfter = locationDb.getStats();
return NextResponse.json({
success: true,
deleted: deletedCount,
retentionHours,
retentionDays: Math.round(retentionHours / 24),
before: {
total: statsBefore.total,
sizeKB: statsBefore.sizeKB,
oldest: statsBefore.oldest,
newest: statsBefore.newest,
},
after: {
total: statsAfter.total,
sizeKB: statsAfter.sizeKB,
oldest: statsAfter.oldest,
newest: statsAfter.newest,
},
freedKB: statsBefore.sizeKB - statsAfter.sizeKB,
});
} catch (error) {
console.error('Cleanup error:', error);
return NextResponse.json(
{
error: 'Failed to cleanup locations',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { locationDb, Location } from '@/lib/db';
/**
* POST /api/locations/ingest
*
* Endpoint for n8n to push location data to local SQLite cache.
* This is called AFTER n8n stores the data in NocoDB.
*
* Expected payload (single location or array):
* {
* "latitude": 48.1351,
* "longitude": 11.5820,
* "timestamp": "2024-01-15T10:30:00Z",
* "user_id": 0,
* "username": "10",
* "marker_label": "Device A",
* "battery": 85,
* "speed": 2.5,
* ...
* }
*
* Security: Add API key validation in production!
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Support both single location and array of locations
const locations = Array.isArray(body) ? body : [body];
if (locations.length === 0) {
return NextResponse.json(
{ error: 'No location data provided' },
{ status: 400 }
);
}
// Debug logging for speed and battery values
console.log('[Ingest Debug] Received locations:', locations.map(loc => ({
username: loc.username,
speed: loc.speed,
speed_type: typeof loc.speed,
battery: loc.battery,
battery_type: typeof loc.battery
})));
// Validate required fields
for (const loc of locations) {
if (!loc.latitude || !loc.longitude || !loc.timestamp) {
return NextResponse.json(
{ error: 'Missing required fields: latitude, longitude, timestamp' },
{ status: 400 }
);
}
}
// Insert into SQLite
let insertedCount = 0;
if (locations.length === 1) {
locationDb.create(locations[0] as Location);
insertedCount = 1;
} else {
insertedCount = locationDb.createMany(locations as Location[]);
}
return NextResponse.json({
success: true,
inserted: insertedCount,
message: `Successfully stored ${insertedCount} location(s)`
});
} catch (error) {
console.error('Location ingest error:', error);
return NextResponse.json(
{
error: 'Failed to store location data',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
/**
* GET /api/locations/ingest/stats
*
* Get database statistics (for debugging)
*/
export async function GET() {
try {
const stats = locationDb.getStats();
return NextResponse.json(stats);
} catch (error) {
console.error('Stats error:', error);
return NextResponse.json(
{ error: 'Failed to get stats' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { getLocationsDb } from '@/lib/db';
/**
* POST /api/locations/optimize (ADMIN only)
*
* Optimize database by running VACUUM and ANALYZE
* This reclaims unused space and updates query planner statistics
*/
export async function POST() {
try {
// Super admin only (username "admin")
const session = await auth();
const username = session?.user?.name || '';
if (!session?.user || username !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const db = getLocationsDb();
// Get size before optimization
const sizeBefore = db.prepare(
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
).get() as { sizeMB: number };
// Run VACUUM to reclaim space
db.exec('VACUUM');
// Run ANALYZE to update query planner statistics
db.exec('ANALYZE');
// Get size after optimization
const sizeAfter = db.prepare(
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
).get() as { sizeMB: number };
db.close();
const freedMB = sizeBefore.sizeMB - sizeAfter.sizeMB;
return NextResponse.json({
success: true,
before: {
sizeMB: Math.round(sizeBefore.sizeMB * 100) / 100,
},
after: {
sizeMB: Math.round(sizeAfter.sizeMB * 100) / 100,
},
freedMB: Math.round(freedMB * 100) / 100,
});
} catch (error) {
console.error('Optimize error:', error);
return NextResponse.json(
{
error: 'Failed to optimize database',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

230
app/api/locations/route.ts Normal file
View File

@@ -0,0 +1,230 @@
import { NextRequest, NextResponse } from "next/server";
import type { LocationResponse } from "@/types/location";
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
import { auth } from "@/lib/auth";
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
/**
* GET /api/locations
*
* Hybrid approach:
* 1. Fetch fresh data from n8n webhook
* 2. Store new locations in local SQLite cache
* 3. Return filtered data from SQLite (enables 24h+ history)
*
* Query parameters:
* - username: Filter by device tracker ID
* - timeRangeHours: Filter by time range (e.g., 1, 3, 6, 12, 24)
* - startTime: Custom range start (ISO string)
* - endTime: Custom range end (ISO string)
* - limit: Maximum number of records (default: 1000)
* - sync: Set to 'false' to skip n8n fetch and read only from cache
*/
export async function GET(request: NextRequest) {
try {
// Check authentication
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get user's allowed device IDs for filtering locations
const userId = (session.user as any).id;
const role = (session.user as any).role;
const sessionUsername = session.user.name || '';
// Get list of device IDs the user is allowed to access
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
// If user has no devices, return empty response
if (userDeviceIds.length === 0) {
return NextResponse.json({
success: true,
current: null,
history: [],
total_points: 0,
last_updated: new Date().toISOString(),
});
}
const searchParams = request.nextUrl.searchParams;
const username = searchParams.get('username') || undefined;
const timeRangeHours = searchParams.get('timeRangeHours')
? parseInt(searchParams.get('timeRangeHours')!, 10)
: undefined;
const startTime = searchParams.get('startTime') || undefined;
const endTime = searchParams.get('endTime') || undefined;
const limit = searchParams.get('limit')
? parseInt(searchParams.get('limit')!, 10)
: 1000;
const sync = searchParams.get('sync') !== 'false'; // Default: true
// Variable to store n8n data as fallback
let n8nData: LocationResponse | null = null;
// Step 1: Optionally fetch and sync from n8n
if (sync) {
try {
const response = await fetch(N8N_API_URL, {
cache: "no-store",
signal: AbortSignal.timeout(3000), // 3 second timeout
});
if (response.ok) {
const data: LocationResponse = await response.json();
// Debug: Log first location from n8n
if (data.history && data.history.length > 0) {
console.log('[N8N Debug] First location from n8n:', {
username: data.history[0].username,
speed: data.history[0].speed,
speed_type: typeof data.history[0].speed,
speed_exists: 'speed' in data.history[0],
battery: data.history[0].battery,
battery_type: typeof data.history[0].battery,
battery_exists: 'battery' in data.history[0]
});
}
// Normalize data: Ensure speed and battery fields exist (treat 0 explicitly)
if (data.history && Array.isArray(data.history)) {
data.history = data.history.map(loc => {
// Generate display_time in German locale (Europe/Berlin timezone)
const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return {
...loc,
display_time: displayTime,
// Explicit handling: 0 is valid, only undefined/null → null
speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null),
battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null),
};
});
}
// Store n8n data for fallback
n8nData = data;
// Store new locations in SQLite
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
// Get latest timestamp from our DB
const stats = locationDb.getStats();
const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z';
// Filter for only newer locations
const newLocations = data.history.filter(loc =>
loc.timestamp > lastLocalTimestamp
);
if (newLocations.length > 0) {
const inserted = locationDb.createMany(newLocations as Location[]);
console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`);
}
}
}
} catch (syncError) {
// n8n not reachable - that's ok, we'll use cached data
console.warn('[Location Sync] n8n webhook not reachable, using cache only:',
syncError instanceof Error ? syncError.message : 'Unknown error');
}
}
// Step 2: Read from local SQLite with filters
let locations = locationDb.findMany({
user_id: 0, // Always filter for MQTT devices
username,
timeRangeHours,
startTime,
endTime,
limit,
});
// Filter locations to only include user's devices
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
// Step 3: If DB is empty, use n8n data as fallback
if (locations.length === 0 && n8nData && n8nData.history) {
console.log('[API] DB empty, using n8n data as fallback');
// Filter n8n data if needed
let filteredHistory = n8nData.history;
// Filter by user's devices
filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username));
if (username) {
filteredHistory = filteredHistory.filter(loc => loc.username === username);
}
// Apply time filters
if (startTime && endTime) {
// Custom range
filteredHistory = filteredHistory.filter(loc =>
loc.timestamp >= startTime && loc.timestamp <= endTime
);
} else if (timeRangeHours) {
// Quick filter
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString();
filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime);
}
return NextResponse.json({
...n8nData,
history: filteredHistory,
total_points: filteredHistory.length,
});
}
// Normalize locations: Ensure speed, battery, and display_time are correct
locations = locations.map(loc => {
// Generate display_time if missing or regenerate from timestamp
const displayTime = loc.display_time || new Date(loc.timestamp).toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return {
...loc,
display_time: displayTime,
speed: loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null,
battery: loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null,
};
});
// Get actual total count from database (not limited by 'limit' parameter)
const stats = locationDb.getStats();
// Step 4: Return data in n8n-compatible format
const response: LocationResponse = {
success: true,
current: locations.length > 0 ? locations[0] : null,
history: locations,
total_points: stats.total, // Use actual total from DB, not limited results
last_updated: locations.length > 0 ? locations[0].timestamp : new Date().toISOString(),
};
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching locations:", error);
return NextResponse.json(
{
error: "Failed to fetch locations",
details: error instanceof Error ? error.message : "Unknown error"
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server';
import { getLocationsDb } from '@/lib/db';
/**
* GET /api/locations/stats
*
* Get detailed database statistics
*/
export async function GET() {
try {
const db = getLocationsDb();
// Overall stats
const totalCount = db.prepare('SELECT COUNT(*) as count FROM Location').get() as { count: number };
// Time range
const timeRange = db.prepare(
'SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM Location'
).get() as { oldest: string | null; newest: string | null };
// Database size
const dbSize = db.prepare(
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
).get() as { sizeMB: number };
// WAL mode check
const walMode = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string };
// Locations per device
const perDevice = db.prepare(`
SELECT username, COUNT(*) as count
FROM Location
WHERE user_id = 0
GROUP BY username
ORDER BY count DESC
`).all() as Array<{ username: string; count: number }>;
// Locations per day (last 7 days)
const perDay = db.prepare(`
SELECT
DATE(timestamp) as date,
COUNT(*) as count
FROM Location
WHERE timestamp >= datetime('now', '-7 days')
GROUP BY DATE(timestamp)
ORDER BY date DESC
`).all() as Array<{ date: string; count: number }>;
// Average locations per day
const avgPerDay = perDay.length > 0
? Math.round(perDay.reduce((sum, day) => sum + day.count, 0) / perDay.length)
: 0;
db.close();
return NextResponse.json({
total: totalCount.count,
oldest: timeRange.oldest,
newest: timeRange.newest,
sizeMB: Math.round(dbSize.sizeMB * 100) / 100,
walMode: walMode.journal_mode,
perDevice,
perDay,
avgPerDay,
});
} catch (error) {
console.error('Stats error:', error);
return NextResponse.json(
{
error: 'Failed to get database stats',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { locationDb, Location } from '@/lib/db';
import type { LocationResponse } from "@/types/location";
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
/**
* POST /api/locations/sync (ADMIN only)
*
* Manually sync location data from n8n webhook to local SQLite cache.
* This fetches all available data from n8n and stores only new records.
*
* Useful for:
* - Initial database population
* - Recovery after downtime
* - Manual refresh
*/
export async function POST() {
try {
// ADMIN only
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get stats before sync
const statsBefore = locationDb.getStats();
// Fetch from n8n webhook
const response = await fetch(N8N_API_URL, {
cache: "no-store",
signal: AbortSignal.timeout(10000), // 10 second timeout for manual sync
});
if (!response.ok) {
throw new Error(`n8n webhook returned ${response.status}`);
}
const data: LocationResponse = await response.json();
let insertedCount = 0;
// Store new locations in SQLite
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
// Get latest timestamp from our DB
const lastLocalTimestamp = statsBefore.newest || '1970-01-01T00:00:00Z';
// Filter for only newer locations
const newLocations = data.history.filter(loc =>
loc.timestamp > lastLocalTimestamp
);
if (newLocations.length > 0) {
insertedCount = locationDb.createMany(newLocations as Location[]);
console.log(`[Manual Sync] Inserted ${insertedCount} new locations from n8n`);
}
}
// Get stats after sync
const statsAfter = locationDb.getStats();
return NextResponse.json({
success: true,
synced: insertedCount,
n8nTotal: data.total_points || data.history.length,
before: {
total: statsBefore.total,
newest: statsBefore.newest,
},
after: {
total: statsAfter.total,
newest: statsAfter.newest,
},
});
} catch (error) {
console.error('Sync error:', error);
return NextResponse.json(
{
error: 'Failed to sync locations',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { locationDb } from '@/lib/db';
/**
* POST /api/locations/test
*
* Create a test location entry (for development/testing)
* Body: { username, latitude, longitude, speed?, battery? }
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { username, latitude, longitude, speed, battery } = body;
// Validation
if (!username || latitude === undefined || longitude === undefined) {
return NextResponse.json(
{ error: 'Missing required fields: username, latitude, longitude' },
{ status: 400 }
);
}
const lat = parseFloat(latitude);
const lon = parseFloat(longitude);
if (isNaN(lat) || isNaN(lon)) {
return NextResponse.json(
{ error: 'Invalid latitude or longitude' },
{ status: 400 }
);
}
if (lat < -90 || lat > 90) {
return NextResponse.json(
{ error: 'Latitude must be between -90 and 90' },
{ status: 400 }
);
}
if (lon < -180 || lon > 180) {
return NextResponse.json(
{ error: 'Longitude must be between -180 and 180' },
{ status: 400 }
);
}
// Create location
const now = new Date();
const location = locationDb.create({
latitude: lat,
longitude: lon,
timestamp: now.toISOString(),
user_id: 0,
username: String(username),
display_time: now.toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}),
chat_id: 0,
first_name: null,
last_name: null,
marker_label: null,
battery: battery !== undefined ? Number(battery) : null,
speed: speed !== undefined ? Number(speed) : null,
});
if (!location) {
return NextResponse.json(
{ error: 'Failed to create location (possibly duplicate)' },
{ status: 409 }
);
}
return NextResponse.json({
success: true,
location,
message: 'Test location created successfully',
});
} catch (error) {
console.error('Test location creation error:', error);
return NextResponse.json(
{ error: 'Failed to create test location' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,146 @@
// API Route für einzelne ACL Regel
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttAclRuleDb } from '@/lib/mqtt-db';
import { deviceDb } from '@/lib/db';
/**
* PATCH /api/mqtt/acl/[id]
* Aktualisiere eine ACL Regel
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid ACL rule ID' },
{ status: 400 }
);
}
const body = await request.json();
const { topic_pattern, permission } = body;
// Validation
if (permission && !['read', 'write', 'readwrite'].includes(permission)) {
return NextResponse.json(
{ error: 'Permission must be read, write, or readwrite' },
{ status: 400 }
);
}
// Get current ACL rule to check device ownership
const userId = (session.user as any).id;
const currentRule = mqttAclRuleDb.findByDeviceId(''); // We need to get by ID first
const aclRules = mqttAclRuleDb.findAll();
const rule = aclRules.find(r => r.id === id);
if (!rule) {
return NextResponse.json(
{ error: 'ACL rule not found' },
{ status: 404 }
);
}
// Check if device belongs to user
const device = deviceDb.findById(rule.device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
);
}
const updated = mqttAclRuleDb.update(id, {
topic_pattern,
permission
});
if (!updated) {
return NextResponse.json(
{ error: 'Failed to update ACL rule' },
{ status: 500 }
);
}
return NextResponse.json(updated);
} catch (error) {
console.error('Failed to update ACL rule:', error);
return NextResponse.json(
{ error: 'Failed to update ACL rule' },
{ status: 500 }
);
}
}
/**
* DELETE /api/mqtt/acl/[id]
* Lösche eine ACL Regel
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid ACL rule ID' },
{ status: 400 }
);
}
// Get current ACL rule to check device ownership
const userId = (session.user as any).id;
const aclRules = mqttAclRuleDb.findAll();
const rule = aclRules.find(r => r.id === id);
if (!rule) {
return NextResponse.json(
{ error: 'ACL rule not found' },
{ status: 404 }
);
}
// Check if device belongs to user
const device = deviceDb.findById(rule.device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
);
}
const deleted = mqttAclRuleDb.delete(id);
if (!deleted) {
return NextResponse.json(
{ error: 'Failed to delete ACL rule' },
{ status: 500 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete ACL rule:', error);
return NextResponse.json(
{ error: 'Failed to delete ACL rule' },
{ status: 500 }
);
}
}

104
app/api/mqtt/acl/route.ts Normal file
View File

@@ -0,0 +1,104 @@
// API Route für MQTT ACL Management
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttAclRuleDb } from '@/lib/mqtt-db';
import { deviceDb } from '@/lib/db';
/**
* GET /api/mqtt/acl?device_id=xxx
* Hole ACL Regeln für ein Device
*/
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const device_id = searchParams.get('device_id');
if (!device_id) {
return NextResponse.json(
{ error: 'device_id query parameter is required' },
{ status: 400 }
);
}
// Check if device belongs to user
const userId = (session.user as any).id;
const device = deviceDb.findById(device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Device not found or access denied' },
{ status: 404 }
);
}
const rules = mqttAclRuleDb.findByDeviceId(device_id);
return NextResponse.json(rules);
} catch (error) {
console.error('Failed to fetch ACL rules:', error);
return NextResponse.json(
{ error: 'Failed to fetch ACL rules' },
{ status: 500 }
);
}
}
/**
* POST /api/mqtt/acl
* Erstelle neue ACL Regel
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { device_id, topic_pattern, permission } = body;
// Validierung
if (!device_id || !topic_pattern || !permission) {
return NextResponse.json(
{ error: 'device_id, topic_pattern, and permission are required' },
{ status: 400 }
);
}
if (!['read', 'write', 'readwrite'].includes(permission)) {
return NextResponse.json(
{ error: 'permission must be one of: read, write, readwrite' },
{ status: 400 }
);
}
// Check if device belongs to user
const userId = (session.user as any).id;
const device = deviceDb.findById(device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Device not found or access denied' },
{ status: 404 }
);
}
const rule = mqttAclRuleDb.create({
device_id,
topic_pattern,
permission
});
return NextResponse.json(rule, { status: 201 });
} catch (error) {
console.error('Failed to create ACL rule:', error);
return NextResponse.json(
{ error: 'Failed to create ACL rule' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,143 @@
// API Route für einzelne MQTT Credentials
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
import { hashPassword } from '@/lib/mosquitto-sync';
import { randomBytes } from 'crypto';
/**
* GET /api/mqtt/credentials/[device_id]
* Hole MQTT Credentials für ein Device
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ device_id: string }> }
) {
const { device_id } = await params;
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const credential = mqttCredentialDb.findByDeviceId(device_id);
if (!credential) {
return NextResponse.json(
{ error: 'Credentials not found' },
{ status: 404 }
);
}
return NextResponse.json(credential);
} catch (error) {
console.error('Failed to fetch MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to fetch credentials' },
{ status: 500 }
);
}
}
/**
* PATCH /api/mqtt/credentials/[device_id]
* Aktualisiere MQTT Credentials
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ device_id: string }> }
) {
const { device_id } = await params;
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { regenerate_password, enabled } = body;
const credential = mqttCredentialDb.findByDeviceId(device_id);
if (!credential) {
return NextResponse.json(
{ error: 'Credentials not found' },
{ status: 404 }
);
}
let newPassword: string | undefined;
let updateData: any = {};
// Regeneriere Passwort wenn angefordert
if (regenerate_password) {
newPassword = randomBytes(16).toString('base64');
const password_hash = await hashPassword(newPassword);
updateData.mqtt_password_hash = password_hash;
}
// Update enabled Status
if (enabled !== undefined) {
updateData.enabled = enabled ? 1 : 0;
}
// Update Credentials
const updated = mqttCredentialDb.update(device_id, updateData);
if (!updated) {
return NextResponse.json(
{ error: 'Failed to update credentials' },
{ status: 500 }
);
}
return NextResponse.json({
...updated,
// Sende neues Passwort nur wenn regeneriert
...(newPassword && { mqtt_password: newPassword })
});
} catch (error) {
console.error('Failed to update MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to update credentials' },
{ status: 500 }
);
}
}
/**
* DELETE /api/mqtt/credentials/[device_id]
* Lösche MQTT Credentials für ein Device
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ device_id: string }> }
) {
const { device_id } = await params;
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Lösche zuerst alle ACL Regeln
mqttAclRuleDb.deleteByDeviceId(device_id);
// Dann lösche Credentials
const deleted = mqttCredentialDb.delete(device_id);
if (!deleted) {
return NextResponse.json(
{ error: 'Credentials not found' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to delete credentials' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,126 @@
// API Route für MQTT Credentials Management
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
import { deviceDb } from '@/lib/db';
import { hashPassword } from '@/lib/mosquitto-sync';
import { randomBytes } from 'crypto';
/**
* GET /api/mqtt/credentials
* Liste alle MQTT Credentials
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = (session.user as any).id;
const credentials = mqttCredentialDb.findAll();
// Filter credentials to only show user's devices
const credentialsWithDevices = credentials
.map(cred => {
const device = deviceDb.findById(cred.device_id);
return {
...cred,
device_name: device?.name || 'Unknown Device',
device_owner: device?.ownerId
};
})
.filter(cred => cred.device_owner === userId);
return NextResponse.json(credentialsWithDevices);
} catch (error) {
console.error('Failed to fetch MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to fetch credentials' },
{ status: 500 }
);
}
}
/**
* POST /api/mqtt/credentials
* Erstelle neue MQTT Credentials für ein Device
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { device_id, mqtt_username, mqtt_password, auto_generate } = body;
// Validierung
if (!device_id) {
return NextResponse.json(
{ error: 'device_id is required' },
{ status: 400 }
);
}
// Prüfe ob Device existiert
const device = deviceDb.findById(device_id);
if (!device) {
return NextResponse.json(
{ error: 'Device not found' },
{ status: 404 }
);
}
// Prüfe ob bereits Credentials existieren
const existing = mqttCredentialDb.findByDeviceId(device_id);
if (existing) {
return NextResponse.json(
{ error: 'MQTT credentials already exist for this device' },
{ status: 409 }
);
}
// Generiere oder verwende übergebene Credentials
let username = mqtt_username;
let password = mqtt_password;
if (auto_generate || !username) {
// Generiere Username: device_[device-id]_[random]
username = `device_${device_id}_${randomBytes(4).toString('hex')}`;
}
if (auto_generate || !password) {
// Generiere sicheres Passwort
password = randomBytes(16).toString('base64');
}
// Hash Passwort
const password_hash = await hashPassword(password);
// Erstelle Credentials
const credential = mqttCredentialDb.create({
device_id,
mqtt_username: username,
mqtt_password_hash: password_hash,
enabled: 1
});
// Erstelle Default ACL Regel
mqttAclRuleDb.createDefaultRule(device_id);
return NextResponse.json({
...credential,
// Sende Plaintext-Passwort nur bei Erstellung zurück
mqtt_password: password,
device_name: device.name
}, { status: 201 });
} catch (error) {
console.error('Failed to create MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to create credentials' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
import { deviceDb, userDb } from '@/lib/db';
// POST /api/mqtt/send-credentials - Send MQTT credentials via email
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Only admins can send credentials
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { deviceId, mqttUsername, mqttPassword } = body;
if (!deviceId || !mqttUsername || !mqttPassword) {
return NextResponse.json(
{ error: 'Missing required fields: deviceId, mqttUsername, mqttPassword' },
{ status: 400 }
);
}
// Get device info
const device = deviceDb.findById(deviceId);
if (!device) {
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
}
// Get device owner
if (!device.ownerId) {
return NextResponse.json(
{ error: 'Device has no owner assigned' },
{ status: 400 }
);
}
const owner = userDb.findById(device.ownerId);
if (!owner) {
return NextResponse.json(
{ error: 'Device owner not found' },
{ status: 404 }
);
}
if (!owner.email) {
return NextResponse.json(
{ error: 'Device owner has no email address' },
{ status: 400 }
);
}
// Parse broker URL from environment or use default
const brokerUrl = process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883';
const brokerHost = brokerUrl.replace(/^mqtt:\/\//, '').replace(/:\d+$/, '');
const brokerPortMatch = brokerUrl.match(/:(\d+)$/);
const brokerPort = brokerPortMatch ? brokerPortMatch[1] : '1883';
// Send email
await emailService.sendMqttCredentialsEmail({
email: owner.email,
deviceName: device.name,
deviceId: device.id,
mqttUsername,
mqttPassword,
brokerUrl,
brokerHost,
brokerPort,
});
console.log(`[MQTT] Credentials sent via email to ${owner.email} for device ${device.name}`);
return NextResponse.json({
success: true,
message: `Credentials sent to ${owner.email}`,
});
} catch (error) {
console.error('Error sending MQTT credentials email:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to send email' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
// API Route für Mosquitto Configuration Sync
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { syncMosquittoConfig, getMosquittoSyncStatus } from '@/lib/mosquitto-sync';
/**
* GET /api/mqtt/sync
* Hole den aktuellen Sync Status
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const status = getMosquittoSyncStatus();
return NextResponse.json(status || { pending_changes: 0, last_sync_status: 'unknown' });
} catch (error) {
console.error('Failed to fetch sync status:', error);
return NextResponse.json(
{ error: 'Failed to fetch sync status' },
{ status: 500 }
);
}
}
/**
* POST /api/mqtt/sync
* Trigger Mosquitto Configuration Sync
*/
export async function POST() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const result = await syncMosquittoConfig();
return NextResponse.json(result, {
status: result.success ? 200 : 500
});
} catch (error) {
console.error('Failed to sync Mosquitto config:', error);
return NextResponse.json(
{ error: 'Failed to sync Mosquitto configuration' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
/**
* GET /api/system/status
*
* Returns system status information
*/
export async function GET() {
try {
const uptimeSeconds = process.uptime();
// Calculate days, hours, minutes, seconds
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
const seconds = Math.floor(uptimeSeconds % 60);
return NextResponse.json({
uptime: {
total: Math.floor(uptimeSeconds),
formatted: `${days}d ${hours}h ${minutes}m ${seconds}s`,
days,
hours,
minutes,
seconds,
},
memory: {
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
},
nodejs: process.version,
platform: process.platform,
});
} catch (error) {
console.error('System status error:', error);
return NextResponse.json(
{ error: 'Failed to get system status' },
{ status: 500 }
);
}
}

209
app/api/users/[id]/route.ts Normal file
View File

@@ -0,0 +1,209 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userDb } from "@/lib/db";
import bcrypt from "bcryptjs";
// GET /api/users/[id] - Get single user (admin only)
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can view users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const user = userDb.findById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can view any user details
// ADMIN users can only view their own created viewers
if (currentUsername !== 'admin') {
// Check if this user is a child of the current user
if (user.parent_user_id !== currentUserId) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
}
// Remove password hash from response
const { passwordHash, ...safeUser } = user;
return NextResponse.json({ user: safeUser });
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}
// PATCH /api/users/[id] - Update user (admin only)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can update users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const user = userDb.findById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can modify any user
// ADMIN users can only modify their own created viewers
if (currentUsername !== 'admin') {
// Check if this user is a child of the current user
if (user.parent_user_id !== currentUserId) {
return NextResponse.json(
{ error: "Forbidden: Cannot modify this user" },
{ status: 403 }
);
}
}
const body = await request.json();
const { username, email, password, role } = body;
// Validation
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be ADMIN or VIEWER" },
{ status: 400 }
);
}
// Check if username is taken by another user
if (username && username !== user.username) {
const existing = userDb.findByUsername(username);
if (existing && existing.id !== id) {
return NextResponse.json(
{ error: "Username already exists" },
{ status: 409 }
);
}
}
// Prepare update data
const updateData: {
username?: string;
email?: string | null;
passwordHash?: string;
role?: string;
} = {};
if (username !== undefined) updateData.username = username;
if (email !== undefined) updateData.email = email;
if (role !== undefined) updateData.role = role;
if (password) {
updateData.passwordHash = await bcrypt.hash(password, 10);
}
const updated = userDb.update(id, updateData);
if (!updated) {
return NextResponse.json({ error: "Failed to update user" }, { status: 500 });
}
// Remove password hash from response
const { passwordHash, ...safeUser } = updated;
return NextResponse.json({ user: safeUser });
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Failed to update user" },
{ status: 500 }
);
}
}
// DELETE /api/users/[id] - Delete user (admin only)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can delete users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const user = userDb.findById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can delete any user
// ADMIN users can only delete their own created viewers
if (currentUsername !== 'admin') {
// Check if this user is a child of the current user
if (user.parent_user_id !== currentUserId) {
return NextResponse.json(
{ error: "Forbidden: Cannot delete this user" },
{ status: 403 }
);
}
}
// Prevent deleting yourself
if ((session.user as any).id === id) {
return NextResponse.json(
{ error: "Cannot delete your own account" },
{ status: 400 }
);
}
const success = userDb.delete(id);
if (!success) {
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 });
}
return NextResponse.json({ message: "User deleted successfully" });
} catch (error) {
console.error("Error deleting user:", error);
return NextResponse.json(
{ error: "Failed to delete user" },
{ status: 500 }
);
}
}

155
app/api/users/route.ts Normal file
View File

@@ -0,0 +1,155 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userDb } from "@/lib/db";
import bcrypt from "bcryptjs";
import { randomUUID } from "crypto";
import { emailService } from '@/lib/email-service';
// GET /api/users - List all users (admin only)
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can view users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can see all users
// Other ADMIN users see only their created viewers (parent-child relationship)
let users: any[];
if (currentUsername === 'admin') {
// Super admin sees all users
users = userDb.findAll();
} else if ((session.user as any).role === 'ADMIN') {
// ADMIN users see only their child viewers
users = userDb.findAll({ parentUserId: currentUserId });
} else {
// VIEWER users see nobody
users = [];
}
// Remove password hashes from response
const safeUsers = users.map(({ passwordHash, ...user }) => user);
return NextResponse.json({ users: safeUsers });
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// POST /api/users - Create new user (admin only)
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can create users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { username, email, password, role } = body;
// Validation
if (!username || !password) {
return NextResponse.json(
{ error: "Missing required fields: username, password" },
{ status: 400 }
);
}
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be ADMIN or VIEWER" },
{ status: 400 }
);
}
// Check if username already exists
const existing = userDb.findByUsername(username);
if (existing) {
return NextResponse.json(
{ error: "Username already exists" },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Determine parent_user_id
// If current user is not "admin", set parent_user_id to current user's ID
// If creating a VIEWER, set parent_user_id to current user's ID
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
let parent_user_id: string | null = null;
if (currentUsername !== 'admin') {
// Non-admin ADMIN users create viewers that belong to them
parent_user_id = currentUserId;
// Force role to VIEWER for non-admin ADMIN users
if (role && role !== 'VIEWER') {
return NextResponse.json(
{ error: 'You can only create VIEWER users' },
{ status: 403 }
);
}
}
// Create user
const user = userDb.create({
id: randomUUID(),
username,
email: email || null,
passwordHash,
role: role || 'VIEWER',
parent_user_id,
});
// Send welcome email (don't fail if email fails)
if (email) {
try {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
await emailService.sendWelcomeEmail({
email,
username,
loginUrl: `${baseUrl}/login`,
temporaryPassword: password, // Send the original password
});
console.log('[UserCreate] Welcome email sent to:', email);
} catch (emailError) {
console.error('[UserCreate] Failed to send welcome email:', emailError);
// Don't fail user creation if email fails
}
}
// Remove password hash from response
const { passwordHash: _, ...safeUser } = user;
return NextResponse.json({ user: safeUser }, { status: 201 });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
}