mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +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 {
|
public static class EnterpriseFeatures {
|
||||||
private PersistentMetrics persistentMetrics = new PersistentMetrics();
|
private PersistentMetrics persistentMetrics = new PersistentMetrics();
|
||||||
private Audit audit = new Audit();
|
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
|
@Data
|
||||||
public static class Audit {
|
public static class Audit {
|
||||||
|
|||||||
@ -85,6 +85,13 @@ premium:
|
|||||||
enabled: true # Enable audit logging
|
enabled: true # Enable audit logging
|
||||||
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||||
retentionDays: 90 # Number of days to retain audit logs
|
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:
|
mail:
|
||||||
enabled: false # set to 'true' to enable sending emails
|
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.Paths;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.nio.file.attribute.BasicFileAttributes;
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
@ -32,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.common.configuration.InstallationPathConfig;
|
import stirling.software.common.configuration.InstallationPathConfig;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.FileInfo;
|
import stirling.software.common.model.FileInfo;
|
||||||
|
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||||
import stirling.software.proprietary.security.model.exception.BackupNotFoundException;
|
import stirling.software.proprietary.security.model.exception.BackupNotFoundException;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -44,12 +48,16 @@ public class DatabaseService implements DatabaseServiceInterface {
|
|||||||
|
|
||||||
private final ApplicationProperties.Datasource datasourceProps;
|
private final ApplicationProperties.Datasource datasourceProps;
|
||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
|
private final DatabaseNotificationServiceInterface backupNotificationService;
|
||||||
|
|
||||||
public DatabaseService(
|
public DatabaseService(
|
||||||
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
|
ApplicationProperties.Datasource datasourceProps,
|
||||||
|
DataSource dataSource,
|
||||||
|
DatabaseNotificationServiceInterface backupNotificationService) {
|
||||||
this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize();
|
this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize();
|
||||||
this.datasourceProps = datasourceProps;
|
this.datasourceProps = datasourceProps;
|
||||||
this.dataSource = dataSource;
|
this.dataSource = dataSource;
|
||||||
|
this.backupNotificationService = backupNotificationService;
|
||||||
moveBackupFiles();
|
moveBackupFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,6 +180,8 @@ public class DatabaseService implements DatabaseServiceInterface {
|
|||||||
public boolean importDatabaseFromUI(String fileName) {
|
public boolean importDatabaseFromUI(String fileName) {
|
||||||
try {
|
try {
|
||||||
importDatabaseFromUI(getBackupFilePath(fileName));
|
importDatabaseFromUI(getBackupFilePath(fileName));
|
||||||
|
backupNotificationService.notifyImportsSuccess(
|
||||||
|
"Database import completed", "Import file: " + fileName);
|
||||||
return true;
|
return true;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error(
|
log.error(
|
||||||
@ -179,6 +189,9 @@ public class DatabaseService implements DatabaseServiceInterface {
|
|||||||
fileName,
|
fileName,
|
||||||
e.getMessage(),
|
e.getMessage(),
|
||||||
e.getCause());
|
e.getCause());
|
||||||
|
backupNotificationService.notifyImportsFailure(
|
||||||
|
"Database import failed",
|
||||||
|
"Import file: " + fileName + " Message: " + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,16 +232,59 @@ public class DatabaseService implements DatabaseServiceInterface {
|
|||||||
PreparedStatement stmt = conn.prepareStatement(query)) {
|
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||||
stmt.setString(1, insertOutputFilePath.toString());
|
stmt.setString(1, insertOutputFilePath.toString());
|
||||||
stmt.execute();
|
stmt.execute();
|
||||||
|
backupNotificationService.notifyBackupsSuccess(
|
||||||
|
"Database backup export completed",
|
||||||
|
"Backup file: " + insertOutputFilePath.getFileName());
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
log.error("Error during database export: {}", e.getMessage(), 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) {
|
} catch (CannotReadScriptException e) {
|
||||||
log.error("Error during database export: File {} not found", insertOutputFilePath);
|
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);
|
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
|
@Override
|
||||||
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
|
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
|
||||||
List<FileInfo> backupList = this.getBackupList();
|
List<FileInfo> backupList = this.getBackupList();
|
||||||
@ -384,6 +440,12 @@ public class DatabaseService implements DatabaseServiceInterface {
|
|||||||
*/
|
*/
|
||||||
private void executeDatabaseScript(Path scriptPath) {
|
private void executeDatabaseScript(Path scriptPath) {
|
||||||
if (isH2Database()) {
|
if (isH2Database()) {
|
||||||
|
|
||||||
|
if (!verifyBackup(scriptPath)) {
|
||||||
|
log.error("Backup verification failed for: {}", scriptPath);
|
||||||
|
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
|
||||||
|
}
|
||||||
|
|
||||||
String query = "RUNSCRIPT from ?;";
|
String query = "RUNSCRIPT from ?;";
|
||||||
|
|
||||||
try (Connection conn = dataSource.getConnection();
|
try (Connection conn = dataSource.getConnection();
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import jakarta.mail.MessagingException;
|
|||||||
import jakarta.mail.internet.MimeMessage;
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.proprietary.security.model.api.Email;
|
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
|
* JavaMailSender to send the email and is designed to handle both the message content and file
|
||||||
* attachments.
|
* attachments.
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
|
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
|
||||||
@ -72,5 +74,39 @@ public class EmailService {
|
|||||||
|
|
||||||
// Sends the email via the configured mail sender
|
// Sends the email via the configured mail sender
|
||||||
mailSender.send(message);
|
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