feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes
## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes
## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table
## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
This commit is contained in:
@@ -57,32 +57,59 @@ async function adminAuth(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if admin still exists and is active
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
// Check if admin still exists and is active, including role info
|
||||
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
|
||||
let admin;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.password_changed_at',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name'
|
||||
)
|
||||
.first();
|
||||
} catch (joinError) {
|
||||
// Fallback: roles table may not exist yet during upgrade
|
||||
// Query without role join - user will have no role info but can still authenticate
|
||||
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'password_changed_at')
|
||||
.first();
|
||||
if (admin) {
|
||||
admin.role_id = null;
|
||||
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
|
||||
}
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
|
||||
// Check if password was changed after token was issued
|
||||
if (admin.password_changed_at) {
|
||||
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
|
||||
if (decoded.iat < passwordChangedTime) {
|
||||
logger.warn('Token used after password change', { userId: decoded.id });
|
||||
return res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info to request
|
||||
|
||||
// Add user info to request (enhanced with role)
|
||||
req.admin = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email
|
||||
email: admin.email,
|
||||
roleId: admin.role_id,
|
||||
roleName: admin.role_name
|
||||
};
|
||||
req.token = token; // Store token for potential revocation
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Permission Checking Middleware for RBAC
|
||||
* Provides role-based access control with caching for performance
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { ForbiddenError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Cache for role permissions (refreshed periodically)
|
||||
let permissionCache = new Map();
|
||||
let cacheLastUpdated = 0;
|
||||
const CACHE_TTL = 60000; // 1 minute
|
||||
|
||||
/**
|
||||
* Refresh permission cache from database
|
||||
* Handles upgrade scenario where RBAC tables may not exist yet
|
||||
*/
|
||||
async function refreshPermissionCache() {
|
||||
const now = Date.now();
|
||||
if (now - cacheLastUpdated < CACHE_TTL && permissionCache.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rolePermissions = await db('role_permissions')
|
||||
.join('roles', 'roles.id', 'role_permissions.role_id')
|
||||
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
|
||||
.select('roles.name as role_name', 'permissions.name as permission_name');
|
||||
|
||||
const newCache = new Map();
|
||||
for (const rp of rolePermissions) {
|
||||
if (!newCache.has(rp.role_name)) {
|
||||
newCache.set(rp.role_name, new Set());
|
||||
}
|
||||
newCache.get(rp.role_name).add(rp.permission_name);
|
||||
}
|
||||
|
||||
permissionCache = newCache;
|
||||
cacheLastUpdated = now;
|
||||
} catch (error) {
|
||||
// Handle case where RBAC tables don't exist yet (upgrade scenario)
|
||||
// Grant super_admin all permissions by default during upgrade window
|
||||
if (error.message.includes('no such table') || error.message.includes('does not exist') || error.message.includes('relation')) {
|
||||
logger.warn('RBAC tables not available yet - granting full access to authenticated users during upgrade');
|
||||
const allPermissions = new Set([
|
||||
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
|
||||
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
|
||||
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
|
||||
'analytics.view', 'email.view', 'email.edit', 'email.send',
|
||||
'branding.view', 'branding.edit', 'cms.view', 'cms.edit',
|
||||
'settings.view', 'settings.edit', 'backup.view', 'backup.create', 'backup.restore', 'backup.delete',
|
||||
'users.view', 'users.create', 'users.edit', 'users.delete',
|
||||
'activity.view', 'activity.export'
|
||||
]);
|
||||
permissionCache.set('super_admin', allPermissions);
|
||||
cacheLastUpdated = now;
|
||||
} else {
|
||||
logger.error('Failed to refresh permission cache', { error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role has a specific permission
|
||||
* @param {string} roleName - Role name to check
|
||||
* @param {string} permissionName - Permission name to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function roleHasPermission(roleName, permissionName) {
|
||||
await refreshPermissionCache();
|
||||
const rolePerms = permissionCache.get(roleName);
|
||||
return rolePerms ? rolePerms.has(permissionName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified permissions
|
||||
* @param {number} userId - User ID to check
|
||||
* @param {string[]} permissions - Array of permission names
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function userHasAnyPermission(userId, permissions) {
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', userId)
|
||||
.select('roles.name as role_name')
|
||||
.first();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
for (const perm of permissions) {
|
||||
if (await roleHasPermission(user.role_name, perm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has all specified permissions
|
||||
* @param {number} userId - User ID to check
|
||||
* @param {string[]} permissions - Array of permission names
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function userHasAllPermissions(userId, permissions) {
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', userId)
|
||||
.select('roles.name as role_name')
|
||||
.first();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
for (const perm of permissions) {
|
||||
if (!(await roleHasPermission(user.role_name, perm))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory: require specific permission(s)
|
||||
* @param {string|string[]} permissions - Permission name(s) required
|
||||
* @param {object} options - { requireAll: boolean }
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
function requirePermission(permissions, options = { requireAll: false }) {
|
||||
const permArray = Array.isArray(permissions) ? permissions : [permissions];
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (!req.admin || !req.admin.id) {
|
||||
throw new ForbiddenError('Authentication required');
|
||||
}
|
||||
|
||||
const hasPermission = options.requireAll
|
||||
? await userHasAllPermissions(req.admin.id, permArray)
|
||||
: await userHasAnyPermission(req.admin.id, permArray);
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn('Permission denied', {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
requiredPermissions: permArray,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Insufficient permissions');
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError) {
|
||||
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: require super_admin role
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
function requireSuperAdmin() {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (!req.admin || !req.admin.id) {
|
||||
throw new ForbiddenError('Authentication required');
|
||||
}
|
||||
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', req.admin.id)
|
||||
.select('roles.name as role_name')
|
||||
.first();
|
||||
|
||||
if (!user || user.role_name !== 'super_admin') {
|
||||
logger.warn('Super admin access denied', {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Super Admin access required');
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError) {
|
||||
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's permissions for client
|
||||
* @param {number} userId - User ID
|
||||
* @returns {Promise<{role: object|null, permissions: string[]}>}
|
||||
*/
|
||||
async function getUserPermissions(userId) {
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', userId)
|
||||
.select('roles.name as role_name', 'roles.display_name as role_display_name')
|
||||
.first();
|
||||
|
||||
if (!user) return { role: null, permissions: [] };
|
||||
|
||||
await refreshPermissionCache();
|
||||
const permissions = permissionCache.get(user.role_name) || new Set();
|
||||
|
||||
return {
|
||||
role: {
|
||||
name: user.role_name,
|
||||
displayName: user.role_display_name
|
||||
},
|
||||
permissions: Array.from(permissions)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear permission cache (useful for testing or when permissions change)
|
||||
*/
|
||||
function clearPermissionCache() {
|
||||
permissionCache.clear();
|
||||
cacheLastUpdated = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requirePermission,
|
||||
requireSuperAdmin,
|
||||
getUserPermissions,
|
||||
userHasAnyPermission,
|
||||
userHasAllPermissions,
|
||||
roleHasPermission,
|
||||
refreshPermissionCache,
|
||||
clearPermissionCache
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Accept Invitation Routes (Public)
|
||||
* Handles invitation token validation and account creation
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const userManagementService = require('../services/userManagementService');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /:token
|
||||
* Validate invitation token
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
router.get('/:token', [
|
||||
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const invitation = await userManagementService.validateInvitationToken(req.params.token);
|
||||
|
||||
if (!invitation) {
|
||||
return res.status(404).json({ error: 'Invalid or expired invitation' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
email: invitation.email,
|
||||
role: invitation.role_name,
|
||||
expiresAt: invitation.expires_at
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /:token
|
||||
* Accept invitation and create account
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
router.post('/:token', [
|
||||
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token'),
|
||||
body('username')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 50 })
|
||||
.withMessage('Username must be 3-50 characters')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/)
|
||||
.withMessage('Username can only contain letters, numbers, underscores, and hyphens'),
|
||||
body('password')
|
||||
.isLength({ min: 12 })
|
||||
.withMessage('Password must be at least 12 characters')
|
||||
.custom((value) => {
|
||||
const validation = validatePasswordStrength(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.messages.join(', '));
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const result = await userManagementService.acceptInvitation({
|
||||
token: req.params.token,
|
||||
username: req.body.username,
|
||||
password: req.body.password
|
||||
});
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Account created successfully. You can now log in.',
|
||||
email: result.email
|
||||
}, 201);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,12 +4,13 @@ const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const AdmZip = require('adm-zip');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all archived events
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
@@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get single archive details
|
||||
router.get('/:id', adminAuth, async (req, res) => {
|
||||
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Restore archive
|
||||
router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download archive
|
||||
router.get('/:id/download', adminAuth, async (req, res) => {
|
||||
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete archive permanently
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
const logger = require('../utils/logger');
|
||||
const fs = require('fs').promises;
|
||||
@@ -12,7 +13,7 @@ const S3StorageAdapter = require('../services/storage/s3Storage');
|
||||
const router = express.Router();
|
||||
|
||||
// Get backup configuration
|
||||
router.get('/config', adminAuth, async (req, res) => {
|
||||
router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
@@ -35,7 +36,7 @@ router.get('/config', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update backup configuration
|
||||
router.put('/config', adminAuth, async (req, res) => {
|
||||
router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
@@ -97,7 +98,7 @@ router.put('/config', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get backup status and history
|
||||
router.get('/status', adminAuth, async (req, res) => {
|
||||
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const status = await getBackupStatus(limit);
|
||||
@@ -110,7 +111,7 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Trigger manual backup
|
||||
router.post('/run', adminAuth, async (req, res) => {
|
||||
router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
// Check if backup is already running
|
||||
const status = await getBackupStatus();
|
||||
@@ -131,7 +132,7 @@ router.post('/run', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get backup run details
|
||||
router.get('/runs/:id', adminAuth, async (req, res) => {
|
||||
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -160,7 +161,7 @@ router.get('/runs/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get file states (for debugging/monitoring)
|
||||
router.get('/files', adminAuth, async (req, res) => {
|
||||
router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 50, search = '' } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -195,7 +196,7 @@ router.get('/files', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Clean up old backup runs
|
||||
router.delete('/cleanup', adminAuth, async (req, res) => {
|
||||
router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
||||
try {
|
||||
const { days = 30 } = req.body;
|
||||
|
||||
@@ -209,7 +210,7 @@ router.delete('/cleanup', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Test backup destination connectivity
|
||||
router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const { destination_type, ...config } = req.body;
|
||||
|
||||
@@ -334,7 +335,7 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get backup manifest for a specific backup run
|
||||
router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
||||
router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupRunId } = req.params;
|
||||
const result = await getBackupManifest(backupRunId);
|
||||
@@ -351,7 +352,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Validate a backup manifest
|
||||
router.post('/manifest/validate', adminAuth, async (req, res) => {
|
||||
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath } = req.body;
|
||||
|
||||
@@ -373,7 +374,7 @@ router.post('/manifest/validate', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download backup manifest
|
||||
router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
||||
router.get('/manifest/:backupRunId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupRunId } = req.params;
|
||||
const { format = 'json' } = req.query;
|
||||
@@ -404,7 +405,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get manifest for specific backup
|
||||
router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
||||
router.get('/manifests/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupId } = req.params;
|
||||
const result = await getBackupManifest(backupId);
|
||||
@@ -421,7 +422,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download manifest file
|
||||
router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
||||
router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupId } = req.params;
|
||||
const { format = 'json' } = req.query;
|
||||
@@ -452,7 +453,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Validate a manifest
|
||||
router.post('/manifests/validate', adminAuth, async (req, res) => {
|
||||
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath, manifestData } = req.body;
|
||||
|
||||
@@ -481,7 +482,7 @@ router.post('/manifests/validate', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// List S3 buckets
|
||||
router.get('/s3/buckets', adminAuth, async (req, res) => {
|
||||
router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const config = await getBackupConfig();
|
||||
|
||||
@@ -513,7 +514,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// List files in S3 backup location
|
||||
router.get('/s3/files', adminAuth, async (req, res) => {
|
||||
router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
|
||||
const config = await getBackupConfig();
|
||||
@@ -550,7 +551,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Clean up old S3 backups
|
||||
router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
||||
router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
||||
try {
|
||||
const { retentionDays = 30, dryRun = false } = req.body;
|
||||
const config = await getBackupConfig();
|
||||
@@ -612,7 +613,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Test S3 upload functionality
|
||||
router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
||||
router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const config = await getBackupConfig();
|
||||
|
||||
@@ -664,7 +665,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download entire backup
|
||||
router.get('/download/:backupId', adminAuth, async (req, res) => {
|
||||
router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupId } = req.params;
|
||||
|
||||
@@ -752,7 +753,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get current file checksums
|
||||
router.get('/checksums', adminAuth, async (req, res) => {
|
||||
router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { path: targetPath = '', recursive = true } = req.query;
|
||||
const checksums = {};
|
||||
@@ -821,7 +822,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Estimate backup size before running
|
||||
router.post('/estimate', adminAuth, async (req, res) => {
|
||||
router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { includeArchived = true } = req.body;
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all CMS pages
|
||||
router.get('/pages', adminAuth, async (req, res) => {
|
||||
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
|
||||
try {
|
||||
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
|
||||
res.json(pages);
|
||||
@@ -16,7 +17,7 @@ router.get('/pages', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get a single CMS page
|
||||
router.get('/pages/:slug', adminAuth, async (req, res) => {
|
||||
router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const page = await db('cms_pages').where('slug', slug).first();
|
||||
@@ -33,7 +34,7 @@ router.get('/pages/:slug', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a CMS page
|
||||
router.put('/pages/:slug', adminAuth, [
|
||||
router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
body('title_en').optional().isString(),
|
||||
body('title_de').optional().isString(),
|
||||
body('content_en').optional().isString(),
|
||||
|
||||
@@ -3,10 +3,11 @@ const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all global categories
|
||||
router.get('/global', adminAuth, async (req, res) => {
|
||||
router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
@@ -20,7 +21,7 @@ router.get('/global', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
router.get('/event/:eventId', adminAuth, async (req, res) => {
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
@@ -40,7 +41,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Create a new category
|
||||
router.post('/', adminAuth, [
|
||||
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().withMessage('Category name is required'),
|
||||
body('slug').optional(),
|
||||
body('is_global').optional().isBoolean(),
|
||||
@@ -104,7 +105,7 @@ router.post('/', adminAuth, [
|
||||
});
|
||||
|
||||
// Update a category
|
||||
router.put('/:id', adminAuth, [
|
||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().withMessage('Category name is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -149,7 +150,7 @@ router.put('/:id', adminAuth, [
|
||||
});
|
||||
|
||||
// Delete a category
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const router = express.Router();
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
|
||||
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
|
||||
|
||||
@@ -15,7 +16,7 @@ const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_temp
|
||||
* GET /admin/css-templates
|
||||
* Get all CSS templates
|
||||
*/
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('branding.view'), async (req, res) => {
|
||||
try {
|
||||
const templates = await withRetry(() =>
|
||||
db('css_templates').orderBy('slot_number')
|
||||
@@ -31,7 +32,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
* GET /admin/css-templates/enabled
|
||||
* Get only enabled templates (for event form dropdown)
|
||||
*/
|
||||
router.get('/enabled', adminAuth, async (req, res) => {
|
||||
router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req, res) => {
|
||||
try {
|
||||
const templates = await withRetry(() =>
|
||||
db('css_templates')
|
||||
@@ -50,7 +51,7 @@ router.get('/enabled', adminAuth, async (req, res) => {
|
||||
* GET /admin/css-templates/:slotNumber
|
||||
* Get a specific template by slot number
|
||||
*/
|
||||
router.get('/:slotNumber', adminAuth, [
|
||||
router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
|
||||
param('slotNumber').isInt({ min: 1, max: 3 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -81,7 +82,7 @@ router.get('/:slotNumber', adminAuth, [
|
||||
* PUT /admin/css-templates/:slotNumber
|
||||
* Update a template
|
||||
*/
|
||||
router.put('/:slotNumber', adminAuth, [
|
||||
router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
|
||||
param('slotNumber').isInt({ min: 1, max: 3 }),
|
||||
body('name').optional().isString().isLength({ max: 50 }),
|
||||
body('css_content').optional().isString(),
|
||||
@@ -158,7 +159,7 @@ router.put('/:slotNumber', adminAuth, [
|
||||
* POST /admin/css-templates/:slotNumber/reset
|
||||
* Reset template to default (only for slot 1)
|
||||
*/
|
||||
router.post('/:slotNumber/reset', adminAuth, [
|
||||
router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'), [
|
||||
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const router = express.Router();
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', adminAuth, async (req, res) => {
|
||||
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
// Get active events count
|
||||
const activeEvents = await db('events')
|
||||
@@ -106,7 +107,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get recent activity
|
||||
router.get('/activity', adminAuth, async (req, res) => {
|
||||
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
|
||||
@@ -144,7 +145,7 @@ router.get('/activity', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get system health status
|
||||
router.get('/health', adminAuth, async (req, res) => {
|
||||
router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const os = require('os');
|
||||
|
||||
@@ -216,7 +217,7 @@ router.get('/health', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get analytics data for charts
|
||||
router.get('/analytics', adminAuth, async (req, res) => {
|
||||
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
const days = sanitizeDays(req.query.days || 7);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { databaseBackupService } = require('../services/databaseBackup');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -11,7 +12,7 @@ router.use(adminAuth);
|
||||
/**
|
||||
* Get database backup status and configuration
|
||||
*/
|
||||
router.get('/status', async (req, res) => {
|
||||
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
// Get configuration
|
||||
const config = await databaseBackupService.getBackupConfig();
|
||||
@@ -45,7 +46,7 @@ router.get('/status', async (req, res) => {
|
||||
/**
|
||||
* Update database backup configuration
|
||||
*/
|
||||
router.put('/config', async (req, res) => {
|
||||
router.put('/config', requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const allowedSettings = [
|
||||
'database_backup_enabled',
|
||||
@@ -108,7 +109,7 @@ router.put('/config', async (req, res) => {
|
||||
/**
|
||||
* Trigger manual database backup
|
||||
*/
|
||||
router.post('/backup', async (req, res) => {
|
||||
router.post('/backup', requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
if (databaseBackupService.isRunning) {
|
||||
return res.status(409).json({ error: 'Backup already in progress' });
|
||||
@@ -134,7 +135,7 @@ router.post('/backup', async (req, res) => {
|
||||
/**
|
||||
* Get current backup progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const progress = databaseBackupService.getProgress();
|
||||
|
||||
@@ -151,7 +152,7 @@ router.get('/progress', async (req, res) => {
|
||||
/**
|
||||
* Get backup history with pagination
|
||||
*/
|
||||
router.get('/history', async (req, res) => {
|
||||
router.get('/history', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
@@ -183,7 +184,7 @@ router.get('/history', async (req, res) => {
|
||||
/**
|
||||
* Delete old backup files
|
||||
*/
|
||||
router.delete('/cleanup', async (req, res) => {
|
||||
router.delete('/cleanup', requirePermission('backup.delete'), async (req, res) => {
|
||||
try {
|
||||
const { retentionDays = 30 } = req.body;
|
||||
|
||||
@@ -202,7 +203,7 @@ router.delete('/cleanup', async (req, res) => {
|
||||
/**
|
||||
* Test database backup configuration
|
||||
*/
|
||||
router.post('/test', async (req, res) => {
|
||||
router.post('/test', requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const config = await databaseBackupService.getBackupConfig();
|
||||
|
||||
@@ -255,7 +256,7 @@ router.post('/test', async (req, res) => {
|
||||
/**
|
||||
* Get table checksums
|
||||
*/
|
||||
router.get('/checksums', async (req, res) => {
|
||||
router.get('/checksums', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const checksums = await databaseBackupService.getTableChecksums();
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ const nodemailer = require('nodemailer');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
router.get('/config', adminAuth, async (req, res) => {
|
||||
router.get('/config', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const config = await db('email_configs').first();
|
||||
|
||||
@@ -37,6 +38,7 @@ router.get('/config', adminAuth, async (req, res) => {
|
||||
// Update email configuration
|
||||
router.post('/config', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('smtp_host').notEmpty().withMessage('SMTP host is required'),
|
||||
body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
|
||||
body('from_email').isEmail().withMessage('Invalid from email address')
|
||||
@@ -100,7 +102,7 @@ router.post('/config', [
|
||||
});
|
||||
|
||||
// Test email configuration
|
||||
router.post('/test', adminAuth, async (req, res) => {
|
||||
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const { test_email } = req.body;
|
||||
|
||||
@@ -236,7 +238,7 @@ router.post('/test', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get email templates
|
||||
router.get('/templates', adminAuth, async (req, res) => {
|
||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const templates = await db('email_templates')
|
||||
.select('*')
|
||||
@@ -290,7 +292,7 @@ router.get('/templates', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get single template
|
||||
router.get('/templates/:key', adminAuth, async (req, res) => {
|
||||
router.get('/templates/:key', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
@@ -346,6 +348,7 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
|
||||
// Update email template
|
||||
router.put('/templates/:key', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
||||
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
||||
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
||||
@@ -424,7 +427,7 @@ router.put('/templates/:key', [
|
||||
});
|
||||
|
||||
// Preview email template
|
||||
router.post('/templates/:key/preview', adminAuth, async (req, res) => {
|
||||
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const eventRenameService = require('../services/eventRenameService');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,7 +14,7 @@ const router = express.Router();
|
||||
* POST /api/admin/events/:eventId/rename
|
||||
* Rename an event
|
||||
*/
|
||||
router.post('/:eventId/rename', adminAuth, [
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
@@ -58,7 +59,7 @@ router.post('/:eventId/rename', adminAuth, [
|
||||
* POST /api/admin/events/:eventId/validate-rename
|
||||
* Validate a potential rename without executing it
|
||||
*/
|
||||
router.post('/:eventId/validate-rename', adminAuth, [
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
router.post('/', adminAuth, [
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
|
||||
@@ -3,6 +3,7 @@ const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
@@ -102,7 +103,7 @@ const hasCustomerContactColumns = async () => {
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
@@ -296,6 +297,7 @@ router.post('/', adminAuth, [
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
upload_category_id,
|
||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||
@@ -375,7 +377,7 @@ router.post('/', adminAuth, [
|
||||
});
|
||||
|
||||
// Get all events with pagination and filters
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
@@ -387,7 +389,12 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
|
||||
// Build query
|
||||
let query = db('events');
|
||||
|
||||
|
||||
// Editor role can only see their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
query = query.where('created_by', req.admin.id);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
const escapedSearch = escapeLikePattern(search);
|
||||
@@ -465,13 +472,18 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get single event details
|
||||
router.get('/:id', adminAuth, async (req, res) => {
|
||||
router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
let query = db('events').where('id', id);
|
||||
|
||||
// Editor role can only see their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
query = query.where('created_by', req.admin.id);
|
||||
}
|
||||
|
||||
const event = await query.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
@@ -525,7 +537,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, [
|
||||
router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
body('event_name').optional().trim().notEmpty(),
|
||||
body('admin_email').optional().isEmail(),
|
||||
body('is_active').optional().isBoolean(),
|
||||
@@ -659,7 +671,12 @@ router.put('/:id', adminAuth, [
|
||||
});
|
||||
|
||||
// Check if event exists
|
||||
const event = await db('events').where('id', id).first();
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
@@ -696,7 +713,7 @@ router.put('/:id', adminAuth, [
|
||||
});
|
||||
|
||||
// Delete event
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -777,11 +794,16 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Toggle event status
|
||||
router.post('/:id/toggle-status', adminAuth, async (req, res) => {
|
||||
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
@@ -812,12 +834,17 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Reset event password
|
||||
router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true } = req.body;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
@@ -874,15 +901,18 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
// Get event details
|
||||
const event = await db('events')
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
@@ -954,7 +984,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -985,7 +1015,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Bulk archive events
|
||||
router.post('/bulk-archive', adminAuth, [
|
||||
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
body('eventIds').isArray().withMessage('eventIds must be an array'),
|
||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||
], async (req, res) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/admin/external-media/list?path=relative/dir
|
||||
router.get('/list', adminAuth, async (req, res) => {
|
||||
router.get('/list', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const relPath = (req.query.path || '').replace(/^\/+/, '');
|
||||
const result = await list(relPath);
|
||||
@@ -45,7 +46,7 @@ async function walkDir(dir, baseDir) {
|
||||
|
||||
// POST /api/admin/events/:id/import-external
|
||||
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
|
||||
router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
||||
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.id);
|
||||
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackModeration = require('../services/feedbackModeration');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -13,8 +14,9 @@ const {
|
||||
} = require('../utils/feedbackValidation');
|
||||
|
||||
// Get event feedback settings
|
||||
router.get('/events/:eventId/feedback-settings',
|
||||
router.get('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -39,6 +41,7 @@ router.get('/events/:eventId/feedback-settings',
|
||||
// Update event feedback settings
|
||||
router.put('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
validateEventId,
|
||||
validateFeedbackSettings,
|
||||
checkValidation,
|
||||
@@ -75,6 +78,7 @@ router.put('/events/:eventId/feedback-settings',
|
||||
// Get feedback for an event (with filters)
|
||||
router.get('/events/:eventId/feedback',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -159,6 +163,7 @@ router.get('/events/:eventId/feedback',
|
||||
// Moderate feedback (approve/hide/reject)
|
||||
router.put('/feedback/:feedbackId/:action',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { feedbackId, action } = req.params;
|
||||
@@ -180,6 +185,7 @@ router.put('/feedback/:feedbackId/:action',
|
||||
// Delete feedback
|
||||
router.delete('/feedback/:feedbackId',
|
||||
adminAuth,
|
||||
requirePermission('events.delete'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { feedbackId } = req.params;
|
||||
@@ -197,6 +203,7 @@ router.delete('/feedback/:feedbackId',
|
||||
// Get feedback analytics for an event
|
||||
router.get('/events/:eventId/feedback-analytics',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -296,6 +303,7 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
// Export feedback data
|
||||
router.get('/events/:eventId/feedback/export',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -324,6 +332,7 @@ router.get('/events/:eventId/feedback/export',
|
||||
// Get pending moderation items (across all events)
|
||||
router.get('/feedback/pending-moderation',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const pending = await feedbackService.getPendingModeration();
|
||||
@@ -338,6 +347,7 @@ router.get('/feedback/pending-moderation',
|
||||
// Word filter management
|
||||
router.get('/word-filters',
|
||||
adminAuth,
|
||||
requirePermission('settings.view'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const filters = await feedbackModeration.getAllWordFilters();
|
||||
@@ -351,6 +361,7 @@ router.get('/word-filters',
|
||||
|
||||
router.post('/word-filters',
|
||||
adminAuth,
|
||||
requirePermission('settings.edit'),
|
||||
validateWordFilter,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -378,6 +389,7 @@ router.post('/word-filters',
|
||||
|
||||
router.put('/word-filters/:id',
|
||||
adminAuth,
|
||||
requirePermission('settings.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -395,6 +407,7 @@ router.put('/word-filters/:id',
|
||||
|
||||
router.delete('/word-filters/:id',
|
||||
adminAuth,
|
||||
requirePermission('settings.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -9,7 +10,7 @@ const router = express.Router();
|
||||
/**
|
||||
* Get image security settings
|
||||
*/
|
||||
router.get('/settings', adminAuth, async (req, res) => {
|
||||
router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
@@ -46,7 +47,7 @@ router.get('/settings', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Update image security settings
|
||||
*/
|
||||
router.put('/settings', adminAuth, async (req, res) => {
|
||||
router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
@@ -97,7 +98,7 @@ router.put('/settings', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Get security monitoring dashboard data
|
||||
*/
|
||||
router.get('/dashboard', adminAuth, async (req, res) => {
|
||||
router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { timeframe = '24h' } = req.query;
|
||||
|
||||
@@ -202,7 +203,7 @@ router.get('/dashboard', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Get detailed security logs
|
||||
*/
|
||||
router.get('/logs', adminAuth, async (req, res) => {
|
||||
router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
@@ -271,7 +272,7 @@ router.get('/logs', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Get image access logs for a specific event
|
||||
*/
|
||||
router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
|
||||
router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { page = 1, limit = 50 } = req.query;
|
||||
@@ -321,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Block/unblock suspicious IPs
|
||||
*/
|
||||
router.post('/block-ip', adminAuth, async (req, res) => {
|
||||
router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { ip, action = 'block' } = req.body;
|
||||
|
||||
@@ -367,7 +368,7 @@ router.post('/block-ip', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Clear security logs older than specified time
|
||||
*/
|
||||
router.delete('/logs/cleanup', adminAuth, async (req, res) => {
|
||||
router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { olderThan = '30d' } = req.body;
|
||||
|
||||
@@ -424,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Export security data for analysis
|
||||
*/
|
||||
router.get('/export', adminAuth, async (req, res) => {
|
||||
router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { format = 'json', timeframe = '7d' } = req.query;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
const express = require('express');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get notifications (unread activity logs)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { limit = 20, includeRead = false } = req.query;
|
||||
|
||||
@@ -64,7 +65,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Mark notification as read
|
||||
router.put('/:id/read', adminAuth, async (req, res) => {
|
||||
router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -82,7 +83,7 @@ router.put('/:id/read', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Mark all notifications as read
|
||||
router.put('/read-all', adminAuth, async (req, res) => {
|
||||
router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
await db('activity_logs')
|
||||
.whereNull('read_at')
|
||||
@@ -98,7 +99,7 @@ router.put('/read-all', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete old notifications (older than 30 days and read)
|
||||
router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
router.delete('/clear-old', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
// Use database-agnostic date calculation
|
||||
const thirtyDaysAgo = new Date();
|
||||
|
||||
@@ -8,6 +8,7 @@ const router = express.Router();
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
||||
const { PhotoExportService } = require('../services/photoExportService');
|
||||
|
||||
@@ -17,7 +18,7 @@ const exportService = new PhotoExportService();
|
||||
* GET /admin/photos/:eventId/filtered
|
||||
* Get filtered photos with pagination
|
||||
*/
|
||||
router.get('/:eventId/filtered', adminAuth, [
|
||||
router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
|
||||
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
|
||||
query('has_likes').optional().isBoolean(),
|
||||
@@ -131,7 +132,7 @@ router.get('/:eventId/filtered', adminAuth, [
|
||||
* GET /admin/photos/:eventId/filter-summary
|
||||
* Get just the summary counts for filter UI
|
||||
*/
|
||||
router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
|
||||
@@ -153,7 +154,7 @@ router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
|
||||
* POST /admin/photos/:eventId/export
|
||||
* Export selected or filtered photos
|
||||
*/
|
||||
router.post('/:eventId/export', adminAuth, [
|
||||
router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [
|
||||
body('photo_ids').optional().isArray(),
|
||||
body('photo_ids.*').optional().isInt(),
|
||||
body('filter').optional().isObject(),
|
||||
@@ -213,7 +214,7 @@ router.post('/:eventId/export', adminAuth, [
|
||||
* GET /admin/photos/export-formats
|
||||
* Get available export format options
|
||||
*/
|
||||
router.get('/export-formats', adminAuth, (req, res) => {
|
||||
router.get('/export-formats', adminAuth, requirePermission('photos.view'), (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: PhotoExportService.getFormatOptions()
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
@@ -109,7 +110,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
|
||||
// Upload photos for an event
|
||||
// Max file count is configurable via general settings
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
@@ -420,7 +421,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
});
|
||||
|
||||
// Delete a photo
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -477,7 +478,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a photo (e.g., change category)
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
@@ -525,7 +526,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Bulk delete photos
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds } = req.body;
|
||||
@@ -593,7 +594,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Bulk update photos
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds, updates } = req.body;
|
||||
@@ -651,7 +652,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download a photo
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -683,7 +684,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
|
||||
});
|
||||
|
||||
// Get all photos for an event
|
||||
router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
@@ -767,7 +768,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Serve photo with admin authentication
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -804,7 +805,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Serve thumbnail with admin authentication
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -844,7 +845,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Debug endpoint to check photo existence
|
||||
router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
@@ -870,7 +871,7 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
// ============================================
|
||||
|
||||
// Initialize a chunked upload
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||
@@ -908,7 +909,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload a chunk
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const { uploadId, chunkIndex } = req.params;
|
||||
|
||||
@@ -929,7 +930,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, a
|
||||
});
|
||||
|
||||
// Complete chunked upload and process the file
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, uploadId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
@@ -971,7 +972,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req
|
||||
});
|
||||
|
||||
// Get upload status
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
@@ -989,7 +990,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, r
|
||||
});
|
||||
|
||||
// Abort chunked upload
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => {
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { restoreService } = require('../services/restoreService');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const logger = require('../utils/logger');
|
||||
const { db } = require('../database/db');
|
||||
@@ -16,10 +17,36 @@ const fs = require('fs').promises;
|
||||
// Apply admin authentication to all routes
|
||||
router.use(adminAuth);
|
||||
|
||||
/**
|
||||
* Transform frontend S3 config to backend format
|
||||
* Frontend sends: s3Endpoint, s3Bucket, s3AccessKey, s3SecretKey, s3Region
|
||||
* Backend expects: endpoint, bucket, accessKeyId, secretAccessKey, region
|
||||
*/
|
||||
function transformS3Config(body) {
|
||||
if (body.s3Config) {
|
||||
// Already in correct format
|
||||
return body.s3Config;
|
||||
}
|
||||
|
||||
// Check if frontend sent flat S3 config fields
|
||||
if (body.s3Endpoint || body.s3Bucket || body.s3AccessKey || body.s3SecretKey) {
|
||||
return {
|
||||
endpoint: body.s3Endpoint,
|
||||
bucket: body.s3Bucket,
|
||||
accessKeyId: body.s3AccessKey,
|
||||
secretAccessKey: body.s3SecretKey,
|
||||
region: body.s3Region || 'us-east-1',
|
||||
forcePathStyle: body.s3ForcePathStyle !== false
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get restore service status and history
|
||||
*/
|
||||
router.get('/status', async (req, res) => {
|
||||
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const history = await restoreService.getRestoreHistory(limit);
|
||||
@@ -47,7 +74,7 @@ router.get('/status', async (req, res) => {
|
||||
/**
|
||||
* Validate restore request
|
||||
*/
|
||||
router.post('/validate', [
|
||||
router.post('/validate', requirePermission('backup.restore'), [
|
||||
body('source').notEmpty().withMessage('Backup source is required'),
|
||||
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
||||
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
||||
@@ -63,18 +90,38 @@ router.post('/validate', [
|
||||
}
|
||||
|
||||
try {
|
||||
// Transform S3 config from frontend format
|
||||
const s3Config = transformS3Config(req.body);
|
||||
|
||||
// Perform dry run validation
|
||||
const result = await restoreService.restore({
|
||||
...req.body,
|
||||
source: req.body.source,
|
||||
manifestPath: req.body.manifestPath,
|
||||
restoreType: req.body.restoreType,
|
||||
selectedItems: req.body.selectedItems,
|
||||
s3Config,
|
||||
dryRun: true,
|
||||
force: false
|
||||
});
|
||||
|
||||
|
||||
// Transform spaceCheck to match frontend expected format
|
||||
const spaceCheck = result.spaceCheck ? {
|
||||
sufficient: result.spaceCheck.hasEnoughSpace,
|
||||
required: result.spaceCheck.requiredBytes,
|
||||
available: result.spaceCheck.availableBytes,
|
||||
requiredFormatted: result.spaceCheck.requiredFormatted,
|
||||
availableFormatted: result.spaceCheck.availableFormatted,
|
||||
// Keep original fields for backwards compatibility
|
||||
hasEnoughSpace: result.spaceCheck.hasEnoughSpace,
|
||||
requiredBytes: result.spaceCheck.requiredBytes,
|
||||
availableBytes: result.spaceCheck.availableBytes
|
||||
} : null;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
validation: result.validation,
|
||||
spaceCheck: result.spaceCheck,
|
||||
spaceCheck,
|
||||
logs: result.logs
|
||||
}
|
||||
});
|
||||
@@ -82,7 +129,7 @@ router.post('/validate', [
|
||||
logger.error('Restore validation failed:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Restore validation failed',
|
||||
error: error.message || 'Restore validation failed',
|
||||
logs: restoreService.restoreLog
|
||||
});
|
||||
}
|
||||
@@ -91,7 +138,7 @@ router.post('/validate', [
|
||||
/**
|
||||
* Start restore operation
|
||||
*/
|
||||
router.post('/start', [
|
||||
router.post('/start', requirePermission('backup.restore'), [
|
||||
body('source').notEmpty().withMessage('Backup source is required'),
|
||||
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
||||
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
||||
@@ -135,19 +182,28 @@ router.post('/start', [
|
||||
|
||||
// Log restore attempt
|
||||
logger.warn('Restore operation started', {
|
||||
user: req.user.email,
|
||||
user: req.admin.email,
|
||||
ip: req.ip,
|
||||
restoreType: req.body.restoreType,
|
||||
source: req.body.source
|
||||
});
|
||||
|
||||
// Transform S3 config from frontend format
|
||||
const s3Config = transformS3Config(req.body);
|
||||
|
||||
// Start restore in background
|
||||
restoreService.restore({
|
||||
...req.body,
|
||||
source: req.body.source,
|
||||
manifestPath: req.body.manifestPath,
|
||||
restoreType: req.body.restoreType,
|
||||
selectedItems: req.body.selectedItems,
|
||||
skipPreBackup: req.body.skipPreBackup,
|
||||
force: req.body.force,
|
||||
s3Config,
|
||||
dryRun: false,
|
||||
operator: {
|
||||
type: 'manual',
|
||||
userId: req.user.id,
|
||||
userId: req.admin.id,
|
||||
ip: req.ip
|
||||
}
|
||||
}).catch(error => {
|
||||
@@ -160,9 +216,10 @@ router.post('/start', [
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start restore:', error);
|
||||
logger.error('Error stack:', error.stack);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to start restore operation'
|
||||
error: error.message || 'Failed to start restore operation'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -170,7 +227,7 @@ router.post('/start', [
|
||||
/**
|
||||
* Get current restore progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const progress = restoreService.getProgress();
|
||||
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
|
||||
@@ -195,7 +252,7 @@ router.get('/progress', async (req, res) => {
|
||||
/**
|
||||
* Get restore run details
|
||||
*/
|
||||
router.get('/run/:id', async (req, res) => {
|
||||
router.get('/run/:id', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const run = await db('restore_runs')
|
||||
.where('id', req.params.id)
|
||||
@@ -250,7 +307,7 @@ router.get('/run/:id', async (req, res) => {
|
||||
/**
|
||||
* Get restore run report
|
||||
*/
|
||||
router.get('/run/:id/report', async (req, res) => {
|
||||
router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const run = await db('restore_runs')
|
||||
.where('id', req.params.id)
|
||||
@@ -289,7 +346,7 @@ router.get('/run/:id/report', async (req, res) => {
|
||||
/**
|
||||
* List available backups for restore
|
||||
*/
|
||||
router.get('/available-backups', async (req, res) => {
|
||||
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const backups = [];
|
||||
|
||||
@@ -349,10 +406,85 @@ router.get('/available-backups', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List backups for restore (POST version for frontend compatibility)
|
||||
* Accepts source type in request body
|
||||
*/
|
||||
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { source } = req.body; // 'local', 's3', or undefined for all
|
||||
const backups = [];
|
||||
|
||||
// Get backup configuration
|
||||
const backupConfig = await getBackupConfig();
|
||||
|
||||
// Get database backups from backup_runs table
|
||||
const backupRuns = await db('backup_runs')
|
||||
.where('status', 'completed')
|
||||
.whereNotNull('manifest_path')
|
||||
.orderBy('completed_at', 'desc')
|
||||
.limit(20);
|
||||
|
||||
for (const run of backupRuns) {
|
||||
const isS3 = run.manifest_path.startsWith('s3://');
|
||||
const backupType = isS3 ? 's3' : 'local';
|
||||
|
||||
// Filter by source if specified
|
||||
if (source && source !== backupType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
backups.push({
|
||||
id: run.id,
|
||||
type: backupType,
|
||||
name: `Backup from ${new Date(run.completed_at).toLocaleString()}`,
|
||||
path: run.manifest_path,
|
||||
manifest_path: run.manifest_path,
|
||||
manifestId: run.manifest_id,
|
||||
manifestPath: run.manifest_path,
|
||||
size: parseInt(run.total_size_bytes) || 0,
|
||||
total_size: parseInt(run.total_size_bytes) || 0,
|
||||
total_size_bytes: parseInt(run.total_size_bytes) || 0,
|
||||
filesCount: run.files_backed_up || 0,
|
||||
files_backed_up: run.files_backed_up || 0,
|
||||
duration: run.duration_seconds,
|
||||
duration_seconds: run.duration_seconds,
|
||||
// Frontend expects snake_case date fields
|
||||
created_at: run.completed_at,
|
||||
completed_at: run.completed_at,
|
||||
started_at: run.started_at,
|
||||
// camelCase aliases
|
||||
completedAt: run.completed_at,
|
||||
startedAt: run.started_at,
|
||||
// Backup metadata
|
||||
status: run.status,
|
||||
backup_type: run.backup_type,
|
||||
backupType: run.backup_type,
|
||||
backup_mode: run.backup_mode,
|
||||
backupMode: run.backup_mode,
|
||||
app_version: run.app_version,
|
||||
appVersion: run.app_version
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: backups,
|
||||
source: source || 'all'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list backups for restore:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to list backups for restore'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get restore settings
|
||||
*/
|
||||
router.get('/settings', async (req, res) => {
|
||||
router.get('/settings', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await getRestoreSettings();
|
||||
res.json({
|
||||
@@ -371,7 +503,7 @@ router.get('/settings', async (req, res) => {
|
||||
/**
|
||||
* Update restore settings
|
||||
*/
|
||||
router.put('/settings', [
|
||||
router.put('/settings', requirePermission('backup.restore'), [
|
||||
body('restore_allow_force').optional().isBoolean(),
|
||||
body('restore_require_pre_backup').optional().isBoolean(),
|
||||
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
|
||||
|
||||
@@ -6,6 +6,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||
const {
|
||||
@@ -92,7 +93,7 @@ const faviconUpload = multer({
|
||||
});
|
||||
|
||||
// Get all settings
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings').select('*');
|
||||
|
||||
@@ -120,7 +121,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get settings by type
|
||||
router.get('/:type', adminAuth, async (req, res) => {
|
||||
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const settings = await db('app_settings')
|
||||
@@ -151,7 +152,7 @@ router.get('/:type', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get password complexity settings for frontend
|
||||
router.get('/password/complexity', adminAuth, async (req, res) => {
|
||||
router.get('/password/complexity', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
|
||||
|
||||
@@ -172,7 +173,7 @@ router.get('/password/complexity', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update branding settings
|
||||
router.put('/branding', adminAuth, async (req, res) => {
|
||||
router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
company_name,
|
||||
@@ -313,7 +314,7 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload logo
|
||||
router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
||||
router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No logo file uploaded' });
|
||||
@@ -375,7 +376,7 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload watermark logo
|
||||
router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => {
|
||||
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
@@ -437,7 +438,7 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'
|
||||
});
|
||||
|
||||
// Update theme settings
|
||||
router.put('/theme', adminAuth, async (req, res) => {
|
||||
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const themeSettings = req.body;
|
||||
|
||||
@@ -474,7 +475,7 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update general settings
|
||||
router.put('/general', adminAuth, async (req, res) => {
|
||||
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = { ...req.body };
|
||||
let uploadLimitTouched = false;
|
||||
@@ -573,7 +574,7 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update security settings
|
||||
router.put('/security', adminAuth, async (req, res) => {
|
||||
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
@@ -612,7 +613,7 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update analytics settings
|
||||
router.put('/analytics', adminAuth, async (req, res) => {
|
||||
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
@@ -649,7 +650,7 @@ router.put('/analytics', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get storage info
|
||||
router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Get total storage used
|
||||
const totalStorage = await db('photos')
|
||||
@@ -877,7 +878,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload favicon endpoint
|
||||
router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => {
|
||||
router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUpload.single('favicon'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No favicon file provided' });
|
||||
@@ -915,7 +916,7 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
||||
});
|
||||
|
||||
// Update rate limit settings
|
||||
router.put('/security/rate-limit', adminAuth, [
|
||||
router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [
|
||||
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||||
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||||
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||||
@@ -979,7 +980,7 @@ router.put('/security/rate-limit', adminAuth, [
|
||||
});
|
||||
|
||||
// Get default public site template
|
||||
router.get('/public-site/default', adminAuth, async (req, res) => {
|
||||
router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
@@ -1000,7 +1001,7 @@ router.get('/public-site/default', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Reset public site template to defaults
|
||||
router.post('/public-site/reset', adminAuth, async (req, res) => {
|
||||
router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const entries = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
router.get('/version', adminAuth, async (req, res) => {
|
||||
router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Read backend version from package.json
|
||||
let backendVersion = '1.0.0';
|
||||
@@ -35,7 +36,7 @@ router.get('/version', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, async (req, res) => {
|
||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Database size - check if PostgreSQL or SQLite
|
||||
let dbSize = 0;
|
||||
@@ -170,7 +171,7 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get database statistics
|
||||
router.get('/database', adminAuth, async (req, res) => {
|
||||
router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Get table info
|
||||
const tables = [
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail } = require('../services/imageProcessor');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -10,7 +11,7 @@ const logger = require('../utils/logger');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Get thumbnail settings
|
||||
router.get('/settings', adminAuth, async (req, res) => {
|
||||
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('key', [
|
||||
@@ -42,7 +43,7 @@ router.get('/settings', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update thumbnail settings
|
||||
router.put('/settings', adminAuth, async (req, res) => {
|
||||
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { width, height, fit, quality, format } = req.body;
|
||||
|
||||
@@ -91,7 +92,7 @@ router.put('/settings', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Regenerate all thumbnails with new settings
|
||||
router.post('/regenerate', adminAuth, async (req, res) => {
|
||||
router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.body; // Optional: regenerate for specific event only
|
||||
|
||||
@@ -164,7 +165,7 @@ router.post('/regenerate', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get regeneration status
|
||||
router.get('/regenerate/status', adminAuth, async (req, res) => {
|
||||
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
// Count photos with and without thumbnails
|
||||
const totalPhotos = await db('photos').count('id as count').first();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Admin Users Routes
|
||||
* Handles user management, roles, and invitations
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission, requireSuperAdmin, getUserPermissions } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const userManagementService = require('../services/userManagementService');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Transform user object from snake_case (DB) to camelCase (API)
|
||||
*/
|
||||
function transformUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isActive: user.is_active,
|
||||
lastLogin: user.last_login,
|
||||
lastLoginIp: user.last_login_ip,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
roleId: user.role_id,
|
||||
roleName: user.role_name,
|
||||
roleDisplayName: user.role_display_name,
|
||||
createdByUsername: user.created_by_username
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform role object from snake_case (DB) to camelCase (API)
|
||||
*/
|
||||
function transformRole(role) {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
displayName: role.display_name,
|
||||
description: role.description,
|
||||
isSystem: role.is_system,
|
||||
priority: role.priority
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me/permissions
|
||||
* Get current user's permissions
|
||||
*/
|
||||
router.get('/me/permissions', adminAuth, handleAsync(async (req, res) => {
|
||||
const permissions = await getUserPermissions(req.admin.id);
|
||||
res.json(permissions);
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* List all admin users
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => {
|
||||
const users = await userManagementService.getAllAdminUsers();
|
||||
res.json({ users: users.map(transformUser) });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /roles
|
||||
* List all roles
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/roles', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => {
|
||||
const roles = await userManagementService.getAllRoles();
|
||||
res.json({ roles: roles.map(transformRole) });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /invitations
|
||||
* List pending invitations
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/invitations', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => {
|
||||
const invitations = await userManagementService.getPendingInvitations();
|
||||
res.json({ invitations });
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /invite
|
||||
* Create invitation
|
||||
* Requires: users.create permission
|
||||
*/
|
||||
router.post('/invite', [
|
||||
adminAuth,
|
||||
requirePermission('users.create'),
|
||||
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
|
||||
body('role_id').isInt({ min: 1 }).withMessage('Role ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const invitation = await userManagementService.createInvitation({
|
||||
email: req.body.email,
|
||||
roleId: req.body.role_id,
|
||||
invitedById: req.admin.id
|
||||
});
|
||||
|
||||
successResponse(res, { invitation }, 201);
|
||||
}));
|
||||
|
||||
/**
|
||||
* DELETE /invitations/:id
|
||||
* Cancel invitation
|
||||
* Requires: users.create permission
|
||||
*/
|
||||
router.delete('/invitations/:id', [
|
||||
adminAuth,
|
||||
requirePermission('users.create'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid invitation ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
await userManagementService.cancelInvitation(parseInt(req.params.id), req.admin.id);
|
||||
successResponse(res, { message: 'Invitation cancelled' });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /:id
|
||||
* Get single user
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/:id', [
|
||||
adminAuth,
|
||||
requirePermission('users.view'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
|
||||
res.json({ user: transformUser(user) });
|
||||
}));
|
||||
|
||||
/**
|
||||
* PUT /:id
|
||||
* Update user
|
||||
* Requires: users.edit permission
|
||||
*/
|
||||
router.put('/:id', [
|
||||
adminAuth,
|
||||
requirePermission('users.edit'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required'),
|
||||
body('username').optional().trim().isLength({ min: 3, max: 50 }).withMessage('Username must be 3-50 characters'),
|
||||
body('email').optional().isEmail().normalizeEmail().withMessage('Valid email is required'),
|
||||
body('role_id').optional().isInt({ min: 1 }).withMessage('Valid role ID is required'),
|
||||
body('is_active').optional().isBoolean().withMessage('is_active must be boolean')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const user = await userManagementService.updateAdminUser(
|
||||
parseInt(req.params.id),
|
||||
req.body,
|
||||
req.admin.id
|
||||
);
|
||||
|
||||
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /:id/deactivate
|
||||
* Deactivate user
|
||||
* Requires: users.delete permission
|
||||
*/
|
||||
router.post('/:id/deactivate', [
|
||||
adminAuth,
|
||||
requirePermission('users.delete'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
await userManagementService.deactivateAdminUser(parseInt(req.params.id), req.admin.id);
|
||||
successResponse(res, { message: 'User deactivated successfully' });
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /:id/reset-password
|
||||
* Reset user password
|
||||
* Requires: super_admin role
|
||||
*/
|
||||
router.post('/:id/reset-password', [
|
||||
adminAuth,
|
||||
requireSuperAdmin(),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await userManagementService.resetAdminPassword(parseInt(req.params.id), req.admin.id);
|
||||
successResponse(res, { message: 'Password reset email sent', ...result });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
+26
-13
@@ -70,52 +70,65 @@ router.post('/admin/login', [
|
||||
logger.warn('Suspicious login pattern detected', { username, ipAddress });
|
||||
}
|
||||
|
||||
// Fetch admin with role information
|
||||
const admin = await db('admin_users')
|
||||
.where({ username })
|
||||
.orWhere({ email: username })
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.username', username)
|
||||
.orWhere('admin_users.email', username)
|
||||
.select(
|
||||
'admin_users.*',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
|
||||
// Use generic error to prevent user enumeration
|
||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
|
||||
if (!admin.is_active) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims
|
||||
const token = jwt.sign({
|
||||
|
||||
// Generate token with additional claims including role
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name, // Add role to JWT
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
|
||||
// Include role in response
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -728,15 +728,15 @@ async function runBackupInternal() {
|
||||
}
|
||||
|
||||
const schemaVersion = await getCurrentSchemaVersion();
|
||||
const [insertedId] = await db('backup_runs').insert({
|
||||
const insertResult = await db('backup_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
backup_type: 'scheduled',
|
||||
app_version: packageJson.version,
|
||||
node_version: process.version,
|
||||
db_schema_version: schemaVersion
|
||||
});
|
||||
runId = insertedId;
|
||||
}).returning('id');
|
||||
runId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
const files = await service.getFilesToBackup(config.backup_include_archived);
|
||||
logger.info(`Found ${files.length} files to check for backup`);
|
||||
@@ -820,11 +820,17 @@ async function runBackupInternal() {
|
||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
||||
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
|
||||
statistics: JSON.stringify({
|
||||
// Use snake_case for frontend compatibility
|
||||
files_processed: result.backedUpCount,
|
||||
total_size: result.backedUpSize,
|
||||
total_files_checked: files.length,
|
||||
average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
||||
destination: destinationType,
|
||||
// Keep camelCase for backward compatibility
|
||||
totalFilesChecked: files.length,
|
||||
filesBackedUp: result.backedUpCount,
|
||||
totalSize: result.backedUpSize,
|
||||
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
||||
destination: destinationType
|
||||
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -927,22 +933,54 @@ async function triggerManualBackup() {
|
||||
|
||||
async function getBackupStatus(limit = 10) {
|
||||
try {
|
||||
const runs = await db('backup_runs')
|
||||
const rawRuns = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.limit(limit);
|
||||
|
||||
// Transform runs to add frontend-compatible field aliases
|
||||
const runs = rawRuns.map(run => {
|
||||
// Parse and transform statistics to snake_case for frontend compatibility
|
||||
let statistics = run.statistics;
|
||||
if (statistics) {
|
||||
// Handle both string (SQLite) and object (PostgreSQL JSONB) types
|
||||
let stats = statistics;
|
||||
if (typeof statistics === 'string') {
|
||||
try {
|
||||
stats = JSON.parse(statistics);
|
||||
} catch (e) {
|
||||
stats = {};
|
||||
}
|
||||
}
|
||||
// Add snake_case aliases for frontend
|
||||
statistics = {
|
||||
...stats,
|
||||
files_processed: stats.filesBackedUp || stats.files_processed || 0,
|
||||
total_size: stats.totalSize || stats.total_size || 0,
|
||||
total_files_checked: stats.totalFilesChecked || stats.total_files_checked || 0,
|
||||
average_file_size: stats.averageFileSize || stats.average_file_size || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
created_at: run.started_at, // Alias for frontend compatibility
|
||||
statistics
|
||||
};
|
||||
});
|
||||
|
||||
const lastRun = runs[0];
|
||||
let manifestValid = false;
|
||||
|
||||
if (lastRun && lastRun.manifest_path) {
|
||||
try {
|
||||
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
|
||||
if (backupManifest.validateManifest) {
|
||||
backupManifest.validateManifest(manifest);
|
||||
// Use validateBackupManifest which handles both local and S3 paths
|
||||
const result = await validateBackupManifest(lastRun.manifest_path);
|
||||
manifestValid = result.valid;
|
||||
if (!result.valid) {
|
||||
logger.warn('Manifest validation failed:', result.error);
|
||||
}
|
||||
manifestValid = true;
|
||||
} catch (error) {
|
||||
logger.warn('Manifest validation failed:', error);
|
||||
logger.warn('Manifest validation failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,6 +989,7 @@ async function getBackupStatus(limit = 10) {
|
||||
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
|
||||
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
|
||||
recentRuns: runs,
|
||||
recentBackups: runs, // Alias for frontend compatibility
|
||||
nextScheduledRun: getNextScheduledRun()
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -153,6 +153,9 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
if (processedVariables.expires_at) {
|
||||
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
|
||||
@@ -75,14 +75,15 @@ class RestoreService {
|
||||
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
|
||||
|
||||
// Create restore run record
|
||||
const [runId] = await db('restore_runs').insert({
|
||||
const result = await db('restore_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
restore_type: options.restoreType,
|
||||
source: options.source,
|
||||
manifest_path: options.manifestPath,
|
||||
is_dry_run: options.dryRun || false
|
||||
});
|
||||
}).returning('id');
|
||||
const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result;
|
||||
|
||||
restoreRun = { id: runId };
|
||||
|
||||
@@ -105,7 +106,8 @@ class RestoreService {
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
|
||||
if (!options.force) {
|
||||
// Only block actual restores (not dry runs/validations) on warnings
|
||||
if (!options.force && !options.dryRun) {
|
||||
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -400,29 +402,55 @@ class RestoreService {
|
||||
* Check available disk space
|
||||
*/
|
||||
async checkDiskSpace(manifest, options) {
|
||||
const { statvfs } = require('fs');
|
||||
const statvfsAsync = promisify(statvfs);
|
||||
|
||||
try {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const stats = await statvfsAsync(storagePath);
|
||||
|
||||
const blockSize = stats.bsize || stats.f_bsize || 4096;
|
||||
const availableBytes = stats.bavail * blockSize;
|
||||
|
||||
|
||||
// Calculate required space (with 20% buffer)
|
||||
let requiredBytes = 0;
|
||||
if (options.restoreType === 'full' || options.restoreType === 'files') {
|
||||
requiredBytes = manifest.files.total_size * 1.2;
|
||||
requiredBytes = (manifest.files?.total_size || 0) * 1.2;
|
||||
}
|
||||
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
||||
requiredBytes += (manifest.database.size || 0) * 1.2;
|
||||
requiredBytes += (manifest.database?.size || 0) * 1.2;
|
||||
}
|
||||
|
||||
// Try to get disk space using df command (works on Linux and macOS)
|
||||
let availableBytes = 0;
|
||||
let diskCheckSucceeded = false;
|
||||
try {
|
||||
const { exec } = require('child_process');
|
||||
const execAsync = promisify(exec);
|
||||
// Use root path as fallback if storage path doesn't exist yet
|
||||
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
||||
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
|
||||
const parsed = parseInt(stdout.trim());
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
availableBytes = parsed * 1024; // Convert from KB to bytes
|
||||
diskCheckSucceeded = true;
|
||||
}
|
||||
} catch (dfError) {
|
||||
this.log('warn', 'Could not determine available disk space', { error: dfError.message });
|
||||
}
|
||||
|
||||
// If disk check failed, return optimistic result
|
||||
if (!diskCheckSucceeded) {
|
||||
return {
|
||||
hasEnoughSpace: true,
|
||||
availableBytes: null, // null indicates unknown
|
||||
requiredBytes,
|
||||
availableFormatted: 'Unknown',
|
||||
requiredFormatted: this.formatBytes(requiredBytes)
|
||||
};
|
||||
}
|
||||
|
||||
// Add space for pre-restore backup
|
||||
if (!options.skipPreBackup) {
|
||||
const currentUsage = await this.calculateCurrentStorageUsage();
|
||||
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
|
||||
try {
|
||||
const currentUsage = await this.calculateCurrentStorageUsage();
|
||||
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
|
||||
} catch (e) {
|
||||
// Ignore errors calculating current usage
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -434,11 +462,11 @@ class RestoreService {
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Fallback for systems without statvfs
|
||||
// Fallback for any errors
|
||||
this.log('warn', 'Could not check disk space', { error: error.message });
|
||||
return {
|
||||
hasEnoughSpace: true, // Assume we have space if we can't check
|
||||
availableBytes: 0,
|
||||
availableBytes: null,
|
||||
requiredBytes: 0,
|
||||
availableFormatted: 'Unknown',
|
||||
requiredFormatted: 'Unknown'
|
||||
|
||||
@@ -76,12 +76,22 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
|
||||
// Add custom endpoint if provided (for S3-compatible services)
|
||||
if (this.config.endpoint) {
|
||||
s3Config.endpoint = this.config.endpoint;
|
||||
// For MinIO and other S3-compatible services
|
||||
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) {
|
||||
s3Config.endpoint = `https://${this.config.endpoint}`;
|
||||
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) {
|
||||
s3Config.endpoint = `http://${this.config.endpoint}`;
|
||||
let endpoint = this.config.endpoint;
|
||||
|
||||
// Only add protocol if endpoint doesn't already have one
|
||||
const hasProtocol = endpoint.startsWith('http://') || endpoint.startsWith('https://');
|
||||
if (!hasProtocol) {
|
||||
// Add protocol based on sslEnabled setting
|
||||
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
|
||||
}
|
||||
|
||||
s3Config.endpoint = endpoint;
|
||||
|
||||
// For S3-compatible services with custom endpoints, force path style
|
||||
// This is required for MinIO and when using IP addresses
|
||||
if (!s3Config.forcePathStyle) {
|
||||
s3Config.forcePathStyle = true;
|
||||
logger.info('Automatically enabling forcePathStyle for custom S3 endpoint');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* User Management Service for Admin Users
|
||||
* Handles invitations, user CRUD, and role management
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* Create a new admin user invitation
|
||||
* @param {object} params - { email, roleId, invitedById }
|
||||
* @returns {Promise<object>} Created invitation details
|
||||
*/
|
||||
async function createInvitation({ email, roleId, invitedById }) {
|
||||
// Check if email already exists
|
||||
const existingUser = await db('admin_users').where('email', email).first();
|
||||
if (existingUser) {
|
||||
throw new ConflictError('User with this email already exists', 'email');
|
||||
}
|
||||
|
||||
// Check for pending invitation
|
||||
const pendingInvite = await db('admin_invitations')
|
||||
.where('email', email)
|
||||
.whereNull('accepted_at')
|
||||
.where('expires_at', '>', new Date())
|
||||
.first();
|
||||
|
||||
if (pendingInvite) {
|
||||
throw new ConflictError('Pending invitation already exists for this email', 'email');
|
||||
}
|
||||
|
||||
// Validate role exists
|
||||
const role = await db('roles').where('id', roleId).first();
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role', roleId);
|
||||
}
|
||||
|
||||
// Generate secure invitation token (64 characters hex = 32 bytes)
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
const [invitationId] = await db('admin_invitations').insert({
|
||||
email,
|
||||
token,
|
||||
role_id: roleId,
|
||||
invited_by: invitedById,
|
||||
expires_at: expiresAt,
|
||||
created_at: new Date()
|
||||
}).returning('id');
|
||||
|
||||
const id = invitationId?.id || invitationId;
|
||||
|
||||
// Queue invitation email
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
|
||||
await queueEmail(null, email, 'admin_invitation', {
|
||||
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
|
||||
role_name: role.display_name,
|
||||
expires_at: expiresAt.toISOString()
|
||||
});
|
||||
|
||||
await logActivity('admin_invitation_created',
|
||||
{ email, roleId, roleName: role.display_name },
|
||||
null,
|
||||
{ type: 'admin', id: invitedById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin invitation created', { email, roleId, invitedById });
|
||||
|
||||
return { id, email, token, role: role.display_name, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation and create the admin user
|
||||
* @param {object} params - { token, username, password }
|
||||
* @returns {Promise<object>} Created user details
|
||||
*/
|
||||
async function acceptInvitation({ token, username, password }) {
|
||||
const invitation = await db('admin_invitations')
|
||||
.where('token', token)
|
||||
.whereNull('accepted_at')
|
||||
.where('expires_at', '>', new Date())
|
||||
.first();
|
||||
|
||||
if (!invitation) {
|
||||
throw new ValidationError('Invalid or expired invitation');
|
||||
}
|
||||
|
||||
// Check username availability
|
||||
const existingUsername = await db('admin_users').where('username', username).first();
|
||||
if (existingUsername) {
|
||||
throw new ConflictError('Username already taken', 'username');
|
||||
}
|
||||
|
||||
// Check email not taken (race condition protection)
|
||||
const existingEmail = await db('admin_users').where('email', invitation.email).first();
|
||||
if (existingEmail) {
|
||||
throw new ConflictError('Email already registered', 'email');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
|
||||
|
||||
// Create user in transaction
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const [userId] = await trx('admin_users').insert({
|
||||
username,
|
||||
email: invitation.email,
|
||||
password_hash: passwordHash,
|
||||
role_id: invitation.role_id,
|
||||
created_by: invitation.invited_by,
|
||||
is_active: formatBoolean(true),
|
||||
must_change_password: formatBoolean(false),
|
||||
invite_accepted_at: new Date(),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
}).returning('id');
|
||||
|
||||
const id = userId?.id || userId;
|
||||
|
||||
// Mark invitation as accepted
|
||||
await trx('admin_invitations')
|
||||
.where('id', invitation.id)
|
||||
.update({
|
||||
accepted_at: new Date(),
|
||||
accepted_user_id: id
|
||||
});
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
await logActivity('admin_invitation_accepted',
|
||||
{ userId: result, email: invitation.email },
|
||||
null,
|
||||
{ type: 'system', id: null, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin invitation accepted', {
|
||||
userId: result,
|
||||
email: invitation.email,
|
||||
invitationId: invitation.id
|
||||
});
|
||||
|
||||
return { userId: result, email: invitation.email };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all admin users with their roles
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async function getAllAdminUsers() {
|
||||
return db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.leftJoin('admin_users as creator', 'creator.id', 'admin_users.created_by')
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.is_active',
|
||||
'admin_users.last_login',
|
||||
'admin_users.last_login_ip',
|
||||
'admin_users.created_at',
|
||||
'admin_users.updated_at',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name',
|
||||
'creator.username as created_by_username'
|
||||
)
|
||||
.orderBy('admin_users.created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single admin user by ID
|
||||
* @param {number} id - User ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function getAdminUserById(id) {
|
||||
const user = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', id)
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.is_active',
|
||||
'admin_users.last_login',
|
||||
'admin_users.last_login_ip',
|
||||
'admin_users.created_at',
|
||||
'admin_users.updated_at',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update admin user
|
||||
* @param {number} id - User ID to update
|
||||
* @param {object} updates - Fields to update
|
||||
* @param {number} updatedById - ID of user making the update
|
||||
* @returns {Promise<object>} Updated user
|
||||
*/
|
||||
async function updateAdminUser(id, updates, updatedById) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
const allowedUpdates = {};
|
||||
|
||||
if (updates.username !== undefined) {
|
||||
const existing = await db('admin_users')
|
||||
.where('username', updates.username)
|
||||
.whereNot('id', id)
|
||||
.first();
|
||||
if (existing) {
|
||||
throw new ConflictError('Username already taken', 'username');
|
||||
}
|
||||
allowedUpdates.username = updates.username;
|
||||
}
|
||||
|
||||
if (updates.email !== undefined) {
|
||||
const existing = await db('admin_users')
|
||||
.where('email', updates.email)
|
||||
.whereNot('id', id)
|
||||
.first();
|
||||
if (existing) {
|
||||
throw new ConflictError('Email already in use', 'email');
|
||||
}
|
||||
allowedUpdates.email = updates.email;
|
||||
}
|
||||
|
||||
if (updates.role_id !== undefined) {
|
||||
const role = await db('roles').where('id', updates.role_id).first();
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role', updates.role_id);
|
||||
}
|
||||
allowedUpdates.role_id = updates.role_id;
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
allowedUpdates.is_active = formatBoolean(updates.is_active);
|
||||
}
|
||||
|
||||
allowedUpdates.updated_at = new Date();
|
||||
|
||||
await db('admin_users').where('id', id).update(allowedUpdates);
|
||||
|
||||
await logActivity('admin_user_updated',
|
||||
{ userId: id, changes: Object.keys(allowedUpdates) },
|
||||
null,
|
||||
{ type: 'admin', id: updatedById, name: 'system' }
|
||||
);
|
||||
|
||||
return getAdminUserById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate admin user
|
||||
* @param {number} id - User ID to deactivate
|
||||
* @param {number} deactivatedById - ID of user performing deactivation
|
||||
*/
|
||||
async function deactivateAdminUser(id, deactivatedById) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
// Prevent self-deactivation
|
||||
if (id === deactivatedById) {
|
||||
throw new ValidationError('Cannot deactivate your own account');
|
||||
}
|
||||
|
||||
// Check if this is the last super_admin
|
||||
const superAdminRole = await db('roles').where('name', 'super_admin').first();
|
||||
if (user.role_id === superAdminRole?.id) {
|
||||
const superAdminCount = await db('admin_users')
|
||||
.where('role_id', superAdminRole.id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (Number(superAdminCount?.count) <= 1) {
|
||||
throw new ValidationError('Cannot deactivate the last Super Admin');
|
||||
}
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', id).update({
|
||||
is_active: formatBoolean(false),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_user_deactivated',
|
||||
{ userId: id, username: user.username },
|
||||
null,
|
||||
{ type: 'admin', id: deactivatedById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin user deactivated', { userId: id, deactivatedById });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset admin user password (generates new password)
|
||||
* @param {number} id - User ID
|
||||
* @param {number} resetById - ID of user performing reset
|
||||
* @returns {Promise<object>} Result with email and status
|
||||
*/
|
||||
async function resetAdminPassword(id, resetById) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
const newPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
await db('admin_users').where('id', id).update({
|
||||
password_hash: passwordHash,
|
||||
must_change_password: formatBoolean(true),
|
||||
password_changed_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Queue password reset email
|
||||
await queueEmail(null, user.email, 'admin_password_reset', {
|
||||
username: user.username,
|
||||
new_password: newPassword
|
||||
});
|
||||
|
||||
await logActivity('admin_password_reset',
|
||||
{ userId: id, username: user.username },
|
||||
null,
|
||||
{ type: 'admin', id: resetById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin password reset', { userId: id, resetById });
|
||||
|
||||
return { email: user.email, passwordSent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async function getAllRoles() {
|
||||
return db('roles')
|
||||
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
|
||||
.orderBy('priority', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending invitations
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async function getPendingInvitations() {
|
||||
return db('admin_invitations')
|
||||
.join('roles', 'roles.id', 'admin_invitations.role_id')
|
||||
.join('admin_users', 'admin_users.id', 'admin_invitations.invited_by')
|
||||
.whereNull('admin_invitations.accepted_at')
|
||||
.where('admin_invitations.expires_at', '>', new Date())
|
||||
.select(
|
||||
'admin_invitations.id',
|
||||
'admin_invitations.email',
|
||||
'admin_invitations.expires_at',
|
||||
'admin_invitations.created_at',
|
||||
'roles.display_name as role_name',
|
||||
'admin_users.username as invited_by'
|
||||
)
|
||||
.orderBy('admin_invitations.created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/delete an invitation
|
||||
* @param {number} id - Invitation ID
|
||||
* @param {number} cancelledById - ID of user cancelling
|
||||
*/
|
||||
async function cancelInvitation(id, cancelledById) {
|
||||
const invitation = await db('admin_invitations').where('id', id).first();
|
||||
if (!invitation) {
|
||||
throw new NotFoundError('Invitation', id);
|
||||
}
|
||||
|
||||
await db('admin_invitations').where('id', id).del();
|
||||
|
||||
await logActivity('admin_invitation_cancelled',
|
||||
{ invitationId: id, email: invitation.email },
|
||||
null,
|
||||
{ type: 'admin', id: cancelledById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin invitation cancelled', { invitationId: id, cancelledById });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an invitation token
|
||||
* @param {string} token - Invitation token
|
||||
* @returns {Promise<object|null>} Invitation details if valid
|
||||
*/
|
||||
async function validateInvitationToken(token) {
|
||||
const invitation = await db('admin_invitations')
|
||||
.join('roles', 'roles.id', 'admin_invitations.role_id')
|
||||
.where('admin_invitations.token', token)
|
||||
.whereNull('admin_invitations.accepted_at')
|
||||
.where('admin_invitations.expires_at', '>', new Date())
|
||||
.select(
|
||||
'admin_invitations.email',
|
||||
'admin_invitations.expires_at',
|
||||
'roles.display_name as role_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
return invitation || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInvitation,
|
||||
acceptInvitation,
|
||||
getAllAdminUsers,
|
||||
getAdminUserById,
|
||||
updateAdminUser,
|
||||
deactivateAdminUser,
|
||||
resetAdminPassword,
|
||||
getAllRoles,
|
||||
getPendingInvitations,
|
||||
cancelInvitation,
|
||||
validateInvitationToken
|
||||
};
|
||||
Reference in New Issue
Block a user