diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 9193eff7f..ca9e7ff57 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 { diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index e6b13812b..f5bf4ebcd 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/DatabaseNotificationServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/DatabaseNotificationServiceInterface.java new file mode 100644 index 000000000..4548a4813 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/DatabaseNotificationServiceInterface.java @@ -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); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/service/DatabaseNotificationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/service/DatabaseNotificationService.java new file mode 100644 index 000000000..ba52116a9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/service/DatabaseNotificationService.java @@ -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; + private final ApplicationProperties props; + private final boolean runningEE; + private DatabaseNotifications notifications; + + DatabaseNotificationService( + Optional 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); + } + }); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java index 1a3f3ee9c..a5755edf6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java @@ -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> deleteAllBackups() { List 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(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 08860a340..651771dd8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -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); } }