feat: implement comprehensive backup and restore system with S3 support

- Add S3/MinIO storage adapter with multipart upload support
- Implement database backup service for SQLite and PostgreSQL
- Create backup manifest generator for tracking backup contents
- Enhance backup service with S3 integration and incremental backups
- Add restore service with safety measures and rollback capability
- Create comprehensive test suite for all backup functionality
- Add admin API endpoints for backup/restore management
- Implement frontend UI with dashboard, configuration, and restore wizard
- Add roadmap section to README with implemented backup feature

This implementation provides:
- Multiple backup destinations (local, rsync, S3/MinIO)
- Intelligent change detection to minimize backup frequency
- Full database backups with compression
- Manifest-based restore with integrity validation
- Pre-restore safety backups with rollback
- Comprehensive error handling and monitoring
- User-friendly admin interface

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-22 09:05:52 +02:00
parent 3c6837bd90
commit f6a79c815e
44 changed files with 15839 additions and 14 deletions
+229
View File
@@ -0,0 +1,229 @@
# Enhanced Backup System Test Suite
This directory contains comprehensive tests for the enhanced backup system with S3 support.
## Test Structure
### Unit Tests
- `services/backupService.enhanced.test.js` - Unit tests for the enhanced backup service
- Configuration management
- S3 backup functionality
- Manifest generation
- Error handling and recovery
- Backward compatibility (local and rsync)
- Service lifecycle management
### Integration Tests
- `integration/backup-s3.test.js` - Integration tests for S3 backups
- Real S3/MinIO connection tests
- Full backup process with actual files
- Incremental backup verification
- Manifest storage and retrieval
- Error recovery scenarios
### Manual Integration Test Script
- `../scripts/test-backup-integration.js` - Comprehensive manual testing script
- Can test against MinIO, AWS S3, or any S3-compatible service
- Tests all backup types (S3, local, rsync)
- Performance testing with large files
- Detailed progress reporting
## Running Tests
### Prerequisites
1. **For Unit Tests**: No special setup required, all dependencies are mocked.
2. **For Integration Tests**: Requires a running S3-compatible service (MinIO recommended)
```bash
# Start MinIO using Docker
docker run -d \
-p 9000:9000 \
-p 9001:9001 \
--name minio-test \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data --console-address ":9001"
```
3. **Environment Variables** (for integration tests):
```bash
# Optional - defaults work with local MinIO
export TEST_S3_ENDPOINT=http://localhost:9000
export TEST_S3_ACCESS_KEY=minioadmin
export TEST_S3_SECRET_KEY=minioadmin
# Skip S3 tests if no S3 service available
export SKIP_S3_TESTS=true
```
### Running Unit Tests
```bash
# Run all backup service tests
npm test -- __tests__/services/backupService.enhanced.test.js
# Run specific test suite
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"
# Run with coverage
npm test -- --coverage __tests__/services/backupService.enhanced.test.js
```
### Running Integration Tests
```bash
# Ensure MinIO is running first!
# Run S3 integration tests
npm test -- __tests__/integration/backup-s3.test.js
# Run with verbose output
npm test -- __tests__/integration/backup-s3.test.js --verbose
# Skip S3 tests if needed
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js
```
### Running Manual Integration Tests
```bash
# Test with local MinIO (default)
node scripts/test-backup-integration.js
# Test with AWS S3
node scripts/test-backup-integration.js \
--endpoint https://s3.amazonaws.com \
--access-key YOUR_ACCESS_KEY \
--secret-key YOUR_SECRET_KEY \
--bucket your-test-bucket
# Test local backup
node scripts/test-backup-integration.js --type local
# Test with cleanup after completion
node scripts/test-backup-integration.js --cleanup
# Verbose output
node scripts/test-backup-integration.js --verbose
```
## Test Coverage
The test suite covers:
### Configuration
- ✅ Database configuration retrieval
- ✅ JSON parsing and error handling
- ✅ Configuration validation
- ✅ Required field validation
### S3 Functionality
- ✅ S3 client initialization
- ✅ Connection testing
- ✅ File upload with progress tracking
- ✅ Large file handling (multipart upload)
- ✅ Metadata and custom headers
- ✅ Error handling and retries
### Backup Process
- ✅ Full backup execution
- ✅ Incremental backup (changed files only)
- ✅ File checksum calculation and comparison
- ✅ Database backup inclusion
- ✅ Archive inclusion toggle
- ✅ File size limits
### Manifest Generation
- ✅ Full manifest generation
- ✅ Incremental manifest with parent reference
- ✅ JSON and YAML format support
- ✅ Manifest validation
- ✅ S3 manifest storage and retrieval
- ✅ Checksum verification
### Error Handling
- ✅ S3 connection failures
- ✅ File read errors
- ✅ Individual file failure recovery
- ✅ Retry logic with exponential backoff
- ✅ Email notifications on failure
- ✅ Concurrent backup prevention
### Backward Compatibility
- ✅ Local directory backup
- ✅ Rsync backup
- ✅ Existing manifest format support
### Service Management
- ✅ Cron job scheduling
- ✅ Service start/stop
- ✅ Manual backup triggering
- ✅ Backup history and status
## Mock Setup
The unit tests use comprehensive mocking:
```javascript
// Database mocking
jest.mock('../../src/database/db');
// S3 client mocking
jest.mock('../../src/services/storage/s3Storage');
// File system mocking
const mockFs = require('mock-fs');
// Cron job mocking
jest.mock('node-cron');
```
## CI/CD Integration
To run tests in CI/CD pipeline:
```yaml
# Example GitHub Actions
- name: Run Unit Tests
run: npm test -- __tests__/services/backupService.enhanced.test.js
- name: Start MinIO
run: |
docker run -d \
-p 9000:9000 \
--name minio-test \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data
- name: Run Integration Tests
run: npm test -- __tests__/integration/backup-s3.test.js
```
## Debugging Tests
```bash
# Run tests in debug mode
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js
# Run single test with console output
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose
```
## Performance Considerations
- Integration tests create real files and S3 objects
- Each test run creates a unique S3 bucket to avoid conflicts
- Cleanup is automatic but can be disabled for debugging
- Large file tests (10MB+) are included but can be slow
## Adding New Tests
When adding new backup features:
1. Add unit tests to `backupService.enhanced.test.js`
2. Add integration tests to `backup-s3.test.js` if S3-specific
3. Update manual test script for comprehensive testing
4. Ensure mocks are properly configured
5. Document any new environment requirements
@@ -0,0 +1,506 @@
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
// Load services
const backupService = require('../../src/services/backupService');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
const { db, initialize: initDb } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
// Test configuration
const TEST_CONFIG = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
bucket: 'test-backup-bucket-' + Date.now(),
region: 'us-east-1'
};
describe('S3 Backup Integration Tests', () => {
let s3Client;
let testStoragePath;
let originalEnv;
beforeAll(async () => {
// Skip if no S3 endpoint configured
if (process.env.SKIP_S3_TESTS === 'true') {
console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)');
return;
}
// Save original environment
originalEnv = { ...process.env };
// Initialize S3 client for test setup
s3Client = new S3Client({
endpoint: TEST_CONFIG.endpoint,
region: TEST_CONFIG.region,
credentials: {
accessKeyId: TEST_CONFIG.accessKeyId,
secretAccessKey: TEST_CONFIG.secretAccessKey
},
forcePathStyle: true
});
// Create test bucket
try {
await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket }));
console.log(`Created test bucket: ${TEST_CONFIG.bucket}`);
} catch (error) {
if (error.name !== 'BucketAlreadyOwnedByYou') {
console.error('Failed to create test bucket:', error);
throw error;
}
}
// Initialize database
await initDb();
await db.migrate.latest();
// Create test storage directory
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
await fs.mkdir(testStoragePath, { recursive: true });
process.env.STORAGE_PATH = testStoragePath;
// Set up test data
await setupTestData();
// Mock logger to reduce noise
logger.info = jest.fn();
logger.debug = jest.fn();
logger.warn = jest.fn();
logger.error = jest.fn();
});
afterAll(async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
try {
// Clean up S3 bucket
await cleanupS3Bucket();
await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket }));
console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`);
} catch (error) {
console.error('Failed to cleanup S3 bucket:', error);
}
// Clean up test storage
await fs.rm(testStoragePath, { recursive: true, force: true });
// Restore environment
process.env = originalEnv;
// Close database
await db.destroy();
});
beforeEach(async () => {
if (process.env.SKIP_S3_TESTS === 'true') {
return;
}
// Clean backup tables
await db('backup_runs').del();
await db('backup_file_states').del();
await db('database_backup_runs').del();
// Configure S3 backup settings
await configureS3Backup();
});
afterEach(async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Clean up S3 objects created during test
await cleanupS3Bucket();
});
describe('S3 Connection and Configuration', () => {
it('should successfully connect to S3-compatible storage', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
const s3Adapter = new S3StorageAdapter({
...TEST_CONFIG,
bucket: TEST_CONFIG.bucket,
forcePathStyle: true,
sslEnabled: false
});
const connected = await s3Adapter.testConnection();
expect(connected).toBe(true);
});
it('should validate S3 configuration before backup', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Remove required configuration
await db('app_settings')
.where('setting_key', 'backup_s3_secret_key')
.del();
await backupService.runBackup();
const lastRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(lastRun.status).toBe('failed');
expect(lastRun.error_message).toContain('S3 backup configuration incomplete');
});
});
describe('Full S3 Backup Process', () => {
it('should perform complete S3 backup with all file types', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Run backup
await backupService.runBackup();
// Verify backup run completed
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.status).toBe('completed');
expect(backupRun.files_backed_up).toBeGreaterThan(0);
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
// Verify files in S3
const s3Objects = await listS3Objects();
expect(s3Objects.length).toBeGreaterThan(0);
// Check for expected file types
const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active'));
const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails'));
const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest'));
const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json'));
expect(hasPhotos).toBe(true);
expect(hasThumbnails).toBe(true);
expect(hasManifest).toBe(true);
expect(hasSummary).toBe(true);
});
it('should handle large file uploads with multipart', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a large test file (15MB)
const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg');
const largeFileSize = 15 * 1024 * 1024; // 15MB
const largeFileContent = Buffer.alloc(largeFileSize, 'x');
await fs.writeFile(largeFilePath, largeFileContent);
// Run backup
await backupService.runBackup();
// Verify large file was uploaded
const s3Objects = await listS3Objects();
const largeFileUploaded = s3Objects.some(obj =>
obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize
);
expect(largeFileUploaded).toBe(true);
});
it('should include database backup when available', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a mock database backup
const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql');
await fs.mkdir(path.dirname(dbBackupPath), { recursive: true });
await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);');
// Record database backup
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'sqlite',
file_path: dbBackupPath,
file_size_bytes: 100,
checksum: 'test123',
statistics: JSON.stringify({ tables: {} }),
table_checksums: JSON.stringify({})
});
// Configure to include database
await db('app_settings')
.where('setting_key', 'backup_include_database')
.update({ setting_value: 'true' });
// Run backup
await backupService.runBackup();
// Verify database backup in S3
const s3Objects = await listS3Objects();
const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql'));
expect(hasDbBackup).toBe(true);
});
});
describe('Incremental Backup', () => {
it('should only upload changed files in incremental backup', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// First backup - full
await backupService.runBackup();
const firstRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
const firstObjectCount = (await listS3Objects()).length;
// Wait a moment to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 100));
// Modify one file
const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg');
await fs.writeFile(modifiedFile, 'modified content');
// Second backup - incremental
await backupService.runBackup();
const secondRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(secondRun.id).not.toBe(firstRun.id);
expect(secondRun.files_backed_up).toBe(1); // Only modified file
// Check manifest indicates incremental
if (secondRun.manifest_path) {
const manifest = await backupService.getBackupManifest(secondRun.id);
expect(manifest.manifest.incremental).toBeDefined();
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
}
});
it('should track file states across backups', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
await backupService.runBackup();
// Check file states are recorded
const fileStates = await db('backup_file_states').select('*');
expect(fileStates.length).toBeGreaterThan(0);
// Verify checksums are stored
const hasChecksums = fileStates.every(state => state.checksum !== null);
expect(hasChecksums).toBe(true);
});
});
describe('S3 Manifest Storage', () => {
it('should upload manifest to S3 and retrieve it', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Configure YAML manifest format
await db('app_settings')
.where('setting_key', 'backup_manifest_format')
.update({ setting_value: '"yaml"' });
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.manifest_path).toMatch(/^s3:\/\//);
// Retrieve manifest
const { manifest, summary } = await backupService.getBackupManifest(backupRun.id);
expect(manifest).toBeDefined();
expect(manifest.backup.id).toBeDefined();
expect(summary).toContain('BACKUP MANIFEST SUMMARY');
});
it('should validate manifest integrity', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path);
expect(validationResult.valid).toBe(true);
expect(validationResult.manifest).toBeDefined();
});
});
describe('Error Recovery', () => {
it('should handle S3 connection failures gracefully', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Configure with invalid endpoint
await db('app_settings')
.where('setting_key', 'backup_s3_endpoint')
.update({ setting_value: '"http://invalid-endpoint:9999"' });
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.status).toBe('failed');
expect(backupRun.error_message).toBeDefined();
});
it('should continue backup despite individual file failures', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a file that will be deleted during backup
const tempFile = path.join(testStoragePath, 'events/active/temp.jpg');
await fs.writeFile(tempFile, 'temporary');
// Mock file deletion during backup
const originalUpload = S3StorageAdapter.prototype.upload;
let callCount = 0;
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
callCount++;
if (callCount === 2) {
// Delete the temp file to cause an error
await fs.unlink(tempFile).catch(() => {});
}
return originalUpload.call(this, localPath, s3Key, options);
});
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
// Should complete despite one file error
expect(backupRun.status).toBe('completed');
expect(backupRun.files_backed_up).toBeGreaterThan(0);
// Restore original method
S3StorageAdapter.prototype.upload = originalUpload;
});
it('should retry failed uploads with exponential backoff', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Mock S3 upload to fail twice then succeed
const originalUpload = S3StorageAdapter.prototype.upload;
let attemptCount = 0;
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
attemptCount++;
if (attemptCount <= 2) {
const error = new Error('Network timeout');
error.code = 'ETIMEDOUT';
throw error;
}
return originalUpload.call(this, localPath, s3Key, options);
});
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
// Should succeed after retries
expect(backupRun.status).toBe('completed');
expect(attemptCount).toBeGreaterThan(2);
// Restore original method
S3StorageAdapter.prototype.upload = originalUpload;
});
});
// Helper functions
async function setupTestData() {
// Create test directory structure
const dirs = [
'events/active/event1',
'events/active/event2',
'events/archived',
'thumbnails',
'uploads'
];
for (const dir of dirs) {
await fs.mkdir(path.join(testStoragePath, dir), { recursive: true });
}
// Create test files
const files = [
{ path: 'events/active/event1/photo1.jpg', content: 'photo1 content' },
{ path: 'events/active/event1/photo2.jpg', content: 'photo2 content' },
{ path: 'events/active/event2/photo3.jpg', content: 'photo3 content' },
{ path: 'events/archived/old-event.zip', content: 'archived content' },
{ path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' },
{ path: 'uploads/logo.png', content: 'logo content' }
];
for (const file of files) {
await fs.writeFile(
path.join(testStoragePath, file.path),
file.content
);
}
}
async function configureS3Backup() {
const settings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
{ setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` },
{ setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` },
{ setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` },
{ setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` },
{ setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` },
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
{ setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' },
{ setting_key: 'backup_include_archived', setting_value: 'true' },
{ setting_key: 'backup_incremental', setting_value: 'true' },
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
for (const setting of settings) {
await db('app_settings')
.insert({
setting_type: 'backup',
...setting,
created_at: new Date(),
updated_at: new Date()
})
.onConflict(['setting_type', 'setting_key'])
.merge();
}
}
async function listS3Objects() {
const response = await s3Client.send(new ListObjectsV2Command({
Bucket: TEST_CONFIG.bucket
}));
return response.Contents || [];
}
async function cleanupS3Bucket() {
try {
const objects = await listS3Objects();
if (objects.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: TEST_CONFIG.bucket,
Delete: {
Objects: objects.map(obj => ({ Key: obj.Key }))
}
}));
}
} catch (error) {
console.error('Failed to cleanup S3 objects:', error);
}
}
});
@@ -0,0 +1,751 @@
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
const mockFs = require('mock-fs');
const path = require('path');
const crypto = require('crypto');
const { EventEmitter } = require('events');
// Mock dependencies before requiring the module
jest.mock('../../src/database/db');
jest.mock('../../src/utils/logger');
jest.mock('../../src/services/emailProcessor');
jest.mock('node-cron');
jest.mock('../../src/services/backupManifest');
jest.mock('../../src/services/storage/s3Storage');
const backupService = require('../../src/services/backupService');
const { db } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
const { queueEmail } = require('../../src/services/emailProcessor');
const cron = require('node-cron');
const backupManifest = require('../../src/services/backupManifest');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
describe('Enhanced Backup Service Tests', () => {
let mockDb;
let mockS3Client;
let mockCronJob;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Mock database
mockDb = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
first: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
db.mockReturnValue(mockDb);
// Mock cron job
mockCronJob = {
stop: jest.fn()
};
cron.schedule.mockReturnValue(mockCronJob);
// Mock S3 client
mockS3Client = {
testConnection: jest.fn().mockResolvedValue(true),
upload: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
uploadStream: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
download: jest.fn().mockResolvedValue(),
exists: jest.fn().mockResolvedValue(false),
delete: jest.fn().mockResolvedValue(),
list: jest.fn().mockResolvedValue({ Contents: [] })
};
S3StorageAdapter.mockImplementation(() => mockS3Client);
// Mock backup manifest
backupManifest.generateManifest = jest.fn().mockResolvedValue({
backup: { id: 'test-backup-123' },
version: '2.0'
});
backupManifest.saveManifest = jest.fn().mockResolvedValue('/path/to/manifest.json');
backupManifest.loadManifest = jest.fn().mockResolvedValue({});
backupManifest.validateManifest = jest.fn();
backupManifest.generateSummaryReport = jest.fn().mockReturnValue('Summary report');
// Mock logger
logger.info = jest.fn();
logger.error = jest.fn();
logger.warn = jest.fn();
logger.debug = jest.fn();
});
afterEach(() => {
mockFs.restore();
});
describe('getBackupConfig', () => {
it('should retrieve and parse backup configuration from database', async () => {
const mockSettings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
{ setting_key: 'backup_s3_bucket', setting_value: '"test-bucket"' },
{ setting_key: 'backup_retention_days', setting_value: '30' }
];
mockDb.select.mockResolvedValue(mockSettings);
const config = await backupService.getBackupConfig();
expect(config).toEqual({
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_retention_days: 30
});
expect(db).toHaveBeenCalledWith('app_settings');
expect(mockDb.where).toHaveBeenCalledWith('setting_type', 'backup');
});
it('should handle JSON parse errors gracefully', async () => {
const mockSettings = [
{ setting_key: 'backup_enabled', setting_value: 'invalid-json' }
];
mockDb.select.mockResolvedValue(mockSettings);
const config = await backupService.getBackupConfig();
expect(config).toEqual({
backup_enabled: 'invalid-json'
});
});
it('should return null on database error', async () => {
mockDb.select.mockRejectedValue(new Error('Database error'));
const config = await backupService.getBackupConfig();
expect(config).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
});
describe('S3 Backup Functionality', () => {
beforeEach(() => {
// Mock file system
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content'),
'photo2.jpg': Buffer.from('photo2 content')
},
'/storage/events/archived/event2.zip': Buffer.from('archived content'),
'/storage/thumbnails': {
'thumb1.jpg': Buffer.from('thumb1 content')
},
'/storage/uploads': {
'logo.png': Buffer.from('logo content')
}
});
process.env.STORAGE_PATH = '/storage';
});
it('should perform S3 backup with correct configuration', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_region: 'us-east-1',
backup_s3_endpoint: 'https://s3.amazonaws.com',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_include_archived: true,
backup_max_file_size_mb: 100
};
mockDb.select.mockResolvedValue([]);
mockDb.where.mockReturnThis();
mockDb.first.mockResolvedValue(null);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
backupFile: null,
hasChanged: true
});
await backupService.runBackup();
expect(S3StorageAdapter).toHaveBeenCalledWith({
bucket: 'test-bucket',
region: 'us-east-1',
endpoint: 'https://s3.amazonaws.com',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
forcePathStyle: false,
sslEnabled: true,
maxRetries: 3,
retryDelay: 1000
});
expect(mockS3Client.testConnection).toHaveBeenCalled();
expect(mockS3Client.upload).toHaveBeenCalled();
});
it('should handle S3 upload failures gracefully', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockS3Client.testConnection.mockRejectedValue(new Error('Connection failed'));
await backupService.runBackup();
expect(logger.error).toHaveBeenCalledWith('S3 backup failed:', expect.any(Error));
expect(mockDb.update).toHaveBeenCalledWith(expect.objectContaining({
status: 'failed',
error_message: expect.stringContaining('Connection failed')
}));
});
it('should skip unchanged files in incremental backup', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_incremental: true
};
// Mock existing file state
mockDb.first.mockImplementation((query) => {
if (query === undefined) {
return Promise.resolve({
file_path: 'events/active/event1/photo1.jpg',
checksum: crypto.createHash('sha256').update('photo1 content').digest('hex')
});
}
return Promise.resolve(null);
});
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
await backupService.runBackup();
// Should skip unchanged file
const uploadCalls = mockS3Client.upload.mock.calls;
const photo1Uploaded = uploadCalls.some(call =>
call[1].includes('photo1.jpg')
);
expect(photo1Uploaded).toBe(false);
});
it('should include database backup when configured', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_include_database: true
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
backupFile: '/backup/db-backup.sql',
size: 1024000,
checksum: 'abc123',
hasChanged: false
});
// Mock database backup file
mockFs({
'/storage/events/active': {},
'/backup/db-backup.sql': Buffer.from('database backup content')
});
await backupService.runBackup();
// Verify database backup was uploaded
const uploadCalls = mockS3Client.upload.mock.calls;
const dbBackupUploaded = uploadCalls.some(call =>
call[1].includes('database/db-backup.sql')
);
expect(dbBackupUploaded).toBe(true);
});
it('should validate required S3 configuration', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket'
// Missing access key and secret key
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
await backupService.runBackup();
expect(logger.error).toHaveBeenCalledWith(
'S3 backup failed:',
expect.objectContaining({
message: expect.stringContaining('S3 backup configuration incomplete')
})
);
});
});
describe('Manifest Generation', () => {
it('should generate and save manifest for successful backup', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup',
backup_manifest_format: 'json'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
'/backup': {}
});
await backupService.runBackup();
expect(backupManifest.generateManifest).toHaveBeenCalledWith(
expect.objectContaining({
backupType: 'full',
backupPath: '/backup',
format: 'json'
})
);
expect(backupManifest.saveManifest).toHaveBeenCalled();
});
it('should generate incremental manifest when parent exists', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup'
};
const lastBackup = {
id: 1,
manifest_path: '/backup/manifests/previous.json',
manifest_id: 'previous-backup-123'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([2]);
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
mockDb.orderBy.mockReturnThis();
mockDb.where.mockReturnThis();
mockDb.whereNot = jest.fn().mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active': {},
'/backup': {}
});
await backupService.runBackup();
expect(backupManifest.loadManifest).toHaveBeenCalledWith('/backup/manifests/previous.json');
expect(backupManifest.generateIncrementalManifest).toHaveBeenCalled();
});
it('should upload manifest to S3 for S3 backups', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_manifest_format: 'yaml'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
const manifest = {
backup: { id: 'backup-123' },
version: '2.0'
};
backupManifest.generateManifest.mockResolvedValue(manifest);
mockFs({
'/storage/events/active': {},
'/storage/temp': {}
});
await backupService.runBackup();
// Verify manifest was uploaded to S3
const uploadCalls = mockS3Client.upload.mock.calls;
const manifestUploaded = uploadCalls.some(call =>
call[1].includes('manifests/backup-manifest-backup-123.yaml')
);
expect(manifestUploaded).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('should support local backup destination', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup/local'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
'/backup/local': {}
});
await backupService.runBackup();
// Verify files were copied to local destination
const fs = require('fs');
const destPath = '/backup/local/events/active/event1/photo1.jpg';
expect(fs.existsSync(destPath)).toBe(true);
});
it('should support rsync backup destination', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'rsync',
backup_rsync_host: 'backup.example.com',
backup_rsync_user: 'backup',
backup_rsync_path: '/remote/backup'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
// Mock exec for rsync
const { exec } = require('child_process');
const mockExec = jest.fn((cmd, callback) => {
callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' });
});
exec.mockImplementation(mockExec);
mockFs({
'/storage/events/active': {}
});
await backupService.runBackup();
expect(mockExec).toHaveBeenCalledWith(
expect.stringContaining('rsync'),
expect.any(Function)
);
});
});
describe('Error Handling and Recovery', () => {
it('should handle file read errors gracefully', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
// Mock file that throws error on read
const fs = require('fs');
const originalCreateReadStream = fs.createReadStream;
fs.createReadStream = jest.fn((path) => {
if (path.includes('error.jpg')) {
const stream = new EventEmitter();
process.nextTick(() => stream.emit('error', new Error('File read error')));
return stream;
}
return originalCreateReadStream(path);
});
mockFs({
'/storage/events/active': {
'error.jpg': Buffer.from('content'),
'good.jpg': Buffer.from('content')
}
});
await backupService.runBackup();
// Should continue with other files despite error
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to backup file'),
expect.any(Error)
);
fs.createReadStream = originalCreateReadStream;
});
it('should send failure email on backup error', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_email_on_failure: true
};
const admins = [
{ email: 'admin1@example.com', is_active: true },
{ email: 'admin2@example.com', is_active: true }
];
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.where.mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig')
.mockResolvedValueOnce(config)
.mockResolvedValueOnce(config);
// Force an error
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
// Mock admin users query
db.mockImplementation((table) => {
if (table === 'admin_users') {
return {
where: jest.fn().mockResolvedValue(admins)
};
}
return mockDb;
});
await backupService.runBackup();
expect(queueEmail).toHaveBeenCalledTimes(2);
expect(queueEmail).toHaveBeenCalledWith(
null,
'admin1@example.com',
'backup_failed',
expect.objectContaining({
error_message: 'Storage error'
})
);
});
it('should handle concurrent backup attempts', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup'
};
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active': {},
'/backup': {}
});
// Start two backups concurrently
const backup1 = backupService.runBackup();
const backup2 = backupService.runBackup();
await Promise.all([backup1, backup2]);
// Second backup should be skipped
expect(logger.warn).toHaveBeenCalledWith('Backup already running, skipping');
});
});
describe('Service Lifecycle', () => {
it('should start backup service with cron schedule', async () => {
const config = {
backup_enabled: true,
backup_schedule: '0 3 * * *' // 3 AM daily
};
mockDb.select.mockResolvedValue(
Object.entries(config).map(([key, value]) => ({
setting_key: key,
setting_value: value.toString()
}))
);
await backupService.startBackupService();
expect(cron.schedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
expect(logger.info).toHaveBeenCalledWith('Backup service started with schedule: 0 3 * * *');
});
it('should stop existing job when restarting service', async () => {
const config = {
backup_enabled: true,
backup_schedule: '0 2 * * *'
};
mockDb.select.mockResolvedValue(
Object.entries(config).map(([key, value]) => ({
setting_key: key,
setting_value: value.toString()
}))
);
// Start service twice
await backupService.startBackupService();
await backupService.startBackupService();
expect(mockCronJob.stop).toHaveBeenCalled();
});
it('should not start service when backup is disabled', async () => {
const config = {
backup_enabled: false
};
mockDb.select.mockResolvedValue([
{ setting_key: 'backup_enabled', setting_value: 'false' }
]);
await backupService.startBackupService();
expect(cron.schedule).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Backup service is disabled');
});
});
describe('Backup Status and History', () => {
it('should return backup status with recent runs', async () => {
const recentRuns = [
{
id: 1,
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
files_backed_up: 100,
total_size_bytes: 1024000,
manifest_path: '/backup/manifest.json'
}
];
mockDb.limit.mockResolvedValue(recentRuns);
backupManifest.validateManifest.mockImplementation(() => true);
const status = await backupService.getBackupStatus();
expect(status).toEqual({
isRunning: false,
isHealthy: true,
lastRun: expect.objectContaining({
...recentRuns[0],
manifestValid: true
}),
recentRuns: recentRuns,
nextScheduledRun: expect.any(String)
});
});
it('should clean up old backup runs', async () => {
mockDb.delete.mockResolvedValue(5);
await backupService.cleanupOldBackupRuns(30);
expect(mockDb.where).toHaveBeenCalledWith('started_at', '<', expect.any(Date));
expect(mockDb.delete).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Cleaned up 5 old backup runs');
});
});
describe('getBackupManifest', () => {
it('should retrieve manifest from local filesystem', async () => {
const backupRun = {
id: 1,
manifest_path: '/backup/manifests/backup-123.json'
};
mockDb.first.mockResolvedValue(backupRun);
const manifest = { backup: { id: 'backup-123' } };
backupManifest.loadManifest.mockResolvedValue(manifest);
backupManifest.generateSummaryReport.mockReturnValue('Summary');
const result = await backupService.getBackupManifest(1);
expect(result).toEqual({
manifest: manifest,
summary: 'Summary'
});
});
it('should retrieve manifest from S3', async () => {
const backupRun = {
id: 1,
manifest_path: 's3://test-bucket/backups/manifests/backup-123.json'
};
mockDb.first.mockResolvedValue(backupRun);
mockDb.select.mockResolvedValue([
{ setting_key: 'backup_s3_access_key', setting_value: '"test-key"' },
{ setting_key: 'backup_s3_secret_key', setting_value: '"test-secret"' }
]);
const manifest = { backup: { id: 'backup-123' } };
backupManifest.loadManifest.mockResolvedValue(manifest);
await backupService.getBackupManifest(1);
expect(S3StorageAdapter).toHaveBeenCalled();
expect(mockS3Client.download).toHaveBeenCalledWith(
'backups/manifests/backup-123.json',
expect.any(String)
);
});
});
});
Binary file not shown.
@@ -0,0 +1,243 @@
const { db } = require('../src/database/db');
async function up() {
console.log('Adding backup service tables and settings...');
// Create backup_runs table to track backup history
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
if (!hasBackupRunsTable) {
await db.schema.createTable('backup_runs', (table) => {
table.increments('id').primary();
table.datetime('started_at').notNullable();
table.datetime('completed_at');
table.string('status').defaultTo('running'); // running, completed, failed
table.string('backup_type'); // full, incremental
table.integer('files_backed_up').defaultTo(0);
table.bigInteger('total_size_bytes').defaultTo(0);
table.integer('duration_seconds');
table.text('error_message');
table.json('statistics'); // Detailed stats about the backup
table.json('file_checksums'); // Store checksums for change detection
});
}
// Create backup_file_states table to track individual file states
const hasBackupFileStatesTable = await db.schema.hasTable('backup_file_states');
if (!hasBackupFileStatesTable) {
await db.schema.createTable('backup_file_states', (table) => {
table.increments('id').primary();
table.string('file_path').notNullable();
table.string('checksum').notNullable();
table.bigInteger('size_bytes');
table.datetime('last_modified');
table.datetime('last_backed_up');
table.boolean('is_archived').defaultTo(false);
table.index(['file_path'], 'idx_backup_file_path');
table.index(['checksum'], 'idx_backup_checksum');
});
}
// Add backup-related settings to app_settings
const backupSettings = [
{
setting_key: 'backup_enabled',
setting_value: JSON.stringify(false),
setting_type: 'backup'
},
{
setting_key: 'backup_schedule',
setting_value: JSON.stringify('0 2 * * *'), // Default: 2 AM daily
setting_type: 'backup'
},
{
setting_key: 'backup_destination_type',
setting_value: JSON.stringify('local'), // local, rsync, s3
setting_type: 'backup'
},
{
setting_key: 'backup_destination_path',
setting_value: JSON.stringify('/backup/picpeak'),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_host',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_user',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_path',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_ssh_key',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_endpoint',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_bucket',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_access_key',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_secret_key',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_region',
setting_value: JSON.stringify('us-east-1'),
setting_type: 'backup'
},
{
setting_key: 'backup_retention_days',
setting_value: JSON.stringify(30),
setting_type: 'backup'
},
{
setting_key: 'backup_include_archived',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_compression',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_email_on_failure',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_email_on_success',
setting_value: JSON.stringify(false),
setting_type: 'backup'
},
{
setting_key: 'backup_max_file_size_mb',
setting_value: JSON.stringify(5000), // Skip files larger than 5GB
setting_type: 'backup'
},
{
setting_key: 'backup_exclude_patterns',
setting_value: JSON.stringify(['*.tmp', '.DS_Store', 'Thumbs.db']),
setting_type: 'backup'
}
];
// Insert backup settings if they don't exist
for (const setting of backupSettings) {
const exists = await db('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await db('app_settings').insert(setting);
}
}
// Add backup-related email templates
const backupEmailTemplates = [
{
template_key: 'backup_failed',
subject_en: 'Backup Failed - Immediate Attention Required',
subject_de: 'Backup fehlgeschlagen - Sofortige Aufmerksamkeit erforderlich',
body_html_en: `<h2>Backup Failed</h2>
<p>The scheduled backup has failed and requires immediate attention.</p>
<p><strong>Error Details:</strong></p>
<ul>
<li>Start Time: {{start_time}}</li>
<li>Backup Type: {{backup_type}}</li>
<li>Error: {{error_message}}</li>
</ul>
<p>Please check the system logs for more details and resolve the issue as soon as possible.</p>`,
body_html_de: `<h2>Backup fehlgeschlagen</h2>
<p>Das geplante Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.</p>
<p><strong>Fehlerdetails:</strong></p>
<ul>
<li>Startzeit: {{start_time}}</li>
<li>Backup-Typ: {{backup_type}}</li>
<li>Fehler: {{error_message}}</li>
</ul>
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und beheben Sie das Problem so schnell wie möglich.</p>`,
body_text_en: 'Backup Failed\n\nThe scheduled backup has failed and requires immediate attention.\n\nStart Time: {{start_time}}\nBackup Type: {{backup_type}}\nError: {{error_message}}\n\nPlease check the system logs for more details.',
body_text_de: 'Backup fehlgeschlagen\n\nDas geplante Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.\n\nStartzeit: {{start_time}}\nBackup-Typ: {{backup_type}}\nFehler: {{error_message}}\n\nBitte überprüfen Sie die Systemprotokolle für weitere Details.',
variables: JSON.stringify(['start_time', 'backup_type', 'error_message'])
},
{
template_key: 'backup_completed',
subject_en: 'Backup Completed Successfully',
subject_de: 'Backup erfolgreich abgeschlossen',
body_html_en: `<h2>Backup Completed</h2>
<p>The scheduled backup has been completed successfully.</p>
<p><strong>Backup Summary:</strong></p>
<ul>
<li>Start Time: {{start_time}}</li>
<li>Duration: {{duration}}</li>
<li>Files Backed Up: {{files_count}}</li>
<li>Total Size: {{total_size}}</li>
<li>Backup Type: {{backup_type}}</li>
</ul>`,
body_html_de: `<h2>Backup abgeschlossen</h2>
<p>Das geplante Backup wurde erfolgreich abgeschlossen.</p>
<p><strong>Backup-Zusammenfassung:</strong></p>
<ul>
<li>Startzeit: {{start_time}}</li>
<li>Dauer: {{duration}}</li>
<li>Gesicherte Dateien: {{files_count}}</li>
<li>Gesamtgröße: {{total_size}}</li>
<li>Backup-Typ: {{backup_type}}</li>
</ul>`,
body_text_en: 'Backup Completed\n\nThe scheduled backup has been completed successfully.\n\nStart Time: {{start_time}}\nDuration: {{duration}}\nFiles Backed Up: {{files_count}}\nTotal Size: {{total_size}}\nBackup Type: {{backup_type}}',
body_text_de: 'Backup abgeschlossen\n\nDas geplante Backup wurde erfolgreich abgeschlossen.\n\nStartzeit: {{start_time}}\nDauer: {{duration}}\nGesicherte Dateien: {{files_count}}\nGesamtgröße: {{total_size}}\nBackup-Typ: {{backup_type}}',
variables: JSON.stringify(['start_time', 'duration', 'files_count', 'total_size', 'backup_type'])
}
];
// Insert backup email templates if they don't exist
for (const template of backupEmailTemplates) {
const exists = await db('email_templates')
.where('template_key', template.template_key)
.first();
if (!exists) {
await db('email_templates').insert(template);
}
}
console.log('Backup service tables and settings added successfully');
}
async function down() {
// Remove backup tables
await db.schema.dropTableIfExists('backup_file_states');
await db.schema.dropTableIfExists('backup_runs');
// Remove backup settings
await db('app_settings')
.where('setting_type', 'backup')
.delete();
// Remove backup email templates
await db('email_templates')
.whereIn('template_key', ['backup_failed', 'backup_completed'])
.delete();
}
module.exports = { up, down };
@@ -0,0 +1,182 @@
const { db } = require('../src/database/db');
async function up() {
console.log('Adding database backup tables and settings...');
// Create database_backup_runs table to track database backup history
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
if (!hasDatabaseBackupRunsTable) {
await db.schema.createTable('database_backup_runs', (table) => {
table.increments('id').primary();
table.datetime('started_at').notNullable();
table.datetime('completed_at');
table.string('status').defaultTo('running'); // running, completed, failed
table.string('backup_type'); // sqlite, postgresql
table.string('destination_path');
table.string('file_path');
table.bigInteger('file_size_bytes').defaultTo(0);
table.bigInteger('original_size_bytes').defaultTo(0);
table.integer('duration_seconds');
table.string('checksum'); // SHA256 checksum of backup file
table.float('compression_ratio'); // Compression percentage
table.json('table_checksums'); // Individual table checksums
table.text('error_message');
table.json('statistics'); // Detailed stats about the backup
table.index(['started_at'], 'idx_db_backup_started');
table.index(['status'], 'idx_db_backup_status');
});
}
// Add database backup-related settings to app_settings
const databaseBackupSettings = [
{
setting_key: 'database_backup_enabled',
setting_value: JSON.stringify(false),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_schedule',
setting_value: JSON.stringify('0 3 * * *'), // Default: 3 AM daily
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_destination_path',
setting_value: JSON.stringify('/backup/database'),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_compress',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_validate_integrity',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_include_checksums',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_retention_days',
setting_value: JSON.stringify(30),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_email_on_failure',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_email_on_success',
setting_value: JSON.stringify(false),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_max_retries',
setting_value: JSON.stringify(3),
setting_type: 'database_backup'
}
];
// Insert database backup settings if they don't exist
for (const setting of databaseBackupSettings) {
const exists = await db('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await db('app_settings').insert(setting);
}
}
// Add database backup-related email templates
const databaseBackupEmailTemplates = [
{
template_key: 'database_backup_failed',
subject_en: 'Database Backup Failed - Critical Alert',
subject_de: 'Datenbank-Backup fehlgeschlagen - Kritische Warnung',
body_html_en: `<h2>Database Backup Failed</h2>
<p>The scheduled database backup has failed and requires immediate attention.</p>
<p><strong>Error Details:</strong></p>
<ul>
<li>Backup Type: {{backup_type}}</li>
<li>Timestamp: {{timestamp}}</li>
<li>Error: {{error_message}}</li>
</ul>
<p>This is a critical issue that could affect disaster recovery. Please investigate immediately.</p>`,
body_html_de: `<h2>Datenbank-Backup fehlgeschlagen</h2>
<p>Das geplante Datenbank-Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.</p>
<p><strong>Fehlerdetails:</strong></p>
<ul>
<li>Backup-Typ: {{backup_type}}</li>
<li>Zeitstempel: {{timestamp}}</li>
<li>Fehler: {{error_message}}</li>
</ul>
<p>Dies ist ein kritisches Problem, das die Disaster-Recovery beeinträchtigen könnte. Bitte untersuchen Sie es sofort.</p>`,
body_text_en: 'Database Backup Failed\n\nThe scheduled database backup has failed.\n\nBackup Type: {{backup_type}}\nTimestamp: {{timestamp}}\nError: {{error_message}}\n\nThis is critical - please investigate immediately.',
body_text_de: 'Datenbank-Backup fehlgeschlagen\n\nDas geplante Datenbank-Backup ist fehlgeschlagen.\n\nBackup-Typ: {{backup_type}}\nZeitstempel: {{timestamp}}\nFehler: {{error_message}}\n\nDies ist kritisch - bitte sofort untersuchen.',
variables: JSON.stringify(['backup_type', 'timestamp', 'error_message'])
},
{
template_key: 'database_backup_completed',
subject_en: 'Database Backup Completed Successfully',
subject_de: 'Datenbank-Backup erfolgreich abgeschlossen',
body_html_en: `<h2>Database Backup Completed</h2>
<p>The scheduled database backup has been completed successfully.</p>
<p><strong>Backup Summary:</strong></p>
<ul>
<li>Backup Type: {{backup_type}}</li>
<li>Duration: {{duration}}</li>
<li>File Size: {{file_size}}</li>
<li>Compression Ratio: {{compression_ratio}}</li>
<li>File Path: {{file_path}}</li>
</ul>`,
body_html_de: `<h2>Datenbank-Backup abgeschlossen</h2>
<p>Das geplante Datenbank-Backup wurde erfolgreich abgeschlossen.</p>
<p><strong>Backup-Zusammenfassung:</strong></p>
<ul>
<li>Backup-Typ: {{backup_type}}</li>
<li>Dauer: {{duration}}</li>
<li>Dateigröße: {{file_size}}</li>
<li>Komprimierungsverhältnis: {{compression_ratio}}</li>
<li>Dateipfad: {{file_path}}</li>
</ul>`,
body_text_en: 'Database Backup Completed\n\nThe scheduled database backup has been completed successfully.\n\nBackup Type: {{backup_type}}\nDuration: {{duration}}\nFile Size: {{file_size}}\nCompression Ratio: {{compression_ratio}}\nFile Path: {{file_path}}',
body_text_de: 'Datenbank-Backup abgeschlossen\n\nDas geplante Datenbank-Backup wurde erfolgreich abgeschlossen.\n\nBackup-Typ: {{backup_type}}\nDauer: {{duration}}\nDateigröße: {{file_size}}\nKomprimierungsverhältnis: {{compression_ratio}}\nDateipfad: {{file_path}}',
variables: JSON.stringify(['backup_type', 'duration', 'file_size', 'compression_ratio', 'file_path'])
}
];
// Insert database backup email templates if they don't exist
for (const template of databaseBackupEmailTemplates) {
const exists = await db('email_templates')
.where('template_key', template.template_key)
.first();
if (!exists) {
await db('email_templates').insert(template);
}
}
console.log('Database backup tables and settings added successfully');
}
async function down() {
// Remove database backup tables
await db.schema.dropTableIfExists('database_backup_runs');
// Remove database backup settings
await db('app_settings')
.where('setting_type', 'database_backup')
.delete();
// Remove database backup email templates
await db('email_templates')
.whereIn('template_key', ['database_backup_failed', 'database_backup_completed'])
.delete();
}
module.exports = { up, down };
@@ -0,0 +1,86 @@
const { db } = require('../src/database/db');
const logger = require('../src/utils/logger');
async function up() {
console.log('Adding backup manifest columns...');
// Add manifest columns to backup_runs table
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
if (!hasManifestPath) {
await db.schema.alterTable('backup_runs', (table) => {
table.string('manifest_path'); // Path to the manifest file
table.string('manifest_id'); // Unique manifest ID
table.string('manifest_format').defaultTo('json'); // json or yaml
});
}
// Add backup manifest-related settings to app_settings
const manifestSettings = [
{
setting_key: 'backup_manifest_enabled',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_format',
setting_value: JSON.stringify('json'), // json or yaml
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_path',
setting_value: JSON.stringify('/backup/manifests'),
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_validate',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_include_checksums',
setting_value: JSON.stringify(true),
setting_type: 'backup'
}
];
// Insert manifest settings if they don't exist
for (const setting of manifestSettings) {
const exists = await db('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await db('app_settings').insert(setting);
}
}
console.log('✓ Backup manifest columns and settings added');
}
async function down() {
// Remove manifest columns from backup_runs table
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
if (hasManifestPath) {
await db.schema.alterTable('backup_runs', (table) => {
table.dropColumn('manifest_path');
table.dropColumn('manifest_id');
table.dropColumn('manifest_format');
});
}
// Remove manifest settings
await db('app_settings')
.where('setting_type', 'backup')
.whereIn('setting_key', [
'backup_manifest_enabled',
'backup_manifest_format',
'backup_manifest_path',
'backup_manifest_validate',
'backup_manifest_include_checksums'
])
.delete();
console.log('✓ Backup manifest columns and settings removed');
}
module.exports = { up, down };
@@ -0,0 +1,131 @@
exports.up = function(knex) {
return knex.schema
// Add new settings to app_settings table
.table('app_settings', table => {
// S3 configuration enhancements
table.boolean('backup_s3_force_path_style').defaultTo(false).comment('Force path-style S3 URLs (for MinIO/self-hosted)');
table.boolean('backup_s3_ssl_enabled').defaultTo(true).comment('Enable SSL/TLS for S3 connections');
table.string('backup_s3_prefix', 255).comment('S3 key prefix for organizing backups');
// Backup features
table.boolean('backup_incremental').defaultTo(false).comment('Enable incremental backups');
table.boolean('backup_include_database').defaultTo(true).comment('Include database dumps in backups');
table.boolean('backup_manifest_enabled').defaultTo(true).comment('Generate backup manifests');
table.enum('backup_manifest_format', ['json', 'yaml']).defaultTo('json').comment('Manifest file format');
table.boolean('backup_encryption_enabled').defaultTo(false).comment('Enable backup encryption');
table.string('backup_database_schedule', 100).comment('Separate cron schedule for database-only backups');
})
// Enhance backup_runs table
.table('backup_runs', table => {
// Manifest tracking
table.string('manifest_path', 500).comment('Path to backup manifest file');
table.uuid('manifest_id').comment('Unique identifier for the manifest');
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
// Incremental backup support
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
// Add indexes for better query performance
table.index(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
table.index(['parent_backup_id'], 'idx_backup_runs_parent');
table.index(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
})
// Create backup_manifest table for storing detailed manifest metadata
.createTable('backup_manifest', table => {
table.increments('id').primary();
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
// Backup metadata
table.timestamp('backup_start').notNullable();
table.timestamp('backup_end').notNullable();
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
table.integer('file_count').unsigned().comment('Number of files in backup');
table.integer('photo_count').unsigned().comment('Number of photos backed up');
table.integer('event_count').unsigned().comment('Number of events backed up');
// Incremental backup metadata
table.boolean('is_incremental').defaultTo(false);
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
// Content checksums
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
// Storage information
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
// Encryption metadata
table.boolean('is_encrypted').defaultTo(false);
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
table.string('encryption_key_id', 255).comment('ID of encryption key used');
// Additional metadata as JSON
table.json('metadata').comment('Additional metadata as JSON');
// Timestamps
table.timestamps(true, true);
// Indexes
table.index(['backup_run_id'], 'idx_manifest_backup_run');
table.index(['manifest_id'], 'idx_manifest_uuid');
table.index(['parent_manifest_id'], 'idx_manifest_parent');
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
})
// Add composite indexes for common query patterns
.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(created_at DESC)
WHERE status = 'completed' AND backup_mode = 'full';
`)
.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, created_at)
WHERE backup_mode = 'incremental';
`);
};
exports.down = function(knex) {
return knex.schema
// Drop indexes first
.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;')
.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;')
// Drop backup_manifest table
.dropTableIfExists('backup_manifest')
// Remove columns from backup_runs table
.table('backup_runs', table => {
table.dropIndex(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
table.dropIndex(['parent_backup_id'], 'idx_backup_runs_parent');
table.dropIndex(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
table.dropColumn('manifest_path');
table.dropColumn('manifest_id');
table.dropColumn('manifest_format');
table.dropColumn('parent_backup_id');
table.dropColumn('backup_mode');
})
// Remove columns from app_settings table
.table('app_settings', table => {
table.dropColumn('backup_s3_force_path_style');
table.dropColumn('backup_s3_ssl_enabled');
table.dropColumn('backup_s3_prefix');
table.dropColumn('backup_incremental');
table.dropColumn('backup_include_database');
table.dropColumn('backup_manifest_enabled');
table.dropColumn('backup_manifest_format');
table.dropColumn('backup_encryption_enabled');
table.dropColumn('backup_database_schedule');
});
};
@@ -0,0 +1,249 @@
const { formatBoolean, parseBoolean } = require('./helpers');
/**
* Add restore_runs table for tracking restore operations
*/
exports.up = async function(knex) {
// Create restore_runs table
await knex.schema.createTable('restore_runs', table => {
table.increments('id').primary();
// Timing
table.timestamp('started_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('completed_at');
table.integer('duration_seconds');
// Status and type
table.string('status', 50).notNullable().defaultTo('running');
table.string('restore_type', 50).notNullable(); // full, database, files, selective
// Source information
table.string('source', 500).notNullable(); // Backup source path or S3 URL
table.string('manifest_path', 500); // Path to manifest file
// Results
table.text('error_message');
table.text('statistics'); // JSON object with detailed statistics
table.text('restore_log'); // JSON array of log entries
// Safety backup
table.string('pre_restore_backup_path', 500); // Path to pre-restore safety backup
// Flags
table.boolean('is_dry_run').defaultTo(formatBoolean(false));
table.boolean('was_rollback_attempted').defaultTo(formatBoolean(false));
table.boolean('was_successful').defaultTo(formatBoolean(false));
// Operator information
table.string('operator_type', 50).defaultTo('manual'); // manual, scheduled, api
table.integer('operator_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
table.string('operator_ip', 50);
// Metadata
table.text('metadata'); // JSON object for additional data
table.index(['status', 'started_at']);
table.index(['restore_type', 'started_at']);
});
// Create restore_file_operations table for tracking individual file operations
await knex.schema.createTable('restore_file_operations', table => {
table.increments('id').primary();
table.integer('restore_run_id').notNullable()
.references('id').inTable('restore_runs').onDelete('CASCADE');
table.string('file_path', 500).notNullable();
table.string('operation', 50).notNullable(); // restore, skip, error
table.string('status', 50).notNullable(); // pending, in_progress, completed, failed
table.bigInteger('file_size');
table.string('checksum', 64);
table.boolean('checksum_verified').defaultTo(formatBoolean(false));
table.text('error_message');
table.timestamp('started_at');
table.timestamp('completed_at');
table.index(['restore_run_id', 'status']);
table.index(['file_path']);
});
// Create restore_validation_results table
await knex.schema.createTable('restore_validation_results', table => {
table.increments('id').primary();
table.integer('restore_run_id').notNullable()
.references('id').inTable('restore_runs').onDelete('CASCADE');
table.string('validation_type', 50).notNullable(); // pre-restore, post-restore
table.boolean('is_valid').notNullable();
table.text('errors'); // JSON array of errors
table.text('warnings'); // JSON array of warnings
table.text('checksums'); // JSON object with checksum comparisons
table.timestamp('validated_at').notNullable().defaultTo(knex.fn.now());
table.index(['restore_run_id', 'validation_type']);
});
// Add restore-related settings to app_settings
await knex('app_settings').insert([
{
setting_key: 'restore_allow_force',
setting_value: formatBoolean(false),
setting_type: 'restore',
description: 'Allow force restore with warnings',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_require_pre_backup',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Require pre-restore backup',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_max_file_size_mb',
setting_value: '5000',
setting_type: 'restore',
description: 'Maximum file size for restore (MB)',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_verify_checksums',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Verify file checksums during restore',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_email_on_completion',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Send email on restore completion',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_retention_days',
setting_value: '30',
setting_type: 'restore',
description: 'Days to retain restore history',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
}
]);
// Add new email templates for restore notifications
const emailTemplates = [
{
name: 'restore_completed',
subject: '✅ Restore Completed Successfully',
body: `<h2>Restore Operation Completed</h2>
<p>A restore operation has completed successfully.</p>
<h3>Details:</h3>
<ul>
<li><strong>Restore Type:</strong> {{restore_type}}</li>
<li><strong>Duration:</strong> {{duration}}</li>
<li><strong>Files Restored:</strong> {{files_restored}}</li>
<li><strong>Backup ID:</strong> {{backup_id}}</li>
<li><strong>Timestamp:</strong> {{timestamp}}</li>
</ul>
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
language: 'en',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_failed',
subject: '❌ Restore Operation Failed',
body: `<h2>Restore Operation Failed</h2>
<p>A restore operation has failed and requires attention.</p>
<h3>Details:</h3>
<ul>
<li><strong>Restore Type:</strong> {{restore_type}}</li>
<li><strong>Error:</strong> {{error_message}}</li>
<li><strong>Timestamp:</strong> {{timestamp}}</li>
</ul>
<p>Please check the system logs for more details and take appropriate action.</p>
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
language: 'en',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_completed',
subject: '✅ Wiederherstellung erfolgreich abgeschlossen',
body: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
<p>Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.</p>
<h3>Details:</h3>
<ul>
<li><strong>Wiederherstellungstyp:</strong> {{restore_type}}</li>
<li><strong>Dauer:</strong> {{duration}}</li>
<li><strong>Wiederhergestellte Dateien:</strong> {{files_restored}}</li>
<li><strong>Backup-ID:</strong> {{backup_id}}</li>
<li><strong>Zeitstempel:</strong> {{timestamp}}</li>
</ul>
<p>Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.</p>`,
language: 'de',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_failed',
subject: '❌ Wiederherstellungsvorgang fehlgeschlagen',
body: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
<p>Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.</p>
<h3>Details:</h3>
<ul>
<li><strong>Wiederherstellungstyp:</strong> {{restore_type}}</li>
<li><strong>Fehler:</strong> {{error_message}}</li>
<li><strong>Zeitstempel:</strong> {{timestamp}}</li>
</ul>
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.</p>
<p><strong>Wichtig:</strong> Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.</p>`,
language: 'de',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
}
];
await knex('email_templates').insert(emailTemplates);
};
exports.down = async function(knex) {
// Remove email templates
await knex('email_templates')
.whereIn('name', ['restore_completed', 'restore_failed'])
.delete();
// Remove settings
await knex('app_settings')
.where('setting_type', 'restore')
.delete();
// Drop tables
await knex.schema.dropTableIfExists('restore_validation_results');
await knex.schema.dropTableIfExists('restore_file_operations');
await knex.schema.dropTableIfExists('restore_runs');
};
+1810 -9
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -10,9 +10,14 @@
"migrate:safe": "node migrations/run-migrations-safe.js",
"fix-temp-photos": "node scripts/fix-temp-photos.js",
"test": "jest",
"lint": "eslint src/"
"lint": "eslint src/",
"test-backup": "node scripts/test-backup-service.js",
"test-restore": "node scripts/test-restore-service.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.10.0",
@@ -30,8 +35,10 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"joi": "^17.9.1",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-cron": "^3.0.2",
"nodemailer": "^6.9.1",
+577
View File
@@ -0,0 +1,577 @@
#!/usr/bin/env node
/**
* Manual Integration Test Script for Enhanced Backup System
*
* This script provides a comprehensive test of the backup system with real services.
* It can be used to test against MinIO, AWS S3, or other S3-compatible services.
*
* Usage:
* node scripts/test-backup-integration.js [options]
*
* Options:
* --endpoint <url> S3 endpoint URL (default: http://localhost:9000)
* --access-key <key> S3 access key (default: minioadmin)
* --secret-key <key> S3 secret key (default: minioadmin)
* --bucket <name> S3 bucket name (default: test-backup-<timestamp>)
* --type <type> Backup type: s3, local, rsync (default: s3)
* --cleanup Clean up test data after completion
* --verbose Enable verbose logging
* --help Show this help message
*
* Examples:
* # Test with local MinIO
* node scripts/test-backup-integration.js
*
* # Test with AWS S3
* node scripts/test-backup-integration.js \
* --endpoint https://s3.amazonaws.com \
* --access-key AKIAIOSFODNN7EXAMPLE \
* --secret-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
* --bucket my-test-bucket
*
* # Test local backup
* node scripts/test-backup-integration.js --type local
*/
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const { S3Client, CreateBucketCommand, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand, DeleteBucketCommand } = require('@aws-sdk/client-s3');
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
endpoint: 'http://localhost:9000',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
bucket: `test-backup-${Date.now()}`,
type: 's3',
cleanup: false,
verbose: false
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--endpoint':
options.endpoint = args[++i];
break;
case '--access-key':
options.accessKey = args[++i];
break;
case '--secret-key':
options.secretKey = args[++i];
break;
case '--bucket':
options.bucket = args[++i];
break;
case '--type':
options.type = args[++i];
break;
case '--cleanup':
options.cleanup = true;
break;
case '--verbose':
options.verbose = true;
break;
case '--help':
console.log(module.exports.description || 'Manual Integration Test Script');
process.exit(0);
}
}
// Load environment and services
require('dotenv').config();
const { db, initialize: initDb } = require('../src/database/db');
const backupService = require('../src/services/backupService');
const S3StorageAdapter = require('../src/services/storage/s3Storage');
const logger = require('../src/utils/logger');
// Configure logger based on verbose flag
if (!options.verbose) {
logger.info = () => {};
logger.debug = () => {};
}
// Test results
const results = {
passed: 0,
failed: 0,
skipped: 0,
tests: []
};
// Test utilities
async function runTest(name, testFn) {
console.log(`\n📋 Running: ${name}`);
try {
const startTime = Date.now();
await testFn();
const duration = Date.now() - startTime;
console.log(`✅ PASSED: ${name} (${duration}ms)`);
results.passed++;
results.tests.push({ name, status: 'passed', duration });
} catch (error) {
console.error(`❌ FAILED: ${name}`);
console.error(` Error: ${error.message}`);
if (options.verbose) {
console.error(error.stack);
}
results.failed++;
results.tests.push({ name, status: 'failed', error: error.message });
}
}
async function skipTest(name, reason) {
console.log(`\n⏭️ Skipping: ${name}`);
console.log(` Reason: ${reason}`);
results.skipped++;
results.tests.push({ name, status: 'skipped', reason });
}
// Test functions
async function testS3Connection() {
const s3Adapter = new S3StorageAdapter({
bucket: options.bucket,
endpoint: options.endpoint,
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey,
region: 'us-east-1',
forcePathStyle: true,
sslEnabled: options.endpoint.startsWith('https')
});
await s3Adapter.testConnection();
console.log(` ✓ Connected to S3 endpoint: ${options.endpoint}`);
console.log(` ✓ Bucket accessible: ${options.bucket}`);
}
async function setupTestData() {
const storagePath = path.join(__dirname, '../test-storage');
process.env.STORAGE_PATH = storagePath;
// Create directory structure
const dirs = [
'events/active/wedding-2024',
'events/active/birthday-2024',
'events/archived',
'thumbnails',
'uploads',
'backups'
];
for (const dir of dirs) {
await fs.mkdir(path.join(storagePath, dir), { recursive: true });
}
// Create test files with various sizes
const files = [
{ path: 'events/active/wedding-2024/photo1.jpg', size: 1024 * 1024 }, // 1MB
{ path: 'events/active/wedding-2024/photo2.jpg', size: 512 * 1024 }, // 512KB
{ path: 'events/active/birthday-2024/photo1.jpg', size: 2 * 1024 * 1024 }, // 2MB
{ path: 'events/archived/old-event.zip', size: 5 * 1024 * 1024 }, // 5MB
{ path: 'thumbnails/thumb1.jpg', size: 50 * 1024 }, // 50KB
{ path: 'uploads/logo.png', size: 100 * 1024 } // 100KB
];
let totalSize = 0;
for (const file of files) {
const content = crypto.randomBytes(file.size);
await fs.writeFile(path.join(storagePath, file.path), content);
totalSize += file.size;
}
console.log(` ✓ Created ${files.length} test files`);
console.log(` ✓ Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
return { storagePath, fileCount: files.length, totalSize };
}
async function configureBackup(type) {
const baseSettings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: `"${type}"` },
{ setting_key: 'backup_include_archived', setting_value: 'true' },
{ setting_key: 'backup_include_database', setting_value: 'true' },
{ setting_key: 'backup_incremental', setting_value: 'true' },
{ setting_key: 'backup_manifest_format', setting_value: '"json"' },
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
const typeSpecificSettings = {
s3: [
{ setting_key: 'backup_s3_bucket', setting_value: `"${options.bucket}"` },
{ setting_key: 'backup_s3_endpoint', setting_value: `"${options.endpoint}"` },
{ setting_key: 'backup_s3_access_key', setting_value: `"${options.accessKey}"` },
{ setting_key: 'backup_s3_secret_key', setting_value: `"${options.secretKey}"` },
{ setting_key: 'backup_s3_region', setting_value: '"us-east-1"' },
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
{ setting_key: 'backup_s3_ssl_enabled', setting_value: options.endpoint.startsWith('https') ? 'true' : 'false' }
],
local: [
{ setting_key: 'backup_destination_path', setting_value: `"${path.join(__dirname, '../test-backup')}"` }
],
rsync: [
{ setting_key: 'backup_rsync_host', setting_value: '"localhost"' },
{ setting_key: 'backup_rsync_path', setting_value: `"${path.join(__dirname, '../test-backup-rsync')}"` }
]
};
const settings = [...baseSettings, ...(typeSpecificSettings[type] || [])];
// Clear existing settings
await db('app_settings').where('setting_type', 'backup').del();
// Insert new settings
for (const setting of settings) {
await db('app_settings').insert({
setting_type: 'backup',
...setting,
created_at: new Date(),
updated_at: new Date()
});
}
console.log(` ✓ Configured ${type} backup with ${settings.length} settings`);
}
async function performBackup() {
const startTime = Date.now();
// Run the backup
await backupService.runBackup();
// Get backup results
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
if (!backupRun) {
throw new Error('No backup run found');
}
if (backupRun.status !== 'completed') {
throw new Error(`Backup failed with status: ${backupRun.status}, error: ${backupRun.error_message}`);
}
const duration = Date.now() - startTime;
console.log(` ✓ Backup completed in ${duration}ms`);
console.log(` ✓ Files backed up: ${backupRun.files_backed_up}`);
console.log(` ✓ Total size: ${(backupRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(` ✓ Manifest: ${backupRun.manifest_path ? 'Generated' : 'Not generated'}`);
return backupRun;
}
async function verifyS3Backup(backupRun) {
const s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
// List objects in bucket
const listResponse = await s3Client.send(new ListObjectsV2Command({
Bucket: options.bucket
}));
const objects = listResponse.Contents || [];
console.log(` ✓ Objects in S3: ${objects.length}`);
// Verify key components
const hasBackupFolder = objects.some(obj => obj.Key.includes('backup-'));
const hasManifest = objects.some(obj => obj.Key.includes('backup-manifest'));
const hasSummary = objects.some(obj => obj.Key.includes('backup-summary.json'));
const hasPhotos = objects.some(obj => obj.Key.includes('events/active'));
if (!hasBackupFolder) throw new Error('No backup folder found in S3');
if (!hasManifest) throw new Error('No manifest found in S3');
if (!hasSummary) throw new Error('No summary found in S3');
if (!hasPhotos) throw new Error('No photos found in S3');
console.log(` ✓ Backup structure verified`);
// Download and verify a file
const photoObject = objects.find(obj => obj.Key.includes('photo1.jpg'));
if (photoObject) {
const getResponse = await s3Client.send(new GetObjectCommand({
Bucket: options.bucket,
Key: photoObject.Key
}));
const chunks = [];
for await (const chunk of getResponse.Body) {
chunks.push(chunk);
}
const content = Buffer.concat(chunks);
console.log(` ✓ Downloaded test file: ${photoObject.Key} (${content.length} bytes)`);
}
}
async function testIncrementalBackup(testData) {
// Modify a file
const modifiedFile = path.join(testData.storagePath, 'events/active/wedding-2024/photo1.jpg');
const newContent = crypto.randomBytes(1024 * 1024 + 100); // Slightly larger
await fs.writeFile(modifiedFile, newContent);
console.log(` ✓ Modified test file`);
// Perform incremental backup
const backupRun = await performBackup();
if (backupRun.files_backed_up !== 1) {
throw new Error(`Expected 1 file in incremental backup, got ${backupRun.files_backed_up}`);
}
console.log(` ✓ Incremental backup correctly identified changed file`);
// Verify manifest indicates incremental
if (backupRun.manifest_path) {
const { manifest } = await backupService.getBackupManifest(backupRun.id);
if (!manifest.incremental) {
throw new Error('Manifest does not indicate incremental backup');
}
console.log(` ✓ Manifest correctly marked as incremental`);
}
return backupRun;
}
async function testManifestValidation(backupRun) {
if (!backupRun.manifest_path) {
throw new Error('No manifest path in backup run');
}
const result = await backupService.validateBackupManifest(backupRun.manifest_path);
if (!result.valid) {
throw new Error(`Manifest validation failed: ${result.error}`);
}
console.log(` ✓ Manifest validation passed`);
console.log(` ✓ Manifest version: ${result.manifest.manifest.version}`);
console.log(` ✓ Files in manifest: ${result.manifest.files.count}`);
}
async function testBackupStatus() {
const status = await backupService.getBackupStatus(5);
console.log(` ✓ Backup service running: ${status.isRunning}`);
console.log(` ✓ Backup service healthy: ${status.isHealthy}`);
console.log(` ✓ Recent runs: ${status.recentRuns.length}`);
if (status.lastRun) {
console.log(` ✓ Last run status: ${status.lastRun.status}`);
console.log(` ✓ Manifest valid: ${status.lastRun.manifestValid}`);
}
}
async function cleanupTestData() {
if (!options.cleanup) {
console.log('\n📌 Test data retained for inspection');
console.log(` Storage: ${process.env.STORAGE_PATH}`);
if (options.type === 's3') {
console.log(` S3 Bucket: ${options.bucket}`);
}
return;
}
console.log('\n🧹 Cleaning up test data...');
// Clean storage directory
if (process.env.STORAGE_PATH) {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true });
console.log(' ✓ Removed test storage directory');
}
// Clean S3 bucket if used
if (options.type === 's3') {
const s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
try {
// List and delete all objects
const listResponse = await s3Client.send(new ListObjectsV2Command({
Bucket: options.bucket
}));
if (listResponse.Contents && listResponse.Contents.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: options.bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key }))
}
}));
console.log(` ✓ Deleted ${listResponse.Contents.length} objects from S3`);
}
// Delete bucket
await s3Client.send(new DeleteBucketCommand({
Bucket: options.bucket
}));
console.log(` ✓ Deleted S3 bucket: ${options.bucket}`);
} catch (error) {
console.error(` ⚠️ Failed to cleanup S3: ${error.message}`);
}
}
// Clean backup directories
const backupDirs = [
path.join(__dirname, '../test-backup'),
path.join(__dirname, '../test-backup-rsync')
];
for (const dir of backupDirs) {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
}
console.log(' ✓ Removed backup directories');
}
// Main test runner
async function main() {
console.log('🚀 Enhanced Backup System Integration Test');
console.log('==========================================');
console.log(`Type: ${options.type}`);
console.log(`Endpoint: ${options.endpoint}`);
console.log(`Bucket: ${options.bucket}`);
console.log('');
let s3Client;
let testData;
try {
// Initialize database
console.log('📦 Initializing database...');
await initDb();
await db.migrate.latest();
console.log(' ✓ Database initialized');
// S3-specific setup
if (options.type === 's3') {
// Test S3 connection
await runTest('S3 Connection Test', testS3Connection);
// Create S3 bucket if needed
s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
try {
await s3Client.send(new HeadBucketCommand({ Bucket: options.bucket }));
console.log(`\n📦 Using existing bucket: ${options.bucket}`);
} catch (error) {
if (error.name === 'NotFound') {
await s3Client.send(new CreateBucketCommand({ Bucket: options.bucket }));
console.log(`\n📦 Created new bucket: ${options.bucket}`);
} else {
throw error;
}
}
}
// Setup test data
console.log('\n📁 Setting up test data...');
testData = await setupTestData();
// Configure backup
console.log(`\n⚙️ Configuring ${options.type} backup...`);
await configureBackup(options.type);
// Run tests based on backup type
await runTest('Initial Full Backup', performBackup);
if (options.type === 's3') {
await runTest('Verify S3 Backup Contents', async () => {
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
await verifyS3Backup(lastRun);
});
}
await runTest('Incremental Backup', () => testIncrementalBackup(testData));
await runTest('Manifest Validation', async () => {
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
await testManifestValidation(lastRun);
});
await runTest('Backup Status Check', testBackupStatus);
// Performance test with larger files
if (options.type === 's3') {
await runTest('Large File Backup (10MB)', async () => {
const largeFile = path.join(testData.storagePath, 'events/active/large.jpg');
await fs.writeFile(largeFile, crypto.randomBytes(10 * 1024 * 1024));
await performBackup();
});
}
// Test backup service lifecycle
await runTest('Backup Service Start/Stop', async () => {
await backupService.startBackupService();
console.log(' ✓ Service started');
backupService.stopBackupService();
console.log(' ✓ Service stopped');
});
// Print results summary
console.log('\n📊 Test Results Summary');
console.log('======================');
console.log(`✅ Passed: ${results.passed}`);
console.log(`❌ Failed: ${results.failed}`);
console.log(`⏭️ Skipped: ${results.skipped}`);
console.log(`📋 Total: ${results.tests.length}`);
if (results.failed > 0) {
console.log('\nFailed Tests:');
results.tests
.filter(t => t.status === 'failed')
.forEach(t => console.log(` - ${t.name}: ${t.error}`));
}
} catch (error) {
console.error('\n💥 Fatal error:', error.message);
if (options.verbose) {
console.error(error.stack);
}
results.failed++;
} finally {
// Cleanup
await cleanupTestData();
// Close database
await db.destroy();
// Exit with appropriate code
process.exit(results.failed > 0 ? 1 : 0);
}
}
// Run if called directly
if (require.main === module) {
main().catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
}
module.exports = { runTest, skipTest };
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Test script for backup manifest generator
* Demonstrates all features of the manifest generator
*/
const path = require('path');
const fs = require('fs').promises;
const backupManifest = require('../src/services/backupManifest');
const logger = require('../src/utils/logger');
async function testManifestGeneration() {
console.log('=== Testing Backup Manifest Generator ===\n');
try {
// 1. Generate a full backup manifest
console.log('1. Generating full backup manifest...');
const fullManifestOptions = {
backupType: 'full',
backupPath: '/backup/full/2025-01-21',
files: [
{
path: '/storage/events/active/wedding-smith-2025/DSC_001.jpg',
relativePath: 'events/active/wedding-smith-2025/DSC_001.jpg',
size: 2456789,
modified: new Date('2025-01-20T10:30:00Z'),
checksum: 'a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890',
permissions: '644'
},
{
path: '/storage/events/active/wedding-smith-2025/DSC_002.jpg',
relativePath: 'events/active/wedding-smith-2025/DSC_002.jpg',
size: 2156789,
modified: new Date('2025-01-20T10:31:00Z'),
checksum: 'b2c3d4e5f67890123456789012345678901234567890123456789012345678901',
permissions: '644'
},
{
path: '/storage/thumbnails/wedding-smith-2025/thumb_DSC_001.jpg',
relativePath: 'thumbnails/wedding-smith-2025/thumb_DSC_001.jpg',
size: 45678,
modified: new Date('2025-01-20T10:35:00Z'),
checksum: 'c3d4e5f678901234567890123456789012345678901234567890123456789012',
permissions: '644'
}
],
databaseInfo: {
type: 'sqlite',
backupFile: 'database-backup-20250121-103000.sql.gz',
size: 1048576,
checksum: 'd4e5f6789012345678901234567890123456789012345678901234567890123',
tables: {
events: 156,
photos: 4523,
access_logs: 12456,
admin_users: 3
},
rowCounts: {
events: 156,
photos: 4523,
access_logs: 12456,
admin_users: 3
}
},
format: 'json',
customMetadata: {
operator: 'admin@example.com',
reason: 'Scheduled daily backup',
retentionDays: 30,
compressionType: 'gzip'
}
};
const fullManifest = await backupManifest.generateManifest(fullManifestOptions);
// Save in both formats
const jsonPath = path.join(__dirname, 'test-manifest-full.json');
const yamlPath = path.join(__dirname, 'test-manifest-full.yaml');
await backupManifest.saveManifest(fullManifest, jsonPath, 'json');
await backupManifest.saveManifest(fullManifest, yamlPath, 'yaml');
console.log('✓ Full backup manifest generated and saved\n');
// 2. Generate summary report
console.log('2. Generating summary report...');
const summaryReport = backupManifest.generateSummaryReport(fullManifest);
console.log(summaryReport);
console.log('\n');
// 3. Load and validate manifest
console.log('3. Loading and validating manifest...');
const loadedManifest = await backupManifest.loadManifest(jsonPath);
console.log('✓ Manifest loaded and validated successfully\n');
// 4. Generate incremental backup manifest
console.log('4. Generating incremental backup manifest...');
const incrementalOptions = {
backupType: 'incremental',
backupPath: '/backup/incremental/2025-01-22',
parentBackupId: fullManifest.backup.id,
files: [
// Original files with same checksums (unchanged)
fullManifestOptions.files[0],
fullManifestOptions.files[2],
// Modified file
{
...fullManifestOptions.files[1],
size: 2256789,
modified: new Date('2025-01-21T14:00:00Z'),
checksum: 'e5f678901234567890123456789012345678901234567890123456789012345'
},
// New file
{
path: '/storage/events/active/wedding-smith-2025/DSC_003.jpg',
relativePath: 'events/active/wedding-smith-2025/DSC_003.jpg',
size: 2356789,
modified: new Date('2025-01-21T14:30:00Z'),
checksum: 'f6789012345678901234567890123456789012345678901234567890123456',
permissions: '644'
}
],
databaseInfo: {
...fullManifestOptions.databaseInfo,
size: 1148576,
checksum: 'g7890123456789012345678901234567890123456789012345678901234567',
rowCounts: {
events: 158,
photos: 4567,
access_logs: 12789,
admin_users: 3
}
}
};
const incrementalManifest = await backupManifest.generateIncrementalManifest(
incrementalOptions,
fullManifest
);
const incrementalJsonPath = path.join(__dirname, 'test-manifest-incremental.json');
await backupManifest.saveManifest(incrementalManifest, incrementalJsonPath, 'json');
console.log('✓ Incremental backup manifest generated');
console.log(` - Added files: ${incrementalManifest.incremental.changes.added_files_count}`);
console.log(` - Modified files: ${incrementalManifest.incremental.changes.modified_files_count}`);
console.log(` - Deleted files: ${incrementalManifest.incremental.changes.deleted_files_count}`);
console.log(` - Size difference: ${(incrementalManifest.incremental.changes.size_difference / 1024).toFixed(2)} KB\n`);
// 5. Compare manifests
console.log('5. Comparing manifests...');
const comparison = backupManifest.compareManifests(incrementalManifest, fullManifest);
console.log('Comparison results:');
console.log(` - Added: ${comparison.added_files.length} files`);
console.log(` - Modified: ${comparison.modified_files.length} files`);
console.log(` - Deleted: ${comparison.deleted_files.length} files`);
console.log(` - Unchanged: ${comparison.unchanged_files.length} files`);
console.log(` - Database changed: ${comparison.database_changes.checksum_changed ? 'Yes' : 'No'}\n`);
// 6. Test manifest integrity
console.log('6. Testing manifest integrity...');
// Corrupt the manifest
const corruptedManifest = JSON.parse(JSON.stringify(incrementalManifest));
corruptedManifest.files.manifest[0].size = 9999999; // Change a file size
try {
backupManifest.validateManifest(corruptedManifest);
console.log('✗ Validation should have failed for corrupted manifest');
} catch (error) {
console.log('✓ Correctly detected corrupted manifest:', error.message);
}
console.log('\n=== All tests completed successfully! ===');
// Clean up test files
await fs.unlink(jsonPath).catch(() => {});
await fs.unlink(yamlPath).catch(() => {});
await fs.unlink(incrementalJsonPath).catch(() => {});
} catch (error) {
console.error('Test failed:', error);
logger.error('Manifest test failed:', error);
process.exit(1);
}
}
// Run tests
testManifestGeneration().catch(console.error);
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
require('dotenv').config();
const { initializeDatabase } = require('../src/database/db');
const { runBackup, getBackupStatus } = require('../src/services/backupService');
const logger = require('../src/utils/logger');
async function testBackupService() {
try {
console.log('Testing backup service...\n');
// Initialize database
await initializeDatabase();
// Get current backup status
console.log('Getting backup status...');
const statusBefore = await getBackupStatus();
console.log('Last run:', statusBefore.lastRun ? statusBefore.lastRun.started_at : 'Never');
console.log('Is healthy:', statusBefore.isHealthy);
console.log('');
// Run backup
console.log('Running backup...');
await runBackup();
// Get status after backup
console.log('\nGetting status after backup...');
const statusAfter = await getBackupStatus();
console.log('Last run:', statusAfter.lastRun ? statusAfter.lastRun.started_at : 'Never');
console.log('Status:', statusAfter.lastRun ? statusAfter.lastRun.status : 'Unknown');
console.log('Files backed up:', statusAfter.lastRun ? statusAfter.lastRun.files_backed_up : 0);
console.log('Total size:', statusAfter.lastRun ? `${(statusAfter.lastRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB` : '0 MB');
if (statusAfter.lastRun && statusAfter.lastRun.error_message) {
console.log('Error:', statusAfter.lastRun.error_message);
}
console.log('\nBackup test completed!');
process.exit(0);
} catch (error) {
console.error('Test failed:', error);
process.exit(1);
}
}
testBackupService();
+325
View File
@@ -0,0 +1,325 @@
/**
* Test script for the restore service
*
* This script demonstrates the restore service functionality with safety checks
*
* Usage:
* node scripts/test-restore-service.js [options]
*
* Options:
* --dry-run Perform validation only without actual restore
* --force Force restore even with warnings
* --type Restore type: full, database, files, selective (default: full)
* --source Backup source path or S3 URL
* --manifest Path to backup manifest
*/
require('dotenv').config();
const { restoreService } = require('../src/services/restoreService');
const { db } = require('../src/database/db');
const logger = require('../src/utils/logger');
const path = require('path');
const fs = require('fs').promises;
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
dryRun: args.includes('--dry-run'),
force: args.includes('--force'),
restoreType: 'full',
source: null,
manifestPath: null
};
// Parse restore type
const typeIndex = args.indexOf('--type');
if (typeIndex !== -1 && args[typeIndex + 1]) {
options.restoreType = args[typeIndex + 1];
}
// Parse source
const sourceIndex = args.indexOf('--source');
if (sourceIndex !== -1 && args[sourceIndex + 1]) {
options.source = args[sourceIndex + 1];
}
// Parse manifest
const manifestIndex = args.indexOf('--manifest');
if (manifestIndex !== -1 && args[manifestIndex + 1]) {
options.manifestPath = args[manifestIndex + 1];
}
async function testRestore() {
console.log('=== PicPeak Restore Service Test ===\n');
try {
// If no source/manifest provided, try to find a recent backup
if (!options.source || !options.manifestPath) {
console.log('No backup source specified. Looking for recent backups...\n');
const recentBackup = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.first();
if (!recentBackup) {
console.error('❌ No completed backups found in the database');
console.log('\nPlease run a backup first or specify --source and --manifest');
process.exit(1);
}
console.log(`Found recent backup from ${recentBackup.completed_at}`);
console.log(`Backup ID: ${recentBackup.manifest_id}`);
console.log(`Files backed up: ${recentBackup.files_backed_up}`);
console.log(`Total size: ${(recentBackup.total_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Manifest: ${recentBackup.manifest_path}\n`);
// For this test, we'll create a mock scenario
console.log('⚠️ This is a TEST MODE - using mock data for safety\n');
// Create test backup directory
const testBackupDir = path.join(__dirname, '../temp/test-backup');
await fs.mkdir(testBackupDir, { recursive: true });
// Create test manifest
const testManifest = {
manifest: {
version: '2.0',
created: new Date().toISOString(),
generator: 'Test Script',
format: 'json'
},
backup: {
id: 'test-backup-' + Date.now(),
type: 'full',
timestamp: new Date().toISOString(),
path: testBackupDir,
parent_backup_id: null,
retention_days: 30
},
system: {
hostname: require('os').hostname(),
platform: process.platform,
os_release: require('os').release(),
architecture: require('os').arch()
},
application: {
name: 'PicPeak',
version: require('../package.json').version,
node_version: process.version,
environment: 'test'
},
files: {
count: 0,
total_size: 0,
checksums: {},
manifest: []
},
database: {
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
backup_file: null,
size: 0,
checksum: null,
tables: {},
row_counts: {}
},
verification: {
total_checksum: null,
file_count_check: 0,
size_check: 0,
integrity_timestamp: new Date().toISOString()
},
metadata: {
test_mode: true
}
};
// Calculate checksum
const crypto = require('crypto');
const manifestCopy = JSON.parse(JSON.stringify(testManifest));
delete manifestCopy.verification.total_checksum;
testManifest.verification.total_checksum = crypto
.createHash('sha256')
.update(JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort()))
.digest('hex');
// Save test manifest
const testManifestPath = path.join(testBackupDir, 'test-manifest.json');
await fs.writeFile(testManifestPath, JSON.stringify(testManifest, null, 2));
options.source = testBackupDir;
options.manifestPath = testManifestPath;
}
// Display restore options
console.log('Restore Options:');
console.log(`- Type: ${options.restoreType}`);
console.log(`- Source: ${options.source}`);
console.log(`- Manifest: ${options.manifestPath}`);
console.log(`- Dry Run: ${options.dryRun ? 'Yes' : 'No'}`);
console.log(`- Force: ${options.force ? 'Yes' : 'No'}`);
console.log('');
// Add S3 config if source is S3
if (options.source.startsWith('s3://')) {
options.s3Config = {
accessKeyId: process.env.BACKUP_S3_ACCESS_KEY,
secretAccessKey: process.env.BACKUP_S3_SECRET_KEY,
region: process.env.BACKUP_S3_REGION || 'us-east-1',
endpoint: process.env.BACKUP_S3_ENDPOINT
};
if (!options.s3Config.accessKeyId || !options.s3Config.secretAccessKey) {
console.error('❌ S3 credentials not configured in environment');
process.exit(1);
}
}
// Confirm before proceeding (unless dry run)
if (!options.dryRun) {
console.log('⚠️ WARNING: This will restore data from the backup!');
console.log('⚠️ Current data may be overwritten!');
console.log('');
console.log('Press Ctrl+C to cancel, or wait 5 seconds to continue...');
await new Promise(resolve => setTimeout(resolve, 5000));
}
console.log('\nStarting restore operation...\n');
// Perform restore
const result = await restoreService.restore(options);
if (options.dryRun) {
console.log('\n=== DRY RUN RESULTS ===\n');
console.log('Validation:');
console.log(`- Valid: ${result.validation.isValid ? '✅ Yes' : '❌ No'}`);
if (result.validation.errors.length > 0) {
console.log('- Errors:');
result.validation.errors.forEach(err => console.log(`${err}`));
}
if (result.validation.warnings.length > 0) {
console.log('- Warnings:');
result.validation.warnings.forEach(warn => console.log(` ⚠️ ${warn}`));
}
console.log('\nDisk Space:');
console.log(`- Required: ${result.spaceCheck.requiredFormatted}`);
console.log(`- Available: ${result.spaceCheck.availableFormatted}`);
console.log(`- Sufficient: ${result.spaceCheck.hasEnoughSpace ? '✅ Yes' : '❌ No'}`);
} else {
console.log('\n=== RESTORE RESULTS ===\n');
console.log(`Status: ${result.success ? '✅ SUCCESS' : '❌ FAILED'}`);
console.log(`Duration: ${result.duration}s`);
if (result.result) {
console.log('\nItems Restored:');
if (result.result.databaseRestored !== undefined) {
console.log(`- Database: ${result.result.databaseRestored ? '✅' : '❌'}`);
}
if (result.result.filesRestored !== undefined) {
console.log(`- Files: ${result.result.filesRestored}`);
}
if (result.result.errors && result.result.errors.length > 0) {
console.log('- Errors:');
result.result.errors.forEach(err => console.log(`${err}`));
}
}
if (result.verification) {
console.log('\nVerification:');
console.log(`- Valid: ${result.verification.isValid ? '✅ Yes' : '❌ No'}`);
if (result.verification.errors.length > 0) {
console.log('- Errors:');
result.verification.errors.forEach(err => console.log(`${err}`));
}
}
if (result.preRestoreBackup) {
console.log('\nSafety Backup:');
console.log(`- Location: ${result.preRestoreBackup}`);
console.log('- This backup can be used to rollback if needed');
}
}
// Show recent log entries
console.log('\nRecent Log Entries:');
result.logs.slice(-10).forEach(log => {
const icon = log.level === 'error' ? '❌' : log.level === 'warn' ? '⚠️ ' : '️ ';
console.log(`${icon} [${log.timestamp}] ${log.message}`);
});
// Clean up test files
if (options.source && options.source.includes('test-backup')) {
await fs.rmdir(path.dirname(options.source), { recursive: true }).catch(() => {});
}
} catch (error) {
console.error('\n❌ Restore operation failed:', error.message);
// Show logs if available
if (restoreService.restoreLog && restoreService.restoreLog.length > 0) {
console.log('\nError Log:');
restoreService.restoreLog.slice(-10).forEach(log => {
if (log.level === 'error' || log.level === 'warn') {
console.log(`[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}`);
}
});
}
process.exit(1);
}
// Cleanup
await db.destroy();
process.exit(0);
}
// Show help if requested
if (args.includes('--help') || args.includes('-h')) {
console.log(`
PicPeak Restore Service Test
This script tests the restore service functionality with safety checks.
Usage:
node scripts/test-restore-service.js [options]
Options:
--dry-run Perform validation only without actual restore
--force Force restore even with warnings
--type Restore type: full, database, files, selective (default: full)
--source Backup source path or S3 URL
--manifest Path to backup manifest
--help Show this help message
Examples:
# Dry run with automatic backup selection
node scripts/test-restore-service.js --dry-run
# Full restore from specific backup
node scripts/test-restore-service.js --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json
# Database-only restore with force
node scripts/test-restore-service.js --type database --force --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json
# Restore from S3
node scripts/test-restore-service.js --source s3://my-bucket/backups/2024-01-20 --manifest s3://my-bucket/backups/2024-01-20/manifest.json
Safety Features:
- Pre-restore validation checks compatibility and warns about potential issues
- Automatic pre-restore backup is created (unless skipped)
- Post-restore verification ensures data integrity
- Rollback capability if restore fails
- Detailed logging of all operations
`);
process.exit(0);
}
// Run the test
testRestore();
+10
View File
@@ -20,6 +20,8 @@ const { initializeDatabase, db } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
const { startExpirationChecker } = require('./src/services/expirationChecker');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
@@ -201,6 +203,8 @@ app.use('/api/gallery', galleryRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
@@ -243,6 +247,12 @@ async function startServer() {
await initializeTransporter();
startEmailQueueProcessor();
// Start backup service
await startBackupService();
// Start database backup service
await startScheduledBackups();
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
+4
View File
@@ -11,6 +11,8 @@ const photosRoutes = require('./adminPhotos');
const categoriesRoutes = require('./adminCategories');
const cmsRoutes = require('./adminCMS');
const notificationsRoutes = require('./adminNotifications');
const backupRoutes = require('./adminBackup');
const restoreRoutes = require('./adminRestore');
// Mount sub-routers
router.use('/dashboard', dashboardRoutes);
@@ -22,5 +24,7 @@ router.use('/events', photosRoutes);
router.use('/categories', categoriesRoutes);
router.use('/cms', cmsRoutes);
router.use('/notifications', notificationsRoutes);
router.use('/backup', backupRoutes);
router.use('/restore', restoreRoutes);
module.exports = router;
+956
View File
@@ -0,0 +1,956 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const archiver = require('archiver');
const S3StorageAdapter = require('../services/storage/s3Storage');
const router = express.Router();
// Get backup configuration
router.get('/config', adminAuth, async (req, res) => {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
res.json(config);
} catch (error) {
logger.error('Failed to get backup configuration:', error);
res.status(500).json({ error: 'Failed to get backup configuration' });
}
});
// Update backup configuration
router.put('/config', adminAuth, async (req, res) => {
try {
const updates = req.body;
// Validate required fields based on destination type
if (updates.backup_destination_type) {
switch (updates.backup_destination_type) {
case 'local':
if (!updates.backup_destination_path) {
return res.status(400).json({ error: 'Local backup requires destination path' });
}
break;
case 'rsync':
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
return res.status(400).json({ error: 'Rsync backup requires host and path' });
}
break;
case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
}
break;
}
}
// Update settings
for (const [key, value] of Object.entries(updates)) {
if (key.startsWith('backup_')) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'backup',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
}
// Restart backup service if enabled status changed
if ('backup_enabled' in updates) {
const { startBackupService, stopBackupService } = require('../services/backupService');
if (updates.backup_enabled) {
await startBackupService();
} else {
stopBackupService();
}
}
res.json({ success: true, message: 'Backup configuration updated' });
} catch (error) {
logger.error('Failed to update backup configuration:', error);
res.status(500).json({ error: 'Failed to update backup configuration' });
}
});
// Get backup status and history
router.get('/status', adminAuth, async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const status = await getBackupStatus(limit);
res.json(status);
} catch (error) {
logger.error('Failed to get backup status:', error);
res.status(500).json({ error: 'Failed to get backup status' });
}
});
// Trigger manual backup
router.post('/run', adminAuth, async (req, res) => {
try {
// Check if backup is already running
const status = await getBackupStatus();
if (status.isRunning) {
return res.status(409).json({ error: 'Backup is already running' });
}
// Start backup in background
triggerManualBackup().catch(error => {
logger.error('Manual backup failed:', error);
});
res.json({ success: true, message: 'Backup started' });
} catch (error) {
logger.error('Failed to trigger manual backup:', error);
res.status(500).json({ error: 'Failed to trigger backup' });
}
});
// Get backup run details
router.get('/runs/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
const run = await db('backup_runs')
.where('id', id)
.first();
if (!run) {
return res.status(404).json({ error: 'Backup run not found' });
}
// Parse JSON fields
if (run.statistics) {
try {
run.statistics = JSON.parse(run.statistics);
} catch (e) {
// Keep as string if parsing fails
}
}
res.json(run);
} catch (error) {
logger.error('Failed to get backup run details:', error);
res.status(500).json({ error: 'Failed to get backup run details' });
}
});
// Get file states (for debugging/monitoring)
router.get('/files', adminAuth, async (req, res) => {
try {
const { page = 1, limit = 50, search = '' } = req.query;
const offset = (page - 1) * limit;
let query = db('backup_file_states');
if (search) {
query = query.where('file_path', 'like', `%${search}%`);
}
const [files, totalCount] = await Promise.all([
query
.orderBy('last_backed_up', 'desc')
.limit(limit)
.offset(offset),
db('backup_file_states').count('* as count').first()
]);
res.json({
files,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: totalCount.count,
pages: Math.ceil(totalCount.count / limit)
}
});
} catch (error) {
logger.error('Failed to get backup file states:', error);
res.status(500).json({ error: 'Failed to get file states' });
}
});
// Clean up old backup runs
router.delete('/cleanup', adminAuth, async (req, res) => {
try {
const { days = 30 } = req.body;
await cleanupOldBackupRuns(days);
res.json({ success: true, message: `Cleaned up backup runs older than ${days} days` });
} catch (error) {
logger.error('Failed to cleanup old backup runs:', error);
res.status(500).json({ error: 'Failed to cleanup backup runs' });
}
});
// Test backup destination connectivity
router.post('/test-connection', adminAuth, async (req, res) => {
try {
const { destination_type, ...config } = req.body;
switch (destination_type) {
case 'local':
// Test local path access
const fs = require('fs').promises;
try {
await fs.access(config.path, fs.constants.W_OK);
res.json({ success: true, message: 'Local path is writable' });
} catch (error) {
res.json({ success: false, message: 'Cannot write to local path: ' + error.message });
}
break;
case 'rsync':
// Test rsync connection
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const sshCommand = config.ssh_key
? `ssh -i ${config.ssh_key} -o StrictHostKeyChecking=no -o ConnectTimeout=10`
: 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10';
const testCommand = config.user
? `${sshCommand} ${config.user}@${config.host} "echo 'Connection successful'"`
: `${sshCommand} ${config.host} "echo 'Connection successful'"`;
try {
const { stdout } = await execAsync(testCommand);
res.json({ success: true, message: 'Rsync connection successful' });
} catch (error) {
res.json({ success: false, message: 'Rsync connection failed: ' + error.message });
}
break;
case 's3':
// Test S3 connection (would need AWS SDK)
res.json({ success: false, message: 'S3 testing not implemented yet' });
break;
default:
res.status(400).json({ error: 'Invalid destination type' });
}
} catch (error) {
logger.error('Failed to test backup connection:', error);
res.status(500).json({ error: 'Failed to test connection' });
}
});
// Get backup manifest for a specific backup run
router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
try {
const { backupRunId } = req.params;
const result = await getBackupManifest(backupRunId);
res.json({
backupRunId,
manifest: result.manifest,
summary: result.summary
});
} catch (error) {
logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Validate a backup manifest
router.post('/manifest/validate', adminAuth, async (req, res) => {
try {
const { manifestPath } = req.body;
if (!manifestPath) {
return res.status(400).json({ error: 'manifestPath is required' });
}
const result = await validateBackupManifest(manifestPath);
res.json({
valid: result.valid,
error: result.error,
manifestPath
});
} catch (error) {
logger.error('Failed to validate manifest:', error);
res.status(500).json({ error: 'Failed to validate manifest' });
}
});
// Download backup manifest
router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
try {
const { backupRunId } = req.params;
const { format = 'json' } = req.query;
const result = await getBackupManifest(backupRunId);
// Set appropriate headers
const filename = `backup-manifest-${backupRunId}.${format}`;
res.setHeader('Content-Type', format === 'yaml' ? 'text/yaml' : 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Send the manifest in requested format
if (format === 'yaml') {
const yaml = require('js-yaml');
res.send(yaml.dump(result.manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: true
}));
} else {
res.json(result.manifest);
}
} catch (error) {
logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Get manifest for specific backup
router.get('/manifests/:backupId', adminAuth, async (req, res) => {
try {
const { backupId } = req.params;
const result = await getBackupManifest(backupId);
res.json({
backupId,
manifest: result.manifest,
summary: result.summary
});
} catch (error) {
logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Download manifest file
router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
try {
const { backupId } = req.params;
const { format = 'json' } = req.query;
const result = await getBackupManifest(backupId);
// Set appropriate headers
const filename = `backup-manifest-${backupId}.${format}`;
res.setHeader('Content-Type', format === 'yaml' ? 'text/yaml' : 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Send the manifest in requested format
if (format === 'yaml') {
const yaml = require('js-yaml');
res.send(yaml.dump(result.manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: true
}));
} else {
res.json(result.manifest);
}
} catch (error) {
logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Validate a manifest
router.post('/manifests/validate', adminAuth, async (req, res) => {
try {
const { manifestPath, manifestData } = req.body;
if (!manifestPath && !manifestData) {
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
}
if (manifestData) {
// Validate provided manifest data directly
const validationResult = await validateManifestData(manifestData);
return res.json(validationResult);
}
// Use existing validation function for path
const result = await validateBackupManifest(manifestPath);
res.json({
valid: result.valid,
error: result.error,
manifestPath
});
} catch (error) {
logger.error('Failed to validate manifest:', error);
res.status(500).json({ error: 'Failed to validate manifest' });
}
});
// List S3 buckets
router.get('/s3/buckets', adminAuth, async (req, res) => {
try {
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
// List buckets using the S3 client
const { ListBucketsCommand } = require('@aws-sdk/client-s3');
const result = await s3Adapter.s3Client.send(new ListBucketsCommand({}));
res.json({
buckets: result.Buckets || [],
owner: result.Owner || null
});
} catch (error) {
logger.error('Failed to list S3 buckets:', error);
res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message });
}
});
// List files in S3 backup location
router.get('/s3/files', adminAuth, async (req, res) => {
try {
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
const result = await s3Adapter.list(prefix, {
maxKeys: parseInt(maxKeys),
continuationToken
});
res.json({
files: result.objects || [],
directories: result.directories || [],
isTruncated: result.isTruncated,
nextContinuationToken: result.nextContinuationToken,
prefix: prefix
});
} catch (error) {
logger.error('Failed to list S3 files:', error);
res.status(500).json({ error: 'Failed to list S3 files: ' + error.message });
}
});
// Clean up old S3 backups
router.delete('/s3/cleanup', adminAuth, async (req, res) => {
try {
const { retentionDays = 30, dryRun = false } = req.body;
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// List all backup files
const backupFiles = await s3Adapter.list('backups/', { maxKeys: 1000 });
const filesToDelete = [];
let totalSize = 0;
for (const file of backupFiles.objects || []) {
if (file.lastModified && new Date(file.lastModified) < cutoffDate) {
filesToDelete.push(file.key);
totalSize += file.size || 0;
}
}
if (dryRun) {
return res.json({
wouldDelete: filesToDelete.length,
totalSize: totalSize,
files: filesToDelete.slice(0, 100), // Limit preview
message: 'Dry run completed - no files were deleted'
});
}
// Delete files in batches
const deleteResult = await s3Adapter.deleteMany(filesToDelete);
const deletedCount = deleteResult.Deleted ? deleteResult.Deleted.length : 0;
// Also clean up database records
await cleanupOldBackupRuns(retentionDays);
res.json({
success: true,
deletedCount: deletedCount,
totalSize: totalSize,
message: `Cleaned up ${deletedCount} S3 backup files older than ${retentionDays} days`
});
} catch (error) {
logger.error('Failed to cleanup S3 backups:', error);
res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message });
}
});
// Test S3 upload functionality
router.post('/s3/test-upload', adminAuth, async (req, res) => {
try {
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
// Create test content
const testKey = `test/backup-test-${Date.now()}.txt`;
const testContent = `PicPeak S3 backup test\nTimestamp: ${new Date().toISOString()}\nEndpoint: ${config.backup_s3_endpoint || 'AWS'}\nBucket: ${config.backup_s3_bucket}`;
// Test upload
const uploadStart = Date.now();
await s3Adapter.upload(testKey, Buffer.from(testContent));
const uploadTime = Date.now() - uploadStart;
// Test download
const downloadStart = Date.now();
const downloadedContent = await s3Adapter.download(testKey);
const downloadTime = Date.now() - downloadStart;
// Verify content
const contentMatch = downloadedContent.toString() === testContent;
// Test deletion
await s3Adapter.delete(testKey);
res.json({
success: true,
testKey: testKey,
uploadTime: uploadTime,
downloadTime: downloadTime,
contentMatch: contentMatch,
message: 'S3 upload test completed successfully'
});
} catch (error) {
logger.error('S3 upload test failed:', error);
res.status(500).json({ error: 'S3 upload test failed: ' + error.message });
}
});
// Download entire backup
router.get('/download/:backupId', adminAuth, async (req, res) => {
try {
const { backupId } = req.params;
// Get backup run details
const backupRun = await db('backup_runs')
.where('id', backupId)
.first();
if (!backupRun) {
return res.status(404).json({ error: 'Backup not found' });
}
if (backupRun.status !== 'completed') {
return res.status(400).json({ error: 'Backup is not completed' });
}
const config = await getBackupConfig();
// Handle different backup types
switch (config.backup_destination_type) {
case 'local':
// Stream local backup as zip
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
archive.pipe(res);
// Add backup directory contents
archive.directory(backupPath, false);
// Add manifest if exists
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
}
await archive.finalize();
break;
case 's3':
// For S3, provide pre-signed URLs or stream files
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
// List all files for this backup
const prefix = `backups/${backupRun.id}/`;
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
// Generate pre-signed URLs
const urls = [];
for (const file of files.objects || []) {
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
urls.push({
key: file.key,
size: file.size,
url: url
});
}
res.json({
backupId: backupRun.id,
type: 's3',
files: urls,
expiresIn: 3600,
message: 'Use the provided URLs to download individual files'
});
break;
case 'rsync':
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
default:
return res.status(400).json({ error: 'Unknown backup type' });
}
} catch (error) {
logger.error('Failed to download backup:', error);
res.status(500).json({ error: 'Failed to download backup: ' + error.message });
}
});
// Get current file checksums
router.get('/checksums', adminAuth, async (req, res) => {
try {
const { path: targetPath = '', recursive = true } = req.query;
const checksums = {};
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const basePath = targetPath ? path.join(storagePath, targetPath) : storagePath;
// Calculate checksums for files
async function calculateDirChecksums(dirPath, relative = '') {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.join(relative, entry.name);
if (entry.isDirectory() && recursive) {
await calculateDirChecksums(fullPath, relativePath);
} else if (entry.isFile()) {
const hash = crypto.createHash('sha256');
const stream = require('fs').createReadStream(fullPath);
await new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => {
checksums[relativePath] = {
checksum: hash.digest('hex'),
size: entry.size,
modified: entry.mtime
};
resolve();
});
stream.on('error', reject);
});
}
}
} catch (error) {
logger.error(`Failed to calculate checksums for ${dirPath}:`, error);
}
}
await calculateDirChecksums(basePath);
// Also get database checksums from backup_file_states
const dbChecksums = await db('backup_file_states')
.select('file_path', 'checksum', 'size_bytes', 'last_modified');
res.json({
currentChecksums: checksums,
totalFiles: Object.keys(checksums).length,
databaseChecksums: dbChecksums.reduce((acc, row) => {
acc[row.file_path] = {
checksum: row.checksum,
size: row.size_bytes,
modified: row.last_modified
};
return acc;
}, {}),
path: targetPath || '/'
});
} catch (error) {
logger.error('Failed to get file checksums:', error);
res.status(500).json({ error: 'Failed to get file checksums: ' + error.message });
}
});
// Estimate backup size before running
router.post('/estimate', adminAuth, async (req, res) => {
try {
const { includeArchived = true } = req.body;
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
let totalSize = 0;
let fileCount = 0;
const breakdown = {};
// Estimate size for each directory
async function estimateDir(dirPath, category) {
let dirSize = 0;
let dirCount = 0;
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const subResult = await estimateDir(fullPath, category);
dirSize += subResult.size;
dirCount += subResult.count;
} else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
dirSize += stats.size;
dirCount++;
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
logger.error(`Failed to estimate ${dirPath}:`, error);
}
}
return { size: dirSize, count: dirCount };
}
// Estimate each category
const categories = [
{ path: 'events/active', name: 'Active Events' },
{ path: 'thumbnails', name: 'Thumbnails' },
{ path: 'uploads', name: 'Uploads' }
];
if (includeArchived) {
categories.push({ path: 'events/archived', name: 'Archived Events' });
}
for (const category of categories) {
const result = await estimateDir(path.join(storagePath, category.path), category.name);
breakdown[category.name] = {
size: result.size,
sizeFormatted: formatBytes(result.size),
fileCount: result.count
};
totalSize += result.size;
fileCount += result.count;
}
// Estimate database size
const dbPath = process.env.DB_TYPE === 'postgresql'
? null
: path.join(__dirname, '../../database.sqlite');
if (dbPath) {
try {
const dbStats = await fs.stat(dbPath);
breakdown['Database'] = {
size: dbStats.size,
sizeFormatted: formatBytes(dbStats.size),
fileCount: 1
};
totalSize += dbStats.size;
fileCount += 1;
} catch (error) {
logger.error('Failed to get database size:', error);
}
}
// Estimate compression ratio (typically 20-40% for mixed media)
const estimatedCompressedSize = Math.round(totalSize * 0.7);
res.json({
totalSize: totalSize,
totalSizeFormatted: formatBytes(totalSize),
estimatedCompressedSize: estimatedCompressedSize,
estimatedCompressedSizeFormatted: formatBytes(estimatedCompressedSize),
fileCount: fileCount,
breakdown: breakdown,
includeArchived: includeArchived,
estimatedDuration: Math.max(60, Math.round(totalSize / (50 * 1024 * 1024))), // Estimate 50MB/s
warnings: totalSize > 10 * 1024 * 1024 * 1024 ? ['Backup size exceeds 10GB, may take significant time'] : []
});
} catch (error) {
logger.error('Failed to estimate backup size:', error);
res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message });
}
});
// Helper function to format bytes
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Helper function to get backup configuration
async function getBackupConfig() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
} catch (error) {
logger.error('Failed to get backup configuration:', error);
return {};
}
}
// Helper function to validate manifest data
async function validateManifestData(manifestData) {
try {
// Check required fields
const requiredFields = ['version', 'backupId', 'timestamp', 'files'];
const missingFields = requiredFields.filter(field => !manifestData[field]);
if (missingFields.length > 0) {
return {
valid: false,
error: `Missing required fields: ${missingFields.join(', ')}`,
details: { missingFields }
};
}
// Validate version
if (manifestData.version !== '1.0') {
return {
valid: false,
error: `Unsupported manifest version: ${manifestData.version}`,
details: { version: manifestData.version }
};
}
// Validate files array
if (!Array.isArray(manifestData.files)) {
return {
valid: false,
error: 'Files must be an array',
details: { filesType: typeof manifestData.files }
};
}
// Validate each file entry
const invalidFiles = [];
for (let i = 0; i < manifestData.files.length; i++) {
const file = manifestData.files[i];
if (!file.path || !file.checksum || typeof file.size !== 'number') {
invalidFiles.push({ index: i, file });
}
}
if (invalidFiles.length > 0) {
return {
valid: false,
error: `Invalid file entries: ${invalidFiles.length}`,
details: { invalidFiles: invalidFiles.slice(0, 10) } // Limit to first 10
};
}
return {
valid: true,
details: {
version: manifestData.version,
backupId: manifestData.backupId,
timestamp: manifestData.timestamp,
fileCount: manifestData.files.length,
totalSize: manifestData.files.reduce((sum, f) => sum + (f.size || 0), 0)
}
};
} catch (error) {
return {
valid: false,
error: `Validation error: ${error.message}`,
details: { error: error.message }
};
}
}
module.exports = router;
+273
View File
@@ -0,0 +1,273 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
// All routes require admin authentication
router.use(adminAuth);
/**
* Get database backup status and configuration
*/
router.get('/status', async (req, res) => {
try {
// Get configuration
const config = await databaseBackupService.getBackupConfig();
// Get recent backup history
const history = await databaseBackupService.getBackupHistory(10);
// Get current progress if running
const progress = databaseBackupService.getProgress();
// Calculate health status
const lastBackup = history[0];
const isHealthy = lastBackup && lastBackup.status === 'completed' &&
new Date(lastBackup.completed_at) > new Date(Date.now() - 48 * 60 * 60 * 1000); // Within 48 hours
res.json({
config,
isRunning: databaseBackupService.isRunning,
isHealthy,
currentProgress: progress,
lastBackup,
recentBackups: history,
dbType: databaseBackupService.dbType
});
} catch (error) {
logger.error('Failed to get database backup status:', error);
res.status(500).json({ error: 'Failed to get backup status' });
}
});
/**
* Update database backup configuration
*/
router.put('/config', async (req, res) => {
try {
const allowedSettings = [
'database_backup_enabled',
'database_backup_schedule',
'database_backup_destination_path',
'database_backup_compress',
'database_backup_validate_integrity',
'database_backup_include_checksums',
'database_backup_retention_days',
'database_backup_email_on_failure',
'database_backup_email_on_success'
];
const updates = [];
for (const [key, value] of Object.entries(req.body)) {
if (allowedSettings.includes(key)) {
// Check if setting exists
const existing = await db('app_settings')
.where('setting_key', key)
.first();
if (existing) {
await db('app_settings')
.where('setting_key', key)
.update({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
} else {
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'database_backup'
});
}
updates.push(key);
}
}
// Restart scheduled backups if enabled state changed
if (updates.includes('database_backup_enabled') || updates.includes('database_backup_schedule')) {
const { startScheduledBackups, stopScheduledBackups } = require('../services/databaseBackup');
stopScheduledBackups();
await startScheduledBackups();
}
res.json({
success: true,
updatedSettings: updates,
message: 'Database backup configuration updated successfully'
});
} catch (error) {
logger.error('Failed to update database backup config:', error);
res.status(500).json({ error: 'Failed to update configuration' });
}
});
/**
* Trigger manual database backup
*/
router.post('/backup', async (req, res) => {
try {
if (databaseBackupService.isRunning) {
return res.status(409).json({ error: 'Backup already in progress' });
}
// Start backup asynchronously
res.json({
success: true,
message: 'Database backup started',
trackingUrl: '/api/admin/database-backup/progress'
});
// Run backup in background
databaseBackupService.backup(req.body).catch(error => {
logger.error('Manual database backup failed:', error);
});
} catch (error) {
logger.error('Failed to start database backup:', error);
res.status(500).json({ error: 'Failed to start backup' });
}
});
/**
* Get current backup progress
*/
router.get('/progress', async (req, res) => {
try {
const progress = databaseBackupService.getProgress();
res.json({
isRunning: databaseBackupService.isRunning,
progress
});
} catch (error) {
logger.error('Failed to get backup progress:', error);
res.status(500).json({ error: 'Failed to get progress' });
}
});
/**
* Get backup history with pagination
*/
router.get('/history', async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const [backups, totalCount] = await Promise.all([
db('database_backup_runs')
.orderBy('started_at', 'desc')
.limit(limit)
.offset(offset),
db('database_backup_runs').count('* as count').first()
]);
res.json({
backups,
pagination: {
page,
limit,
total: totalCount.count,
pages: Math.ceil(totalCount.count / limit)
}
});
} catch (error) {
logger.error('Failed to get backup history:', error);
res.status(500).json({ error: 'Failed to get history' });
}
});
/**
* Delete old backup files
*/
router.delete('/cleanup', async (req, res) => {
try {
const { retentionDays = 30 } = req.body;
await databaseBackupService.cleanupOldBackups(retentionDays);
res.json({
success: true,
message: `Cleaned up backups older than ${retentionDays} days`
});
} catch (error) {
logger.error('Failed to cleanup old backups:', error);
res.status(500).json({ error: 'Failed to cleanup backups' });
}
});
/**
* Test database backup configuration
*/
router.post('/test', async (req, res) => {
try {
const config = await databaseBackupService.getBackupConfig();
// Test database connection
const testResults = {
databaseConnection: false,
destinationWritable: false,
compressionAvailable: true,
estimatedSize: null
};
// Test database connection
try {
await db.raw('SELECT 1');
testResults.databaseConnection = true;
} catch (error) {
testResults.databaseConnectionError = error.message;
}
// Test destination path
if (config.destinationPath) {
try {
const fs = require('fs').promises;
const testFile = `${config.destinationPath}/.test-${Date.now()}`;
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
testResults.destinationWritable = true;
} catch (error) {
testResults.destinationError = error.message;
}
}
// Estimate database size
try {
testResults.estimatedSize = await databaseBackupService.getDatabaseSize();
} catch (error) {
testResults.sizeError = error.message;
}
res.json({
success: testResults.databaseConnection && testResults.destinationWritable,
results: testResults
});
} catch (error) {
logger.error('Failed to test backup configuration:', error);
res.status(500).json({ error: 'Failed to test configuration' });
}
});
/**
* Get table checksums
*/
router.get('/checksums', async (req, res) => {
try {
const checksums = await databaseBackupService.getTableChecksums();
res.json({
checksums,
tableCount: Object.keys(checksums).length,
totalRows: Object.values(checksums).reduce((sum, table) => sum + table.rowCount, 0)
});
} catch (error) {
logger.error('Failed to get table checksums:', error);
res.status(500).json({ error: 'Failed to get checksums' });
}
});
module.exports = router;
+461
View File
@@ -0,0 +1,461 @@
const express = require('express');
const router = express.Router();
const { restoreService } = require('../services/restoreService');
const { adminAuth } = require('../middleware/auth');
const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
/**
* Admin routes for restore operations
* All routes require admin authentication
*/
// Apply admin authentication to all routes
router.use(adminAuth);
/**
* Get restore service status and history
*/
router.get('/status', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const history = await restoreService.getRestoreHistory(limit);
const status = {
isRunning: restoreService.isRunning,
currentProgress: restoreService.getProgress(),
history: history,
settings: await getRestoreSettings()
};
res.json({
success: true,
data: status
});
} catch (error) {
logger.error('Failed to get restore status:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore status'
});
}
});
/**
* Validate restore request
*/
router.post('/validate', [
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'),
body('selectedItems').optional().isArray(),
body('s3Config').optional().isObject()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Perform dry run validation
const result = await restoreService.restore({
...req.body,
dryRun: true,
force: false
});
res.json({
success: true,
data: {
validation: result.validation,
spaceCheck: result.spaceCheck,
logs: result.logs
}
});
} catch (error) {
logger.error('Restore validation failed:', error);
res.status(400).json({
success: false,
error: error.message,
logs: restoreService.restoreLog
});
}
});
/**
* Start restore operation
*/
router.post('/start', [
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'),
body('selectedItems').optional().isArray(),
body('skipPreBackup').optional().isBoolean(),
body('force').optional().isBoolean(),
body('s3Config').optional().isObject()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Check if restore is already running
if (restoreService.isRunning) {
return res.status(409).json({
success: false,
error: 'Restore operation already in progress'
});
}
// Check permissions for dangerous options
const settings = await getRestoreSettings();
if (req.body.force && !settings.restore_allow_force) {
return res.status(403).json({
success: false,
error: 'Force restore is not allowed by system settings'
});
}
if (req.body.skipPreBackup && settings.restore_require_pre_backup) {
return res.status(403).json({
success: false,
error: 'Skipping pre-restore backup is not allowed by system settings'
});
}
// Log restore attempt
logger.warn('Restore operation started', {
user: req.user.email,
ip: req.ip,
restoreType: req.body.restoreType,
source: req.body.source
});
// Start restore in background
restoreService.restore({
...req.body,
dryRun: false,
operator: {
type: 'manual',
userId: req.user.id,
ip: req.ip
}
}).catch(error => {
logger.error('Background restore failed:', error);
});
res.json({
success: true,
message: 'Restore operation started'
});
} catch (error) {
logger.error('Failed to start restore:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* Get current restore progress
*/
router.get('/progress', async (req, res) => {
try {
const progress = restoreService.getProgress();
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
res.json({
success: true,
data: {
isRunning: restoreService.isRunning,
progress: progress,
logs: logs
}
});
} catch (error) {
logger.error('Failed to get restore progress:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore progress'
});
}
});
/**
* Get restore run details
*/
router.get('/run/:id', async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
.first();
if (!run) {
return res.status(404).json({
success: false,
error: 'Restore run not found'
});
}
// Parse JSON fields
if (run.statistics) run.statistics = JSON.parse(run.statistics);
if (run.restore_log) run.restore_log = JSON.parse(run.restore_log);
if (run.metadata) run.metadata = JSON.parse(run.metadata);
// Get validation results
const validations = await db('restore_validation_results')
.where('restore_run_id', run.id)
.select('*');
validations.forEach(v => {
if (v.errors) v.errors = JSON.parse(v.errors);
if (v.warnings) v.warnings = JSON.parse(v.warnings);
if (v.checksums) v.checksums = JSON.parse(v.checksums);
});
// Get file operations summary
const fileOps = await db('restore_file_operations')
.where('restore_run_id', run.id)
.select('status', db.raw('COUNT(*) as count'))
.groupBy('status');
res.json({
success: true,
data: {
run: run,
validations: validations,
fileOperations: fileOps
}
});
} catch (error) {
logger.error('Failed to get restore run details:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore run details'
});
}
});
/**
* Get restore run report
*/
router.get('/run/:id/report', async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
.first();
if (!run) {
return res.status(404).json({
success: false,
error: 'Restore run not found'
});
}
// Parse JSON fields
if (run.statistics) run.statistics = JSON.parse(run.statistics);
if (run.restore_log) run.restore_log = JSON.parse(run.restore_log);
// Generate report
const report = restoreService.generateRestoreReport({
success: run.status === 'completed',
duration: run.duration_seconds,
dryRun: run.is_dry_run,
result: run.statistics,
logs: run.restore_log || []
});
res.type('text/plain').send(report);
} catch (error) {
logger.error('Failed to generate restore report:', error);
res.status(500).json({
success: false,
error: 'Failed to generate restore report'
});
}
});
/**
* List available backups for restore
*/
router.get('/available-backups', async (req, res) => {
try {
const backups = [];
// Get local file backups
const backupConfig = await getBackupConfig();
if (backupConfig.backup_destination_type === 'local' && backupConfig.backup_destination_path) {
try {
const files = await fs.readdir(backupConfig.backup_destination_path);
for (const file of files) {
if (file.endsWith('.json') || file.endsWith('.yaml')) {
const filePath = path.join(backupConfig.backup_destination_path, file);
const stats = await fs.stat(filePath);
backups.push({
type: 'local',
name: file,
path: filePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
logger.warn('Failed to list local backups:', error);
}
}
// 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) {
backups.push({
type: run.manifest_path.startsWith('s3://') ? 's3' : 'local',
name: `Backup ${run.completed_at}`,
path: run.manifest_path,
manifestId: run.manifest_id,
size: run.total_size_bytes,
filesCount: run.files_backed_up,
duration: run.duration_seconds,
completed: run.completed_at
});
}
res.json({
success: true,
data: backups
});
} catch (error) {
logger.error('Failed to list available backups:', error);
res.status(500).json({
success: false,
error: 'Failed to list available backups'
});
}
});
/**
* Get restore settings
*/
router.get('/settings', async (req, res) => {
try {
const settings = await getRestoreSettings();
res.json({
success: true,
data: settings
});
} catch (error) {
logger.error('Failed to get restore settings:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore settings'
});
}
});
/**
* Update restore settings
*/
router.put('/settings', [
body('restore_allow_force').optional().isBoolean(),
body('restore_require_pre_backup').optional().isBoolean(),
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
body('restore_verify_checksums').optional().isBoolean(),
body('restore_email_on_completion').optional().isBoolean(),
body('restore_retention_days').optional().isInt({ min: 1 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Update settings
for (const [key, value] of Object.entries(req.body)) {
await db('app_settings')
.where('setting_key', key)
.where('setting_type', 'restore')
.update({
setting_value: typeof value === 'boolean' ? (value ? '1' : '0') : value.toString(),
updated_at: db.fn.now()
});
}
logger.info('Restore settings updated', {
user: req.user.email,
settings: req.body
});
res.json({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
logger.error('Failed to update restore settings:', error);
res.status(500).json({
success: false,
error: 'Failed to update settings'
});
}
});
/**
* Helper function to get restore settings
*/
async function getRestoreSettings() {
const settings = await db('app_settings')
.where('setting_type', 'restore')
.select('setting_key', 'setting_value');
const result = {};
settings.forEach(setting => {
// Convert boolean strings to actual booleans
if (setting.setting_value === '1' || setting.setting_value === '0') {
result[setting.setting_key] = setting.setting_value === '1';
} else {
result[setting.setting_key] = setting.setting_value;
}
});
return result;
}
/**
* Helper function to get backup configuration
*/
async function getBackupConfig() {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
}
module.exports = router;
@@ -0,0 +1,251 @@
const { DatabaseBackupService } = require('../databaseBackup');
const { db } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
// Mock dependencies
jest.mock('../../database/db');
jest.mock('../../utils/logger');
jest.mock('../emailProcessor');
jest.mock('child_process');
describe('DatabaseBackupService', () => {
let service;
let mockExecAsync;
beforeEach(() => {
service = new DatabaseBackupService();
mockExecAsync = jest.fn();
// Reset mocks
jest.clearAllMocks();
// Mock execAsync
const childProcess = require('child_process');
childProcess.exec = jest.fn((cmd, opts, callback) => {
if (callback) {
callback(null, { stdout: 'ok', stderr: '' });
}
});
});
afterEach(async () => {
// Cleanup test files
try {
await fs.rmdir('/tmp/test-backup', { recursive: true });
} catch (e) {
// Ignore
}
});
describe('calculateChecksum', () => {
it('should calculate SHA256 checksum of a file', async () => {
const testFile = '/tmp/test-checksum.txt';
const testContent = 'Hello, World!';
await fs.writeFile(testFile, testContent);
const checksum = await service.calculateChecksum(testFile);
// Expected checksum for "Hello, World!"
const expectedChecksum = crypto
.createHash('sha256')
.update(testContent)
.digest('hex');
expect(checksum).toBe(expectedChecksum);
await fs.unlink(testFile);
});
});
describe('getTableChecksums', () => {
it('should get checksums for all tables', async () => {
// Mock getTables
service.getTables = jest.fn().mockResolvedValue(['events', 'photos']);
// Mock SQLite response
db.raw = jest.fn()
.mockResolvedValueOnce([{ row_count: 10, data_sum: 1000 }])
.mockResolvedValueOnce([{ row_count: 20, data_sum: 2000 }]);
const checksums = await service.getTableChecksums();
expect(checksums).toHaveProperty('events');
expect(checksums).toHaveProperty('photos');
expect(checksums.events.rowCount).toBe(10);
expect(checksums.photos.rowCount).toBe(20);
expect(checksums.events.checksum).toBeDefined();
expect(checksums.photos.checksum).toBeDefined();
});
});
describe('getTables', () => {
it('should get list of tables for SQLite', async () => {
service.dbType = 'sqlite';
db.raw = jest.fn().mockResolvedValue([
{ name: 'events' },
{ name: 'photos' },
{ name: 'admin_users' }
]);
const tables = await service.getTables();
expect(tables).toEqual(['events', 'photos', 'admin_users']);
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('sqlite_master'));
});
it('should get list of tables for PostgreSQL', async () => {
service.dbType = 'postgresql';
db.raw = jest.fn().mockResolvedValue({
rows: [
{ table_name: 'events' },
{ table_name: 'photos' },
{ table_name: 'admin_users' }
]
});
const tables = await service.getTables();
expect(tables).toEqual(['events', 'photos', 'admin_users']);
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('information_schema.tables'));
});
});
describe('getDatabaseSize', () => {
it('should get database size for SQLite', async () => {
service.dbType = 'sqlite';
const mockSize = 1024 * 1024 * 10; // 10MB
// Mock fs.stat
const originalStat = fs.stat;
fs.stat = jest.fn().mockResolvedValue({ size: mockSize });
const size = await service.getDatabaseSize();
expect(size).toBe(mockSize);
fs.stat = originalStat;
});
it('should get database size for PostgreSQL', async () => {
service.dbType = 'postgresql';
const mockSize = 1024 * 1024 * 100; // 100MB
db.raw = jest.fn().mockResolvedValue({
rows: [{ size: mockSize.toString() }]
});
const size = await service.getDatabaseSize();
expect(size).toBe(mockSize);
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('pg_database_size'));
});
});
describe('compressFile', () => {
it('should compress file and return stats', async () => {
const testFile = '/tmp/test-compress.txt';
const compressedFile = '/tmp/test-compress.txt.gz';
// Create test file with repetitive content (compresses well)
const testContent = 'Hello, World! '.repeat(1000);
await fs.writeFile(testFile, testContent);
const stats = await service.compressFile(testFile, compressedFile);
expect(stats.originalSize).toBeGreaterThan(0);
expect(stats.compressedSize).toBeGreaterThan(0);
expect(stats.compressedSize).toBeLessThan(stats.originalSize);
expect(parseFloat(stats.compressionRatio)).toBeGreaterThan(0);
// Cleanup
await fs.unlink(testFile);
await fs.unlink(compressedFile);
});
});
describe('backup configuration', () => {
it('should get backup configuration from database', async () => {
const mockConfig = [
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_compress', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: '30' }
];
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue(mockConfig)
});
const config = await service.getBackupConfig();
expect(config.database_backup_enabled).toBe(true);
expect(config.database_backup_compress).toBe(true);
expect(config.database_backup_retention_days).toBe(30);
});
});
describe('cleanupOldBackups', () => {
it('should delete old backup files and records', async () => {
const oldBackups = [
{ id: 1, file_path: '/backup/old1.sql.gz' },
{ id: 2, file_path: '/backup/old2.sql.gz' }
];
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue(oldBackups),
delete: jest.fn().mockResolvedValue(1)
});
// Mock fs.unlink
fs.unlink = jest.fn().mockResolvedValue(undefined);
await service.cleanupOldBackups(30);
expect(fs.unlink).toHaveBeenCalledTimes(2);
expect(fs.unlink).toHaveBeenCalledWith('/backup/old1.sql.gz');
expect(fs.unlink).toHaveBeenCalledWith('/backup/old2.sql.gz');
});
});
describe('progress tracking', () => {
it('should update and retrieve progress', () => {
expect(service.getProgress()).toBeNull();
service.updateProgress('Testing...', { step: 1 });
const progress = service.getProgress();
expect(progress.message).toBe('Testing...');
expect(progress.details.step).toBe(1);
expect(progress.timestamp).toBeDefined();
});
});
describe('backup history', () => {
it('should retrieve backup history', async () => {
const mockHistory = [
{
id: 1,
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
file_size_bytes: 1024000
}
];
db.mockReturnValue({
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockResolvedValue(mockHistory)
});
const history = await service.getBackupHistory(10);
expect(history).toEqual(mockHistory);
expect(history.length).toBe(1);
});
});
});
+501
View File
@@ -0,0 +1,501 @@
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const yaml = require('js-yaml');
const os = require('os');
const { db } = require('../database/db');
const logger = require('../utils/logger');
/**
* Backup Manifest Generator
*
* Generates comprehensive manifests for backups including:
* - Version information (app, node, OS)
* - File listings with metadata and checksums
* - Database information
* - System state at backup time
* - Support for both JSON and YAML formats
* - Incremental backup support with parent references
*/
class BackupManifestGenerator {
constructor() {
this.appVersion = require('../../package.json').version;
this.nodeVersion = process.version;
this.platform = process.platform;
this.osRelease = os.release();
this.hostname = os.hostname();
}
/**
* Generate a comprehensive backup manifest
* @param {Object} options - Manifest generation options
* @param {string} options.backupType - 'full' or 'incremental'
* @param {string} options.backupPath - Path to the backup directory
* @param {Array} options.files - Array of backed up files with metadata
* @param {Object} options.databaseInfo - Database backup information
* @param {string} options.parentBackupId - For incremental backups, reference to parent
* @param {string} options.format - 'json' or 'yaml' (default: 'json')
* @param {Object} options.customMetadata - Additional metadata to include
* @returns {Object} Generated manifest object
*/
async generateManifest(options) {
const {
backupType = 'full',
backupPath,
files = [],
databaseInfo = {},
parentBackupId = null,
format = 'json',
customMetadata = {}
} = options;
const manifest = {
// Manifest metadata
manifest: {
version: '2.0',
created: new Date().toISOString(),
generator: 'PicPeak Backup Manifest Generator',
format: format
},
// Backup information
backup: {
id: this.generateBackupId(),
type: backupType,
timestamp: new Date().toISOString(),
path: backupPath,
parent_backup_id: parentBackupId,
retention_days: customMetadata.retentionDays || 30
},
// System information
system: {
hostname: this.hostname,
platform: this.platform,
os_release: this.osRelease,
architecture: os.arch(),
cpu_count: os.cpus().length,
total_memory: os.totalmem(),
free_memory: os.freemem(),
uptime: os.uptime()
},
// Application information
application: {
name: 'PicPeak',
version: this.appVersion,
node_version: this.nodeVersion,
environment: process.env.NODE_ENV || 'production',
storage_path: process.env.STORAGE_PATH || path.join(__dirname, '../../../storage')
},
// Files information
files: {
count: files.length,
total_size: files.reduce((sum, file) => sum + (file.size || 0), 0),
checksums: await this.generateFileChecksums(files),
manifest: files.map(file => ({
path: file.relativePath || file.path,
size: file.size,
modified: file.modified,
checksum: file.checksum,
type: this.getFileType(file.path),
permissions: file.permissions
}))
},
// Database information
database: {
type: databaseInfo.type || this.getDatabaseType(),
backup_file: databaseInfo.backupFile,
size: databaseInfo.size,
checksum: databaseInfo.checksum,
tables: databaseInfo.tables || {},
row_counts: databaseInfo.rowCounts || {},
schema_version: await this.getSchemaVersion()
},
// Verification information
verification: {
total_checksum: null, // Will be calculated after manifest is complete
file_count_check: files.length,
size_check: files.reduce((sum, file) => sum + (file.size || 0), 0),
integrity_timestamp: new Date().toISOString()
},
// Custom metadata
metadata: {
...customMetadata,
backup_settings: await this.getBackupSettings(),
active_events_count: await this.getActiveEventsCount(),
archived_events_count: await this.getArchivedEventsCount(),
total_photos_count: await this.getTotalPhotosCount()
}
};
// Calculate total checksum of the manifest
manifest.verification.total_checksum = this.calculateManifestChecksum(manifest);
return manifest;
}
/**
* Save manifest to file
* @param {Object} manifest - Manifest object to save
* @param {string} filePath - Path to save the manifest
* @param {string} format - 'json' or 'yaml'
*/
async saveManifest(manifest, filePath, format = 'json') {
try {
let content;
if (format === 'yaml') {
content = yaml.dump(manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: true
});
} else {
content = JSON.stringify(manifest, null, 2);
}
await fs.writeFile(filePath, content, 'utf8');
logger.info(`Manifest saved to ${filePath} (format: ${format})`);
return filePath;
} catch (error) {
logger.error('Failed to save manifest:', error);
throw error;
}
}
/**
* Load and validate an existing manifest
* @param {string} filePath - Path to the manifest file
* @returns {Object} Loaded and validated manifest
*/
async loadManifest(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
let manifest;
// Detect format and parse
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
manifest = yaml.load(content);
} else {
manifest = JSON.parse(content);
}
// Validate manifest structure
this.validateManifest(manifest);
return manifest;
} catch (error) {
logger.error('Failed to load manifest:', error);
throw error;
}
}
/**
* Validate manifest structure and integrity
* @param {Object} manifest - Manifest to validate
* @throws {Error} If validation fails
*/
validateManifest(manifest) {
// Check required sections
const requiredSections = ['manifest', 'backup', 'system', 'application', 'files', 'database', 'verification'];
for (const section of requiredSections) {
if (!manifest[section]) {
throw new Error(`Missing required section: ${section}`);
}
}
// Validate manifest version
if (!manifest.manifest.version) {
throw new Error('Missing manifest version');
}
// Validate file checksums
if (manifest.files.count !== manifest.files.manifest.length) {
throw new Error('File count mismatch');
}
// Validate total checksum
const calculatedChecksum = this.calculateManifestChecksum(manifest);
if (manifest.verification.total_checksum !== calculatedChecksum) {
throw new Error('Manifest checksum verification failed');
}
logger.info('Manifest validation passed');
return true;
}
/**
* Compare two manifests for incremental backup
* @param {Object} currentManifest - Current backup manifest
* @param {Object} parentManifest - Parent backup manifest
* @returns {Object} Comparison results
*/
compareManifests(currentManifest, parentManifest) {
const comparison = {
added_files: [],
modified_files: [],
deleted_files: [],
unchanged_files: [],
size_difference: 0,
database_changes: {}
};
// Create file maps for easy comparison
const currentFiles = new Map(
currentManifest.files.manifest.map(f => [f.path, f])
);
const parentFiles = new Map(
parentManifest.files.manifest.map(f => [f.path, f])
);
// Find added and modified files
for (const [path, file] of currentFiles) {
const parentFile = parentFiles.get(path);
if (!parentFile) {
comparison.added_files.push(file);
comparison.size_difference += file.size;
} else if (file.checksum !== parentFile.checksum) {
comparison.modified_files.push(file);
comparison.size_difference += file.size - parentFile.size;
} else {
comparison.unchanged_files.push(file);
}
}
// Find deleted files
for (const [path, file] of parentFiles) {
if (!currentFiles.has(path)) {
comparison.deleted_files.push(file);
comparison.size_difference -= file.size;
}
}
// Compare database info
comparison.database_changes = {
size_difference: currentManifest.database.size - parentManifest.database.size,
checksum_changed: currentManifest.database.checksum !== parentManifest.database.checksum,
schema_version_changed: currentManifest.database.schema_version !== parentManifest.database.schema_version
};
return comparison;
}
/**
* Generate incremental manifest based on parent
* @param {Object} options - Manifest generation options
* @param {Object} parentManifest - Parent backup manifest
* @returns {Object} Incremental manifest
*/
async generateIncrementalManifest(options, parentManifest) {
const fullManifest = await this.generateManifest({
...options,
backupType: 'incremental'
});
const comparison = this.compareManifests(fullManifest, parentManifest);
// Add incremental-specific information
fullManifest.incremental = {
parent_backup_id: parentManifest.backup.id,
parent_timestamp: parentManifest.backup.timestamp,
changes: {
added_files_count: comparison.added_files.length,
modified_files_count: comparison.modified_files.length,
deleted_files_count: comparison.deleted_files.length,
unchanged_files_count: comparison.unchanged_files.length,
size_difference: comparison.size_difference
},
added_files: comparison.added_files.map(f => f.path),
modified_files: comparison.modified_files.map(f => f.path),
deleted_files: comparison.deleted_files.map(f => f.path)
};
return fullManifest;
}
// Helper methods
generateBackupId() {
const timestamp = new Date().toISOString().replace(/[:-]/g, '').replace('T', '-').split('.')[0];
const random = crypto.randomBytes(4).toString('hex');
return `backup-${timestamp}-${random}`;
}
async generateFileChecksums(files) {
const checksums = {};
for (const file of files) {
if (file.checksum) {
checksums[file.relativePath || file.path] = file.checksum;
}
}
return checksums;
}
getFileType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const typeMap = {
'.jpg': 'image',
'.jpeg': 'image',
'.png': 'image',
'.gif': 'image',
'.webp': 'image',
'.zip': 'archive',
'.sql': 'database',
'.db': 'database',
'.json': 'config',
'.yaml': 'config',
'.yml': 'config'
};
return typeMap[ext] || 'other';
}
getDatabaseType() {
return process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite';
}
async getSchemaVersion() {
try {
const result = await db('migrations')
.orderBy('run_at', 'desc')
.first();
return result ? result.migration_name : 'unknown';
} catch (error) {
return 'unknown';
}
}
async getBackupSettings() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
} catch (error) {
return {};
}
}
async getActiveEventsCount() {
try {
const result = await db('events')
.where('status', 'active')
.count('* as count')
.first();
return result ? parseInt(result.count) : 0;
} catch (error) {
return 0;
}
}
async getArchivedEventsCount() {
try {
const result = await db('events')
.where('status', 'archived')
.count('* as count')
.first();
return result ? parseInt(result.count) : 0;
} catch (error) {
return 0;
}
}
async getTotalPhotosCount() {
try {
const result = await db('photos')
.count('* as count')
.first();
return result ? parseInt(result.count) : 0;
} catch (error) {
return 0;
}
}
calculateManifestChecksum(manifest) {
// Create a copy without the checksum field
const manifestCopy = JSON.parse(JSON.stringify(manifest));
if (manifestCopy.verification) {
delete manifestCopy.verification.total_checksum;
}
// Calculate SHA256 of the sorted JSON
const content = JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort());
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Generate a summary report from a manifest
* @param {Object} manifest - Manifest to summarize
* @returns {string} Human-readable summary
*/
generateSummaryReport(manifest) {
const report = [];
report.push('=== BACKUP MANIFEST SUMMARY ===');
report.push(`Backup ID: ${manifest.backup.id}`);
report.push(`Type: ${manifest.backup.type}`);
report.push(`Created: ${manifest.backup.timestamp}`);
if (manifest.backup.parent_backup_id) {
report.push(`Parent Backup: ${manifest.backup.parent_backup_id}`);
}
report.push('\n--- System Information ---');
report.push(`Host: ${manifest.system.hostname}`);
report.push(`Platform: ${manifest.system.platform} ${manifest.system.os_release}`);
report.push(`Architecture: ${manifest.system.architecture}`);
report.push('\n--- Application Information ---');
report.push(`App Version: ${manifest.application.version}`);
report.push(`Node Version: ${manifest.application.node_version}`);
report.push(`Environment: ${manifest.application.environment}`);
report.push('\n--- Files Summary ---');
report.push(`Total Files: ${manifest.files.count}`);
report.push(`Total Size: ${(manifest.files.total_size / 1024 / 1024).toFixed(2)} MB`);
if (manifest.incremental) {
report.push('\n--- Incremental Changes ---');
report.push(`Added Files: ${manifest.incremental.changes.added_files_count}`);
report.push(`Modified Files: ${manifest.incremental.changes.modified_files_count}`);
report.push(`Deleted Files: ${manifest.incremental.changes.deleted_files_count}`);
report.push(`Size Difference: ${(manifest.incremental.changes.size_difference / 1024 / 1024).toFixed(2)} MB`);
}
report.push('\n--- Database Information ---');
report.push(`Type: ${manifest.database.type}`);
report.push(`Size: ${manifest.database.size ? (manifest.database.size / 1024 / 1024).toFixed(2) + ' MB' : 'N/A'}`);
report.push(`Schema Version: ${manifest.database.schema_version}`);
report.push('\n--- Content Statistics ---');
report.push(`Active Events: ${manifest.metadata.active_events_count}`);
report.push(`Archived Events: ${manifest.metadata.archived_events_count}`);
report.push(`Total Photos: ${manifest.metadata.total_photos_count}`);
report.push('\n--- Verification ---');
report.push(`Manifest Checksum: ${manifest.verification.total_checksum}`);
report.push(`Integrity Timestamp: ${manifest.verification.integrity_timestamp}`);
return report.join('\n');
}
}
// Export singleton instance
module.exports = new BackupManifestGenerator();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,720 @@
const cron = require('node-cron');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { db } = require('../database/db');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const backupManifest = require('./backupManifest');
// Backup job reference
let backupJob = null;
let backupConfig = null;
let isRunning = false;
// Storage paths
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Calculate file checksum using SHA256
*/
async function calculateChecksum(filePath) {
const hash = crypto.createHash('sha256');
const stream = require('fs').createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Get database backup information
*/
async function getDatabaseBackupInfo() {
try {
// Check for recent database backup
const recentDbBackup = await db('database_backup_runs')
.where('status', 'completed')
.orderBy('completed_at', 'desc')
.first();
if (recentDbBackup && recentDbBackup.file_path) {
return {
type: recentDbBackup.backup_type,
backupFile: recentDbBackup.file_path,
size: recentDbBackup.file_size_bytes,
checksum: recentDbBackup.checksum,
tables: recentDbBackup.statistics ? JSON.parse(recentDbBackup.statistics).tables : {},
rowCounts: recentDbBackup.table_checksums ? JSON.parse(recentDbBackup.table_checksums) : {}
};
}
return {
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
backupFile: null,
size: 0,
checksum: null,
tables: {},
rowCounts: {}
};
} catch (error) {
logger.error('Failed to get database backup info:', error);
return {
type: 'unknown',
backupFile: null,
size: 0,
checksum: null,
tables: {},
rowCounts: {}
};
}
}
/**
* Get backup configuration from database
*/
async function getBackupConfig() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
} catch (error) {
logger.error('Failed to get backup configuration:', error);
return null;
}
}
/**
* Get list of files to backup
*/
async function getFilesToBackup(includeArchived = true) {
const files = [];
const storagePath = getStoragePath();
try {
// Active events
const activePath = path.join(storagePath, 'events/active');
await scanDirectory(activePath, files, storagePath);
// Archived events (if enabled)
if (includeArchived) {
const archivePath = path.join(storagePath, 'events/archived');
await scanDirectory(archivePath, files, storagePath);
}
// Thumbnails
const thumbsPath = path.join(storagePath, 'thumbnails');
await scanDirectory(thumbsPath, files, storagePath);
// Uploads (logos, favicons, etc.)
const uploadsPath = path.join(storagePath, 'uploads');
await scanDirectory(uploadsPath, files, storagePath);
return files;
} catch (error) {
logger.error('Failed to get files to backup:', error);
throw error;
}
}
/**
* Recursively scan directory for files
*/
async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.relative(basePath, fullPath);
// Check exclude patterns
if (excludePatterns.some(pattern => {
if (pattern.includes('*')) {
return new RegExp(pattern.replace(/\*/g, '.*')).test(entry.name);
}
return entry.name === pattern;
})) {
continue;
}
if (entry.isDirectory()) {
await scanDirectory(fullPath, fileList, basePath, excludePatterns);
} else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
fileList.push({
path: fullPath,
relativePath: relativePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
logger.error(`Failed to scan directory ${dirPath}:`, error);
}
}
}
/**
* Check if file has changed since last backup
*/
async function hasFileChanged(filePath, checksum) {
try {
const fileState = await db('backup_file_states')
.where('file_path', filePath)
.first();
return !fileState || fileState.checksum !== checksum;
} catch (error) {
logger.error('Failed to check file state:', error);
return true; // Assume changed if we can't check
}
}
/**
* Update file state in database
*/
async function updateFileState(filePath, checksum, size, modified) {
try {
const existing = await db('backup_file_states')
.where('file_path', filePath)
.first();
const data = {
file_path: filePath,
checksum: checksum,
size_bytes: size,
last_modified: modified,
last_backed_up: new Date()
};
if (existing) {
await db('backup_file_states')
.where('id', existing.id)
.update(data);
} else {
await db('backup_file_states').insert(data);
}
} catch (error) {
logger.error('Failed to update file state:', error);
}
}
/**
* Perform local directory backup
*/
async function performLocalBackup(config, files) {
const destPath = config.backup_destination_path;
const storagePath = getStoragePath();
let backedUpCount = 0;
let backedUpSize = 0;
const backedUpFiles = [];
// Ensure destination exists
await fs.mkdir(destPath, { recursive: true });
for (const file of files) {
try {
// Skip large files if configured
const maxSizeMB = config.backup_max_file_size_mb || 5000;
if (file.size > maxSizeMB * 1024 * 1024) {
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
continue;
}
// Calculate checksum
const checksum = await calculateChecksum(file.path);
file.checksum = checksum; // Add checksum to file object
// Check if file has changed
const changed = await hasFileChanged(file.relativePath, checksum);
if (!changed) {
continue;
}
// Copy file
const destFilePath = path.join(destPath, file.relativePath);
const destDir = path.dirname(destFilePath);
await fs.mkdir(destDir, { recursive: true });
await fs.copyFile(file.path, destFilePath);
// Update state
await updateFileState(file.relativePath, checksum, file.size, file.modified);
backedUpCount++;
backedUpSize += file.size;
backedUpFiles.push(file.relativePath);
} catch (error) {
logger.error(`Failed to backup file ${file.relativePath}:`, error);
}
}
return { backedUpCount, backedUpSize, backedUpFiles };
}
/**
* Perform rsync backup
*/
async function performRsyncBackup(config, files) {
const storagePath = getStoragePath();
const host = config.backup_rsync_host;
const user = config.backup_rsync_user;
const remotePath = config.backup_rsync_path;
const sshKey = config.backup_rsync_ssh_key;
if (!host || !remotePath) {
throw new Error('Rsync configuration incomplete');
}
// Build rsync command
const rsyncOptions = [
'-avz', // archive, verbose, compress
'--delete', // remove deleted files
'--stats' // show statistics
];
if (sshKey) {
rsyncOptions.push(`-e "ssh -i ${sshKey} -o StrictHostKeyChecking=no"`);
}
// Add exclude patterns
const excludePatterns = config.backup_exclude_patterns || [];
excludePatterns.forEach(pattern => {
rsyncOptions.push(`--exclude="${pattern}"`);
});
const source = `${storagePath}/`;
const destination = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`;
const rsyncCommand = `rsync ${rsyncOptions.join(' ')} "${source}" "${destination}"`;
try {
const { stdout, stderr } = await execAsync(rsyncCommand);
// Parse rsync stats
const stats = parseRsyncStats(stdout);
// Update file states for successfully synced files
for (const file of files) {
try {
const checksum = await calculateChecksum(file.path);
await updateFileState(file.relativePath, checksum, file.size, file.modified);
} catch (error) {
logger.error(`Failed to update state for ${file.relativePath}:`, error);
}
}
return {
backedUpCount: stats.filesTransferred || files.length,
backedUpSize: stats.totalSize || files.reduce((sum, f) => sum + f.size, 0),
backedUpFiles: files.map(f => f.relativePath)
};
} catch (error) {
logger.error('Rsync backup failed:', error);
throw new Error(`Rsync backup failed: ${error.message}`);
}
}
/**
* Parse rsync statistics from output
*/
function parseRsyncStats(output) {
const stats = {};
// Extract files transferred
const filesMatch = output.match(/Number of files transferred: (\d+)/);
if (filesMatch) {
stats.filesTransferred = parseInt(filesMatch[1]);
}
// Extract total size
const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/);
if (sizeMatch) {
stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''));
}
return stats;
}
/**
* Perform S3-compatible backup
*/
async function performS3Backup(config, files) {
// This would require AWS SDK or similar
// For now, return a placeholder
throw new Error('S3 backup not implemented yet');
}
/**
* Run backup process
*/
async function runBackup() {
if (isRunning) {
logger.warn('Backup already running, skipping');
return;
}
isRunning = true;
const startTime = new Date();
let backupRun = null;
try {
// Get current configuration
const config = await getBackupConfig();
if (!config.backup_enabled) {
logger.info('Backup is disabled, skipping');
return;
}
// Create backup run record
const [runId] = await db('backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: 'scheduled'
});
backupRun = { id: runId };
// Get files to backup
const files = await getFilesToBackup(config.backup_include_archived);
logger.info(`Found ${files.length} files to check for backup`);
// Perform backup based on destination type
let result;
switch (config.backup_destination_type) {
case 'local':
result = await performLocalBackup(config, files);
break;
case 'rsync':
result = await performRsyncBackup(config, files);
break;
case 's3':
result = await performS3Backup(config, files);
break;
default:
throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`);
}
// Calculate duration
const endTime = new Date();
const durationSeconds = Math.round((endTime - startTime) / 1000);
// Generate backup manifest
let manifestPath = null;
try {
logger.info('Generating backup manifest...');
// Get database backup info if available
const databaseInfo = await getDatabaseBackupInfo();
// Determine if this is an incremental backup
const lastSuccessfulBackup = await db('backup_runs')
.where('status', 'completed')
.whereNot('id', runId)
.orderBy('completed_at', 'desc')
.first();
let manifest;
const manifestOptions = {
backupType: lastSuccessfulBackup ? 'incremental' : 'full',
backupPath: config.backup_destination_path || config.backup_destination_type,
files: files.filter(f => result.backedUpFiles && result.backedUpFiles.includes(f.relativePath)),
databaseInfo: databaseInfo,
parentBackupId: lastSuccessfulBackup ? lastSuccessfulBackup.manifest_id : null,
format: config.backup_manifest_format || 'json',
customMetadata: {
backup_run_id: runId,
destination_type: config.backup_destination_type,
operator: 'system',
reason: 'scheduled',
retentionDays: config.backup_retention_days || 30
}
};
if (lastSuccessfulBackup && lastSuccessfulBackup.manifest_path) {
try {
const parentManifest = await backupManifest.loadManifest(lastSuccessfulBackup.manifest_path);
manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest);
} catch (error) {
logger.warn('Failed to load parent manifest, generating full manifest:', error);
manifest = await backupManifest.generateManifest(manifestOptions);
}
} else {
manifest = await backupManifest.generateManifest(manifestOptions);
}
// Save manifest
const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests');
await fs.mkdir(manifestDir, { recursive: true });
const manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`;
manifestPath = path.join(manifestDir, manifestFileName);
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
logger.info(`Backup manifest saved to ${manifestPath}`);
} catch (error) {
logger.error('Failed to generate backup manifest:', error);
// Don't fail the entire backup for manifest generation failure
}
// Update backup run record
await db('backup_runs')
.where('id', runId)
.update({
completed_at: endTime,
status: 'completed',
files_backed_up: result.backedUpCount,
total_size_bytes: result.backedUpSize,
duration_seconds: durationSeconds,
manifest_path: manifestPath,
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
statistics: JSON.stringify({
totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount > 0 ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
manifestGenerated: !!manifestPath
})
});
logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`);
// Send success email if configured
if (config.backup_email_on_success) {
// Get admin emails
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_completed', {
start_time: startTime.toISOString(),
duration: `${durationSeconds} seconds`,
files_count: result.backedUpCount.toString(),
total_size: `${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB`,
backup_type: config.backup_destination_type
});
}
}
} catch (error) {
logger.error('Backup failed:', error);
// Update backup run record
if (backupRun) {
await db('backup_runs')
.where('id', backupRun.id)
.update({
completed_at: new Date(),
status: 'failed',
error_message: error.message
});
}
// Send failure email
const config = await getBackupConfig();
if (config && config.backup_email_on_failure) {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_failed', {
start_time: startTime.toISOString(),
backup_type: config.backup_destination_type || 'unknown',
error_message: error.message
});
}
}
} finally {
isRunning = false;
}
}
/**
* Start backup service
*/
async function startBackupService() {
try {
// Get configuration
backupConfig = await getBackupConfig();
if (!backupConfig || !backupConfig.backup_enabled) {
logger.info('Backup service is disabled');
return;
}
// Cancel existing job if any
if (backupJob) {
backupJob.stop();
}
// Schedule backup job
const schedule = backupConfig.backup_schedule || '0 2 * * *'; // Default: 2 AM daily
backupJob = cron.schedule(schedule, async () => {
logger.info('Starting scheduled backup');
await runBackup();
});
logger.info(`Backup service started with schedule: ${schedule}`);
} catch (error) {
logger.error('Failed to start backup service:', error);
}
}
/**
* Stop backup service
*/
function stopBackupService() {
if (backupJob) {
backupJob.stop();
backupJob = null;
logger.info('Backup service stopped');
}
}
/**
* Trigger manual backup
*/
async function triggerManualBackup() {
logger.info('Starting manual backup');
await runBackup();
}
/**
* Get backup status and history
*/
async function getBackupStatus(limit = 10) {
try {
const runs = await db('backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
const lastRun = runs[0];
const isHealthy = lastRun && lastRun.status === 'completed';
// Validate manifest if exists
let manifestValid = false;
if (lastRun && lastRun.manifest_path) {
try {
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
backupManifest.validateManifest(manifest);
manifestValid = true;
} catch (error) {
logger.warn('Manifest validation failed:', error);
}
}
return {
isRunning,
isHealthy,
lastRun: lastRun ? {
...lastRun,
manifestValid
} : null,
recentRuns: runs,
nextScheduledRun: backupJob ? getNextScheduledRun() : null
};
} catch (error) {
logger.error('Failed to get backup status:', error);
return {
isRunning,
isHealthy: false,
error: error.message
};
}
}
/**
* Get next scheduled run time
*/
function getNextScheduledRun() {
// This is a simplified version - would need proper cron parsing
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(2, 0, 0, 0); // Assuming default 2 AM schedule
return tomorrow.toISOString();
}
/**
* Clean up old backup runs
*/
async function cleanupOldBackupRuns(retentionDays = 30) {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const deleted = await db('backup_runs')
.where('started_at', '<', cutoffDate)
.delete();
if (deleted > 0) {
logger.info(`Cleaned up ${deleted} old backup runs`);
}
} catch (error) {
logger.error('Failed to cleanup old backup runs:', error);
}
}
/**
* Get backup manifest for a specific backup run
*/
async function getBackupManifest(backupRunId) {
try {
const run = await db('backup_runs')
.where('id', backupRunId)
.first();
if (!run || !run.manifest_path) {
throw new Error('Backup manifest not found');
}
const manifest = await backupManifest.loadManifest(run.manifest_path);
return {
manifest,
summary: backupManifest.generateSummaryReport(manifest)
};
} catch (error) {
logger.error('Failed to get backup manifest:', error);
throw error;
}
}
/**
* Validate a backup manifest file
*/
async function validateBackupManifest(manifestPath) {
try {
const manifest = await backupManifest.loadManifest(manifestPath);
backupManifest.validateManifest(manifest);
return { valid: true, manifest };
} catch (error) {
return { valid: false, error: error.message };
}
}
module.exports = {
startBackupService,
stopBackupService,
triggerManualBackup,
getBackupStatus,
runBackup,
cleanupOldBackupRuns,
getBackupManifest,
validateBackupManifest
};
@@ -0,0 +1,147 @@
/**
* Database Backup Service Usage Examples
*
* This service provides comprehensive database backup functionality
* with support for both SQLite and PostgreSQL databases.
*/
const { databaseBackupService } = require('./databaseBackup');
// Example 1: Manual backup with default settings
async function manualBackup() {
try {
const result = await databaseBackupService.backup();
console.log('Backup completed:', result);
// Result includes: path, size, duration, checksum, compressionRatio
} catch (error) {
console.error('Backup failed:', error);
}
}
// Example 2: Backup with custom options
async function customBackup() {
try {
const result = await databaseBackupService.backup({
destinationPath: '/custom/backup/path',
compress: true, // Enable gzip compression
validateIntegrity: true, // Validate backup after creation
includeChecksums: true, // Calculate table checksums
noTransaction: false // Use transaction for consistency (PostgreSQL)
});
console.log('Custom backup completed:', result);
} catch (error) {
console.error('Backup failed:', error);
}
}
// Example 3: Check backup progress (useful for long-running backups)
async function backupWithProgress() {
// Start backup asynchronously
const backupPromise = databaseBackupService.backup();
// Poll for progress
const progressInterval = setInterval(() => {
const progress = databaseBackupService.getProgress();
if (progress) {
console.log(`Progress: ${progress.message}`, progress.details);
}
}, 1000);
try {
const result = await backupPromise;
clearInterval(progressInterval);
console.log('Backup completed:', result);
} catch (error) {
clearInterval(progressInterval);
console.error('Backup failed:', error);
}
}
// Example 4: Get backup history
async function getBackupHistory() {
const history = await databaseBackupService.getBackupHistory(10);
history.forEach(backup => {
console.log(`Backup ${backup.id}:`);
console.log(` Started: ${backup.started_at}`);
console.log(` Status: ${backup.status}`);
console.log(` Size: ${(backup.file_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(` Duration: ${backup.duration_seconds}s`);
});
}
// Example 5: Clean up old backups
async function cleanupBackups() {
// Delete backups older than 30 days
await databaseBackupService.cleanupOldBackups(30);
console.log('Old backups cleaned up');
}
// Example 6: Get table checksums (useful for monitoring changes)
async function getTableChecksums() {
const checksums = await databaseBackupService.getTableChecksums();
console.log('Table Checksums:');
Object.entries(checksums).forEach(([table, info]) => {
console.log(` ${table}: ${info.rowCount} rows, checksum: ${info.checksum}`);
});
}
// Example 7: Using the scheduled backup service
const { startScheduledBackups, stopScheduledBackups } = require('./databaseBackup');
async function setupScheduledBackups() {
// Start scheduled backups (reads schedule from database config)
await startScheduledBackups();
console.log('Scheduled backups started');
// Later, if needed, stop scheduled backups
// stopScheduledBackups();
}
// Example 8: Admin API endpoints available
/*
GET /api/admin/database-backup/status - Get backup status and config
PUT /api/admin/database-backup/config - Update backup configuration
POST /api/admin/database-backup/backup - Trigger manual backup
GET /api/admin/database-backup/progress - Get current backup progress
GET /api/admin/database-backup/history - Get backup history with pagination
DELETE /api/admin/database-backup/cleanup - Delete old backup files
POST /api/admin/database-backup/test - Test backup configuration
GET /api/admin/database-backup/checksums - Get current table checksums
*/
// Example 9: Configuration options stored in database
/*
database_backup_enabled: boolean - Enable/disable scheduled backups
database_backup_schedule: string - Cron schedule (default: '0 3 * * *')
database_backup_destination_path: string - Where to store backups
database_backup_compress: boolean - Enable gzip compression
database_backup_validate_integrity: boolean - Validate after backup
database_backup_include_checksums: boolean - Calculate table checksums
database_backup_retention_days: number - Days to keep old backups
database_backup_email_on_failure: boolean - Send email on failure
database_backup_email_on_success: boolean - Send email on success
*/
// Example 10: Production considerations
/*
1. Ensure destination path has sufficient space
2. For large databases, backups may take significant time
3. PostgreSQL backups use single-transaction mode by default
4. Compression typically reduces size by 70-90%
5. Schedule backups during low-traffic periods
6. Monitor backup history for failures
7. Test restore procedures regularly
8. Consider replication for real-time redundancy
*/
module.exports = {
manualBackup,
customBackup,
backupWithProgress,
getBackupHistory,
cleanupBackups,
getTableChecksums,
setupScheduledBackups
};
+631
View File
@@ -0,0 +1,631 @@
const fs = require('fs').promises;
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
const { queueEmail } = require('./emailProcessor');
const { formatBoolean } = require('../utils/dbCompat');
// Constants
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
/**
* Database Backup Service
* Supports both SQLite and PostgreSQL with proper escaping,
* compression, checksums, and validation
*/
class DatabaseBackupService {
constructor() {
this.isRunning = false;
this.currentProgress = null;
this.dbType = knexConfig.client === 'pg' ? 'postgresql' : 'sqlite';
}
/**
* Calculate checksum for a file
*/
async calculateChecksum(filePath) {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Compress a file using gzip
*/
async compressFile(inputPath, outputPath) {
const gzip = zlib.createGzip({ level: 6 }); // Balanced compression
const source = createReadStream(inputPath);
const destination = createWriteStream(outputPath);
await pipeline(source, gzip, destination);
// Get compression ratio
const inputStats = await fs.stat(inputPath);
const outputStats = await fs.stat(outputPath);
const ratio = (1 - outputStats.size / inputStats.size) * 100;
return {
originalSize: inputStats.size,
compressedSize: outputStats.size,
compressionRatio: ratio.toFixed(2)
};
}
/**
* Get database size
*/
async getDatabaseSize() {
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
const stats = await fs.stat(dbPath);
return stats.size;
} else {
// PostgreSQL
const result = await db.raw(`
SELECT pg_database_size(current_database()) as size
`);
return parseInt(result.rows[0].size);
}
}
/**
* Get table checksums for change detection
*/
async getTableChecksums() {
const checksums = {};
const tables = await this.getTables();
for (const table of tables) {
if (this.dbType === 'sqlite') {
// SQLite: Use aggregate of all row data
const result = await db.raw(`
SELECT
COUNT(*) as row_count,
COALESCE(SUM(LENGTH(CAST(t.* AS TEXT))), 0) as data_sum
FROM "${table}" t
`);
checksums[table] = {
rowCount: result[0].row_count,
checksum: crypto
.createHash('md5')
.update(`${result[0].row_count}-${result[0].data_sum}`)
.digest('hex')
};
} else {
// PostgreSQL: Use built-in functions
const result = await db.raw(`
SELECT
COUNT(*) as row_count,
MD5(COALESCE(STRING_AGG(MD5(t::text), ''), '')) as checksum
FROM "${table}" t
`);
checksums[table] = {
rowCount: parseInt(result.rows[0].row_count),
checksum: result.rows[0].checksum || 'empty'
};
}
}
return checksums;
}
/**
* Get list of tables
*/
async getTables() {
if (this.dbType === 'sqlite') {
const result = await db.raw(`
SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name != 'knex_migrations'
AND name != 'knex_migrations_lock'
ORDER BY name
`);
return result.map(row => row.name);
} else {
// PostgreSQL
const result = await db.raw(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name NOT IN ('knex_migrations', 'knex_migrations_lock')
ORDER BY table_name
`);
return result.rows.map(row => row.table_name);
}
}
/**
* Create SQLite backup
*/
async createSQLiteBackup(outputPath, options = {}) {
const dbPath = knexConfig.connection.filename;
const tempPath = `${outputPath}.tmp`;
try {
// Use SQLite's backup API for consistency
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
// Verify the backup
const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`);
if (!verifyResult.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
// Move temp file to final location
await fs.rename(tempPath, outputPath);
return { success: true };
} catch (error) {
// Cleanup temp file if exists
try {
await fs.unlink(tempPath);
} catch (e) {
// Ignore
}
throw error;
}
}
/**
* Create PostgreSQL backup with proper escaping
*/
async createPostgreSQLBackup(outputPath, options = {}) {
const { host, port, user, password, database } = knexConfig.connection;
// Build connection string with proper escaping
const connectionParts = [
`host=${host}`,
`port=${port}`,
`dbname=${database}`,
`user=${user}`
];
// Set PGPASSWORD environment variable for security
const env = { ...process.env };
if (password) {
env.PGPASSWORD = password;
}
// Build pg_dump command with options
const pgDumpOptions = [
'--verbose',
'--no-owner',
'--no-privileges',
'--clean',
'--if-exists',
'--format=plain',
'--encoding=UTF8'
];
// Add transaction support for consistency
if (!options.noTransaction) {
pgDumpOptions.push('--single-transaction');
}
// Add compression if not doing it separately
if (options.compress && !options.separateCompression) {
pgDumpOptions.push('--compress=6');
}
const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`;
try {
const { stderr } = await execAsync(command, {
env,
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
});
// pg_dump writes progress to stderr, not an error
if (stderr && !stderr.includes('dump complete')) {
logger.warn('pg_dump warnings:', stderr);
}
// Verify the dump file is not empty
const stats = await fs.stat(outputPath);
if (stats.size === 0) {
throw new Error('Backup file is empty');
}
return { success: true, warnings: stderr };
} catch (error) {
throw new Error(`PostgreSQL backup failed: ${error.message}`);
}
}
/**
* Validate backup integrity
*/
async validateBackup(backupPath, originalChecksums) {
const tempDbPath = `${backupPath}.validate`;
try {
if (this.dbType === 'sqlite') {
// For SQLite, we can directly check integrity
const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`);
if (!result.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
} else {
// For PostgreSQL, we'd need to restore to a temp database
// This is more complex and might not be feasible in production
logger.info('PostgreSQL backup validation would require restore test');
}
return { valid: true };
} finally {
// Cleanup
try {
await fs.unlink(tempDbPath);
} catch (e) {
// Ignore
}
}
}
/**
* Main backup method
*/
async backup(options = {}) {
if (this.isRunning) {
throw new Error('Backup already in progress');
}
this.isRunning = true;
const startTime = new Date();
let backupRun = null;
try {
// Get configuration
const config = await this.getBackupConfig();
const {
destinationPath = '/backup/database',
compress = true,
validateIntegrity = true,
includeChecksums = true
} = { ...config, ...options };
// Create backup directory
await fs.mkdir(destinationPath, { recursive: true });
// Generate backup filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `picpeak-db-${this.dbType}-${timestamp}`;
const sqlFile = path.join(destinationPath, `${baseName}.sql`);
const finalFile = compress ? path.join(destinationPath, `${baseName}.sql.gz`) : sqlFile;
// Create backup run record
const [runId] = await db('database_backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: this.dbType,
destination_path: finalFile
});
backupRun = { id: runId };
// Get initial checksums
let tableChecksums = null;
if (includeChecksums) {
this.updateProgress('Calculating table checksums...');
tableChecksums = await this.getTableChecksums();
}
// Get database size
const dbSize = await this.getDatabaseSize();
// Create the backup
this.updateProgress('Creating database backup...');
if (this.dbType === 'sqlite') {
await this.createSQLiteBackup(sqlFile, options);
} else {
await this.createPostgreSQLBackup(sqlFile, options);
}
// Compress if requested
let compressionStats = null;
if (compress) {
this.updateProgress('Compressing backup...');
compressionStats = await this.compressFile(sqlFile, finalFile);
await fs.unlink(sqlFile); // Remove uncompressed file
}
// Calculate checksum
this.updateProgress('Calculating backup checksum...');
const backupChecksum = await this.calculateChecksum(finalFile);
// Validate if requested
if (validateIntegrity && !compress) {
this.updateProgress('Validating backup integrity...');
await this.validateBackup(finalFile, tableChecksums);
}
// Get final file size
const finalStats = await fs.stat(finalFile);
// Calculate duration
const endTime = new Date();
const durationSeconds = Math.round((endTime - startTime) / 1000);
// Update backup run record
await db('database_backup_runs')
.where('id', runId)
.update({
completed_at: endTime,
status: 'completed',
file_path: finalFile,
file_size_bytes: finalStats.size,
original_size_bytes: dbSize,
duration_seconds: durationSeconds,
checksum: backupChecksum,
compression_ratio: compressionStats?.compressionRatio || null,
table_checksums: tableChecksums ? JSON.stringify(tableChecksums) : null,
statistics: JSON.stringify({
dbType: this.dbType,
compressed: compress,
validated: validateIntegrity,
compressionStats,
tableCount: tableChecksums ? Object.keys(tableChecksums).length : null
})
});
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
// Send success notification if configured
if (config.emailOnSuccess) {
await this.sendBackupNotification('success', {
duration: durationSeconds,
size: finalStats.size,
compressionRatio: compressionStats?.compressionRatio,
path: finalFile
});
}
return {
success: true,
path: finalFile,
size: finalStats.size,
duration: durationSeconds,
checksum: backupChecksum,
compressionRatio: compressionStats?.compressionRatio
};
} catch (error) {
logger.error('Database backup failed:', error);
// Update backup run record
if (backupRun) {
await db('database_backup_runs')
.where('id', backupRun.id)
.update({
completed_at: new Date(),
status: 'failed',
error_message: error.message
});
}
// Send failure notification
const config = await this.getBackupConfig();
if (config.emailOnFailure) {
await this.sendBackupNotification('failure', {
error: error.message
});
}
throw error;
} finally {
this.isRunning = false;
this.currentProgress = null;
}
}
/**
* Update progress
*/
updateProgress(message, details = {}) {
this.currentProgress = {
message,
details,
timestamp: new Date()
};
logger.info(`Backup progress: ${message}`, details);
}
/**
* Get current progress
*/
getProgress() {
return this.currentProgress;
}
/**
* Get backup configuration
*/
async getBackupConfig() {
const settings = await db('app_settings')
.where('setting_type', 'database_backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
}
/**
* Send backup notification email
*/
async sendBackupNotification(type, details) {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
if (type === 'success') {
await queueEmail(null, admin.email, 'database_backup_completed', {
backup_type: this.dbType,
duration: `${details.duration} seconds`,
file_size: `${(details.size / 1024 / 1024).toFixed(2)} MB`,
compression_ratio: details.compressionRatio ? `${details.compressionRatio}%` : 'N/A',
file_path: details.path
});
} else {
await queueEmail(null, admin.email, 'database_backup_failed', {
backup_type: this.dbType,
error_message: details.error,
timestamp: new Date().toISOString()
});
}
}
}
/**
* Clean up old backups
*/
async cleanupOldBackups(retentionDays = 30) {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Get old backup records
const oldBackups = await db('database_backup_runs')
.where('completed_at', '<', cutoffDate)
.where('status', 'completed')
.select('id', 'file_path');
let deletedCount = 0;
for (const backup of oldBackups) {
try {
// Delete the file
if (backup.file_path) {
await fs.unlink(backup.file_path);
}
// Delete the record
await db('database_backup_runs')
.where('id', backup.id)
.delete();
deletedCount++;
} catch (error) {
logger.error(`Failed to delete old backup ${backup.file_path}:`, error);
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} old database backups`);
}
// Also clean up old failed runs
await db('database_backup_runs')
.where('started_at', '<', cutoffDate)
.where('status', 'failed')
.delete();
} catch (error) {
logger.error('Failed to cleanup old database backups:', error);
}
}
/**
* Get backup history
*/
async getBackupHistory(limit = 10) {
return await db('database_backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
}
/**
* Restore from backup (careful!)
*/
async restore(backupPath, options = {}) {
// This is a dangerous operation and should be used with extreme caution
throw new Error('Restore functionality not implemented for safety. Please restore manually.');
}
}
// Create singleton instance
const databaseBackupService = new DatabaseBackupService();
// Scheduled backup runner
let backupSchedule = null;
/**
* Start scheduled database backups
*/
async function startScheduledBackups() {
const cron = require('node-cron');
try {
const config = await databaseBackupService.getBackupConfig();
if (!config.enabled) {
logger.info('Database backup service is disabled');
return;
}
// Stop existing schedule
if (backupSchedule) {
backupSchedule.stop();
}
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
const schedule = config.schedule || '0 3 * * *';
backupSchedule = cron.schedule(schedule, async () => {
logger.info('Starting scheduled database backup');
try {
await databaseBackupService.backup();
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
} catch (error) {
logger.error('Scheduled database backup failed:', error);
}
});
logger.info(`Database backup service started with schedule: ${schedule}`);
} catch (error) {
logger.error('Failed to start database backup service:', error);
}
}
/**
* Stop scheduled database backups
*/
function stopScheduledBackups() {
if (backupSchedule) {
backupSchedule.stop();
backupSchedule = null;
logger.info('Database backup service stopped');
}
}
module.exports = {
databaseBackupService,
startScheduledBackups,
stopScheduledBackups,
DatabaseBackupService // Export class for testing
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
const S3StorageAdapter = require('../s3Storage');
const { S3Client } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const fs = require('fs');
const stream = require('stream');
// Mock AWS SDK
jest.mock('@aws-sdk/client-s3');
jest.mock('@aws-sdk/lib-storage');
jest.mock('@aws-sdk/s3-request-presigner');
describe('S3StorageAdapter', () => {
let mockS3Client;
let mockSend;
let s3Storage;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Mock S3Client
mockSend = jest.fn();
mockS3Client = {
send: mockSend
};
S3Client.mockImplementation(() => mockS3Client);
// Create adapter instance
s3Storage = new S3StorageAdapter({
bucket: 'test-bucket',
region: 'us-east-1',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret'
});
});
describe('constructor', () => {
it('should initialize with required config', () => {
expect(s3Storage.bucket).toBe('test-bucket');
expect(s3Storage.config.region).toBe('us-east-1');
});
it('should throw error if bucket is not provided', () => {
expect(() => {
new S3StorageAdapter({ region: 'us-east-1' });
}).toThrow('S3 bucket name is required');
});
it('should configure for MinIO with path style', () => {
const minioStorage = new S3StorageAdapter({
bucket: 'test-bucket',
endpoint: 'http://localhost:9000',
forcePathStyle: true,
sslEnabled: false
});
expect(S3Client).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: 'http://localhost:9000',
forcePathStyle: true
})
);
});
});
describe('testConnection', () => {
it('should successfully test connection', async () => {
mockSend.mockResolvedValueOnce({});
const result = await s3Storage.testConnection();
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
input: { Bucket: 'test-bucket' }
})
);
});
it('should throw error on connection failure', async () => {
mockSend.mockRejectedValueOnce(new Error('Access Denied'));
await expect(s3Storage.testConnection()).rejects.toThrow('S3 connection test failed');
});
});
describe('upload', () => {
let mockUpload;
let mockDone;
beforeEach(() => {
mockDone = jest.fn().mockResolvedValue({
Location: 'https://test-bucket.s3.amazonaws.com/test-key',
ETag: '"test-etag"'
});
mockUpload = {
on: jest.fn().mockReturnThis(),
done: mockDone
};
Upload.mockImplementation(() => mockUpload);
// Mock fs.stat
jest.spyOn(fs.promises, 'stat').mockResolvedValue({
size: 1024
});
// Mock fs.createReadStream
jest.spyOn(fs, 'createReadStream').mockReturnValue(new stream.Readable());
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should upload file successfully', async () => {
const result = await s3Storage.upload('/path/to/file.jpg', 'test-key');
expect(result.Location).toBe('https://test-bucket.s3.amazonaws.com/test-key');
expect(Upload).toHaveBeenCalledWith(
expect.objectContaining({
client: mockS3Client,
params: expect.objectContaining({
Bucket: 'test-bucket',
Key: 'test-key',
ContentType: 'application/octet-stream'
})
})
);
});
it('should track upload progress', async () => {
const onProgress = jest.fn();
let progressCallback;
mockUpload.on.mockImplementation((event, callback) => {
if (event === 'httpUploadProgress') {
progressCallback = callback;
}
return mockUpload;
});
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
onProgress
});
// Simulate progress
progressCallback({ loaded: 512, total: 1024 });
await uploadPromise;
expect(onProgress).toHaveBeenCalledWith(512, 1024);
});
it('should emit upload events', async () => {
const uploadStartSpy = jest.fn();
const uploadCompleteSpy = jest.fn();
s3Storage.on('uploadStart', uploadStartSpy);
s3Storage.on('uploadComplete', uploadCompleteSpy);
await s3Storage.upload('/path/to/file.jpg', 'test-key');
expect(uploadStartSpy).toHaveBeenCalledWith({ key: 'test-key', size: 1024 });
expect(uploadCompleteSpy).toHaveBeenCalledWith({
key: 'test-key',
location: 'https://test-bucket.s3.amazonaws.com/test-key'
});
});
});
describe('exists', () => {
it('should return true if object exists', async () => {
mockSend.mockResolvedValueOnce({});
const result = await s3Storage.exists('test-key');
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
input: { Bucket: 'test-bucket', Key: 'test-key' }
})
);
});
it('should return false if object does not exist', async () => {
const error = new Error('Not Found');
error.name = 'NotFound';
error.$metadata = { httpStatusCode: 404 };
mockSend.mockRejectedValueOnce(error);
const result = await s3Storage.exists('test-key');
expect(result).toBe(false);
});
});
describe('generateKey', () => {
it('should generate unique key with timestamp and random string', () => {
const key = s3Storage.generateKey('photo.jpg');
expect(key).toMatch(/^\d+_[a-f0-9]{16}_photo\.jpg$/);
});
it('should add prefix if provided', () => {
const key = s3Storage.generateKey('photo.jpg', 'events/wedding');
expect(key).toMatch(/^events\/wedding\/\d+_[a-f0-9]{16}_photo\.jpg$/);
});
it('should sanitize filename', () => {
const key = s3Storage.generateKey('my photo (1).jpg');
expect(key).toMatch(/^\d+_[a-f0-9]{16}_my_photo__1_\.jpg$/);
});
});
describe('retry logic', () => {
it('should retry on retryable errors', async () => {
const retryableError = new Error('Connection reset');
retryableError.code = 'ECONNRESET';
// First attempt fails, second succeeds
mockSend
.mockRejectedValueOnce(retryableError)
.mockResolvedValueOnce({});
// Mock setTimeout to speed up test
jest.useFakeTimers();
const promise = s3Storage.exists('test-key');
// Advance timers
jest.runAllTimers();
const result = await promise;
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
it('should not retry on non-retryable errors', async () => {
const nonRetryableError = new Error('Invalid credentials');
nonRetryableError.code = 'InvalidCredentials';
mockSend.mockRejectedValueOnce(nonRetryableError);
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials');
expect(mockSend).toHaveBeenCalledTimes(1);
});
it('should stop retrying after max attempts', async () => {
const retryableError = new Error('Service unavailable');
retryableError.code = 'ServiceUnavailable';
mockSend.mockRejectedValue(retryableError);
// Mock setTimeout to speed up test
jest.useFakeTimers();
const promise = s3Storage.exists('test-key');
// Advance timers for all retries
for (let i = 0; i < 4; i++) {
jest.runAllTimers();
}
await expect(promise).rejects.toThrow('Service unavailable');
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
jest.useRealTimers();
});
});
describe('getStats', () => {
it('should calculate storage statistics', async () => {
mockSend.mockResolvedValueOnce({
Contents: [
{ Key: 'file1.jpg', Size: 1024 },
{ Key: 'file2.jpg', Size: 2048 }
],
NextContinuationToken: 'token123'
}).mockResolvedValueOnce({
Contents: [
{ Key: 'file3.jpg', Size: 3072 }
]
});
const stats = await s3Storage.getStats('events/');
expect(stats).toEqual({
totalSize: 6144,
totalCount: 3,
totalSizeFormatted: '6 KB'
});
});
});
describe('_formatBytes', () => {
it('should format bytes correctly', () => {
expect(s3Storage._formatBytes(0)).toBe('0 Bytes');
expect(s3Storage._formatBytes(1024)).toBe('1 KB');
expect(s3Storage._formatBytes(1048576)).toBe('1 MB');
expect(s3Storage._formatBytes(1073741824)).toBe('1 GB');
expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB');
});
});
});
@@ -0,0 +1,221 @@
/**
* Example usage of the S3StorageAdapter
*
* This file demonstrates how to use the S3 storage adapter for various operations
*/
const S3StorageAdapter = require('./s3Storage');
// Example 1: Basic AWS S3 Configuration
const s3Storage = new S3StorageAdapter({
bucket: 'my-photo-bucket',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
});
// Example 2: MinIO Configuration (S3-compatible)
const minioStorage = new S3StorageAdapter({
bucket: 'photo-storage',
endpoint: 'http://localhost:9000', // MinIO endpoint
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
forcePathStyle: true, // Required for MinIO
sslEnabled: false // For local development
});
// Example 3: DigitalOcean Spaces Configuration
const spacesStorage = new S3StorageAdapter({
bucket: 'my-space-name',
endpoint: 'https://nyc3.digitaloceanspaces.com',
region: 'nyc3',
accessKeyId: process.env.DO_SPACES_KEY,
secretAccessKey: process.env.DO_SPACES_SECRET
});
// Usage Examples
async function examples() {
try {
// Test connection
await s3Storage.testConnection();
console.log('Connection successful!');
// Upload a file with progress tracking
const uploadResult = await s3Storage.upload(
'/path/to/local/photo.jpg',
'events/wedding-2024/photo.jpg',
{
contentType: 'image/jpeg',
metadata: {
event: 'wedding-2024',
photographer: 'John Doe'
},
onProgress: (loaded, total) => {
const percentage = Math.round((loaded / total) * 100);
console.log(`Upload progress: ${percentage}%`);
}
}
);
console.log('Upload complete:', uploadResult.Location);
// Upload from stream
const readStream = fs.createReadStream('/path/to/large-video.mp4');
await s3Storage.uploadStream(readStream, 'events/wedding-2024/video.mp4', {
contentType: 'video/mp4',
onProgress: (loaded, total) => {
console.log(`Streamed ${loaded} of ${total} bytes`);
}
});
// Download a file
await s3Storage.download(
'events/wedding-2024/photo.jpg',
'/path/to/downloaded/photo.jpg',
{
onProgress: (loaded, total) => {
const percentage = Math.round((loaded / total) * 100);
console.log(`Download progress: ${percentage}%`);
}
}
);
// Get a download stream
const downloadStream = await s3Storage.downloadStream('events/wedding-2024/photo.jpg');
downloadStream.pipe(fs.createWriteStream('/path/to/output.jpg'));
// List files
const listing = await s3Storage.list('events/wedding-2024/');
console.log(`Found ${listing.Contents.length} files`);
listing.Contents.forEach(file => {
console.log(`- ${file.Key} (${file.Size} bytes)`);
});
// Generate pre-signed URL for temporary access
const downloadUrl = await s3Storage.getSignedUrl('getObject', 'events/wedding-2024/photo.jpg', {
expiresIn: 3600 // 1 hour
});
console.log('Pre-signed download URL:', downloadUrl);
// Generate pre-signed upload URL
const uploadUrl = await s3Storage.getSignedUrl('putObject', 'events/wedding-2024/new-photo.jpg', {
expiresIn: 1800, // 30 minutes
params: {
ContentType: 'image/jpeg'
}
});
console.log('Pre-signed upload URL:', uploadUrl);
// Check if file exists
const exists = await s3Storage.exists('events/wedding-2024/photo.jpg');
console.log('File exists:', exists);
// Get metadata
const metadata = await s3Storage.getMetadata('events/wedding-2024/photo.jpg');
console.log('File metadata:', metadata);
// Copy file
await s3Storage.copy(
'events/wedding-2024/photo.jpg',
'events/wedding-2024/photo-copy.jpg'
);
// Move file
await s3Storage.move(
'events/wedding-2024/photo-copy.jpg',
'events/wedding-2024/archived/photo.jpg'
);
// Delete file
await s3Storage.delete('events/wedding-2024/temp-photo.jpg');
// Delete multiple files
const deleteResult = await s3Storage.deleteMany([
'events/wedding-2024/temp1.jpg',
'events/wedding-2024/temp2.jpg',
'events/wedding-2024/temp3.jpg'
]);
console.log(`Deleted ${deleteResult.Deleted.length} files`);
// Get storage statistics
const stats = await s3Storage.getStats('events/');
console.log(`Total files: ${stats.totalCount}`);
console.log(`Total size: ${stats.totalSizeFormatted}`);
// Listen to events
s3Storage.on('uploadProgress', (data) => {
console.log(`Uploading ${data.key}: ${data.loaded}/${data.total}`);
});
s3Storage.on('uploadComplete', (data) => {
console.log(`Upload completed: ${data.key}`);
});
s3Storage.on('uploadError', (data) => {
console.error(`Upload failed for ${data.key}:`, data.error);
});
} catch (error) {
console.error('Error:', error);
}
}
// Integration with existing photo upload workflow
async function integrateWithPhotoUpload(eventId, files) {
const storage = new S3StorageAdapter({
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
});
const uploadedPhotos = [];
for (const file of files) {
try {
// Generate unique S3 key
const s3Key = storage.generateKey(file.originalname, `events/${eventId}`);
// Upload to S3
const result = await storage.upload(file.path, s3Key, {
contentType: file.mimetype,
metadata: {
eventId: eventId,
originalName: file.originalname,
uploadedAt: new Date().toISOString()
}
});
uploadedPhotos.push({
filename: s3Key,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
s3Location: result.Location,
s3Key: s3Key
});
// Clean up local temp file
await fs.promises.unlink(file.path);
} catch (error) {
console.error(`Failed to upload ${file.originalname}:`, error);
throw error;
}
}
return uploadedPhotos;
}
// Environment variables needed:
// AWS_ACCESS_KEY_ID=your-access-key
// AWS_SECRET_ACCESS_KEY=your-secret-key
// AWS_REGION=us-east-1
// S3_BUCKET=your-bucket-name
// For MinIO:
// MINIO_ENDPOINT=http://localhost:9000
// MINIO_ACCESS_KEY=minioadmin
// MINIO_SECRET_KEY=minioadmin
// MINIO_BUCKET=photo-storage
module.exports = { examples, integrateWithPhotoUpload };
+733
View File
@@ -0,0 +1,733 @@
const { S3Client, HeadBucketCommand, HeadObjectCommand, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, CopyObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const stream = require('stream');
const crypto = require('crypto');
const logger = require('../../utils/logger');
/**
* S3 Storage Adapter for handling file uploads to S3 and S3-compatible services
*
* Features:
* - Support for S3 and S3-compatible services (MinIO, DigitalOcean Spaces, etc.)
* - Multipart upload for large files (>100MB)
* - Progress tracking with event emitter
* - Retry logic with exponential backoff
* - Stream support for memory efficiency
* - Path-style URL support for MinIO
* - Connection testing
* - Comprehensive error handling
*
* @class S3StorageAdapter
*/
class S3StorageAdapter extends stream.EventEmitter {
/**
* Creates an instance of S3StorageAdapter
*
* @param {Object} config - Configuration object
* @param {string} config.bucket - S3 bucket name
* @param {string} [config.region='us-east-1'] - AWS region
* @param {string} [config.endpoint] - Custom endpoint URL for S3-compatible services
* @param {string} [config.accessKeyId] - AWS access key ID
* @param {string} [config.secretAccessKey] - AWS secret access key
* @param {boolean} [config.forcePathStyle=false] - Force path-style URLs (required for MinIO)
* @param {boolean} [config.sslEnabled=true] - Enable SSL for connections
* @param {number} [config.multipartThreshold=104857600] - Threshold for multipart upload (default 100MB)
* @param {number} [config.partSize=10485760] - Part size for multipart upload (default 10MB)
* @param {number} [config.maxRetries=3] - Maximum number of retry attempts
* @param {number} [config.retryDelay=1000] - Initial retry delay in milliseconds
*/
constructor(config) {
super();
// Validate required config
if (!config.bucket) {
throw new Error('S3 bucket name is required');
}
// Set defaults
this.config = {
region: 'us-east-1',
forcePathStyle: false,
sslEnabled: true,
multipartThreshold: 100 * 1024 * 1024, // 100MB
partSize: 10 * 1024 * 1024, // 10MB
maxRetries: 3,
retryDelay: 1000,
...config
};
// Initialize S3 client
const s3Config = {
region: this.config.region,
forcePathStyle: this.config.forcePathStyle
};
// Add credentials if provided
if (this.config.accessKeyId && this.config.secretAccessKey) {
s3Config.credentials = {
accessKeyId: this.config.accessKeyId,
secretAccessKey: this.config.secretAccessKey
};
}
// 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}`;
}
}
this.s3Client = new S3Client(s3Config);
this.bucket = this.config.bucket;
// Bind methods to preserve context
this.upload = this.upload.bind(this);
this.uploadStream = this.uploadStream.bind(this);
this.download = this.download.bind(this);
this.downloadStream = this.downloadStream.bind(this);
this.delete = this.delete.bind(this);
this.exists = this.exists.bind(this);
this.list = this.list.bind(this);
this.copy = this.copy.bind(this);
this.move = this.move.bind(this);
this.getSignedUrl = this.getSignedUrl.bind(this);
this.testConnection = this.testConnection.bind(this);
}
/**
* Test connection to S3 bucket
*
* @returns {Promise<boolean>} - True if connection successful
* @throws {Error} - If connection fails
*/
async testConnection() {
try {
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
logger.info(`Successfully connected to S3 bucket: ${this.bucket}`);
return true;
} catch (error) {
logger.error(`Failed to connect to S3 bucket ${this.bucket}:`, error);
throw new Error(`S3 connection test failed: ${error.message}`);
}
}
/**
* Upload a file to S3 with automatic multipart for large files
*
* @param {string} localPath - Local file path to upload
* @param {string} s3Key - S3 object key (path in bucket)
* @param {Object} [options={}] - Additional options
* @param {Object} [options.metadata] - Object metadata
* @param {string} [options.contentType] - Content type
* @param {string} [options.cacheControl] - Cache control header
* @param {Function} [options.onProgress] - Progress callback function(loaded, total)
* @returns {Promise<Object>} - Upload result with Location, ETag, etc.
*/
async upload(localPath, s3Key, options = {}) {
try {
const stats = await fsPromises.stat(localPath);
const fileSize = stats.size;
// Emit upload start event
this.emit('uploadStart', { key: s3Key, size: fileSize });
// Create file stream
const fileStream = fs.createReadStream(localPath);
// Prepare upload parameters
const uploadParams = {
Bucket: this.bucket,
Key: s3Key,
Body: fileStream,
ContentType: options.contentType || 'application/octet-stream',
Metadata: options.metadata || {},
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
// Use AWS SDK v3 Upload for automatic multipart handling
const parallelUploads3 = new Upload({
client: this.s3Client,
params: uploadParams,
queueSize: 4, // Optional: concurrent uploads
partSize: this.config.partSize,
leavePartsOnError: false
});
// Track upload progress
parallelUploads3.on('httpUploadProgress', (progress) => {
if (options.onProgress) {
options.onProgress(progress.loaded, progress.total);
}
this.emit('uploadProgress', { key: s3Key, loaded: progress.loaded, total: progress.total });
});
// Perform upload with retry
const result = await this._retryOperation(() => parallelUploads3.done());
this.emit('uploadComplete', { key: s3Key, location: result.Location });
return result;
} catch (error) {
logger.error(`Failed to upload file ${localPath} to S3:`, error);
this.emit('uploadError', { key: s3Key, error });
throw error;
}
}
/**
* Upload a stream to S3
*
* @param {stream.Readable} readStream - Readable stream to upload
* @param {string} s3Key - S3 object key
* @param {Object} [options={}] - Additional options
* @returns {Promise<Object>} - Upload result
*/
async uploadStream(readStream, s3Key, options = {}) {
const uploadParams = {
Bucket: this.bucket,
Key: s3Key,
Body: readStream,
ContentType: options.contentType || 'application/octet-stream',
Metadata: options.metadata || {},
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
const parallelUploads3 = new Upload({
client: this.s3Client,
params: uploadParams,
queueSize: 4,
partSize: this.config.partSize,
leavePartsOnError: false
});
// Track progress if callback provided
if (options.onProgress) {
parallelUploads3.on('httpUploadProgress', (progress) => {
options.onProgress(progress.loaded, progress.total);
this.emit('uploadProgress', { key: s3Key, ...progress });
});
}
return await this._retryOperation(() => parallelUploads3.done());
}
/**
* Download a file from S3
*
* @param {string} s3Key - S3 object key
* @param {string} localPath - Local file path to save to
* @param {Object} [options={}] - Additional options
* @param {Function} [options.onProgress] - Progress callback
* @returns {Promise<void>}
*/
async download(s3Key, localPath, options = {}) {
try {
// Ensure directory exists
await fsPromises.mkdir(path.dirname(localPath), { recursive: true });
// Get object metadata first for progress tracking
const headResult = await this._retryOperation(() =>
this.s3Client.send(new HeadObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
const fileSize = headResult.ContentLength;
this.emit('downloadStart', { key: s3Key, size: fileSize });
// Get object
const getObjectResult = await this._retryOperation(() =>
this.s3Client.send(new GetObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
// Create write stream
const writeStream = fs.createWriteStream(localPath);
// Download with progress tracking
await new Promise((resolve, reject) => {
let downloaded = 0;
const bodyStream = getObjectResult.Body;
bodyStream.on('data', (chunk) => {
downloaded += chunk.length;
if (options.onProgress) {
options.onProgress(downloaded, fileSize);
}
this.emit('downloadProgress', { key: s3Key, loaded: downloaded, total: fileSize });
});
bodyStream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
bodyStream.pipe(writeStream);
});
this.emit('downloadComplete', { key: s3Key });
} catch (error) {
logger.error(`Failed to download file ${s3Key} from S3:`, error);
this.emit('downloadError', { key: s3Key, error });
throw error;
}
}
/**
* Get a download stream from S3
*
* @param {string} s3Key - S3 object key
* @param {Object} [options={}] - Additional options
* @param {string} [options.range] - Byte range to download (e.g., 'bytes=0-1023')
* @returns {Promise<stream.Readable>} - Readable stream
*/
async downloadStream(s3Key, options = {}) {
const params = {
Bucket: this.bucket,
Key: s3Key,
Range: options.range
};
// Remove undefined values
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
const result = await this.s3Client.send(new GetObjectCommand(params));
return result.Body;
}
/**
* Delete a file from S3
*
* @param {string} s3Key - S3 object key
* @returns {Promise<void>}
*/
async delete(s3Key) {
return await this._retryOperation(() =>
this.s3Client.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
}
/**
* Delete multiple files from S3
*
* @param {string[]} s3Keys - Array of S3 object keys
* @returns {Promise<Object>} - Delete result
*/
async deleteMany(s3Keys) {
if (!s3Keys || s3Keys.length === 0) {
return { Deleted: [], Errors: [] };
}
// S3 deleteObjects has a limit of 1000 keys per request
const chunks = [];
for (let i = 0; i < s3Keys.length; i += 1000) {
chunks.push(s3Keys.slice(i, i + 1000));
}
const results = await Promise.all(
chunks.map(chunk =>
this._retryOperation(() =>
this.s3Client.send(new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: chunk.map(key => ({ Key: key })),
Quiet: false
}
}))
)
)
);
// Combine results
return {
Deleted: results.flatMap(r => r.Deleted || []),
Errors: results.flatMap(r => r.Errors || [])
};
}
/**
* Check if a file exists in S3
*
* @param {string} s3Key - S3 object key
* @returns {Promise<boolean>} - True if exists
*/
async exists(s3Key) {
try {
await this.s3Client.send(new HeadObjectCommand({
Bucket: this.bucket,
Key: s3Key
}));
return true;
} catch (error) {
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
return false;
}
throw error;
}
}
/**
* List files in S3
*
* @param {string} prefix - S3 prefix to list
* @param {Object} [options={}] - Additional options
* @param {number} [options.maxKeys=1000] - Maximum number of keys to return
* @param {string} [options.continuationToken] - Continuation token for pagination
* @returns {Promise<Object>} - List result with Contents array and NextContinuationToken
*/
async list(prefix, options = {}) {
const params = {
Bucket: this.bucket,
Prefix: prefix,
MaxKeys: options.maxKeys || 1000,
ContinuationToken: options.continuationToken
};
return await this._retryOperation(() =>
this.s3Client.send(new ListObjectsV2Command(params))
);
}
/**
* Copy a file within S3
*
* @param {string} sourceKey - Source S3 object key
* @param {string} targetKey - Target S3 object key
* @param {Object} [options={}] - Additional options
* @returns {Promise<Object>} - Copy result
*/
async copy(sourceKey, targetKey, options = {}) {
const copySource = `${this.bucket}/${sourceKey}`;
const params = {
Bucket: this.bucket,
CopySource: copySource,
Key: targetKey,
MetadataDirective: options.metadata ? 'REPLACE' : 'COPY',
Metadata: options.metadata,
ContentType: options.contentType,
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
return await this._retryOperation(() =>
this.s3Client.send(new CopyObjectCommand(params))
);
}
/**
* Move a file within S3 (copy then delete)
*
* @param {string} sourceKey - Source S3 object key
* @param {string} targetKey - Target S3 object key
* @param {Object} [options={}] - Additional options
* @returns {Promise<Object>} - Move result
*/
async move(sourceKey, targetKey, options = {}) {
// First copy the object
const copyResult = await this.copy(sourceKey, targetKey, options);
// Then delete the original
await this.delete(sourceKey);
return copyResult;
}
/**
* Get a pre-signed URL for downloading or uploading
*
* @param {string} operation - Operation type ('getObject' or 'putObject')
* @param {string} s3Key - S3 object key
* @param {Object} [options={}] - Additional options
* @param {number} [options.expiresIn=3600] - URL expiration in seconds
* @param {Object} [options.params] - Additional parameters for the operation
* @returns {Promise<string>} - Pre-signed URL
*/
async getSignedUrl(operation, s3Key, options = {}) {
const params = {
Bucket: this.bucket,
Key: s3Key,
...options.params
};
let command;
switch (operation.toLowerCase()) {
case 'getobject':
command = new GetObjectCommand(params);
break;
case 'putobject':
command = new PutObjectCommand(params);
break;
default:
throw new Error(`Unsupported operation: ${operation}`);
}
return await getSignedUrl(this.s3Client, command, {
expiresIn: options.expiresIn || 3600
});
}
/**
* Get object metadata
*
* @param {string} s3Key - S3 object key
* @returns {Promise<Object>} - Object metadata
*/
async getMetadata(s3Key) {
return await this._retryOperation(() =>
this.s3Client.send(new HeadObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
}
/**
* Update object metadata
*
* @param {string} s3Key - S3 object key
* @param {Object} metadata - New metadata
* @returns {Promise<Object>} - Update result
*/
async updateMetadata(s3Key, metadata) {
// S3 requires copying the object to itself to update metadata
return await this.copy(s3Key, s3Key, { metadata });
}
/**
* Manual multipart upload for advanced use cases
*
* @param {string} localPath - Local file path
* @param {string} s3Key - S3 object key
* @param {number} fileSize - File size in bytes
* @param {Object} options - Upload options
* @returns {Promise<Object>} - Upload result
* @private
*/
async _manualMultipartUpload(localPath, s3Key, fileSize, options) {
logger.info(`Starting manual multipart upload for ${s3Key} (${fileSize} bytes)`);
// Initiate multipart upload
const multipartParams = {
Bucket: this.bucket,
Key: s3Key,
ContentType: options.contentType || 'application/octet-stream',
Metadata: options.metadata || {},
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(multipartParams).forEach(key => multipartParams[key] === undefined && delete multipartParams[key]);
const multipart = await this._retryOperation(() =>
this.s3Client.send(new CreateMultipartUploadCommand(multipartParams))
);
const uploadId = multipart.UploadId;
const partSize = this.config.partSize;
const numParts = Math.ceil(fileSize / partSize);
let uploaded = 0;
const parts = [];
try {
// Upload parts
for (let partNum = 1; partNum <= numParts; partNum++) {
const start = (partNum - 1) * partSize;
const end = Math.min(start + partSize, fileSize);
const partStream = fs.createReadStream(localPath, {
start,
end: end - 1
});
const partParams = {
Bucket: this.bucket,
Key: s3Key,
PartNumber: partNum,
UploadId: uploadId,
Body: partStream
};
// Upload part with retry
const partResult = await this._retryOperation(() =>
this.s3Client.send(new UploadPartCommand(partParams))
);
parts.push({
ETag: partResult.ETag,
PartNumber: partNum
});
uploaded += (end - start);
if (options.onProgress) {
options.onProgress(uploaded, fileSize);
}
this.emit('uploadProgress', { key: s3Key, loaded: uploaded, total: fileSize });
logger.info(`Uploaded part ${partNum}/${numParts} for ${s3Key}`);
}
// Complete multipart upload
const completeParams = {
Bucket: this.bucket,
Key: s3Key,
UploadId: uploadId,
MultipartUpload: { Parts: parts }
};
const result = await this._retryOperation(() =>
this.s3Client.send(new CompleteMultipartUploadCommand(completeParams))
);
this.emit('uploadComplete', { key: s3Key, location: result.Location });
logger.info(`Completed multipart upload for ${s3Key}`);
return result;
} catch (error) {
// Abort multipart upload on error
logger.error(`Multipart upload failed for ${s3Key}, aborting:`, error);
try {
await this.s3Client.send(new AbortMultipartUploadCommand({
Bucket: this.bucket,
Key: s3Key,
UploadId: uploadId
}));
} catch (abortError) {
logger.error(`Failed to abort multipart upload:`, abortError);
}
throw error;
}
}
/**
* Retry operation with exponential backoff
* @private
*/
async _retryOperation(operation, retryCount = 0) {
try {
return await operation();
} catch (error) {
if (retryCount >= this.config.maxRetries) {
throw error;
}
// Check if error is retryable
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
const isRetryable = retryableErrors.some(code =>
error.code === code ||
error.name === code ||
error.$metadata?.httpStatusCode === 503 ||
error.$metadata?.httpStatusCode === 500
);
if (!isRetryable) {
throw error;
}
// Calculate delay with exponential backoff and jitter
const delay = Math.min(
this.config.retryDelay * Math.pow(2, retryCount) + Math.random() * 1000,
30000 // Max 30 seconds
);
logger.warn(`Retrying operation after ${delay}ms (attempt ${retryCount + 1}/${this.config.maxRetries}):`, error.message);
await new Promise(resolve => setTimeout(resolve, delay));
return this._retryOperation(operation, retryCount + 1);
}
}
/**
* Generate a unique S3 key for a file
*
* @param {string} originalName - Original filename
* @param {string} [prefix=''] - Optional prefix for the key
* @returns {string} - Generated S3 key
*/
generateKey(originalName, prefix = '') {
const timestamp = Date.now();
const randomStr = crypto.randomBytes(8).toString('hex');
const ext = path.extname(originalName);
const basename = path.basename(originalName, ext);
// Sanitize basename
const safeName = basename.replace(/[^a-zA-Z0-9-_]/g, '_');
const key = `${timestamp}_${randomStr}_${safeName}${ext}`;
return prefix ? path.posix.join(prefix, key) : key;
}
/**
* Get storage statistics
*
* @param {string} [prefix=''] - Optional prefix to filter
* @returns {Promise<Object>} - Storage statistics
*/
async getStats(prefix = '') {
let totalSize = 0;
let totalCount = 0;
let continuationToken;
do {
const result = await this.list(prefix, { continuationToken });
if (result.Contents) {
totalCount += result.Contents.length;
totalSize += result.Contents.reduce((sum, obj) => sum + (obj.Size || 0), 0);
}
continuationToken = result.NextContinuationToken;
} while (continuationToken);
return {
totalSize,
totalCount,
totalSizeFormatted: this._formatBytes(totalSize)
};
}
/**
* Format bytes to human readable format
* @private
*/
_formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
}
module.exports = S3StorageAdapter;