mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-01 01:21:18 +01:00
feat(database): add email notifications for backups/imports & backup verification (#4253)
# Description of Changes
**What was changed**
- Added enterprise configuration for database-related email
notifications:
-
`premium.enterpriseFeatures.databaseNotifications.backups.successful|failed`
-
`premium.enterpriseFeatures.databaseNotifications.imports.successful|failed`
- Extended `ApplicationProperties` and `settings.yml.template`
accordingly.
- Introduced `DatabaseNotificationServiceInterface` and
`DatabaseNotificationService` to centralize and gate notification
sending (EE-only, respects `mail.enabled`).
- Wired notifications into `DatabaseService`:
- Sends emails on successful/failed **backups** and **imports**.
- Added backup verification step:
- Compute SHA‑256 checksum for backup files.
- Validate backup by loading it into an in‑memory H2 instance
(`RUNSCRIPT`) before using it.
- Abort import if verification fails.
- Enhanced `EmailService`:
- Added `sendSimpleMail(String to, String subject, String body)` (async)
for lightweight notifications.
- Added debug logging for successful sends.
- Minor refactors and improved logging around backup/export/import
flows.
**Why the change was made**
- Provide admins with timely, configurable notifications about critical
database operations (backups/imports).
- Increase reliability by verifying backup integrity before execution,
reducing risk from corrupted or incomplete scripts.
- Keep configuration explicit and self-documenting via new keys in
`settings.yml.template`.
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
parent
30d83ec896
commit
6cc3494e62
@ -595,6 +595,25 @@ public class ApplicationProperties {
|
||||
public static class EnterpriseFeatures {
|
||||
private PersistentMetrics persistentMetrics = new PersistentMetrics();
|
||||
private Audit audit = new Audit();
|
||||
private DatabaseNotifications databaseNotifications = new DatabaseNotifications();
|
||||
|
||||
@Data
|
||||
public static class DatabaseNotifications {
|
||||
private Backup backups = new Backup();
|
||||
private Imports imports = new Imports();
|
||||
|
||||
@Data
|
||||
public static class Backup {
|
||||
private boolean successful = false;
|
||||
private boolean failed = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Imports {
|
||||
private boolean successful = false;
|
||||
private boolean failed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Audit {
|
||||
|
||||
@ -85,6 +85,13 @@ premium:
|
||||
enabled: true # Enable audit logging
|
||||
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
retentionDays: 90 # Number of days to retain audit logs
|
||||
databaseNotifications:
|
||||
backups:
|
||||
successful: false # set to 'true' to enable email notifications for successful database backups
|
||||
failed: false # set to 'true' to enable email notifications for failed database backups
|
||||
imports:
|
||||
successful: false # set to 'true' to enable email notifications for successful database imports
|
||||
failed: false # set to 'true' to enable email notifications for failed database imports
|
||||
|
||||
mail:
|
||||
enabled: false # set to 'true' to enable sending emails
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
package stirling.software.proprietary.security.database;
|
||||
|
||||
public interface DatabaseNotificationServiceInterface {
|
||||
void notifyBackupsSuccess(String subject, String message);
|
||||
|
||||
void notifyBackupsFailure(String subject, String message);
|
||||
|
||||
void notifyImportsSuccess(String subject, String message);
|
||||
|
||||
void notifyImportsFailure(String subject, String message);
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package stirling.software.proprietary.security.database.service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.EnterpriseFeatures.DatabaseNotifications;
|
||||
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class DatabaseNotificationService implements DatabaseNotificationServiceInterface {
|
||||
|
||||
private final Optional<EmailService> emailService;
|
||||
private final ApplicationProperties props;
|
||||
private final boolean runningEE;
|
||||
private DatabaseNotifications notifications;
|
||||
|
||||
DatabaseNotificationService(
|
||||
Optional<EmailService> emailService,
|
||||
ApplicationProperties props,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
this.emailService = emailService;
|
||||
this.props = props;
|
||||
this.runningEE = runningEE;
|
||||
notifications = props.getPremium().getEnterpriseFeatures().getDatabaseNotifications();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyBackupsSuccess(String subject, String message) {
|
||||
if (notifications.getBackups().isSuccessful() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyBackupsFailure(String subject, String message) {
|
||||
if (notifications.getBackups().isFailed() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyImportsSuccess(String subject, String message) {
|
||||
if (notifications.getImports().isSuccessful() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyImportsFailure(String subject, String message) {
|
||||
if (notifications.getImports().isFailed() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMail(String subject, String message) {
|
||||
emailService.ifPresent(
|
||||
service -> {
|
||||
try {
|
||||
String to = props.getMail().getFrom();
|
||||
service.sendSimpleMail(to, subject, message);
|
||||
} catch (MessagingException e) {
|
||||
log.error("Error sending notification email: {}", e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,10 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
@ -32,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||
import stirling.software.proprietary.security.model.exception.BackupNotFoundException;
|
||||
|
||||
@Slf4j
|
||||
@ -44,12 +48,16 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Datasource datasourceProps;
|
||||
private final DataSource dataSource;
|
||||
private final DatabaseNotificationServiceInterface backupNotificationService;
|
||||
|
||||
public DatabaseService(
|
||||
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
|
||||
ApplicationProperties.Datasource datasourceProps,
|
||||
DataSource dataSource,
|
||||
DatabaseNotificationServiceInterface backupNotificationService) {
|
||||
this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize();
|
||||
this.datasourceProps = datasourceProps;
|
||||
this.dataSource = dataSource;
|
||||
this.backupNotificationService = backupNotificationService;
|
||||
moveBackupFiles();
|
||||
}
|
||||
|
||||
@ -172,6 +180,8 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
public boolean importDatabaseFromUI(String fileName) {
|
||||
try {
|
||||
importDatabaseFromUI(getBackupFilePath(fileName));
|
||||
backupNotificationService.notifyImportsSuccess(
|
||||
"Database import completed", "Import file: " + fileName);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
@ -179,6 +189,9 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
fileName,
|
||||
e.getMessage(),
|
||||
e.getCause());
|
||||
backupNotificationService.notifyImportsFailure(
|
||||
"Database import failed",
|
||||
"Import file: " + fileName + " Message: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -219,16 +232,59 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||
stmt.setString(1, insertOutputFilePath.toString());
|
||||
stmt.execute();
|
||||
backupNotificationService.notifyBackupsSuccess(
|
||||
"Database backup export completed",
|
||||
"Backup file: " + insertOutputFilePath.getFileName());
|
||||
} catch (SQLException e) {
|
||||
log.error("Error during database export: {}", e.getMessage(), e);
|
||||
backupNotificationService.notifyBackupsFailure(
|
||||
"Database backup export failed",
|
||||
"Backup file: "
|
||||
+ insertOutputFilePath.getFileName()
|
||||
+ " Message: "
|
||||
+ e.getMessage());
|
||||
} catch (CannotReadScriptException e) {
|
||||
log.error("Error during database export: File {} not found", insertOutputFilePath);
|
||||
backupNotificationService.notifyBackupsFailure(
|
||||
"Database backup export failed",
|
||||
"Error during database export: File "
|
||||
+ insertOutputFilePath.getFileName()
|
||||
+ " not found. Message: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
|
||||
log.info("Database export completed: {}", insertOutputFilePath);
|
||||
verifyBackup(insertOutputFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyBackup(Path backupPath) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] content = Files.readAllBytes(backupPath);
|
||||
String checksum = bytesToHex(digest.digest(content));
|
||||
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
|
||||
|
||||
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
|
||||
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
|
||||
stmt.setString(1, backupPath.toString());
|
||||
stmt.execute();
|
||||
}
|
||||
return true;
|
||||
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] hash) {
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
hexString.append(String.format("%02x", b));
|
||||
}
|
||||
return hexString.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
|
||||
List<FileInfo> backupList = this.getBackupList();
|
||||
@ -384,6 +440,12 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
*/
|
||||
private void executeDatabaseScript(Path scriptPath) {
|
||||
if (isH2Database()) {
|
||||
|
||||
if (!verifyBackup(scriptPath)) {
|
||||
log.error("Backup verification failed for: {}", scriptPath);
|
||||
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
|
||||
}
|
||||
|
||||
String query = "RUNSCRIPT from ?;";
|
||||
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
|
||||
@ -11,6 +11,7 @@ import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.api.Email;
|
||||
@ -20,6 +21,7 @@ import stirling.software.proprietary.security.model.api.Email;
|
||||
* JavaMailSender to send the email and is designed to handle both the message content and file
|
||||
* attachments.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
|
||||
@ -72,5 +74,39 @@ public class EmailService {
|
||||
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
log.debug(
|
||||
"Email sent successfully to {} with subject: {} body: {}",
|
||||
email.getTo(),
|
||||
email.getSubject(),
|
||||
email.getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a simple email without attachments asynchronously.
|
||||
*
|
||||
* @param to the recipient address
|
||||
* @param subject subject line
|
||||
* @param body message body
|
||||
* @throws MessagingException if sending fails or address is invalid
|
||||
*/
|
||||
@Async
|
||||
public void sendSimpleMail(String to, String subject, String body) throws MessagingException {
|
||||
if (to == null || to.trim().isEmpty()) {
|
||||
throw new MessagingException("Invalid Addresses");
|
||||
}
|
||||
|
||||
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, false);
|
||||
helper.addTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body, false);
|
||||
helper.setFrom(mailProperties.getFrom());
|
||||
mailSender.send(message);
|
||||
log.debug(
|
||||
"Simple email sent successfully to {} with subject: {} body: {}",
|
||||
to,
|
||||
subject,
|
||||
body);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user