mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-21 15:09:21 +00:00
first commit
This commit is contained in:
98
backend/src/middleware/auth.js
Normal file
98
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Middleware to verify JWT token
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'Invalid or inactive user' });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() }
|
||||
});
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
console.error('Auth middleware error:', error);
|
||||
return res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to check admin role
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Middleware to check if user is authenticated (optional)
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (token) {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user && user.isActive) {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
// Continue without authentication for optional auth
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
optionalAuth
|
||||
};
|
||||
59
backend/src/middleware/permissions.js
Normal file
59
backend/src/middleware/permissions.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Permission middleware factory
|
||||
const requirePermission = (permission) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Get user's role permissions
|
||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role: req.user.role }
|
||||
});
|
||||
|
||||
// If no specific permissions found, default to admin permissions (for backward compatibility)
|
||||
if (!rolePermissions) {
|
||||
console.warn(`No permissions found for role: ${req.user.role}, defaulting to admin access`);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if user has the required permission
|
||||
if (!rolePermissions[permission]) {
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Permission check error:', error);
|
||||
res.status(500).json({ error: 'Permission check failed' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Specific permission middlewares
|
||||
const requireViewDashboard = requirePermission('canViewDashboard');
|
||||
const requireViewHosts = requirePermission('canViewHosts');
|
||||
const requireManageHosts = requirePermission('canManageHosts');
|
||||
const requireViewPackages = requirePermission('canViewPackages');
|
||||
const requireManagePackages = requirePermission('canManagePackages');
|
||||
const requireViewUsers = requirePermission('canViewUsers');
|
||||
const requireManageUsers = requirePermission('canManageUsers');
|
||||
const requireViewReports = requirePermission('canViewReports');
|
||||
const requireExportData = requirePermission('canExportData');
|
||||
const requireManageSettings = requirePermission('canManageSettings');
|
||||
|
||||
module.exports = {
|
||||
requirePermission,
|
||||
requireViewDashboard,
|
||||
requireViewHosts,
|
||||
requireManageHosts,
|
||||
requireViewPackages,
|
||||
requireManagePackages,
|
||||
requireViewUsers,
|
||||
requireManageUsers,
|
||||
requireViewReports,
|
||||
requireExportData,
|
||||
requireManageSettings
|
||||
};
|
||||
452
backend/src/routes/authRoutes.js
Normal file
452
backend/src/routes/authRoutes.js
Normal file
@@ -0,0 +1,452 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { requireViewUsers, requireManageUsers } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Generate JWT token
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign(
|
||||
{ userId },
|
||||
process.env.JWT_SECRET || 'your-secret-key',
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
};
|
||||
|
||||
// Admin endpoint to list all users
|
||||
router.get('/admin/users', authenticateToken, requireViewUsers, async (req, res) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
res.json(users)
|
||||
} catch (error) {
|
||||
console.error('List users error:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch users' })
|
||||
}
|
||||
})
|
||||
|
||||
// Admin endpoint to create a new user
|
||||
router.post('/admin/users', authenticateToken, requireManageUsers, [
|
||||
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||
body('email').isEmail().withMessage('Valid email is required'),
|
||||
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
|
||||
body('role').optional().custom(async (value) => {
|
||||
if (!value) return true; // Optional field
|
||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role: value }
|
||||
});
|
||||
if (!rolePermissions) {
|
||||
throw new Error('Invalid role specified');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email, password, role = 'user' } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ email }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'Username or email already exists' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
role
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User created successfully',
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User creation error:', error);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to update a user
|
||||
router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
||||
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||
body('email').optional().isEmail().withMessage('Valid email is required'),
|
||||
body('role').optional().custom(async (value) => {
|
||||
if (!value) return true; // Optional field
|
||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role: value }
|
||||
});
|
||||
if (!rolePermissions) {
|
||||
throw new Error('Invalid role specified');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const errors = validationResult(req);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email, role, isActive } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (username) updateData.username = username;
|
||||
if (email) updateData.email = email;
|
||||
if (role) updateData.role = role;
|
||||
if (typeof isActive === 'boolean') updateData.isActive = isActive;
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Check if username/email already exists (excluding current user)
|
||||
if (username || email) {
|
||||
const duplicateUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: userId } },
|
||||
{
|
||||
OR: [
|
||||
...(username ? [{ username }] : []),
|
||||
...(email ? [{ email }] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (duplicateUser) {
|
||||
return res.status(409).json({ error: 'Username or email already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent deactivating the last admin
|
||||
if (isActive === false && existingUser.role === 'admin') {
|
||||
const adminCount = await prisma.user.count({
|
||||
where: {
|
||||
role: 'admin',
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot deactivate the last admin user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'User updated successfully',
|
||||
user: updatedUser
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to delete a user
|
||||
router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = await prisma.user.count({
|
||||
where: {
|
||||
role: 'admin',
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot delete the last admin user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user
|
||||
await prisma.user.delete({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'User deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User deletion error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', [
|
||||
body('username').notEmpty().withMessage('Username is required'),
|
||||
body('password').notEmpty().withMessage('Password is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Find user by username or email
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ email: username }
|
||||
],
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() }
|
||||
});
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
router.get('/profile', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
user: req.user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get profile error:', error);
|
||||
res.status(500).json({ error: 'Failed to get profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update user profile
|
||||
router.put('/profile', authenticateToken, [
|
||||
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||
body('email').optional().isEmail().withMessage('Valid email is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (username) updateData.username = username;
|
||||
if (email) updateData.email = email;
|
||||
|
||||
// Check if username/email already exists (excluding current user)
|
||||
if (username || email) {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: req.user.id } },
|
||||
{
|
||||
OR: [
|
||||
...(username ? [{ username }] : []),
|
||||
...(email ? [{ email }] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'Username or email already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Profile updated successfully',
|
||||
user: updatedUser
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update profile error:', error);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.put('/change-password', authenticateToken, [
|
||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||
body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
// Get user with password hash
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { passwordHash: newPasswordHash }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Password changed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (client-side token removal)
|
||||
router.post('/logout', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
message: 'Logout successful'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
89
backend/src/routes/dashboardPreferencesRoutes.js
Normal file
89
backend/src/routes/dashboardPreferencesRoutes.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get user's dashboard preferences
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const preferences = await prisma.dashboardPreferences.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { order: 'asc' }
|
||||
});
|
||||
|
||||
res.json(preferences);
|
||||
} catch (error) {
|
||||
console.error('Dashboard preferences fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dashboard preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update dashboard preferences (bulk update)
|
||||
router.put('/', authenticateToken, [
|
||||
body('preferences').isArray().withMessage('Preferences must be an array'),
|
||||
body('preferences.*.cardId').isString().withMessage('Card ID is required'),
|
||||
body('preferences.*.enabled').isBoolean().withMessage('Enabled must be boolean'),
|
||||
body('preferences.*.order').isInt().withMessage('Order must be integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { preferences } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Delete existing preferences for this user
|
||||
await prisma.dashboardPreferences.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
// Create new preferences
|
||||
const newPreferences = preferences.map(pref => ({
|
||||
userId,
|
||||
cardId: pref.cardId,
|
||||
enabled: pref.enabled,
|
||||
order: pref.order
|
||||
}));
|
||||
|
||||
const createdPreferences = await prisma.dashboardPreferences.createMany({
|
||||
data: newPreferences
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Dashboard preferences updated successfully',
|
||||
preferences: newPreferences
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard preferences update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update dashboard preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get default dashboard card configuration
|
||||
router.get('/defaults', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const defaultCards = [
|
||||
{ cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
|
||||
{ cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 },
|
||||
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
|
||||
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
|
||||
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
|
||||
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
|
||||
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
|
||||
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
|
||||
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
|
||||
];
|
||||
|
||||
res.json(defaultCards);
|
||||
} catch (error) {
|
||||
console.error('Default dashboard cards error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch default dashboard cards' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
313
backend/src/routes/dashboardRoutes.js
Normal file
313
backend/src/routes/dashboardRoutes.js
Normal file
@@ -0,0 +1,313 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const moment = require('moment');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const {
|
||||
requireViewDashboard,
|
||||
requireViewHosts,
|
||||
requireViewPackages
|
||||
} = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const twentyFourHoursAgo = moment(now).subtract(24, 'hours').toDate();
|
||||
|
||||
// Get all statistics in parallel for better performance
|
||||
const [
|
||||
totalHosts,
|
||||
hostsNeedingUpdates,
|
||||
totalOutdatedPackages,
|
||||
erroredHosts,
|
||||
securityUpdates,
|
||||
osDistribution,
|
||||
updateTrends
|
||||
] = await Promise.all([
|
||||
// Total hosts count
|
||||
prisma.host.count({
|
||||
where: { status: 'active' }
|
||||
}),
|
||||
|
||||
// Hosts needing updates (distinct hosts with packages needing updates)
|
||||
prisma.host.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
hostPackages: {
|
||||
some: {
|
||||
needsUpdate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Total outdated packages across all hosts
|
||||
prisma.hostPackage.count({
|
||||
where: { needsUpdate: true }
|
||||
}),
|
||||
|
||||
// Errored hosts (not updated in 24 hours)
|
||||
prisma.host.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
lastUpdate: {
|
||||
lt: twentyFourHoursAgo
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Security updates count
|
||||
prisma.hostPackage.count({
|
||||
where: {
|
||||
needsUpdate: true,
|
||||
isSecurityUpdate: true
|
||||
}
|
||||
}),
|
||||
|
||||
// OS distribution for pie chart
|
||||
prisma.host.groupBy({
|
||||
by: ['osType'],
|
||||
where: { status: 'active' },
|
||||
_count: {
|
||||
osType: true
|
||||
}
|
||||
}),
|
||||
|
||||
// Update trends for the last 7 days
|
||||
prisma.updateHistory.groupBy({
|
||||
by: ['timestamp'],
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: moment(now).subtract(7, 'days').toDate()
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
id: true
|
||||
},
|
||||
_sum: {
|
||||
packagesCount: true,
|
||||
securityCount: true
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Format OS distribution for pie chart
|
||||
const osDistributionFormatted = osDistribution.map(item => ({
|
||||
name: item.osType,
|
||||
count: item._count.osType
|
||||
}));
|
||||
|
||||
// Calculate update status distribution
|
||||
const updateStatusDistribution = [
|
||||
{ name: 'Up to date', count: totalHosts - hostsNeedingUpdates },
|
||||
{ name: 'Needs updates', count: hostsNeedingUpdates },
|
||||
{ name: 'Errored', count: erroredHosts }
|
||||
];
|
||||
|
||||
// Package update priority distribution
|
||||
const packageUpdateDistribution = [
|
||||
{ name: 'Security', count: securityUpdates },
|
||||
{ name: 'Regular', count: totalOutdatedPackages - securityUpdates }
|
||||
];
|
||||
|
||||
res.json({
|
||||
cards: {
|
||||
totalHosts,
|
||||
hostsNeedingUpdates,
|
||||
totalOutdatedPackages,
|
||||
erroredHosts,
|
||||
securityUpdates
|
||||
},
|
||||
charts: {
|
||||
osDistribution: osDistributionFormatted,
|
||||
updateStatusDistribution,
|
||||
packageUpdateDistribution
|
||||
},
|
||||
trends: updateTrends,
|
||||
lastUpdated: now.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosts with their update status
|
||||
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const hosts = await prisma.host.findMany({
|
||||
// Show all hosts regardless of status
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
lastUpdate: true,
|
||||
status: true,
|
||||
agentVersion: true,
|
||||
autoUpdate: true,
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
hostPackages: {
|
||||
where: {
|
||||
needsUpdate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { lastUpdate: 'desc' }
|
||||
});
|
||||
|
||||
// Get update counts for each host separately
|
||||
const hostsWithUpdateInfo = await Promise.all(
|
||||
hosts.map(async (host) => {
|
||||
const updatesCount = await prisma.hostPackage.count({
|
||||
where: {
|
||||
hostId: host.id,
|
||||
needsUpdate: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
isStale: moment(host.lastUpdate).isBefore(moment().subtract(24, 'hours'))
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(hostsWithUpdateInfo);
|
||||
} catch (error) {
|
||||
console.error('Error fetching hosts:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch hosts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get packages that need updates across all hosts
|
||||
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
|
||||
try {
|
||||
const packages = await prisma.package.findMany({
|
||||
where: {
|
||||
hostPackages: {
|
||||
some: {
|
||||
needsUpdate: true
|
||||
}
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
latestVersion: true,
|
||||
hostPackages: {
|
||||
where: { needsUpdate: true },
|
||||
select: {
|
||||
currentVersion: true,
|
||||
availableVersion: true,
|
||||
isSecurityUpdate: true,
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
osType: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
const packagesWithHostInfo = packages.map(pkg => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
category: pkg.category,
|
||||
latestVersion: pkg.latestVersion,
|
||||
affectedHostsCount: pkg.hostPackages.length,
|
||||
isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate),
|
||||
affectedHosts: pkg.hostPackages.map(hp => ({
|
||||
hostId: hp.host.id,
|
||||
hostname: hp.host.hostname,
|
||||
osType: hp.host.osType,
|
||||
currentVersion: hp.currentVersion,
|
||||
availableVersion: hp.availableVersion,
|
||||
isSecurityUpdate: hp.isSecurityUpdate
|
||||
}))
|
||||
}));
|
||||
|
||||
res.json(packagesWithHostInfo);
|
||||
} catch (error) {
|
||||
console.error('Error fetching packages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch packages' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get detailed host information
|
||||
router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
hostPackages: {
|
||||
include: {
|
||||
package: true
|
||||
},
|
||||
orderBy: {
|
||||
needsUpdate: 'desc'
|
||||
}
|
||||
},
|
||||
updateHistory: {
|
||||
orderBy: {
|
||||
timestamp: 'desc'
|
||||
},
|
||||
take: 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
}
|
||||
|
||||
const hostWithStats = {
|
||||
...host,
|
||||
stats: {
|
||||
totalPackages: host.hostPackages.length,
|
||||
outdatedPackages: host.hostPackages.filter(hp => hp.needsUpdate).length,
|
||||
securityUpdates: host.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length
|
||||
}
|
||||
};
|
||||
|
||||
res.json(hostWithStats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host details:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host details' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
225
backend/src/routes/hostGroupRoutes.js
Normal file
225
backend/src/routes/hostGroupRoutes.js
Normal file
@@ -0,0 +1,225 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { requireManageHosts } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all host groups
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const hostGroups = await prisma.hostGroup.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hosts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hostGroups);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host groups:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host groups' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a specific host group by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
status: true,
|
||||
lastUpdate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(404).json({ error: 'Host group not found' });
|
||||
}
|
||||
|
||||
res.json(hostGroup);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host group:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new host group
|
||||
router.post('/', authenticateToken, requireManageHosts, [
|
||||
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
|
||||
body('description').optional().trim(),
|
||||
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { name, description, color } = req.body;
|
||||
|
||||
// Check if host group with this name already exists
|
||||
const existingGroup = await prisma.hostGroup.findUnique({
|
||||
where: { name }
|
||||
});
|
||||
|
||||
if (existingGroup) {
|
||||
return res.status(400).json({ error: 'A host group with this name already exists' });
|
||||
}
|
||||
|
||||
const hostGroup = await prisma.hostGroup.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
color: color || '#3B82F6'
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(hostGroup);
|
||||
} catch (error) {
|
||||
console.error('Error creating host group:', error);
|
||||
res.status(500).json({ error: 'Failed to create host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a host group
|
||||
router.put('/:id', authenticateToken, requireManageHosts, [
|
||||
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
|
||||
body('description').optional().trim(),
|
||||
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { name, description, color } = req.body;
|
||||
|
||||
// Check if host group exists
|
||||
const existingGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!existingGroup) {
|
||||
return res.status(404).json({ error: 'Host group not found' });
|
||||
}
|
||||
|
||||
// Check if another host group with this name already exists
|
||||
const duplicateGroup = await prisma.hostGroup.findFirst({
|
||||
where: {
|
||||
name,
|
||||
id: { not: id }
|
||||
}
|
||||
});
|
||||
|
||||
if (duplicateGroup) {
|
||||
return res.status(400).json({ error: 'A host group with this name already exists' });
|
||||
}
|
||||
|
||||
const hostGroup = await prisma.hostGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
color: color || '#3B82F6'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hostGroup);
|
||||
} catch (error) {
|
||||
console.error('Error updating host group:', error);
|
||||
res.status(500).json({ error: 'Failed to update host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a host group
|
||||
router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if host group exists
|
||||
const existingGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hosts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingGroup) {
|
||||
return res.status(404).json({ error: 'Host group not found' });
|
||||
}
|
||||
|
||||
// Check if host group has hosts
|
||||
if (existingGroup._count.hosts > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot delete host group that contains hosts. Please move or remove hosts first.'
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.hostGroup.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Host group deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting host group:', error);
|
||||
res.status(500).json({ error: 'Failed to delete host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosts in a specific group
|
||||
router.get('/:id/hosts', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { hostGroupId: id },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
architecture: true,
|
||||
status: true,
|
||||
lastUpdate: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: {
|
||||
hostname: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hosts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching hosts in group:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch hosts in group' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
928
backend/src/routes/hostRoutes.js
Normal file
928
backend/src/routes/hostRoutes.js
Normal file
@@ -0,0 +1,928 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { requireManageHosts, requireManageSettings } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Public endpoint to download the agent script
|
||||
router.get('/agent/download', async (req, res) => {
|
||||
try {
|
||||
const { version } = req.query;
|
||||
|
||||
let agentVersion;
|
||||
|
||||
if (version) {
|
||||
// Download specific version
|
||||
agentVersion = await prisma.agentVersion.findUnique({
|
||||
where: { version }
|
||||
});
|
||||
|
||||
if (!agentVersion) {
|
||||
return res.status(404).json({ error: 'Agent version not found' });
|
||||
}
|
||||
} else {
|
||||
// Download current version (latest)
|
||||
agentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isCurrent: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (!agentVersion) {
|
||||
// Fallback to default version
|
||||
agentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isDefault: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentVersion) {
|
||||
return res.status(404).json({ error: 'No agent version available' });
|
||||
}
|
||||
|
||||
// Use script content from database if available, otherwise fallback to file
|
||||
if (agentVersion.scriptContent) {
|
||||
res.setHeader('Content-Type', 'application/x-shellscript');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`);
|
||||
res.send(agentVersion.scriptContent);
|
||||
} else {
|
||||
// Fallback to file system
|
||||
const agentPath = path.join(__dirname, '../../../agents/patchmon-agent.sh');
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: 'Agent script not found' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/x-shellscript');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`);
|
||||
res.sendFile(path.resolve(agentPath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Agent download error:', error);
|
||||
res.status(500).json({ error: 'Failed to download agent script' });
|
||||
}
|
||||
});
|
||||
|
||||
// Version check endpoint for agents
|
||||
router.get('/agent/version', async (req, res) => {
|
||||
try {
|
||||
const currentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isCurrent: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (!currentVersion) {
|
||||
return res.status(404).json({ error: 'No current agent version found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
currentVersion: currentVersion.version,
|
||||
downloadUrl: currentVersion.downloadUrl || `/api/v1/hosts/agent/download`,
|
||||
releaseNotes: currentVersion.releaseNotes,
|
||||
minServerVersion: currentVersion.minServerVersion
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Version check error:', error);
|
||||
res.status(500).json({ error: 'Failed to get agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate API credentials
|
||||
const generateApiCredentials = () => {
|
||||
const apiId = `patchmon_${crypto.randomBytes(8).toString('hex')}`;
|
||||
const apiKey = crypto.randomBytes(32).toString('hex');
|
||||
return { apiId, apiKey };
|
||||
};
|
||||
|
||||
// Middleware to validate API credentials
|
||||
const validateApiCredentials = async (req, res, next) => {
|
||||
try {
|
||||
const apiId = req.headers['x-api-id'] || req.body.apiId;
|
||||
const apiKey = req.headers['x-api-key'] || req.body.apiKey;
|
||||
|
||||
if (!apiId || !apiKey) {
|
||||
return res.status(401).json({ error: 'API ID and Key required' });
|
||||
}
|
||||
|
||||
const host = await prisma.host.findFirst({
|
||||
where: {
|
||||
apiId: apiId,
|
||||
apiKey: apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(401).json({ error: 'Invalid API credentials' });
|
||||
}
|
||||
|
||||
req.hostRecord = host;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('API credential validation error:', error);
|
||||
res.status(500).json({ error: 'API credential validation failed' });
|
||||
}
|
||||
};
|
||||
|
||||
// Admin endpoint to create a new host manually (replaces auto-registration)
|
||||
router.post('/create', authenticateToken, requireManageHosts, [
|
||||
body('hostname').isLength({ min: 1 }).withMessage('Hostname is required'),
|
||||
body('hostGroupId').optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostname, hostGroupId } = req.body;
|
||||
|
||||
// Generate unique API credentials for this host
|
||||
const { apiId, apiKey } = generateApiCredentials();
|
||||
|
||||
// Check if host already exists
|
||||
const existingHost = await prisma.host.findUnique({
|
||||
where: { hostname }
|
||||
});
|
||||
|
||||
if (existingHost) {
|
||||
return res.status(409).json({ error: 'Host already exists' });
|
||||
}
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id: hostGroupId }
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: 'Host group not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create new host with API credentials - system info will be populated when agent connects
|
||||
const host = await prisma.host.create({
|
||||
data: {
|
||||
hostname,
|
||||
osType: 'unknown', // Will be updated when agent connects
|
||||
osVersion: 'unknown', // Will be updated when agent connects
|
||||
ip: null, // Will be updated when agent connects
|
||||
architecture: null, // Will be updated when agent connects
|
||||
apiId,
|
||||
apiKey,
|
||||
hostGroupId: hostGroupId || null,
|
||||
status: 'pending' // Will change to 'active' when agent connects
|
||||
},
|
||||
include: {
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Host created successfully',
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
apiId: host.apiId,
|
||||
apiKey: host.apiKey,
|
||||
hostGroup: host.hostGroup,
|
||||
instructions: 'Use these credentials in your patchmon agent configuration. System information will be automatically detected when the agent connects.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host creation error:', error);
|
||||
res.status(500).json({ error: 'Failed to create host' });
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy register endpoint (deprecated - returns error message)
|
||||
router.post('/register', async (req, res) => {
|
||||
res.status(400).json({
|
||||
error: 'Host registration has been disabled. Please contact your administrator to add this host to PatchMon.',
|
||||
deprecated: true,
|
||||
message: 'Hosts must now be pre-created by administrators with specific API credentials.'
|
||||
});
|
||||
});
|
||||
|
||||
// Update host information and packages (now uses API credentials)
|
||||
router.post('/update', validateApiCredentials, [
|
||||
body('packages').isArray().withMessage('Packages must be an array'),
|
||||
body('packages.*.name').isLength({ min: 1 }).withMessage('Package name is required'),
|
||||
body('packages.*.currentVersion').isLength({ min: 1 }).withMessage('Current version is required'),
|
||||
body('packages.*.availableVersion').optional().isLength({ min: 1 }),
|
||||
body('packages.*.needsUpdate').isBoolean().withMessage('needsUpdate must be boolean'),
|
||||
body('packages.*.isSecurityUpdate').optional().isBoolean().withMessage('isSecurityUpdate must be boolean'),
|
||||
body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { packages, repositories } = req.body;
|
||||
const host = req.hostRecord;
|
||||
|
||||
// Update host last update timestamp and OS info if provided
|
||||
const updateData = { lastUpdate: new Date() };
|
||||
if (req.body.osType) updateData.osType = req.body.osType;
|
||||
if (req.body.osVersion) updateData.osVersion = req.body.osVersion;
|
||||
if (req.body.ip) updateData.ip = req.body.ip;
|
||||
if (req.body.architecture) updateData.architecture = req.body.architecture;
|
||||
if (req.body.agentVersion) updateData.agentVersion = req.body.agentVersion;
|
||||
|
||||
// If this is the first update (status is 'pending'), change to 'active'
|
||||
if (host.status === 'pending') {
|
||||
updateData.status = 'active';
|
||||
}
|
||||
|
||||
await prisma.host.update({
|
||||
where: { id: host.id },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// Process packages in transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear existing host packages
|
||||
await tx.hostPackage.deleteMany({
|
||||
where: { hostId: host.id }
|
||||
});
|
||||
|
||||
// Process each package
|
||||
for (const packageData of packages) {
|
||||
// Find or create package
|
||||
let pkg = await tx.package.findUnique({
|
||||
where: { name: packageData.name }
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
pkg = await tx.package.create({
|
||||
data: {
|
||||
name: packageData.name,
|
||||
description: packageData.description || null,
|
||||
category: packageData.category || null,
|
||||
latestVersion: packageData.availableVersion || packageData.currentVersion
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Update package latest version if newer
|
||||
if (packageData.availableVersion && packageData.availableVersion !== pkg.latestVersion) {
|
||||
await tx.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: { latestVersion: packageData.availableVersion }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create host package relationship
|
||||
await tx.hostPackage.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
packageId: pkg.id,
|
||||
currentVersion: packageData.currentVersion,
|
||||
availableVersion: packageData.availableVersion || null,
|
||||
needsUpdate: packageData.needsUpdate,
|
||||
isSecurityUpdate: packageData.isSecurityUpdate || false,
|
||||
lastChecked: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process repositories if provided
|
||||
if (repositories && Array.isArray(repositories)) {
|
||||
// Clear existing host repositories
|
||||
await tx.hostRepository.deleteMany({
|
||||
where: { hostId: host.id }
|
||||
});
|
||||
|
||||
// Process each repository
|
||||
for (const repoData of repositories) {
|
||||
// Find or create repository
|
||||
let repo = await tx.repository.findFirst({
|
||||
where: {
|
||||
url: repoData.url,
|
||||
distribution: repoData.distribution,
|
||||
components: repoData.components
|
||||
}
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
repo = await tx.repository.create({
|
||||
data: {
|
||||
name: repoData.name,
|
||||
url: repoData.url,
|
||||
distribution: repoData.distribution,
|
||||
components: repoData.components,
|
||||
repoType: repoData.repoType,
|
||||
isActive: true,
|
||||
isSecure: repoData.isSecure || false,
|
||||
description: `${repoData.repoType} repository for ${repoData.distribution}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create host repository relationship
|
||||
await tx.hostRepository.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
repositoryId: repo.id,
|
||||
isEnabled: repoData.isEnabled !== false, // Default to enabled
|
||||
lastChecked: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create update history record
|
||||
const securityCount = packages.filter(pkg => pkg.isSecurityUpdate).length;
|
||||
const updatesCount = packages.filter(pkg => pkg.needsUpdate).length;
|
||||
|
||||
await prisma.updateHistory.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
packagesCount: updatesCount,
|
||||
securityCount,
|
||||
status: 'success'
|
||||
}
|
||||
});
|
||||
|
||||
// Check if auto-update is enabled and if there's a newer agent version available
|
||||
let autoUpdateResponse = null;
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
// Check both global auto-update setting AND host-specific auto-update setting
|
||||
if (settings && settings.autoUpdate && host.autoUpdate) {
|
||||
// Get current agent version from the request
|
||||
const currentAgentVersion = req.body.agentVersion;
|
||||
|
||||
if (currentAgentVersion) {
|
||||
// Get the latest agent version
|
||||
const latestAgentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isCurrent: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (latestAgentVersion && latestAgentVersion.version !== currentAgentVersion) {
|
||||
// There's a newer version available
|
||||
autoUpdateResponse = {
|
||||
shouldUpdate: true,
|
||||
currentVersion: currentAgentVersion,
|
||||
latestVersion: latestAgentVersion.version,
|
||||
message: 'A newer agent version is available. Run: /usr/local/bin/patchmon-agent.sh update-agent',
|
||||
updateCommand: 'update-agent'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-update check error:', error);
|
||||
// Don't fail the update if auto-update check fails
|
||||
}
|
||||
|
||||
const response = {
|
||||
message: 'Host updated successfully',
|
||||
packagesProcessed: packages.length,
|
||||
updatesAvailable: updatesCount,
|
||||
securityUpdates: securityCount
|
||||
};
|
||||
|
||||
// Add auto-update response if available
|
||||
if (autoUpdateResponse) {
|
||||
response.autoUpdate = autoUpdateResponse;
|
||||
}
|
||||
|
||||
// Check if crontab update is needed (when update interval changes)
|
||||
// This is a simple check - if the host has auto-update enabled, we'll suggest crontab update
|
||||
if (host.autoUpdate) {
|
||||
// For now, we'll always suggest crontab update to ensure it's current
|
||||
// In a more sophisticated implementation, we could track when the interval last changed
|
||||
response.crontabUpdate = {
|
||||
shouldUpdate: true,
|
||||
message: 'Please ensure your crontab is up to date with current interval settings',
|
||||
command: 'update-crontab'
|
||||
};
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Host update error:', error);
|
||||
|
||||
// Log error in update history
|
||||
try {
|
||||
await prisma.updateHistory.create({
|
||||
data: {
|
||||
hostId: req.hostRecord.id,
|
||||
packagesCount: 0,
|
||||
securityCount: 0,
|
||||
status: 'error',
|
||||
errorMessage: error.message
|
||||
}
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('Failed to log update error:', logError);
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to update host' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get host information (now uses API credentials)
|
||||
router.get('/info', validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: req.hostRecord.id },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
architecture: true,
|
||||
lastUpdate: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
apiId: true // Include API ID for reference
|
||||
}
|
||||
});
|
||||
|
||||
res.json(host);
|
||||
} catch (error) {
|
||||
console.error('Get host info error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host information' });
|
||||
}
|
||||
});
|
||||
|
||||
// Ping endpoint for health checks (now uses API credentials)
|
||||
router.post('/ping', validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
// Update last update timestamp
|
||||
await prisma.host.update({
|
||||
where: { id: req.hostRecord.id },
|
||||
data: { lastUpdate: new Date() }
|
||||
});
|
||||
|
||||
const response = {
|
||||
message: 'Ping successful',
|
||||
timestamp: new Date().toISOString(),
|
||||
hostname: req.hostRecord.hostname
|
||||
};
|
||||
|
||||
// Check if this is a crontab update trigger
|
||||
if (req.body.triggerCrontabUpdate && req.hostRecord.autoUpdate) {
|
||||
console.log(`Triggering crontab update for host: ${req.hostRecord.hostname}`);
|
||||
response.crontabUpdate = {
|
||||
shouldUpdate: true,
|
||||
message: 'Update interval changed, please run: /usr/local/bin/patchmon-agent.sh update-crontab',
|
||||
command: 'update-crontab'
|
||||
};
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Ping error:', error);
|
||||
res.status(500).json({ error: 'Ping failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to regenerate API credentials for a host
|
||||
router.post('/:hostId/regenerate-credentials', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId }
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
}
|
||||
|
||||
// Generate new API credentials
|
||||
const { apiId, apiKey } = generateApiCredentials();
|
||||
|
||||
// Update host with new credentials
|
||||
const updatedHost = await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: { apiId, apiKey }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'API credentials regenerated successfully',
|
||||
hostname: updatedHost.hostname,
|
||||
apiId: updatedHost.apiId,
|
||||
apiKey: updatedHost.apiKey,
|
||||
warning: 'Previous credentials are now invalid. Update your agent configuration.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Credential regeneration error:', error);
|
||||
res.status(500).json({ error: 'Failed to regenerate credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to bulk update host groups
|
||||
router.put('/bulk/group', authenticateToken, requireManageHosts, [
|
||||
body('hostIds').isArray().withMessage('Host IDs must be an array'),
|
||||
body('hostIds.*').isLength({ min: 1 }).withMessage('Each host ID must be provided'),
|
||||
body('hostGroupId').optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostIds, hostGroupId } = req.body;
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id: hostGroupId }
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: 'Host group not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all hosts exist
|
||||
const existingHosts = await prisma.host.findMany({
|
||||
where: { id: { in: hostIds } },
|
||||
select: { id: true, hostname: true }
|
||||
});
|
||||
|
||||
if (existingHosts.length !== hostIds.length) {
|
||||
const foundIds = existingHosts.map(h => h.id);
|
||||
const missingIds = hostIds.filter(id => !foundIds.includes(id));
|
||||
return res.status(400).json({
|
||||
error: 'Some hosts not found',
|
||||
missingHostIds: missingIds
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk update host groups
|
||||
const updateResult = await prisma.host.updateMany({
|
||||
where: { id: { in: hostIds } },
|
||||
data: {
|
||||
hostGroupId: hostGroupId || null
|
||||
}
|
||||
});
|
||||
|
||||
// Get updated hosts with group information
|
||||
const updatedHosts = await prisma.host.findMany({
|
||||
where: { id: { in: hostIds } },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Successfully updated ${updateResult.count} host${updateResult.count !== 1 ? 's' : ''}`,
|
||||
updatedCount: updateResult.count,
|
||||
hosts: updatedHosts
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Bulk host group update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update host groups' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to update host group
|
||||
router.put('/:hostId/group', authenticateToken, requireManageHosts, [
|
||||
body('hostGroupId').optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId } = req.params;
|
||||
const { hostGroupId } = req.body;
|
||||
|
||||
// Check if host exists
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId }
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
}
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id: hostGroupId }
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: 'Host group not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update host group
|
||||
const updatedHost = await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: {
|
||||
hostGroupId: hostGroupId || null
|
||||
},
|
||||
include: {
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Host group updated successfully',
|
||||
host: updatedHost
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host group update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to list all hosts
|
||||
router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const hosts = await prisma.host.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
architecture: true,
|
||||
lastUpdate: true,
|
||||
status: true,
|
||||
apiId: true,
|
||||
agentVersion: true,
|
||||
autoUpdate: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(hosts);
|
||||
} catch (error) {
|
||||
console.error('List hosts error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch hosts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to delete host
|
||||
router.delete('/:hostId', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
// Delete host and all related data (cascade)
|
||||
await prisma.host.delete({
|
||||
where: { id: hostId }
|
||||
});
|
||||
|
||||
res.json({ message: 'Host deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Host deletion error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete host' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle host auto-update setting
|
||||
router.patch('/:hostId/auto-update', authenticateToken, requireManageHosts, [
|
||||
body('autoUpdate').isBoolean().withMessage('Auto-update must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId } = req.params;
|
||||
const { autoUpdate } = req.body;
|
||||
|
||||
const host = await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: { autoUpdate }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Host auto-update ${autoUpdate ? 'enabled' : 'disabled'} successfully`,
|
||||
host: {
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
autoUpdate: host.autoUpdate
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host auto-update toggle error:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle host auto-update' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve the installation script
|
||||
router.get('/install', async (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scriptPath = path.join(__dirname, '../../../agents/patchmon_install.sh');
|
||||
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
return res.status(404).json({ error: 'Installation script not found' });
|
||||
}
|
||||
|
||||
let script = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
// Get the configured server URL from settings
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings) {
|
||||
// Replace the default server URL in the script with the configured one
|
||||
script = script.replace(
|
||||
/PATCHMON_URL="[^"]*"/g,
|
||||
`PATCHMON_URL="${settings.serverUrl}"`
|
||||
);
|
||||
}
|
||||
} catch (settingsError) {
|
||||
console.warn('Could not fetch settings, using default server URL:', settingsError.message);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Content-Disposition', 'inline; filename="patchmon_install.sh"');
|
||||
res.send(script);
|
||||
} catch (error) {
|
||||
console.error('Installation script error:', error);
|
||||
res.status(500).json({ error: 'Failed to serve installation script' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== AGENT VERSION MANAGEMENT ====================
|
||||
|
||||
// Get all agent versions (admin only)
|
||||
router.get('/agent/versions', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const versions = await prisma.agentVersion.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(versions);
|
||||
} catch (error) {
|
||||
console.error('Get agent versions error:', error);
|
||||
res.status(500).json({ error: 'Failed to get agent versions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new agent version (admin only)
|
||||
router.post('/agent/versions', authenticateToken, requireManageSettings, [
|
||||
body('version').isLength({ min: 1 }).withMessage('Version is required'),
|
||||
body('releaseNotes').optional().isString(),
|
||||
body('downloadUrl').optional().isURL().withMessage('Download URL must be valid'),
|
||||
body('minServerVersion').optional().isString(),
|
||||
body('scriptContent').optional().isString(),
|
||||
body('isDefault').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { version, releaseNotes, downloadUrl, minServerVersion, scriptContent, isDefault } = req.body;
|
||||
|
||||
// Check if version already exists
|
||||
const existingVersion = await prisma.agentVersion.findUnique({
|
||||
where: { version }
|
||||
});
|
||||
|
||||
if (existingVersion) {
|
||||
return res.status(400).json({ error: 'Version already exists' });
|
||||
}
|
||||
|
||||
// If this is being set as default, unset other defaults
|
||||
if (isDefault) {
|
||||
await prisma.agentVersion.updateMany({
|
||||
where: { isDefault: true },
|
||||
data: { isDefault: false }
|
||||
});
|
||||
}
|
||||
|
||||
const agentVersion = await prisma.agentVersion.create({
|
||||
data: {
|
||||
version,
|
||||
releaseNotes,
|
||||
downloadUrl,
|
||||
minServerVersion,
|
||||
scriptContent,
|
||||
isDefault: isDefault || false,
|
||||
isCurrent: false
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(agentVersion);
|
||||
} catch (error) {
|
||||
console.error('Create agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to create agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set current agent version (admin only)
|
||||
router.patch('/agent/versions/:versionId/current', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { versionId } = req.params;
|
||||
|
||||
// First, unset all current versions
|
||||
await prisma.agentVersion.updateMany({
|
||||
where: { isCurrent: true },
|
||||
data: { isCurrent: false }
|
||||
});
|
||||
|
||||
// Set the specified version as current
|
||||
const agentVersion = await prisma.agentVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { isCurrent: true }
|
||||
});
|
||||
|
||||
res.json(agentVersion);
|
||||
} catch (error) {
|
||||
console.error('Set current agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to set current agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set default agent version (admin only)
|
||||
router.patch('/agent/versions/:versionId/default', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { versionId } = req.params;
|
||||
|
||||
// First, unset all default versions
|
||||
await prisma.agentVersion.updateMany({
|
||||
where: { isDefault: true },
|
||||
data: { isDefault: false }
|
||||
});
|
||||
|
||||
// Set the specified version as default
|
||||
const agentVersion = await prisma.agentVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { isDefault: true }
|
||||
});
|
||||
|
||||
res.json(agentVersion);
|
||||
} catch (error) {
|
||||
console.error('Set default agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to set default agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete agent version (admin only)
|
||||
router.delete('/agent/versions/:versionId', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { versionId } = req.params;
|
||||
|
||||
const agentVersion = await prisma.agentVersion.findUnique({
|
||||
where: { id: versionId }
|
||||
});
|
||||
|
||||
if (!agentVersion) {
|
||||
return res.status(404).json({ error: 'Agent version not found' });
|
||||
}
|
||||
|
||||
if (agentVersion.isCurrent) {
|
||||
return res.status(400).json({ error: 'Cannot delete current agent version' });
|
||||
}
|
||||
|
||||
await prisma.agentVersion.delete({
|
||||
where: { id: versionId }
|
||||
});
|
||||
|
||||
res.json({ message: 'Agent version deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
213
backend/src/routes/packageRoutes.js
Normal file
213
backend/src/routes/packageRoutes.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all packages with their update status
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 50,
|
||||
search = '',
|
||||
category = '',
|
||||
needsUpdate = '',
|
||||
isSecurityUpdate = ''
|
||||
} = req.query;
|
||||
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
const take = parseInt(limit);
|
||||
|
||||
// Build where clause
|
||||
const where = {
|
||||
AND: [
|
||||
// Search filter
|
||||
search ? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
} : {},
|
||||
// Category filter
|
||||
category ? { category: { equals: category } } : {},
|
||||
// Update status filters
|
||||
needsUpdate ? {
|
||||
hostPackages: {
|
||||
some: {
|
||||
needsUpdate: needsUpdate === 'true'
|
||||
}
|
||||
}
|
||||
} : {},
|
||||
isSecurityUpdate ? {
|
||||
hostPackages: {
|
||||
some: {
|
||||
isSecurityUpdate: isSecurityUpdate === 'true'
|
||||
}
|
||||
}
|
||||
} : {}
|
||||
]
|
||||
};
|
||||
|
||||
// Get packages with counts
|
||||
const [packages, totalCount] = await Promise.all([
|
||||
prisma.package.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
latestVersion: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
hostPackages: true
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
}),
|
||||
prisma.package.count({ where })
|
||||
]);
|
||||
|
||||
// Get additional stats for each package
|
||||
const packagesWithStats = await Promise.all(
|
||||
packages.map(async (pkg) => {
|
||||
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
|
||||
prisma.hostPackage.count({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
needsUpdate: true
|
||||
}
|
||||
}),
|
||||
prisma.hostPackage.count({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
needsUpdate: true,
|
||||
isSecurityUpdate: true
|
||||
}
|
||||
}),
|
||||
prisma.hostPackage.findMany({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
needsUpdate: true
|
||||
},
|
||||
select: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
osType: true
|
||||
}
|
||||
}
|
||||
},
|
||||
take: 10 // Limit to first 10 for performance
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
stats: {
|
||||
totalInstalls: pkg._count.hostPackages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
affectedHosts: affectedHosts.map(hp => hp.host)
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
packages: packagesWithStats,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: totalCount,
|
||||
pages: Math.ceil(totalCount / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching packages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch packages' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get package details by ID
|
||||
router.get('/:packageId', async (req, res) => {
|
||||
try {
|
||||
const { packageId } = req.params;
|
||||
|
||||
const packageData = await prisma.package.findUnique({
|
||||
where: { id: packageId },
|
||||
include: {
|
||||
hostPackages: {
|
||||
include: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
lastUpdate: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
needsUpdate: 'desc'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!packageData) {
|
||||
return res.status(404).json({ error: 'Package not found' });
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
totalInstalls: packageData.hostPackages.length,
|
||||
updatesNeeded: packageData.hostPackages.filter(hp => hp.needsUpdate).length,
|
||||
securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
|
||||
upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length
|
||||
};
|
||||
|
||||
// Group by version
|
||||
const versionDistribution = packageData.hostPackages.reduce((acc, hp) => {
|
||||
const version = hp.currentVersion;
|
||||
acc[version] = (acc[version] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Group by OS type
|
||||
const osDistribution = packageData.hostPackages.reduce((acc, hp) => {
|
||||
const osType = hp.host.osType;
|
||||
acc[osType] = (acc[osType] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
res.json({
|
||||
...packageData,
|
||||
stats,
|
||||
distributions: {
|
||||
versions: Object.entries(versionDistribution).map(([version, count]) => ({
|
||||
version,
|
||||
count
|
||||
})),
|
||||
osTypes: Object.entries(osDistribution).map(([osType, count]) => ({
|
||||
osType,
|
||||
count
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching package details:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch package details' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
173
backend/src/routes/permissionsRoutes.js
Normal file
173
backend/src/routes/permissionsRoutes.js
Normal file
@@ -0,0 +1,173 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { requireManageSettings } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all role permissions
|
||||
router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const permissions = await prisma.rolePermissions.findMany({
|
||||
orderBy: {
|
||||
role: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
console.error('Get role permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get permissions for a specific role
|
||||
router.get('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { role } = req.params;
|
||||
|
||||
const permissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role }
|
||||
});
|
||||
|
||||
if (!permissions) {
|
||||
return res.status(404).json({ error: 'Role not found' });
|
||||
}
|
||||
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
console.error('Get role permission error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role permission' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create or update role permissions
|
||||
router.put('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { role } = req.params;
|
||||
const {
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
} = req.body;
|
||||
|
||||
// Prevent modifying admin role permissions (admin should always have full access)
|
||||
if (role === 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot modify admin role permissions' });
|
||||
}
|
||||
|
||||
const permissions = await prisma.rolePermissions.upsert({
|
||||
where: { role },
|
||||
update: {
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
},
|
||||
create: {
|
||||
role,
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Role permissions updated successfully',
|
||||
permissions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update role permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to update role permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a role (and its permissions)
|
||||
router.delete('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { role } = req.params;
|
||||
|
||||
// Prevent deleting admin role
|
||||
if (role === 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot delete admin role' });
|
||||
}
|
||||
|
||||
// Check if any users are using this role
|
||||
const usersWithRole = await prisma.user.count({
|
||||
where: { role }
|
||||
});
|
||||
|
||||
if (usersWithRole > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.rolePermissions.delete({
|
||||
where: { role }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Role "${role}" deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete role error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete role' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's permissions based on their role
|
||||
router.get('/user-permissions', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userRole = req.user.role;
|
||||
|
||||
const permissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role: userRole }
|
||||
});
|
||||
|
||||
if (!permissions) {
|
||||
// If no specific permissions found, return default admin permissions
|
||||
return res.json({
|
||||
role: userRole,
|
||||
canViewDashboard: true,
|
||||
canViewHosts: true,
|
||||
canManageHosts: true,
|
||||
canViewPackages: true,
|
||||
canManagePackages: true,
|
||||
canViewUsers: true,
|
||||
canManageUsers: true,
|
||||
canViewReports: true,
|
||||
canExportData: true,
|
||||
canManageSettings: true,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
console.error('Get user permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
301
backend/src/routes/repositoryRoutes.js
Normal file
301
backend/src/routes/repositoryRoutes.js
Normal file
@@ -0,0 +1,301 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { requireViewHosts, requireManageHosts } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all repositories with host count
|
||||
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const repositories = await prisma.repository.findMany({
|
||||
include: {
|
||||
hostRepositories: {
|
||||
include: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
hostRepositories: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ name: 'asc' },
|
||||
{ url: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// Transform data to include host counts and status
|
||||
const transformedRepos = repositories.map(repo => ({
|
||||
...repo,
|
||||
hostCount: repo._count.hostRepositories,
|
||||
enabledHostCount: repo.hostRepositories.filter(hr => hr.isEnabled).length,
|
||||
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
|
||||
hosts: repo.hostRepositories.map(hr => ({
|
||||
id: hr.host.id,
|
||||
hostname: hr.host.hostname,
|
||||
status: hr.host.status,
|
||||
isEnabled: hr.isEnabled,
|
||||
lastChecked: hr.lastChecked
|
||||
}))
|
||||
}));
|
||||
|
||||
res.json(transformedRepos);
|
||||
} catch (error) {
|
||||
console.error('Repository list error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch repositories' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get repositories for a specific host
|
||||
router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
const hostRepositories = await prisma.hostRepository.findMany({
|
||||
where: { hostId },
|
||||
include: {
|
||||
repository: true,
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
repository: {
|
||||
name: 'asc'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hostRepositories);
|
||||
} catch (error) {
|
||||
console.error('Host repositories error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host repositories' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get repository details with all hosts
|
||||
router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const { repositoryId } = req.params;
|
||||
|
||||
const repository = await prisma.repository.findUnique({
|
||||
where: { id: repositoryId },
|
||||
include: {
|
||||
hostRepositories: {
|
||||
include: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
status: true,
|
||||
lastUpdate: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
host: {
|
||||
hostname: 'asc'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
return res.status(404).json({ error: 'Repository not found' });
|
||||
}
|
||||
|
||||
res.json(repository);
|
||||
} catch (error) {
|
||||
console.error('Repository detail error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch repository details' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update repository information (admin only)
|
||||
router.put('/:repositoryId', authenticateToken, requireManageHosts, [
|
||||
body('name').optional().isLength({ min: 1 }).withMessage('Name is required'),
|
||||
body('description').optional(),
|
||||
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean'),
|
||||
body('priority').optional().isInt({ min: 0 }).withMessage('Priority must be a positive integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { repositoryId } = req.params;
|
||||
const { name, description, isActive, priority } = req.body;
|
||||
|
||||
const repository = await prisma.repository.update({
|
||||
where: { id: repositoryId },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(description !== undefined && { description }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
...(priority !== undefined && { priority })
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hostRepositories: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(repository);
|
||||
} catch (error) {
|
||||
console.error('Repository update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update repository' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle repository status for a specific host
|
||||
router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requireManageHosts, [
|
||||
body('isEnabled').isBoolean().withMessage('isEnabled must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId, repositoryId } = req.params;
|
||||
const { isEnabled } = req.body;
|
||||
|
||||
const hostRepository = await prisma.hostRepository.update({
|
||||
where: {
|
||||
hostId_repositoryId: {
|
||||
hostId,
|
||||
repositoryId
|
||||
}
|
||||
},
|
||||
data: {
|
||||
isEnabled,
|
||||
lastChecked: new Date()
|
||||
},
|
||||
include: {
|
||||
repository: true,
|
||||
host: {
|
||||
select: {
|
||||
hostname: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
|
||||
hostRepository
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host repository toggle error:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle repository status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get repository statistics
|
||||
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const stats = await prisma.repository.aggregate({
|
||||
_count: true
|
||||
});
|
||||
|
||||
const hostRepoStats = await prisma.hostRepository.aggregate({
|
||||
_count: {
|
||||
isEnabled: true
|
||||
},
|
||||
where: {
|
||||
isEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
const secureRepos = await prisma.repository.count({
|
||||
where: { isSecure: true }
|
||||
});
|
||||
|
||||
const activeRepos = await prisma.repository.count({
|
||||
where: { isActive: true }
|
||||
});
|
||||
|
||||
res.json({
|
||||
totalRepositories: stats._count,
|
||||
activeRepositories: activeRepos,
|
||||
secureRepositories: secureRepos,
|
||||
enabledHostRepositories: hostRepoStats._count.isEnabled,
|
||||
securityPercentage: stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Repository stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch repository statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup orphaned repositories (admin only)
|
||||
router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
console.log('Cleaning up orphaned repositories...');
|
||||
|
||||
// Find repositories with no host relationships
|
||||
const orphanedRepos = await prisma.repository.findMany({
|
||||
where: {
|
||||
hostRepositories: {
|
||||
none: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (orphanedRepos.length === 0) {
|
||||
return res.json({
|
||||
message: 'No orphaned repositories found',
|
||||
deletedCount: 0,
|
||||
deletedRepositories: []
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned repositories
|
||||
const deleteResult = await prisma.repository.deleteMany({
|
||||
where: {
|
||||
hostRepositories: {
|
||||
none: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Deleted ${deleteResult.count} orphaned repositories`);
|
||||
|
||||
res.json({
|
||||
message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
|
||||
deletedCount: deleteResult.count,
|
||||
deletedRepositories: orphanedRepos.map(repo => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
url: repo.url
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Repository cleanup error:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup orphaned repositories' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
253
backend/src/routes/settingsRoutes.js
Normal file
253
backend/src/routes/settingsRoutes.js
Normal file
@@ -0,0 +1,253 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { requireManageSettings } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Function to trigger crontab updates on all hosts with auto-update enabled
|
||||
async function triggerCrontabUpdates() {
|
||||
try {
|
||||
console.log('Triggering crontab updates on all hosts with auto-update enabled...');
|
||||
|
||||
// Get all hosts that have auto-update enabled
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: {
|
||||
autoUpdate: true,
|
||||
status: 'active' // Only update active hosts
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
apiId: true,
|
||||
apiKey: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
|
||||
|
||||
// For each host, we'll send a special update command that triggers crontab update
|
||||
// This is done by sending a ping with a special flag
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
console.log(`Triggering crontab update for host: ${host.hostname}`);
|
||||
|
||||
// We'll use the existing ping endpoint but add a special parameter
|
||||
// The agent will detect this and run update-crontab command
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const serverUrl = process.env.SERVER_URL || 'http://localhost:3001';
|
||||
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const postData = JSON.stringify({
|
||||
triggerCrontabUpdate: true,
|
||||
message: 'Update interval changed, please update your crontab'
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'X-API-ID': host.apiId,
|
||||
'X-API-KEY': host.apiKey
|
||||
}
|
||||
};
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log(`Successfully triggered crontab update for ${host.hostname}`);
|
||||
} else {
|
||||
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Crontab update trigger completed');
|
||||
} catch (error) {
|
||||
console.error('Error in triggerCrontabUpdates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
let settings = await prisma.settings.findFirst();
|
||||
|
||||
// If no settings exist, create default settings
|
||||
if (!settings) {
|
||||
settings = await prisma.settings.create({
|
||||
data: {
|
||||
serverUrl: 'http://localhost:3001',
|
||||
serverProtocol: 'http',
|
||||
serverHost: 'localhost',
|
||||
serverPort: 3001,
|
||||
frontendUrl: 'http://localhost:3000',
|
||||
updateInterval: 60,
|
||||
autoUpdate: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Returning settings:', settings);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update settings
|
||||
router.put('/', authenticateToken, requireManageSettings, [
|
||||
body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
|
||||
body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
|
||||
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
|
||||
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
|
||||
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Settings update request body:', req.body);
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('Validation errors:', errors.array());
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate } = req.body;
|
||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate });
|
||||
|
||||
// Construct server URL from components
|
||||
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
||||
|
||||
let settings = await prisma.settings.findFirst();
|
||||
|
||||
if (settings) {
|
||||
// Update existing settings
|
||||
console.log('Updating existing settings with data:', {
|
||||
serverUrl,
|
||||
serverProtocol,
|
||||
serverHost,
|
||||
serverPort,
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false
|
||||
});
|
||||
const oldUpdateInterval = settings.updateInterval;
|
||||
|
||||
settings = await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
serverUrl,
|
||||
serverProtocol,
|
||||
serverHost,
|
||||
serverPort,
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false
|
||||
}
|
||||
});
|
||||
console.log('Settings updated successfully:', settings);
|
||||
|
||||
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
|
||||
if (oldUpdateInterval !== (updateInterval || 60)) {
|
||||
console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
|
||||
await triggerCrontabUpdates();
|
||||
}
|
||||
} else {
|
||||
// Create new settings
|
||||
settings = await prisma.settings.create({
|
||||
data: {
|
||||
serverUrl,
|
||||
serverProtocol,
|
||||
serverHost,
|
||||
serverPort,
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Settings updated successfully',
|
||||
settings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get server URL for public use (used by installation scripts)
|
||||
router.get('/server-url', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ serverUrl: 'http://localhost:3001' });
|
||||
}
|
||||
|
||||
res.json({ serverUrl: settings.serverUrl });
|
||||
} catch (error) {
|
||||
console.error('Server URL fetch error:', error);
|
||||
res.json({ serverUrl: 'http://localhost:3001' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get update interval policy for agents (public endpoint)
|
||||
router.get('/update-interval', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ updateInterval: 60 });
|
||||
}
|
||||
|
||||
res.json({
|
||||
updateInterval: settings.updateInterval,
|
||||
cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update interval fetch error:', error);
|
||||
res.json({ updateInterval: 60, cronExpression: '0 * * * *' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get auto-update policy for agents (public endpoint)
|
||||
router.get('/auto-update', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ autoUpdate: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
autoUpdate: settings.autoUpdate || false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auto-update fetch error:', error);
|
||||
res.json({ autoUpdate: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
179
backend/src/server.js
Normal file
179
backend/src/server.js
Normal file
@@ -0,0 +1,179 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const winston = require('winston');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const hostRoutes = require('./routes/hostRoutes');
|
||||
const hostGroupRoutes = require('./routes/hostGroupRoutes');
|
||||
const packageRoutes = require('./routes/packageRoutes');
|
||||
const dashboardRoutes = require('./routes/dashboardRoutes');
|
||||
const permissionsRoutes = require('./routes/permissionsRoutes');
|
||||
const settingsRoutes = require('./routes/settingsRoutes');
|
||||
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
|
||||
const repositoryRoutes = require('./routes/repositoryRoutes');
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Initialize logger - only if logging is enabled
|
||||
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||
],
|
||||
}) : {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {}
|
||||
};
|
||||
|
||||
if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.simple()
|
||||
}));
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
|
||||
if (process.env.TRUST_PROXY) {
|
||||
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : parseInt(process.env.TRUST_PROXY, 10) || true);
|
||||
} else {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
|
||||
// Middleware
|
||||
// Helmet with stricter defaults (CSP/HSTS only in production)
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
objectSrc: ["'none'"]
|
||||
}
|
||||
} : false,
|
||||
hsts: process.env.ENABLE_HSTS === 'true' || process.env.NODE_ENV === 'production'
|
||||
}));
|
||||
|
||||
// CORS allowlist from comma-separated env
|
||||
const parseOrigins = (val) => (val || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowedOrigins = parseOrigins(process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || 'http://localhost:3000');
|
||||
app.use(cors({
|
||||
origin: function(origin, callback) {
|
||||
// Allow non-browser/SSR tools with no origin
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true
|
||||
}));
|
||||
app.use(limiter);
|
||||
// Reduce body size limits to reasonable defaults
|
||||
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '5mb' }));
|
||||
|
||||
// Request logging - only if logging is enabled
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path} - ${req.ip}`);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API routes
|
||||
const apiVersion = process.env.API_VERSION || 'v1';
|
||||
|
||||
// Per-route rate limits
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20
|
||||
});
|
||||
const agentLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120
|
||||
});
|
||||
|
||||
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
|
||||
app.use(`/api/${apiVersion}/hosts`, agentLimiter, hostRoutes);
|
||||
app.use(`/api/${apiVersion}/host-groups`, hostGroupRoutes);
|
||||
app.use(`/api/${apiVersion}/packages`, packageRoutes);
|
||||
app.use(`/api/${apiVersion}/dashboard`, dashboardRoutes);
|
||||
app.use(`/api/${apiVersion}/permissions`, permissionsRoutes);
|
||||
app.use(`/api/${apiVersion}/settings`, settingsRoutes);
|
||||
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.error(err.stack);
|
||||
}
|
||||
res.status(500).json({
|
||||
error: 'Something went wrong!',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
Reference in New Issue
Block a user